commit 09c42edfdcc205c27d771c095a4394acaa4d78b4 Author: User Date: Tue May 5 18:25:28 2026 +0300 Initial commit: VK Sales Bot project structure diff --git a/.env b/.env new file mode 100644 index 0000000..9895b63 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +VK_GROUP_ID=233127658 +VK_TOKEN="vk1.a.LAMaFdJdNPYBM2OyaMnw71WS4I4ITwTirs7dxRa3zcrVCzWIEp_wOYe75zZp_ZDpXARQFV4ZXlNpqLtCWUDpAsXGKDfhYjuT9G1vjw0blI3zXTh5k6S-P0Txy5a_JUbZiAfSVIZEYCb6hHs_mv71te9TpHaw-knSwbmxLYRklAIwX5hBlRe-vWM8cBZljmdNx7vh0Poe0-W6hnoEEDh6uQ" +ADMIN_IDS=25076348 +DATA_DIR=data +LEADS_FILE=data/leads.xlsx +BACKUP_DIR=data/backups +LOG_LEVEL=DEBUG +TIMEZONE=Europe/Moscow \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51d9377 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +VK_GROUP_ID=233127658 +VK_TOKEN=vk1.a.XTkUvOpbeC9ReN0WxLLynwe19Xdfr5kt4_lWcFpJINpS4O-MsQMeSxc9WWj7IqBTDDOLcpdgjGS4MdBsorXIEosmvV-iYwbExBqZaRTdtl7DcrRsYg0uW1gavDc_SBItLjoCnje7WlO5vz8i5pxXgkSuiki9vMbfYZdjYZYF8q5z7sResjJ-ZdBKS73WMCmrgQx0I22rGY0rRR7HKCeC_g +ADMIN_IDS=25076348 +DATA_DIR=data +LEADS_FILE=data/leads.xlsx +BACKUP_DIR=data/backups +LOG_LEVEL=INFO +TIMEZONE=Europe/Moscow \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.kilo/kilo.jsonc b/.kilo/kilo.jsonc new file mode 100644 index 0000000..571a15b --- /dev/null +++ b/.kilo/kilo.jsonc @@ -0,0 +1,3 @@ +{ + "$schema": "https://app.kilo.ai/config.json" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c9ebf2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fcc55b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "main.py"] \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d78aa98 Binary files /dev/null and b/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/config/__pycache__/phrases.cpython-311.pyc b/config/__pycache__/phrases.cpython-311.pyc new file mode 100644 index 0000000..80fe86a Binary files /dev/null and b/config/__pycache__/phrases.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000..ecb6a45 Binary files /dev/null and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/phrases.py b/config/phrases.py new file mode 100644 index 0000000..238093b --- /dev/null +++ b/config/phrases.py @@ -0,0 +1,84 @@ +# Все тексты бота вынесены сюда для быстрой правки +ASK_PARENT_CONSENT = ( + "👪 Укажите, пожалуйста, контактные данные родителя или опекуна. " + "Вы можете предоставить их сейчас или пропустить этот шаг. " + "Напишите 'пропустить', чтобы пропустить, или укажите ФИО родителя (два слова на кириллице):" +) +ASK_PARENT_FIO = "Укажите ФИО родителя (Фамилия Имя):" +ASK_PARENT_PHONE = "Укажите телефон родителя в формате +7 (XXX) XXX-XX-XX:" +PARENT_FIO_INVALID = "❌ ФИО родителя должно состоять из двух слов (Фамилия Имя) кириллицей." +PARENT_PHONE_INVALID = "❌ Неверный формат телефона родителя. Пример: +7 (912) 345-67-89" +PARENT_DATA_SKIPPED = "✅ Данные родителя пропущены. Продолжаем оформление." +PARENT_DATA_SAVED = "✅ Данные родителя сохранены." + +GREETING = ( + "Здравствуйте! 👋 Меня зовут Алексей, я специалист отдела продаж.\n\n" + "Подскажите, как к вам обращаться? (Фамилия, Имя, Отчество)" +) + +ASK_FI = "Пожалуйста, укажите вашу фамилию и имя (два слова на кириллице):" +FIO_INVALID = "❌ ФИО должно состоять из двух слов (Фамилия Имя) и содержать только буквы кириллицы. Попробуйте ещё раз:" + +ASK_PHONE = "Отлично! Теперь укажите ваш номер телефона в формате +7 (XXX) XXX-XX-XX:" +PHONE_INVALID = "❌ Неверный формат. Нужно +7 и 10 цифр после кода страны. Пример: +7 (912) 345-67-89" + +ASK_TIME = "Укажите удобное время по МСК для звонка (например: 'завтра после 18', 'в среду утром', 'сегодня в 15:00'):" +TIME_INVALID = "❌ Не понял время. Попробуйте сказать иначе: 'завтра в 10 утра', 'сегодня после обеда'." + +CONSENT_TEXT = ( + "🔐 Перед началом работы, пожалуйста, ознакомьтесь с условиями обработки персональных данных.\n\n" + "Мы собираем ваши ФИО, номер телефона и предпочтительное время звонка для связи с вами " + "по вопросам, связанным с услугами нашей компании. Ваши данные будут использоваться только " + "сотрудниками нашей компании и не будут переданы третьим лицам.\n\n" + "Дайте согласие на обработку персональных данных:" +) +ASK_CONSENT = "Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет):" +CONSENT_YES = "✅ Спасибо за согласие! Продолжим оформление заявки." +CONSENT_NO = "❌ К сожалению, без согласия на обработку данных мы не можем продолжить. Спасибо за обращение! Хорошего дня." + +BUTTON_CONSENT_YES = "✅ Да, согласен" +BUTTON_CONSENT_NO = "❌ Нет, не согласен" + +CONSENT_YES_PHRASES = ["да", "yes", "согласен", "даю согласие", "хорошо", "ok"] +CONSENT_NO_PHRASES = [ + "нет", "no", "не согласен", "отказываюсь", "не даю согласие", + "нет, не согласен", "не хочу давать согласие", "отказ" +] + +CONFIRM_MESSAGE = ( + "Проверьте данные:\n" + "ФИ: {fio}\n" + "Телефон: {phone}\n" + "Время звонка по МСК: {time}\n" + "{parent_info}" + "Всё верно? (Да / Нет)" +) + +CONFIRM_YES = "Спасибо! Ваши данные сохранены. Менеджер свяжется с вами в указанное время. Хорошего дня! 🌟" +CONFIRM_NO = "Давайте начнём заново. " + GREETING + +OBJECTION_NOT_NOW = ( + "Понимаю, возможно сейчас неудобно. Я оставлю заявку в статусе 'отложено'. " + "Напишите 'начать заново', когда будете готовы продолжить." +) +OBJECTION_THINK = ( + "Хорошо, подумайте. Я сохраню текущие данные. Для продолжения напишите 'продолжить'." +) +OBJECTION_NO_PHONE = ( + "К сожалению, без номера телефона мы не сможем с вами связаться. " + "Если передумаете – напишите 'начать заново'." +) + +# Кнопки +BUTTON_YES = "✅ Да" +BUTTON_NO = "❌ Нет" +BUTTON_RESTART = "🔄 Начать заново" +BUTTON_CONTINUE = "➡️ Продолжить" + +# Команды +CMD_START = "/start" +CMD_STATUS = "/status" +CMD_EXPORT = "/export" +CMD_BACKUP = "/backup" +CMD_STATS = "/stats" +CMD_RELOAD = "/reload" \ No newline at end of file diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..a5ba064 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv +from pathlib import Path + +load_dotenv() + +BASE_DIR = Path(__file__).parent.parent + +VK_GROUP_ID = int(os.getenv("VK_GROUP_ID")) +VK_TOKEN = os.getenv("VK_TOKEN") +ADMIN_IDS = [int(x.strip()) for x in os.getenv("ADMIN_IDS", "").split(",") if x.strip()] + +DATA_DIR = Path(os.getenv("DATA_DIR", BASE_DIR / "data")) +LEADS_FILE = Path(os.getenv("LEADS_FILE", DATA_DIR / "leads.xlsx")) +BACKUP_DIR = Path(os.getenv("BACKUP_DIR", DATA_DIR / "backups")) +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") +TIMEZONE = os.getenv("TIMEZONE", "Europe/Moscow") + +# Создаём директории +DATA_DIR.mkdir(exist_ok=True) +BACKUP_DIR.mkdir(exist_ok=True) + +# Настройки валидации +PHONE_MASK = "+7 (___) ___-__-__" +PHONE_LENGTH = 11 # цифр после +7 \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..cb5fc77 Binary files /dev/null and b/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/__pycache__/exporter.cpython-311.pyc b/core/__pycache__/exporter.cpython-311.pyc new file mode 100644 index 0000000..e8514f3 Binary files /dev/null and b/core/__pycache__/exporter.cpython-311.pyc differ diff --git a/core/__pycache__/fsm.cpython-311.pyc b/core/__pycache__/fsm.cpython-311.pyc new file mode 100644 index 0000000..855a670 Binary files /dev/null and b/core/__pycache__/fsm.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..0dbb835 Binary files /dev/null and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/validator.cpython-311.pyc b/core/__pycache__/validator.cpython-311.pyc new file mode 100644 index 0000000..b7d4d7a Binary files /dev/null and b/core/__pycache__/validator.cpython-311.pyc differ diff --git a/core/exceptions.py b/core/exceptions.py new file mode 100644 index 0000000..8adba98 --- /dev/null +++ b/core/exceptions.py @@ -0,0 +1,5 @@ +class ValidationError(Exception): + pass + +class ExportError(Exception): + pass \ No newline at end of file diff --git a/core/exporter.py b/core/exporter.py new file mode 100644 index 0000000..28c7694 --- /dev/null +++ b/core/exporter.py @@ -0,0 +1,76 @@ +import pandas as pd +from pathlib import Path +from filelock import FileLock +from datetime import datetime +from loguru import logger +from core.models import LeadData +from config import settings +import shutil + +class ExcelExporter: + def __init__(self, file_path: Path): + self.file_path = file_path + self.lock_path = file_path.with_suffix('.lock') + self._init_file() + + def _init_file(self): + """Создаёт файл с заголовками, если не существует""" + if not self.file_path.exists(): + df = pd.DataFrame(columns=[ + "timestamp", "user_id", "full_name", "phone", + "preferred_time", "status", "consent_given", + "parent_full_name", "parent_phone" +]) + df.to_excel(self.file_path, index=False, engine='openpyxl') + + def save_lead(self, lead: LeadData): + """Атомарная дозапись с блокировкой""" + lock = FileLock(self.lock_path) + with lock: + try: + # Читаем существующий DataFrame + df = pd.read_excel(self.file_path, engine='openpyxl') + # Добавляем новую строку + new_row = pd.DataFrame([{ + "timestamp": lead.timestamp, + "user_id": lead.user_id, + "full_name": lead.full_name, + "phone": lead.phone, + "preferred_time": lead.preferred_time, + "status": lead.status, + "consent_given": lead.consent_given, + "parent_full_name": lead.parent_full_name, + "parent_phone": lead.parent_phone + }]) + df = pd.concat([df, new_row], ignore_index=True) + # Записываем обратно + df.to_excel(self.file_path, index=False, engine='openpyxl') + logger.info(f"Lead saved for user {lead.user_id}") + except Exception as e: + logger.error(f"Failed to save lead: {e}") + raise + + def backup(self): + """Создаёт копию файла с меткой времени""" + if self.file_path.exists(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = settings.BACKUP_DIR / f"leads_backup_{timestamp}.xlsx" + shutil.copy2(self.file_path, backup_path) + logger.info(f"Backup created: {backup_path}") + + def get_stats(self) -> dict: + """Возвращает статистику по лидам""" + if not self.file_path.exists(): + return {"total": 0, "today": 0, "statuses": {}} + df = pd.read_excel(self.file_path, engine='openpyxl') + total = len(df) + today = len(df[pd.to_datetime(df['timestamp']).dt.date == datetime.now().date()]) + statuses = df['status'].value_counts().to_dict() + return {"total": total, "today": today, "statuses": statuses} + + def export(self, target_path: Path = None): + """Принудительный экспорт (копия)""" + if target_path is None: + target_path = settings.DATA_DIR / f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + shutil.copy2(self.file_path, target_path) + return target_path \ No newline at end of file diff --git a/core/fsm.py b/core/fsm.py new file mode 100644 index 0000000..56686cb --- /dev/null +++ b/core/fsm.py @@ -0,0 +1,233 @@ +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 "Пожалуйста, ответьте 'Да' (согласен) или 'Нет' (не согласен)." \ No newline at end of file diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..8b73734 --- /dev/null +++ b/core/models.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from pydantic import BaseModel, Field +from typing import Optional + +class LeadData(BaseModel): + user_id: int + full_name: Optional[str] = None + phone: Optional[str] = None + preferred_time: Optional[str] = None + timestamp: datetime = Field(default_factory=datetime.now) + status: str = "new" + consent_given: bool = False + parent_full_name: Optional[str] = None + parent_phone: Optional[str] = None + +class ValidationResult(BaseModel): + is_valid: bool + value: Optional[str] = None + error_message: Optional[str] = None \ No newline at end of file diff --git a/core/validator.py b/core/validator.py new file mode 100644 index 0000000..30fbbfe --- /dev/null +++ b/core/validator.py @@ -0,0 +1,52 @@ +from loguru import logger +import re +import dateparser +from datetime import datetime +from core.models import ValidationResult +from config import settings + + +def validate_fio(text: str) -> ValidationResult: + """Проверка ФИ: 2 слова, кириллица, возможен дефис""" + text = text.strip() + words = text.split() + if len(words) != 2: + return ValidationResult(is_valid=False, error_message="Должно быть 2 слова") + pattern = r'^[А-ЯЁа-яё\-]+$' + for word in words: + if not re.match(pattern, word): + return ValidationResult(is_valid=False, error_message="Только буквы кириллицы и дефис") + return ValidationResult(is_valid=True, value=text) + + +def validate_phone(phone: str) -> ValidationResult: + """Принимает +7 (XXX) XXX-XX-XX или просто 10 цифр после 7/8""" + cleaned = re.sub(r'\D', '', phone) + if len(cleaned) == 11 and cleaned.startswith('7'): + cleaned = '8' + cleaned[1:] + if len(cleaned) == 10: + cleaned = '8' + cleaned + if len(cleaned) == 11 and cleaned.startswith('8'): + formatted = f"+7 ({cleaned[1:4]}) {cleaned[4:7]}-{cleaned[7:9]}-{cleaned[9:]}" + return ValidationResult(is_valid=True, value=formatted) + return ValidationResult(is_valid=False, error_message="Некорректный номер") + + +def parse_time(text: str) -> ValidationResult: + """Парсит естественно-языковое время, возвращает строку для отображения""" + text_lower = text.lower().strip() + if any(phrase in text_lower for phrase in ["любое время", "не важно", "в любое", "когда удобно"]): + return ValidationResult(is_valid=True, value="Любое удобное время") + + try: + parsed = dateparser.parse( + text, + settings={'TIMEZONE': settings.TIMEZONE, 'RETURN_AS_TIMEZONE_AWARE': False} + ) + if parsed and parsed > datetime.now(): + formatted = parsed.strftime("%d.%m.%Y в %H:%M") + return ValidationResult(is_valid=True, value=formatted) + except Exception as e: + logger.warning(f"Failed to parse time '{text}': {e}") + + return ValidationResult(is_valid=False, error_message="Не удалось распознать время. Укажите, например: 'завтра после 18', 'в среду утром' или 'любое время'.") \ No newline at end of file diff --git a/create_repo.ps1 b/create_repo.ps1 new file mode 100644 index 0000000..a676233 --- /dev/null +++ b/create_repo.ps1 @@ -0,0 +1,15 @@ +$body = @{ + name = 'bot_vk_ikp' + description = 'VK Sales Bot' + private = $false +} | ConvertTo-Json + +$bytes = [System.Text.Encoding]::UTF8.GetBytes($body) + +$headers = @{ + 'Authorization' = 'Bearer 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' + 'Accept' = 'application/vnd.github.v3+json' +} + +$result = Invoke-RestMethod -Method POST -Uri 'https://api.github.com/user/repos' -Headers $headers -Body $bytes -ContentType 'application/json; charset=utf-8' +Write-Host "Repository created: $($result.html_url)" \ No newline at end of file diff --git a/data/leads.xlsx b/data/leads.xlsx new file mode 100644 index 0000000..989e6a5 Binary files /dev/null and b/data/leads.xlsx differ diff --git a/docs/developer_guide.md b/docs/developer_guide.md new file mode 100644 index 0000000..2ea7e17 --- /dev/null +++ b/docs/developer_guide.md @@ -0,0 +1,30 @@ +# Документация разработчика + +## Архитектура +Проект построен на **конечном автомате (FSM)**. Диалог управляется классом `DialogManager`. +Состояния: `COLLECT_FI` → `COLLECT_PHONE` → `COLLECT_TIME` → `CONFIRM` → `FINISHED`. +Поддерживаются возражения и перезапуск. + +**Компоненты:** +- `core/fsm.py` – логика диалога. +- `core/validator.py` – валидация ФИО, телефона, парсинг времени. +- `core/exporter.py` – работа с Excel (атомарная запись, бэкапы). +- `services/vk_bot.py` – интеграция с VK API, обработка команд. +- `utils/middleware.py` – мидлвары (логирование, аналитика). + +## Добавление нового поля в анкету +1. Добавить текст в `config/phrases.py`. +2. Расширить модель `LeadData` в `core/models.py`. +3. Добавить состояние в `DialogState` и методы в `DialogManager`. +4. Добавить валидатор в `core/validator.py`. +5. Обновить `ExcelExporter._init_file` и метод сохранения. + +## Тестирование +Установите тестовые зависимости: `pip install pytest pytest-asyncio responses`. +Запуск: `pytest tests/`. +Пример теста для валидатора: +```python +def test_validate_fio(): + from core.validator import validate_fio + assert validate_fio("Иванов Иван Иванович").is_valid + assert not validate_fio("John Doe").is_valid \ No newline at end of file diff --git a/docs/user_guide.md b/docs/user_guide.md new file mode 100644 index 0000000..24546b8 --- /dev/null +++ b/docs/user_guide.md @@ -0,0 +1,22 @@ +# Руководство оператора "Сотрудник первой линии" + +## Быстрый старт +1. Убедитесь, что установлен Python 3.11+ и все зависимости (`pip install -r requirements.txt`). +2. Скопируйте `.env.example` в `.env` и укажите токен группы VK. +3. Запустите бота: `python main.py` +4. Проверьте статус: напишите боту `/status` (только для администраторов, указанных в ADMIN_IDS). + +## Управление +- `/status` – проверить, работает ли бот. +- `/export` – создать копию файла лидов с меткой времени. +- `/backup` – вручную создать резервную копию. +- `/stats` – показать статистику (всего лидов, за сегодня, по статусам). +- `/reload` – перезагрузить текстовые фразы без перезапуска. + +## Просмотр данных +Файл с лидами находится в `data/leads.xlsx`. Резервные копии – в `data/backups/`. + +## Решение проблем +- Бот не отвечает: проверьте токен и права доступа группы (нужны `messages` и `groups`). +- Ошибка записи в файл: убедитесь, что папка `data` существует и доступна для записи. +- Бот не видит сообщения: группа должна быть публичной или добавьте бота в беседу. \ No newline at end of file diff --git a/logs/bot.log b/logs/bot.log new file mode 100644 index 0000000..8568474 --- /dev/null +++ b/logs/bot.log @@ -0,0 +1,590 @@ +2026-05-04T13:22:21.443454+0300 | INFO | Initializing bot... +2026-05-04T13:22:21.685842+0300 | INFO | Bot started +2026-05-04T13:22:21.686877+0300 | INFO | Backup scheduler started +2026-05-04T13:23:07.722613+0300 | DEBUG | Message from 25076348 in peer 2000000002: [club233127658|@ikpro] 🔄 Начать заново +2026-05-04T13:23:07.724180+0300 | INFO | Processed message from 25076348: [club233127658|@ikpro] 🔄 Начать заново +2026-05-04T13:23:07.724696+0300 | INFO | === HANDLE: user 25076348, text='[club233127658|@ikpro] 🔄 Начать заново', current state=None === +2026-05-04T13:23:07.727877+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T13:23:11.904304+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T13:23:11.904304+0300 | INFO | Processed message from 25076348: да +2026-05-04T13:23:11.904840+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T13:23:11.904840+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T13:23:34.586781+0300 | DEBUG | Message from 25076348 in peer 2000000002: ффф ыавпва +2026-05-04T13:23:34.586781+0300 | INFO | Processed message from 25076348: ффф ыавпва +2026-05-04T13:23:34.587312+0300 | INFO | === HANDLE: user 25076348, text='ффф ыавпва', current state=collect_fi === +2026-05-04T13:23:34.588357+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T13:23:37.967348+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T13:23:37.967876+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T13:23:37.967876+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T13:23:37.967876+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T13:23:37.968411+0300 | DEBUG | cleaned: '12' +2026-05-04T13:23:37.968411+0300 | DEBUG | age valid: 12 +2026-05-04T13:23:37.969451+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T13:36:16.863923+0300 | INFO | Initializing bot... +2026-05-04T13:36:17.084594+0300 | INFO | Bot started +2026-05-04T13:36:17.085630+0300 | INFO | Backup scheduler started +2026-05-04T13:37:06.672828+0300 | DEBUG | Message from 25076348 in peer 2000000002: Привет +2026-05-04T13:37:06.673343+0300 | INFO | Processed message from 25076348: Привет +2026-05-04T13:37:06.673343+0300 | INFO | === HANDLE: user 25076348, text='Привет', current state=None === +2026-05-04T13:37:06.673883+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T13:37:09.159946+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T13:37:09.159946+0300 | INFO | Processed message from 25076348: да +2026-05-04T13:37:09.160473+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T13:37:09.160473+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T13:37:15.495991+0300 | DEBUG | Message from 25076348 in peer 2000000002: ааа ррр +2026-05-04T13:37:15.496513+0300 | INFO | Processed message from 25076348: ааа ррр +2026-05-04T13:37:15.497027+0300 | INFO | === HANDLE: user 25076348, text='ааа ррр', current state=collect_fi === +2026-05-04T13:37:15.497027+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T13:37:18.753724+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T13:37:18.753724+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T13:37:18.754244+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T13:37:18.754244+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T13:37:18.754244+0300 | DEBUG | cleaned: '12' +2026-05-04T13:37:18.754244+0300 | DEBUG | age valid: 12 +2026-05-04T13:37:18.754756+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T13:37:22.075536+0300 | DEBUG | Message from 25076348 in peer 2000000002: 23 +2026-05-04T13:37:22.076047+0300 | INFO | Processed message from 25076348: 23 +2026-05-04T13:37:22.076047+0300 | INFO | === HANDLE: user 25076348, text='23', current state=collect_age === +2026-05-04T13:37:22.076047+0300 | DEBUG | validate_age input: ''23'' +2026-05-04T13:37:22.076566+0300 | DEBUG | cleaned: '23' +2026-05-04T13:37:22.076566+0300 | DEBUG | age valid: 23 +2026-05-04T13:37:22.076566+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:09:25.567877+0300 | INFO | Initializing bot... +2026-05-04T14:09:25.744659+0300 | INFO | Bot started +2026-05-04T14:09:25.745178+0300 | INFO | Backup scheduler started +2026-05-04T14:09:34.624213+0300 | DEBUG | Message from 25076348 in peer 2000000002: Привет +2026-05-04T14:09:34.624213+0300 | INFO | Processed message from 25076348: Привет +2026-05-04T14:09:34.624213+0300 | INFO | === HANDLE: user 25076348, text='Привет', current state=None === +2026-05-04T14:09:34.624740+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T14:09:36.950764+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T14:09:36.950764+0300 | INFO | Processed message from 25076348: да +2026-05-04T14:09:36.951300+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T14:09:36.951300+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T14:09:45.685252+0300 | DEBUG | Message from 25076348 in peer 2000000002: вав авп +2026-05-04T14:09:45.685252+0300 | INFO | Processed message from 25076348: вав авп +2026-05-04T14:09:45.685252+0300 | INFO | === HANDLE: user 25076348, text='вав авп', current state=collect_fi === +2026-05-04T14:09:45.685773+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T14:09:49.196572+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T14:09:49.197077+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T14:09:49.197077+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T14:09:49.197077+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T14:09:49.197077+0300 | DEBUG | cleaned: '12' +2026-05-04T14:09:49.197077+0300 | DEBUG | age valid: 12 +2026-05-04T14:09:49.197077+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:09:53.320692+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12лет +2026-05-04T14:09:53.320692+0300 | INFO | Processed message from 25076348: 12лет +2026-05-04T14:09:53.320692+0300 | INFO | === HANDLE: user 25076348, text='12лет', current state=collect_age === +2026-05-04T14:09:53.321204+0300 | DEBUG | validate_age input: ''12лет'' +2026-05-04T14:09:53.321204+0300 | DEBUG | cleaned: '12' +2026-05-04T14:09:53.321204+0300 | DEBUG | age valid: 12 +2026-05-04T14:09:53.321724+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:10:19.043522+0300 | DEBUG | Message from 25076348 in peer 2000000002: 13 +2026-05-04T14:10:19.043522+0300 | INFO | Processed message from 25076348: 13 +2026-05-04T14:10:19.043522+0300 | INFO | === HANDLE: user 25076348, text='13', current state=collect_age === +2026-05-04T14:10:19.044052+0300 | DEBUG | validate_age input: ''13'' +2026-05-04T14:10:19.044052+0300 | DEBUG | cleaned: '13' +2026-05-04T14:10:19.044052+0300 | DEBUG | age valid: 13 +2026-05-04T14:10:19.044052+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:10:22.029805+0300 | DEBUG | Message from 25076348 in peer 2000000002: 18 +2026-05-04T14:10:22.029805+0300 | INFO | Processed message from 25076348: 18 +2026-05-04T14:10:22.029805+0300 | INFO | === HANDLE: user 25076348, text='18', current state=collect_age === +2026-05-04T14:10:22.030324+0300 | DEBUG | validate_age input: ''18'' +2026-05-04T14:10:22.030324+0300 | DEBUG | cleaned: '18' +2026-05-04T14:10:22.030324+0300 | DEBUG | age valid: 18 +2026-05-04T14:10:22.030324+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:52:44.897174+0300 | INFO | Initializing bot... +2026-05-04T14:52:45.151496+0300 | INFO | Bot started +2026-05-04T14:52:45.152530+0300 | INFO | Backup scheduler started +2026-05-04T14:53:41.300176+0300 | DEBUG | Message from 25076348 in peer 2000000002: привет +2026-05-04T14:53:41.302249+0300 | INFO | Processed message from 25076348: привет +2026-05-04T14:53:41.302249+0300 | INFO | === HANDLE: user 25076348, text='привет', current state=None === +2026-05-04T14:53:41.306431+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T14:53:43.736066+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T14:53:43.736066+0300 | INFO | Processed message from 25076348: да +2026-05-04T14:53:43.736066+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T14:53:43.736606+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T14:53:49.142783+0300 | DEBUG | Message from 25076348 in peer 2000000002: ппп ррр +2026-05-04T14:53:49.142783+0300 | INFO | Processed message from 25076348: ппп ррр +2026-05-04T14:53:49.143321+0300 | INFO | === HANDLE: user 25076348, text='ппп ррр', current state=collect_fi === +2026-05-04T14:53:49.152638+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T14:53:51.561650+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T14:53:51.561650+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T14:53:51.562169+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T14:53:51.562169+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T14:53:51.562169+0300 | DEBUG | cleaned: '12' +2026-05-04T14:53:51.562710+0300 | DEBUG | age valid: 12 +2026-05-04T14:53:51.563228+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:53:57.436097+0300 | DEBUG | Message from 25076348 in peer 2000000002: 13 +2026-05-04T14:53:57.436097+0300 | INFO | Processed message from 25076348: 13 +2026-05-04T14:53:57.436624+0300 | INFO | === HANDLE: user 25076348, text='13', current state=collect_age === +2026-05-04T14:53:57.436624+0300 | DEBUG | validate_age input: ''13'' +2026-05-04T14:53:57.436624+0300 | DEBUG | cleaned: '13' +2026-05-04T14:53:57.436624+0300 | DEBUG | age valid: 13 +2026-05-04T14:53:57.437143+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T14:54:17.932582+0300 | ERROR | Fatal error: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) +Traceback (most recent call last): + + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 787, in urlopen + response = self._make_request( + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 534, in _make_request + response = conn.getresponse() + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connection.py", line 571, in getresponse + httplib_response = super().getresponse() + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 1374, in getresponse + response.begin() + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 318, in begin + version, status, reason = self._read_status() + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 287, in _read_status + raise RemoteDisconnected("Remote end closed connection without" + └ + +http.client.RemoteDisconnected: Remote end closed connection without response + + +During handling of the above exception, another exception occurred: + + +Traceback (most recent call last): + + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\requests\adapters.py", line 645, in send + resp = conn.urlopen( + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 841, in urlopen + retries = retries.increment( + │ └ + └ Retry(total=0, connect=None, read=False, redirect=None, status=None) + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\util\retry.py", line 490, in increment + raise reraise(type(error), error, _stacktrace) + │ │ │ └ + │ │ └ ProtocolError('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) + │ └ ProtocolError('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\util\util.py", line 38, in reraise + raise value.with_traceback(tb) + │ └ None + └ None + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 787, in urlopen + response = self._make_request( + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 534, in _make_request + response = conn.getresponse() + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connection.py", line 571, in getresponse + httplib_response = super().getresponse() + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 1374, in getresponse + response.begin() + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 318, in begin + version, status, reason = self._read_status() + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 287, in _read_status + raise RemoteDisconnected("Remote end closed connection without" + └ + +urllib3.exceptions.ProtocolError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) + + +During handling of the above exception, another exception occurred: + + +Traceback (most recent call last): + + File "m:\bot_vk_ikp\vk-sales-bot\main.py", line 28, in + main() + └ + +> File "m:\bot_vk_ikp\vk-sales-bot\main.py", line 20, in main + bot.run() + │ └ + └ + + File "m:\bot_vk_ikp\vk-sales-bot\services\vk_bot.py", line 90, in run + for event in self.longpoll.listen(): + │ │ │ └ + │ │ └ + │ └ + └ <({'group_id': 233127658, 'type': 'message_new', 'event_id': 'a8c630d5264c1fc3... + + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\vk_api\bot_longpoll.py", line 286, in listen + for event in self.check(): + │ │ └ + │ └ + └ <({'group_id': 233127658, 'type': 'message_new', 'event_id': 'a8c630d5264c1fc3... + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\vk_api\bot_longpoll.py", line 255, in check + response = self.session.get( + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\requests\sessions.py", line 605, in get + return self.request("GET", url, **kwargs) + │ │ │ └ {'params': {'act': 'a_check', 'key': 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJxdWV1ZV9pZCI6IjIzMzEyNzY1OCIsInVudGlsIjoxNzc3OD... + │ │ └ 'https://lp.vk.com/whp/233127658' + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\requests\sessions.py", line 592, in request + resp = self.send(prep, **send_kwargs) + │ │ │ └ {'timeout': 35, 'allow_redirects': True, 'proxies': OrderedDict(), 'stream': False, 'verify': True, 'cert': None} + │ │ └ + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\requests\sessions.py", line 706, in send + r = adapter.send(request, **kwargs) + │ │ │ └ {'timeout': 35, 'proxies': OrderedDict(), 'stream': False, 'verify': True, 'cert': None} + │ │ └ + │ └ + └ + File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\requests\adapters.py", line 660, in send + raise ConnectionError(err, request=request) + │ └ + └ + +requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) +2026-05-04T15:32:36.840163+0300 | INFO | Initializing bot... +2026-05-04T15:32:37.085881+0300 | INFO | Bot started +2026-05-04T15:32:37.086882+0300 | INFO | Backup scheduler started +2026-05-04T15:32:43.889888+0300 | DEBUG | Message from 25076348 in peer 2000000002: привет +2026-05-04T15:32:43.891134+0300 | INFO | Processed message from 25076348: привет +2026-05-04T15:32:43.891134+0300 | INFO | === HANDLE: user 25076348, text='привет', current state=None === +2026-05-04T15:32:43.894134+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T15:32:47.508547+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T15:32:47.508547+0300 | INFO | Processed message from 25076348: да +2026-05-04T15:32:47.508547+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T15:32:47.508547+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T15:32:55.451299+0300 | DEBUG | Message from 25076348 in peer 2000000002: ааа ппп +2026-05-04T15:32:55.451299+0300 | INFO | Processed message from 25076348: ааа ппп +2026-05-04T15:32:55.451299+0300 | INFO | === HANDLE: user 25076348, text='ааа ппп', current state=collect_fi === +2026-05-04T15:32:55.453302+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T15:32:59.095014+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T15:32:59.095014+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T15:32:59.095014+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T15:32:59.095014+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T15:32:59.096013+0300 | DEBUG | cleaned: '12' +2026-05-04T15:32:59.096013+0300 | DEBUG | age valid: 12 +2026-05-04T15:32:59.098012+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T15:33:02.913004+0300 | DEBUG | Message from 25076348 in peer 2000000002: 23 +2026-05-04T15:33:02.914007+0300 | INFO | Processed message from 25076348: 23 +2026-05-04T15:33:02.914007+0300 | INFO | === HANDLE: user 25076348, text='23', current state=collect_age === +2026-05-04T15:33:02.914007+0300 | DEBUG | validate_age input: ''23'' +2026-05-04T15:33:02.914007+0300 | DEBUG | cleaned: '23' +2026-05-04T15:33:02.914007+0300 | DEBUG | age valid: 23 +2026-05-04T15:33:02.915005+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:09:52.845335+0300 | INFO | Initializing bot... +2026-05-04T18:09:53.046373+0300 | INFO | Bot started +2026-05-04T18:09:53.047373+0300 | INFO | Backup scheduler started +2026-05-04T18:10:10.241270+0300 | DEBUG | Message from 25076348 in peer 2000000002: привет +2026-05-04T18:10:10.242269+0300 | INFO | Processed message from 25076348: привет +2026-05-04T18:10:10.242269+0300 | INFO | === HANDLE: user 25076348, text='привет', current state=None === +2026-05-04T18:10:10.245270+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T18:10:13.272789+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T18:10:13.273787+0300 | INFO | Processed message from 25076348: да +2026-05-04T18:10:13.273787+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T18:10:13.273787+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T18:10:18.694847+0300 | DEBUG | Message from 25076348 in peer 2000000002: аа рп +2026-05-04T18:10:18.694847+0300 | INFO | Processed message from 25076348: аа рп +2026-05-04T18:10:18.694847+0300 | INFO | === HANDLE: user 25076348, text='аа рп', current state=collect_fi === +2026-05-04T18:10:18.696350+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T18:10:21.671256+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T18:10:21.671256+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T18:10:21.672255+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T18:10:21.672255+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T18:10:21.672255+0300 | DEBUG | cleaned: '12' +2026-05-04T18:10:21.672255+0300 | DEBUG | age valid: 12 +2026-05-04T18:10:21.672255+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:12:55.999161+0300 | INFO | Initializing bot... +2026-05-04T18:12:56.620865+0300 | INFO | Bot started +2026-05-04T18:12:56.620865+0300 | INFO | Backup scheduler started +2026-05-04T18:13:16.401304+0300 | DEBUG | Message from 25076348 in peer 2000000002: 5 +2026-05-04T18:13:16.402310+0300 | INFO | Processed message from 25076348: 5 +2026-05-04T18:13:16.402310+0300 | INFO | === HANDLE: user 25076348, text='5', current state=None === +2026-05-04T18:13:16.402310+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T18:13:19.498303+0300 | DEBUG | Message from 25076348 in peer 2000000002: всыв +2026-05-04T18:13:19.498303+0300 | INFO | Processed message from 25076348: всыв +2026-05-04T18:13:19.498303+0300 | INFO | === HANDLE: user 25076348, text='всыв', current state=ask_consent === +2026-05-04T18:13:19.498303+0300 | INFO | Response to user 25076348: Пожалуйста, ответьте 'Да' (согласен) или 'Нет' (не согласен). +2026-05-04T18:13:22.869492+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T18:13:22.869492+0300 | INFO | Processed message from 25076348: да +2026-05-04T18:13:22.870492+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T18:13:22.870492+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T18:13:30.113454+0300 | DEBUG | Message from 25076348 in peer 2000000002: пав пав +2026-05-04T18:13:30.113454+0300 | INFO | Processed message from 25076348: пав пав +2026-05-04T18:13:30.113454+0300 | INFO | === HANDLE: user 25076348, text='пав пав', current state=collect_fi === +2026-05-04T18:13:30.114454+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T18:13:32.914351+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T18:13:32.915351+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T18:13:32.915351+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T18:13:32.915351+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T18:13:32.915351+0300 | DEBUG | cleaned: '12' +2026-05-04T18:13:32.915351+0300 | DEBUG | age valid: 12 +2026-05-04T18:13:32.915351+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:13:36.111171+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T18:13:36.111171+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T18:13:36.111171+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T18:13:36.112171+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T18:13:36.112171+0300 | DEBUG | cleaned: '12' +2026-05-04T18:13:36.112171+0300 | DEBUG | age valid: 12 +2026-05-04T18:13:36.112171+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:13:43.488167+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12лет +2026-05-04T18:13:43.488167+0300 | INFO | Processed message from 25076348: 12лет +2026-05-04T18:13:43.488167+0300 | INFO | === HANDLE: user 25076348, text='12лет', current state=collect_age === +2026-05-04T18:13:43.489166+0300 | DEBUG | validate_age input: ''12лет'' +2026-05-04T18:13:43.489166+0300 | DEBUG | cleaned: '12' +2026-05-04T18:13:43.489166+0300 | DEBUG | age valid: 12 +2026-05-04T18:13:43.489166+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:13:48.007000+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 лет +2026-05-04T18:13:48.007000+0300 | INFO | Processed message from 25076348: 12 лет +2026-05-04T18:13:48.007000+0300 | INFO | === HANDLE: user 25076348, text='12 лет', current state=collect_age === +2026-05-04T18:13:48.008153+0300 | DEBUG | validate_age input: ''12 лет'' +2026-05-04T18:13:48.008153+0300 | DEBUG | cleaned: '12' +2026-05-04T18:13:48.008153+0300 | DEBUG | age valid: 12 +2026-05-04T18:13:48.008153+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:13:51.898194+0300 | DEBUG | Message from 25076348 in peer 2000000002: 1 +2026-05-04T18:13:51.898194+0300 | INFO | Processed message from 25076348: 1 +2026-05-04T18:13:51.899194+0300 | INFO | === HANDLE: user 25076348, text='1', current state=collect_age === +2026-05-04T18:13:51.899194+0300 | DEBUG | validate_age input: ''1'' +2026-05-04T18:13:51.899194+0300 | DEBUG | cleaned: '1' +2026-05-04T18:13:51.899194+0300 | DEBUG | age valid: 1 +2026-05-04T18:13:51.899194+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:13:55.621492+0300 | DEBUG | Message from 25076348 in peer 2000000002: 120 +2026-05-04T18:13:55.622492+0300 | INFO | Processed message from 25076348: 120 +2026-05-04T18:13:55.622492+0300 | INFO | === HANDLE: user 25076348, text='120', current state=collect_age === +2026-05-04T18:13:55.622492+0300 | DEBUG | validate_age input: ''120'' +2026-05-04T18:13:55.622492+0300 | DEBUG | cleaned: '120' +2026-05-04T18:13:55.622492+0300 | DEBUG | age valid: 120 +2026-05-04T18:13:55.622492+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T18:14:00.545873+0300 | DEBUG | Message from 25076348 in peer 2000000002: 158 +2026-05-04T18:14:00.545873+0300 | INFO | Processed message from 25076348: 158 +2026-05-04T18:14:00.545873+0300 | INFO | === HANDLE: user 25076348, text='158', current state=collect_age === +2026-05-04T18:14:00.545873+0300 | DEBUG | validate_age input: ''158'' +2026-05-04T18:14:00.545873+0300 | DEBUG | cleaned: '158' +2026-05-04T18:14:00.546870+0300 | INFO | Response to user 25076348: Возраст должен быть от 1 до 120 лет. +Укажите ваш возраст (целое число лет): +2026-05-04T18:14:29.195477+0300 | DEBUG | Message from 25076348 in peer 2000000002: 11 +2026-05-04T18:14:29.195477+0300 | INFO | Processed message from 25076348: 11 +2026-05-04T18:14:29.196478+0300 | INFO | === HANDLE: user 25076348, text='11', current state=collect_age === +2026-05-04T18:14:29.196478+0300 | DEBUG | validate_age input: ''11'' +2026-05-04T18:14:29.196478+0300 | DEBUG | cleaned: '11' +2026-05-04T18:14:29.196478+0300 | DEBUG | age valid: 11 +2026-05-04T18:14:29.196478+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T21:42:30.942354+0300 | INFO | Initializing bot... +2026-05-04T21:42:31.199461+0300 | INFO | Bot started +2026-05-04T21:42:31.200460+0300 | INFO | Backup scheduler started +2026-05-04T21:43:03.630075+0300 | INFO | Initializing bot... +2026-05-04T21:43:04.103937+0300 | INFO | Bot started +2026-05-04T21:43:04.104937+0300 | INFO | Backup scheduler started +2026-05-04T21:43:32.010181+0300 | DEBUG | Message from 25076348 in peer 2000000002: привет +2026-05-04T21:43:32.012181+0300 | INFO | Processed message from 25076348: привет +2026-05-04T21:43:32.012181+0300 | INFO | === HANDLE: user 25076348, text='привет', current state=None === +2026-05-04T21:43:32.015181+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-04T21:43:34.178176+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-04T21:43:34.178176+0300 | INFO | Processed message from 25076348: да +2026-05-04T21:43:34.178176+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-04T21:43:34.178176+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-04T21:43:40.704112+0300 | DEBUG | Message from 25076348 in peer 2000000002: ааа ппп +2026-05-04T21:43:40.704112+0300 | INFO | Processed message from 25076348: ааа ппп +2026-05-04T21:43:40.704112+0300 | INFO | === HANDLE: user 25076348, text='ааа ппп', current state=collect_fi === +2026-05-04T21:43:40.706111+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-04T21:43:43.405452+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T21:43:43.405452+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T21:43:43.406451+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T21:43:43.406451+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T21:43:43.406451+0300 | DEBUG | cleaned: '12' +2026-05-04T21:43:43.406451+0300 | DEBUG | age valid: 12 +2026-05-04T21:43:43.407452+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T21:43:53.242075+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-04T21:43:53.243076+0300 | INFO | Processed message from 25076348: 12 +2026-05-04T21:43:53.243076+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-04T21:43:53.243076+0300 | DEBUG | validate_age input: ''12'' +2026-05-04T21:43:53.243076+0300 | DEBUG | cleaned: '12' +2026-05-04T21:43:53.243076+0300 | DEBUG | age valid: 12 +2026-05-04T21:43:53.243076+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-04T21:44:00.659922+0300 | DEBUG | Message from 25076348 in peer 2000000002: "12" +2026-05-04T21:44:00.659922+0300 | INFO | Processed message from 25076348: "12" +2026-05-04T21:44:00.659922+0300 | INFO | === HANDLE: user 25076348, text='"12"', current state=collect_age === +2026-05-04T21:44:00.659922+0300 | DEBUG | validate_age input: ''"12"'' +2026-05-04T21:44:00.659922+0300 | DEBUG | cleaned: '12' +2026-05-04T21:44:00.660921+0300 | DEBUG | age valid: 12 +2026-05-04T21:44:00.660921+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-05T07:00:09.347195+0300 | INFO | Initializing bot... +2026-05-05T07:00:09.569224+0300 | INFO | Bot started +2026-05-05T07:00:09.570223+0300 | INFO | Backup scheduler started +2026-05-05T07:00:20.263695+0300 | INFO | Initializing bot... +2026-05-05T07:00:20.447752+0300 | INFO | Bot started +2026-05-05T07:00:20.447752+0300 | INFO | Backup scheduler started +2026-05-05T07:00:51.798725+0300 | DEBUG | Message from 25076348 in peer 2000000002: привет +2026-05-05T07:00:51.798725+0300 | INFO | Processed message from 25076348: привет +2026-05-05T07:00:51.798725+0300 | INFO | === HANDLE: user 25076348, text='привет', current state=None === +2026-05-05T07:00:51.799725+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-05T07:00:55.300865+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-05T07:00:55.300865+0300 | INFO | Processed message from 25076348: да +2026-05-05T07:00:55.301864+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-05T07:00:55.301864+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-05T07:01:00.589119+0300 | DEBUG | Message from 25076348 in peer 2000000002: ааа ппп +2026-05-05T07:01:00.589119+0300 | INFO | Processed message from 25076348: ааа ппп +2026-05-05T07:01:00.590119+0300 | INFO | === HANDLE: user 25076348, text='ааа ппп', current state=collect_fi === +2026-05-05T07:01:00.590119+0300 | INFO | Response to user 25076348: Укажите ваш возраст (целое число лет): +2026-05-05T07:01:03.015859+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-05T07:01:03.015859+0300 | INFO | Processed message from 25076348: 12 +2026-05-05T07:01:03.015859+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-05T07:01:03.016362+0300 | DEBUG | validate_age input: ''12'' +2026-05-05T07:01:03.016362+0300 | DEBUG | cleaned: '12' +2026-05-05T07:01:03.016362+0300 | DEBUG | age valid: 12 +2026-05-05T07:01:03.016362+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-05T07:01:04.461068+0300 | DEBUG | Message from 25076348 in peer 2000000002: 12 +2026-05-05T07:01:04.461068+0300 | INFO | Processed message from 25076348: 12 +2026-05-05T07:01:04.461068+0300 | INFO | === HANDLE: user 25076348, text='12', current state=collect_age === +2026-05-05T07:01:04.461068+0300 | DEBUG | validate_age input: ''12'' +2026-05-05T07:01:04.462069+0300 | DEBUG | cleaned: '12' +2026-05-05T07:01:04.462069+0300 | DEBUG | age valid: 12 +2026-05-05T07:01:04.462069+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-05T07:01:06.186293+0300 | DEBUG | Message from 25076348 in peer 2000000002: 31 +2026-05-05T07:01:06.186798+0300 | INFO | Processed message from 25076348: 31 +2026-05-05T07:01:06.186798+0300 | INFO | === HANDLE: user 25076348, text='31', current state=collect_age === +2026-05-05T07:01:06.186798+0300 | DEBUG | validate_age input: ''31'' +2026-05-05T07:01:06.186798+0300 | DEBUG | cleaned: '31' +2026-05-05T07:01:06.186798+0300 | DEBUG | age valid: 31 +2026-05-05T07:01:06.187799+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-05T07:01:08.344400+0300 | DEBUG | Message from 25076348 in peer 2000000002: 41 +2026-05-05T07:01:08.344400+0300 | INFO | Processed message from 25076348: 41 +2026-05-05T07:01:08.344400+0300 | INFO | === HANDLE: user 25076348, text='41', current state=collect_age === +2026-05-05T07:01:08.344400+0300 | DEBUG | validate_age input: ''41'' +2026-05-05T07:01:08.344400+0300 | DEBUG | cleaned: '41' +2026-05-05T07:01:08.344400+0300 | DEBUG | age valid: 41 +2026-05-05T07:01:08.345902+0300 | INFO | Response to user 25076348: Пожалуйста, введите число (ваш возраст). +Укажите ваш возраст (целое число лет): +2026-05-05T08:33:06.946148+0300 | INFO | Initializing bot... +2026-05-05T08:33:07.210217+0300 | INFO | Bot started +2026-05-05T08:33:07.211222+0300 | INFO | Backup scheduler started +2026-05-05T08:33:29.318049+0300 | DEBUG | Message from 25076348 in peer 2000000002: авсыв +2026-05-05T08:33:29.319049+0300 | INFO | Processed message from 25076348: авсыв +2026-05-05T08:33:29.319049+0300 | INFO | === HANDLE: user 25076348, text='авсыв', current state=None === +2026-05-05T08:33:29.321050+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-05T08:33:34.372307+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-05T08:33:34.372307+0300 | INFO | Processed message from 25076348: да +2026-05-05T08:33:34.372307+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-05T08:33:34.372307+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-05T08:33:39.200486+0300 | DEBUG | Message from 25076348 in peer 2000000002: аваыв павпва +2026-05-05T08:33:39.200486+0300 | INFO | Processed message from 25076348: аваыв павпва +2026-05-05T08:33:39.200486+0300 | INFO | === HANDLE: user 25076348, text='аваыв павпва', current state=collect_fi === +2026-05-05T08:33:39.201486+0300 | INFO | Response to user 25076348: 👪 Укажите, пожалуйста, контактные данные родителя или опекуна. Вы можете предоставить их сейчас или +2026-05-05T08:34:08.120612+0300 | DEBUG | Message from 25076348 in peer 2000000002: пропустить +2026-05-05T08:34:08.121613+0300 | INFO | Processed message from 25076348: пропустить +2026-05-05T08:34:08.121613+0300 | INFO | === HANDLE: user 25076348, text='пропустить', current state=ask_parent_data === +2026-05-05T08:34:08.121613+0300 | INFO | Parent data skipped for user 25076348 +2026-05-05T08:34:08.121613+0300 | INFO | Response to user 25076348: ✅ Данные родителя пропущены. Продолжаем оформление. +Отлично! Теперь укажите ваш номер телефона в фор +2026-05-05T08:34:34.619625+0300 | DEBUG | Message from 25076348 in peer 2000000002: +7(900)000-00-00 +2026-05-05T08:34:34.619625+0300 | INFO | Processed message from 25076348: +7(900)000-00-00 +2026-05-05T08:34:34.619625+0300 | INFO | === HANDLE: user 25076348, text='+7(900)000-00-00', current state=collect_phone === +2026-05-05T08:34:34.619625+0300 | INFO | Response to user 25076348: Укажите удобное время по МСК для звонка (например: 'завтра после 18', 'в среду утром', 'сегодня в 15 +2026-05-05T08:34:52.867495+0300 | DEBUG | Message from 25076348 in peer 2000000002: сегодня в 15:00 +2026-05-05T08:34:52.867495+0300 | INFO | Processed message from 25076348: сегодня в 15:00 +2026-05-05T08:34:52.867495+0300 | INFO | === HANDLE: user 25076348, text='сегодня в 15:00', current state=collect_time === +2026-05-05T08:34:54.349050+0300 | INFO | Response to user 25076348: Проверьте данные: +ФИ: аваыв павпва +Телефон: +7 (900) 000-00-00 +Время звонка по МСК: 05.05.2026 в 15: +2026-05-05T08:34:58.958675+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-05T08:34:58.958675+0300 | INFO | Processed message from 25076348: да +2026-05-05T08:34:58.958675+0300 | INFO | === HANDLE: user 25076348, text='да', current state=collect_time === +2026-05-05T08:34:59.533476+0300 | INFO | Response to user 25076348: Не удалось распознать время. Укажите, например: 'завтра после 18', 'в среду утром' или 'любое время' +2026-05-05T08:35:39.028252+0300 | DEBUG | Message from 25076348 in peer 2000000002: сегодня в 15:00 +2026-05-05T08:35:39.028252+0300 | INFO | Processed message from 25076348: сегодня в 15:00 +2026-05-05T08:35:39.028252+0300 | INFO | === HANDLE: user 25076348, text='сегодня в 15:00', current state=collect_time === +2026-05-05T08:35:39.030760+0300 | INFO | Response to user 25076348: Проверьте данные: +ФИ: аваыв павпва +Телефон: +7 (900) 000-00-00 +Время звонка по МСК: 05.05.2026 в 15: +2026-05-05T08:35:46.969686+0300 | DEBUG | Message from 25076348 in peer 2000000002: Да +2026-05-05T08:35:46.969686+0300 | INFO | Processed message from 25076348: Да +2026-05-05T08:35:46.970686+0300 | INFO | === HANDLE: user 25076348, text='Да', current state=collect_time === +2026-05-05T08:35:46.974190+0300 | INFO | Response to user 25076348: Не удалось распознать время. Укажите, например: 'завтра после 18', 'в среду утром' или 'любое время' +2026-05-05T09:07:22.565746+0300 | INFO | Initializing bot... +2026-05-05T09:07:22.797990+0300 | INFO | Bot started +2026-05-05T09:07:22.798991+0300 | INFO | Backup scheduler started +2026-05-05T09:07:25.017845+0300 | DEBUG | Message from 25076348 in peer 2000000002: авп +2026-05-05T09:07:25.017845+0300 | INFO | Processed message from 25076348: авп +2026-05-05T09:07:25.018845+0300 | INFO | === HANDLE: user 25076348, text='авп', current state=None === +2026-05-05T09:07:25.018845+0300 | INFO | Response to user 25076348: Пожалуйста, подтвердите согласие на обработку персональных данных (Да / Нет): +2026-05-05T09:07:29.353564+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-05T09:07:29.354564+0300 | INFO | Processed message from 25076348: да +2026-05-05T09:07:29.354564+0300 | INFO | === HANDLE: user 25076348, text='да', current state=ask_consent === +2026-05-05T09:07:29.354564+0300 | INFO | Response to user 25076348: ✅ Спасибо за согласие! Продолжим оформление заявки. +Пожалуйста, укажите вашу фамилию и имя (два слов +2026-05-05T09:07:36.689785+0300 | DEBUG | Message from 25076348 in peer 2000000002: ваыва авп +2026-05-05T09:07:36.689785+0300 | INFO | Processed message from 25076348: ваыва авп +2026-05-05T09:07:36.689785+0300 | INFO | === HANDLE: user 25076348, text='ваыва авп', current state=collect_fi === +2026-05-05T09:07:36.689785+0300 | INFO | Response to user 25076348: 👪 Укажите, пожалуйста, контактные данные родителя или опекуна. Вы можете предоставить их сейчас или +2026-05-05T09:07:47.065944+0300 | DEBUG | Message from 25076348 in peer 2000000002: пропустить +2026-05-05T09:07:47.065944+0300 | INFO | Processed message from 25076348: пропустить +2026-05-05T09:07:47.065944+0300 | INFO | === HANDLE: user 25076348, text='пропустить', current state=ask_parent_data === +2026-05-05T09:07:47.066947+0300 | INFO | Parent data skipped for user 25076348 +2026-05-05T09:07:47.066947+0300 | INFO | Response to user 25076348: ✅ Данные родителя пропущены. Продолжаем оформление. +Отлично! Теперь укажите ваш номер телефона в фор +2026-05-05T09:08:13.305154+0300 | DEBUG | Message from 25076348 in peer 2000000002: +7(999)000-00-00 +2026-05-05T09:08:13.306155+0300 | INFO | Processed message from 25076348: +7(999)000-00-00 +2026-05-05T09:08:13.306155+0300 | INFO | === HANDLE: user 25076348, text='+7(999)000-00-00', current state=collect_phone === +2026-05-05T09:08:13.306155+0300 | INFO | Response to user 25076348: Укажите удобное время по МСК для звонка (например: 'завтра после 18', 'в среду утром', 'сегодня в 15 +2026-05-05T09:08:26.129071+0300 | DEBUG | Message from 25076348 in peer 2000000002: сегодня в 15:00 +2026-05-05T09:08:26.130574+0300 | INFO | Processed message from 25076348: сегодня в 15:00 +2026-05-05T09:08:26.130574+0300 | INFO | === HANDLE: user 25076348, text='сегодня в 15:00', current state=collect_time === +2026-05-05T09:08:27.594462+0300 | INFO | Response to user 25076348: Проверьте данные: +ФИ: ваыва авп +Телефон: +7 (999) 000-00-00 +Время звонка по МСК: 05.05.2026 в 15:00 + +2026-05-05T09:08:31.676242+0300 | DEBUG | Message from 25076348 in peer 2000000002: [club233127658|@ikpro] ✅ Да +2026-05-05T09:08:31.677239+0300 | INFO | Processed message from 25076348: [club233127658|@ikpro] ✅ Да +2026-05-05T09:08:31.677239+0300 | INFO | === HANDLE: user 25076348, text='[club233127658|@ikpro] ✅ Да', current state=confirm === +2026-05-05T09:08:31.677239+0300 | INFO | Response to user 25076348: Пожалуйста, ответьте 'Да' или 'Нет'. +2026-05-05T09:08:38.235869+0300 | DEBUG | Message from 25076348 in peer 2000000002: да +2026-05-05T09:08:38.235869+0300 | INFO | Processed message from 25076348: да +2026-05-05T09:08:38.236871+0300 | INFO | === HANDLE: user 25076348, text='да', current state=confirm === +2026-05-05T09:08:38.653742+0300 | INFO | Lead saved for user 25076348 +2026-05-05T09:08:38.653742+0300 | INFO | Response to user 25076348: Спасибо! Ваши данные сохранены. Менеджер свяжется с вами в указанное время. Хорошего дня! 🌟 diff --git a/main.py b/main.py new file mode 100644 index 0000000..a6a44f2 --- /dev/null +++ b/main.py @@ -0,0 +1,28 @@ +from utils.logger import logger +from core.exporter import ExcelExporter +from core.fsm import DialogManager +from services.vk_bot import VKBot +from config import settings +from utils.middleware import logging_middleware + + +def main(): + """Точка входа в приложение""" + logger.info("Initializing bot...") + exporter = ExcelExporter(settings.LEADS_FILE) + fsm = DialogManager(exporter) + bot = VKBot(fsm, exporter) + + # Добавляем мидлвары (можно расширить) + bot.middlewares.add(logging_middleware) + + try: + bot.run() + except KeyboardInterrupt: + logger.info("Bot stopped") + except Exception as e: + logger.exception(f"Fatal error: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b746e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +vk_api==11.9.9 +pandas==2.2.0 +openpyxl==3.1.2 +python-dotenv==1.0.0 +loguru==0.7.2 +pydantic==2.5.3 +dateparser==1.2.0 +filelock==3.13.1 +schedule==1.2.0 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/__pycache__/__init__.cpython-311.pyc b/services/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d7eff79 Binary files /dev/null and b/services/__pycache__/__init__.cpython-311.pyc differ diff --git a/services/__pycache__/vk_bot.cpython-311.pyc b/services/__pycache__/vk_bot.cpython-311.pyc new file mode 100644 index 0000000..06b1cb7 Binary files /dev/null and b/services/__pycache__/vk_bot.cpython-311.pyc differ diff --git a/services/vk_bot.py b/services/vk_bot.py new file mode 100644 index 0000000..189b047 --- /dev/null +++ b/services/vk_bot.py @@ -0,0 +1,127 @@ +import vk_api +from vk_api.bot_longpoll import VkBotLongPoll, VkBotEventType +from vk_api.keyboard import VkKeyboard, VkKeyboardColor +from loguru import logger +from config import settings, phrases +from core.fsm import DialogManager +from core.exporter import ExcelExporter +from utils.backup import schedule_daily_backup +from utils.middleware import MiddlewareChain +from vk_api.exceptions import ApiError +import requests.exceptions + +class VKBot: + def __init__(self, fsm: DialogManager, exporter: ExcelExporter): + self.fsm = fsm + self.exporter = exporter + self.vk_session = vk_api.VkApi(token=settings.VK_TOKEN) + self.longpoll = VkBotLongPoll(self.vk_session, settings.VK_GROUP_ID) + self.vk = self.vk_session.get_api() + self.middlewares = MiddlewareChain() + + def _send_message(self, peer_id: int, text: str, keyboard=None): + """Отправка сообщения с возможной клавиатурой""" + try: + self.vk.messages.send( + peer_id=peer_id, + message=text, + random_id=0, + keyboard=keyboard.get_keyboard() if keyboard else None + ) + + except ApiError as e: + if e.code == 901: + logger.warning(f"Can't send to peer {peer_id}: {e}") + # Здесь можно отправить уведомление администратору или сохранить ID + else: + logger.error(f"VK API error {e.code} for peer {peer_id}: {e}") + raise + except Exception as e: + logger.error(f"Failed to send message to {peer_id}: {e}") + raise + + + def _get_keyboard_for_state(self, state): + """Генерирует клавиатуру в зависимости от состояния""" + keyboard = VkKeyboard(one_time=False) + if state == "ASK_CONSENT": # <-- новый блок + keyboard.add_button(phrases.BUTTON_CONSENT_YES, color=VkKeyboardColor.POSITIVE) + keyboard.add_button(phrases.BUTTON_CONSENT_NO, color=VkKeyboardColor.NEGATIVE) + elif state in ("CONFIRM", "confirm"): + keyboard.add_button(phrases.BUTTON_YES, color=VkKeyboardColor.POSITIVE) + keyboard.add_button(phrases.BUTTON_NO, color=VkKeyboardColor.NEGATIVE) + else: + keyboard.add_button(phrases.BUTTON_RESTART, color=VkKeyboardColor.SECONDARY) + return keyboard + + def _handle_command(self, user_id: int, text: str) -> bool: + """Обработка команд администрирования""" + if user_id not in settings.ADMIN_IDS: + return False + if text == phrases.CMD_STATUS: + self._send_message(user_id, "✅ Бот работает и принимает сообщения.") + return True + elif text == phrases.CMD_EXPORT: + path = self.exporter.export() + self._send_message(user_id, f"Экспорт создан: {path}") + return True + elif text == phrases.CMD_BACKUP: + self.exporter.backup() + self._send_message(user_id, "Резервная копия создана.") + return True + elif text == phrases.CMD_STATS: + stats = self.exporter.get_stats() + msg = (f"📊 Статистика:\nВсего лидов: {stats['total']}\n" + f"За сегодня: {stats['today']}\nСтатусы: {stats['statuses']}") + self._send_message(user_id, msg) + return True + elif text == phrases.CMD_RELOAD: + import importlib + import config.phrases + importlib.reload(config.phrases) + # Обновляем ссылку на phrases в текущем модуле + globals()['phrases'] = config.phrases + self._send_message(user_id, "Конфигурация перезагружена.") + return True + return False + + def run(self): + logger.info("Bot started") + schedule_daily_backup(self.exporter) + + while True: + try: + for event in self.longpoll.listen(): + try: + if event.type == VkBotEventType.MESSAGE_NEW and event.message: + user_id = event.message.from_id + peer_id = event.message.peer_id + text = event.message.text + logger.debug(f"Message from {user_id} in peer {peer_id}: {text}") + + if not self.middlewares.process(user_id, text): + continue + + if self._handle_command(user_id, text): + continue + + response = self.fsm.handle_message(user_id, text) + logger.info(f"Response to user {user_id}: {response[:100]}") + state = self.fsm.sessions.get(user_id, {}).get("state", "") + keyboard = self._get_keyboard_for_state(state) + self._send_message(peer_id, response, keyboard) + + except ApiError as e: + logger.error(f"VK API error while processing event: {e}") + except Exception as e: + logger.error(f"Error processing event: {e}", exc_info=True) + + except requests.exceptions.ConnectionError as e: + logger.warning(f"LongPoll connection lost: {e}. Reconnecting in 5 seconds...") + import time + time.sleep(5) + except Exception as e: + logger.error(f"Fatal error in longpoll loop: {e}", exc_info=True) + logger.warning("Reconnecting in 10 seconds...") + import time + time.sleep(10) diff --git a/start.py b/start.py new file mode 100644 index 0000000..817298f --- /dev/null +++ b/start.py @@ -0,0 +1,27 @@ +""" +Заглушка для проверки токена VK API. + +Перед использованием: +1. Скопируйте .env.example в .env +2. Заполните VK_TOKEN и VK_GROUP_ID в .env +3. Запустите: python start.py +""" +import os +from vk_api import VkApi +from dotenv import load_dotenv + +load_dotenv() + +token = os.getenv("VK_TOKEN") +if not token: + print("❌ Ошибка: VK_TOKEN не найден в .env файле!") + print("Скопируйте .env.example в .env и заполните данные.") + exit(1) + +vk_session = VkApi(token=token) +try: + perms = vk_session.get_api().account.getAppPermissions() + print(f"✅ Токен активен. Права: {perms}") + print("4096 = messages, 8192 = groups, 12288 = messages + groups") +except Exception as e: + print(f"❌ Ошибка проверки токена: {e}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_exporter.py b/tests/test_exporter.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_fsm.py b/tests/test_fsm.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..b8696ae --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,24 @@ +import pytest +from core.validator import validate_fio, validate_phone, parse_time + +def test_fio_valid(): + res = validate_fio("Иванов Иван Иванович") + assert res.is_valid + assert res.value == "Иванов Иван Иванович" + +def test_fio_invalid(): + res = validate_fio("Иванов Иван") + assert not res.is_valid + +def test_phone_valid(): + res = validate_phone("+7 (912) 345-67-89") + assert res.is_valid + assert "912" in res.value + +def test_phone_clean(): + res = validate_phone("89123456789") + assert res.is_valid + +def test_parse_time(): + res = parse_time("завтра в 15:00") + assert res.is_valid \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/__pycache__/__init__.cpython-311.pyc b/utils/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6f9db10 Binary files /dev/null and b/utils/__pycache__/__init__.cpython-311.pyc differ diff --git a/utils/__pycache__/backup.cpython-311.pyc b/utils/__pycache__/backup.cpython-311.pyc new file mode 100644 index 0000000..d75e1a8 Binary files /dev/null and b/utils/__pycache__/backup.cpython-311.pyc differ diff --git a/utils/__pycache__/logger.cpython-311.pyc b/utils/__pycache__/logger.cpython-311.pyc new file mode 100644 index 0000000..855a0d5 Binary files /dev/null and b/utils/__pycache__/logger.cpython-311.pyc differ diff --git a/utils/__pycache__/middleware.cpython-311.pyc b/utils/__pycache__/middleware.cpython-311.pyc new file mode 100644 index 0000000..bfdefce Binary files /dev/null and b/utils/__pycache__/middleware.cpython-311.pyc differ diff --git a/utils/backup.py b/utils/backup.py new file mode 100644 index 0000000..a3677b7 --- /dev/null +++ b/utils/backup.py @@ -0,0 +1,29 @@ +import schedule +import threading +import time +from loguru import logger + + +def schedule_daily_backup(exporter): + """Запускает ежесуточное резервное копирование в отдельном потоке""" + + def job(): + try: + exporter.backup() + logger.info("Daily backup executed successfully") + except Exception as e: + logger.error(f"Backup failed: {e}") + + schedule.every().day.at("03:00").do(job) + + def run_scheduler(): + while True: + try: + schedule.run_pending() + except Exception as e: + logger.error(f"Scheduler error: {e}") + time.sleep(30) # Уменьшил интервал с 60 до 30 секунд для более точного срабатывания + + thread = threading.Thread(target=run_scheduler, daemon=True) + thread.start() + logger.info("Backup scheduler started") diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..3c54b4e --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,17 @@ +from loguru import logger +import sys +from config import settings + +logger.remove() +logger.add( + sys.stdout, + format="{time:HH:mm:ss} | {level: <8} | {name} - {message}", + level=settings.LOG_LEVEL +) +logger.add( + "logs/bot.log", + rotation="1 day", + retention="30 days", + format="{time} | {level} | {message}", + level="DEBUG" +) \ No newline at end of file diff --git a/utils/middleware.py b/utils/middleware.py new file mode 100644 index 0000000..8ee32bd --- /dev/null +++ b/utils/middleware.py @@ -0,0 +1,71 @@ +from loguru import logger +from typing import Dict, List +from datetime import datetime, timedelta + + +class MiddlewareChain: + """Простая цепочка мидлваров (логирование, антиспам, аналитика)""" + def __init__(self): + self.middlewares: List[callable] = [] + + def add(self, middleware: callable): + self.middlewares.append(middleware) + + def process(self, user_id: int, text: str) -> bool: + for mw in self.middlewares: + if not mw(user_id, text): + return False + return True + + +# ==================== Антиспам-фильтр ==================== + +class SpamFilter: + """Простой антиспам-фильтр: блокирует пользователя, если он отправляет + более `max_messages` сообщений за `window_seconds` секунд.""" + + def __init__(self, max_messages: int = 5, window_seconds: int = 60): + self.max_messages = max_messages + self.window_seconds = window_seconds + # user_id -> [timestamp1, timestamp2, ...] + self._messages: Dict[int, List[float]] = {} + + def is_spam(self, user_id: int) -> bool: + now = datetime.now().timestamp() + window_start = now - self.window_seconds + + # Инициализируем список, если нет + if user_id not in self._messages: + self._messages[user_id] = [] + + # Удаляем старые записи за пределами окна + self._messages[user_id] = [ + ts for ts in self._messages[user_id] if ts > window_start + ] + + # Проверяем, не превышен ли лимит + if len(self._messages[user_id]) >= self.max_messages: + return True + + # Записываем текущее сообщение + self._messages[user_id].append(now) + return False + + def reset(self, user_id: int): + """Сброс счётчика для пользователя (например, при /start)""" + self._messages.pop(user_id, None) + + +# ==================== Логирование ==================== + +def logging_middleware(user_id: int, text: str) -> bool: + logger.info(f"Processed message from {user_id}: {text}") + return True + + +# ==================== Пример: мидлвар для аналитики ==================== + +def analytics_middleware(user_id: int, text: str) -> bool: + """Заглушка для аналитики — можно расширить позже.""" + logger.debug(f"Analytics: user {user_id} sent '{text}'") + return True