Files
vk-sales-bot/core/fsm.py
T

233 lines
12 KiB
Python

from enum import StrEnum, auto
from typing import Dict, Optional
from loguru import logger
from core.models import LeadData
from core.validator import validate_fio, validate_phone, parse_time
from core.exporter import ExcelExporter
from config import phrases
class DialogState(StrEnum):
START = auto()
ASK_CONSENT = auto()
COLLECT_FI = auto() # ФИО пользователя
ASK_PARENT_DATA = auto() # Спрашиваем, хочет ли пользователь ввести данные родителей
COLLECT_PARENT_FIO = auto() # Ввод ФИО родителя
COLLECT_PARENT_PHONE = auto() # Ввод телефона родителя
COLLECT_PHONE = auto()
COLLECT_TIME = auto()
CONFIRM = auto()
FINISHED = auto()
OBJECTION_HANDLED = auto()
class DialogManager:
def __init__(self, exporter: ExcelExporter):
self.exporter = exporter
self.sessions: Dict[int, dict] = {}
# def _handle_age(self, user_id: int, text: str) -> str:
# result = validate_age(text)
# if not result.is_valid:
# return result.error_message + "\n" + phrases.ASK_AGE
# session = self.sessions[user_id]
# session["data"].age = result.value
# logger.info(f"User {user_id} age = {result.value}")
# if result.value < 18:
# session["state"] = DialogState.ASK_PARENT_DATA
# logger.info(f"State changed to ASK_PARENT_DATA for user {user_id}")
# return phrases.ASK_PARENT_CONSENT
# else:
# session["state"] = DialogState.COLLECT_PHONE
# logger.info(f"Age >=18, state changed to COLLECT_PHONE")
# return phrases.ASK_PHONE
def _handle_parent_consent(self, user_id: int, text: str) -> str:
"""Обрабатывает ответ на вопрос про данные родителей.
Если пользователь вводит 'пропустить' и т.п. — пропускает.
Иначе считает, что это и есть ФИО родителя, и переходит к телефону.
"""
session = self.sessions[user_id]
text_lower = text.lower().strip()
if text_lower in ("пропустить", "нет", "не надо", "skip", "не хочу"):
session["state"] = DialogState.COLLECT_PHONE
logger.info(f"Parent data skipped for user {user_id}")
return phrases.PARENT_DATA_SKIPPED + "\n" + phrases.ASK_PHONE
else:
# Считаем, что пользователь сразу ввёл ФИО родителя
result = validate_fio(text)
if not result.is_valid:
return phrases.PARENT_FIO_INVALID + "\n" + phrases.ASK_PARENT_CONSENT
session["data"].parent_full_name = result.value
session["state"] = DialogState.COLLECT_PARENT_PHONE
logger.info(f"Parent FIO saved for user {user_id}, state changed to COLLECT_PARENT_PHONE")
return phrases.ASK_PARENT_PHONE
def _handle_parent_fio(self, user_id: int, text: str) -> str:
result = validate_fio(text) # используем ту же валидацию, что и для ФИО пользователя
if not result.is_valid:
# Подменяем сообщение об ошибке для родителя
return phrases.PARENT_FIO_INVALID + "\n" + phrases.ASK_PARENT_FIO
session = self.sessions[user_id]
session["data"].parent_full_name = result.value
session["state"] = DialogState.COLLECT_PARENT_PHONE
return phrases.ASK_PARENT_PHONE
def _handle_parent_phone(self, user_id: int, text: str) -> str:
result = validate_phone(text) # используем существующий валидатор телефона
if not result.is_valid:
return phrases.PARENT_PHONE_INVALID + "\n" + phrases.ASK_PARENT_PHONE
session = self.sessions[user_id]
session["data"].parent_phone = result.value
session["state"] = DialogState.COLLECT_PHONE
return phrases.PARENT_DATA_SAVED + "\n" + phrases.ASK_PHONE
def handle_message(self, user_id: int, text: str) -> str:
"""Обрабатывает сообщение и возвращает ответ бота"""
logger.info(f"=== HANDLE: user {user_id}, text='{text}', current state={self.sessions.get(user_id, {}).get('state')} ===")
# Команда /start или "начать заново"
if text.lower() in (phrases.CMD_START, phrases.BUTTON_RESTART.lower(), "начать заново"):
return self._reset_dialog(user_id)
session = self.sessions.get(user_id)
if not session:
return self._start_dialog(user_id)
state = session["state"]
lead = session["data"]
# Обработка возражений на любом этапе
if self._is_objection(text):
return self._handle_objection(user_id, text)
# Продолжение после возражения "подумаю"
if state == DialogState.OBJECTION_HANDLED and text.lower() == phrases.BUTTON_CONTINUE.lower():
# Возвращаемся к предыдущему состоянию
prev_state = session.get("prev_state", DialogState.COLLECT_FI)
session["state"] = prev_state
# Не рекурсивный вызов, а продолжение основного потока
state = session["state"]
lead = session["data"]
# Обычные состояния
if state == DialogState.ASK_CONSENT:
return self._handle_consent(user_id, text)
if state == DialogState.COLLECT_FI:
return self._handle_fio(user_id, text)
elif state == DialogState.ASK_PARENT_DATA: # Спрашиваем про данные родителей
return self._handle_parent_consent(user_id, text)
elif state == DialogState.COLLECT_PARENT_FIO:
return self._handle_parent_fio(user_id, text)
elif state == DialogState.COLLECT_PARENT_PHONE:
return self._handle_parent_phone(user_id, text)
elif state == DialogState.COLLECT_PHONE:
return self._handle_phone(user_id, text)
elif state == DialogState.COLLECT_TIME:
return self._handle_time(user_id, text)
elif state == DialogState.CONFIRM:
return self._handle_confirm(user_id, text)
def _start_dialog(self, user_id: int) -> str:
self.sessions[user_id] = {
"state": DialogState.ASK_CONSENT, # ← было COLLECT_FI
"data": LeadData(user_id=user_id)
}
return phrases.ASK_CONSENT
def _reset_dialog(self, user_id: int) -> str:
if user_id in self.sessions:
del self.sessions[user_id]
return self._start_dialog(user_id)
def _handle_fio(self, user_id: int, text: str) -> str:
result = validate_fio(text)
if not result.is_valid:
return result.error_message + "\n" + phrases.ASK_FI
session = self.sessions[user_id]
session["data"].full_name = result.value
session["state"] = DialogState.ASK_PARENT_DATA
return phrases.ASK_PARENT_CONSENT
def _handle_phone(self, user_id: int, text: str) -> str:
result = validate_phone(text)
if not result.is_valid:
return result.error_message + "\n" + phrases.ASK_PHONE
session = self.sessions[user_id]
session["data"].phone = result.value
session["state"] = DialogState.COLLECT_TIME
return phrases.ASK_TIME
def _handle_time(self, user_id: int, text: str) -> str:
result = parse_time(text)
if not result.is_valid:
return result.error_message + "\n" + phrases.ASK_TIME
session = self.sessions[user_id]
session["data"].preferred_time = result.value
parent_info = ""
if session["data"].parent_full_name and session["data"].parent_phone:
parent_info = f"Родитель: {session['data'].parent_full_name}, тел: {session['data'].parent_phone}\n"
elif session["data"].parent_full_name:
parent_info = f"Родитель: {session['data'].parent_full_name} (телефон не указан)\n"
elif session["data"].parent_phone:
parent_info = f"Родитель: телефон {session['data'].parent_phone} (ФИО не указано)\n"
session["state"] = DialogState.CONFIRM
return phrases.CONFIRM_MESSAGE.format(
fio=session["data"].full_name,
phone=session["data"].phone,
time=session["data"].preferred_time,
parent_info=parent_info
)
def _handle_confirm(self, user_id: int, text: str) -> str:
if text.lower() in ("да", phrases.BUTTON_YES.lower()):
lead = self.sessions[user_id]["data"]
lead.status = "completed"
self.exporter.save_lead(lead)
self.sessions[user_id]["state"] = DialogState.FINISHED
return phrases.CONFIRM_YES
elif text.lower() in ("нет", phrases.BUTTON_NO.lower()):
return self._reset_dialog(user_id)
else:
return "Пожалуйста, ответьте 'Да' или 'Нет'."
def _is_objection(self, text: str) -> bool:
obj_phrases = ["не сейчас", "подумаю", "не хочу оставлять телефон", "не хочу говорить номер"]
return any(phrase in text.lower() for phrase in obj_phrases)
def _handle_objection(self, user_id: int, text: str) -> str:
session = self.sessions.get(user_id)
if not session:
return self._start_dialog(user_id)
lead = session["data"]
if "не сейчас" in text.lower():
lead.status = "postponed"
self.exporter.save_lead(lead)
self.sessions[user_id]["state"] = DialogState.FINISHED
return phrases.OBJECTION_NOT_NOW
elif "подумаю" in text.lower():
# Сохраняем текущее состояние, переходим в OBJECTION_HANDLED
session["prev_state"] = session["state"]
session["state"] = DialogState.OBJECTION_HANDLED
return phrases.OBJECTION_THINK
elif any(phrase in text.lower() for phrase in ["не хочу оставлять телефон", "не хочу говорить номер"]):
lead.status = "rejected"
self.exporter.save_lead(lead)
self.sessions[user_id]["state"] = DialogState.FINISHED
return phrases.OBJECTION_NO_PHONE
return self.handle_message(user_id, text) # fallback
def _handle_consent(self, user_id: int, text: str) -> str:
text_lower = text.lower().strip()
session = self.sessions.get(user_id)
if not session:
return self._start_dialog(user_id)
if any(phrase in text_lower for phrase in phrases.CONSENT_YES_PHRASES):
session["data"].consent_given = True
session["state"] = DialogState.COLLECT_FI # ← было COLLECT_FIO
return phrases.CONSENT_YES + "\n" + phrases.ASK_FI # ← было ASK_FIO
elif any(phrase in text_lower for phrase in phrases.CONSENT_NO_PHRASES):
del self.sessions[user_id]
return phrases.CONSENT_NO
else:
return "Пожалуйста, ответьте 'Да' (согласен) или 'Нет' (не согласен)."