Initial commit: VK Sales Bot project structure

This commit is contained in:
User
2026-05-05 18:25:28 +03:00
commit 09c42edfdc
47 changed files with 1512 additions and 0 deletions
+8
View File
@@ -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
+8
View File
@@ -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
View File
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
+3
View File
@@ -0,0 +1,3 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system"
}
+6
View File
@@ -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"]
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+84
View File
@@ -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"
+25
View File
@@ -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
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
class ValidationError(Exception):
pass
class ExportError(Exception):
pass
+76
View File
@@ -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
+233
View File
@@ -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 "Пожалуйста, ответьте 'Да' (согласен) или 'Нет' (не согласен)."
+20
View File
@@ -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
+52
View File
@@ -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', 'в среду утром' или 'любое время'.")
+15
View File
@@ -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)"
BIN
View File
Binary file not shown.
+30
View File
@@ -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
+22
View File
@@ -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` существует и доступна для записи.
- Бот не видит сообщения: группа должна быть публичной или добавьте бота в беседу.
+590
View File
@@ -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(
│ └ <function HTTPConnectionPool._make_request at 0x000001F29A685300>
└ <urllib3.connectionpool.HTTPSConnectionPool object at 0x000001F29A814050>
File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 534, in _make_request
response = conn.getresponse()
│ └ <function HTTPConnection.getresponse at 0x000001F29A6551C0>
└ <HTTPSConnection(host='lp.vk.com', port=443) at 0x1f29a814d10>
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()
│ └ <function HTTPResponse.begin at 0x000001F29A55B4C0>
└ <http.client.HTTPResponse object at 0x000001F29A4FF4F0>
File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 318, in begin
version, status, reason = self._read_status()
│ └ <function HTTPResponse._read_status at 0x000001F29A55B420>
└ <http.client.HTTPResponse object at 0x000001F29A4FF4F0>
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"
└ <class 'http.client.RemoteDisconnected'>
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(
│ └ <function HTTPConnectionPool.urlopen at 0x000001F29A6854E0>
└ <urllib3.connectionpool.HTTPSConnectionPool object at 0x000001F29A814050>
File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 841, in urlopen
retries = retries.increment(
│ └ <function Retry.increment at 0x000001F29A46DEE0>
└ 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)
│ │ │ └ <traceback object at 0x000001F298640380>
│ │ └ ProtocolError('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
│ └ ProtocolError('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
└ <function reraise at 0x000001F29A46C5E0>
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(
│ └ <function HTTPConnectionPool._make_request at 0x000001F29A685300>
└ <urllib3.connectionpool.HTTPSConnectionPool object at 0x000001F29A814050>
File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\urllib3\connectionpool.py", line 534, in _make_request
response = conn.getresponse()
│ └ <function HTTPConnection.getresponse at 0x000001F29A6551C0>
└ <HTTPSConnection(host='lp.vk.com', port=443) at 0x1f29a814d10>
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()
│ └ <function HTTPResponse.begin at 0x000001F29A55B4C0>
└ <http.client.HTTPResponse object at 0x000001F29A4FF4F0>
File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\http\client.py", line 318, in begin
version, status, reason = self._read_status()
│ └ <function HTTPResponse._read_status at 0x000001F29A55B420>
└ <http.client.HTTPResponse object at 0x000001F29A4FF4F0>
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"
└ <class 'http.client.RemoteDisconnected'>
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 <module>
main()
└ <function main at 0x000001F2FEC99EE0>
> File "m:\bot_vk_ikp\vk-sales-bot\main.py", line 20, in main
bot.run()
│ └ <function VKBot.run at 0x000001F29A7D77E0>
└ <services.vk_bot.VKBot object at 0x000001F29A6BA5D0>
File "m:\bot_vk_ikp\vk-sales-bot\services\vk_bot.py", line 90, in run
for event in self.longpoll.listen():
│ │ │ └ <function VkBotLongPoll.listen at 0x000001F29A7D4180>
│ │ └ <vk_api.bot_longpoll.VkBotLongPoll object at 0x000001F29A7AF160>
│ └ <services.vk_bot.VKBot object at 0x000001F29A6BA5D0>
└ <<class 'vk_api.bot_longpoll.VkBotMessageEvent'>({'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():
│ │ └ <function VkBotLongPoll.check at 0x000001F29A7D40E0>
│ └ <vk_api.bot_longpoll.VkBotLongPoll object at 0x000001F29A7AF160>
└ <<class 'vk_api.bot_longpoll.VkBotMessageEvent'>({'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(
│ └ <member 'session' of 'VkBotLongPoll' objects>
└ <vk_api.bot_longpoll.VkBotLongPoll object at 0x000001F29A7AF160>
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'
│ └ <function Session.request at 0x000001F29A7BF4C0>
└ <requests.sessions.Session object at 0x000001F29A7E9E90>
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}
│ │ └ <PreparedRequest [GET]>
│ └ <function Session.send at 0x000001F29A7BF9C0>
└ <requests.sessions.Session object at 0x000001F29A7E9E90>
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}
│ │ └ <PreparedRequest [GET]>
│ └ <function HTTPAdapter.send at 0x000001F29A7BED40>
└ <requests.adapters.HTTPAdapter object at 0x000001F29A7EA390>
File "C:\Users\User\AppData\Local\Programs\Python\Python311\Lib\site-packages\requests\adapters.py", line 660, in send
raise ConnectionError(err, request=request)
│ └ <PreparedRequest [GET]>
└ <class 'requests.exceptions.ConnectionError'>
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: Спасибо! Ваши данные сохранены. Менеджер свяжется с вами в указанное время. Хорошего дня! 🌟
+28
View File
@@ -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()
+9
View File
@@ -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
View File
Binary file not shown.
Binary file not shown.
+127
View File
@@ -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)
+27
View File
@@ -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}")
View File
View File
View File
+24
View File
@@ -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
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -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")
+17
View File
@@ -0,0 +1,17 @@
from loguru import logger
import sys
from config import settings
logger.remove()
logger.add(
sys.stdout,
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan> - {message}",
level=settings.LOG_LEVEL
)
logger.add(
"logs/bot.log",
rotation="1 day",
retention="30 days",
format="{time} | {level} | {message}",
level="DEBUG"
)
+71
View File
@@ -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