Files
bot_vk_ikp_prodagi/bot.py
T
Kenik 7a59b035ce Воронка БАЗА: приведение к ТЗ (метки, сохранённые данные, тексты, шаги)
Закрыты расхождения с ТЗ:
- Метки (§12) переименованы под спецификацию: role_player, game_dota2, format_help,
  interest_trial, interest_summer_intensive, manager_requested, source_main_menu,
  source_keyword_summer; убраны служебные age_*/level_* метки.
- Сохранённые данные (§13): кодовое слово, дата запуска бота (started_at), дата
  передачи менеджеру (manager_at), интересы (пробное/интенсив). Карточка менеджеру
  дополнена этими полями, заголовок «Новый запрос из бота».
- «Пока не знаю, нужен совет» (§7): показ собранных данных + свободный текст → менеджеру.
- «Задать вопрос» (§9): промпт «напишите одним сообщением» → BAZA_QUESTION → передача.
- Летний интенсив: свой набор уровней (§10.3), экран младше-12 (Передать менеджеру/
  Другие тренировки), двухшаговая оплата (Оплатить интенсив → ссылка) (§10.4),
  различение запуска по кодовому слову vs меню.
- Индивидуальная ветка без «пробного» (§6). Fallback-текст дословно по §15.
- «Выбрать абонемент» ведёт на страницу тарифов ссылкой (§8). Тексты веток — по ТЗ.

Тесты воронки обновлены и дополнены (интенсив-оплата, младше-12 интенсив, свободный
вопрос, «нужен совет»). pytest 30 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:55:34 +03:00

1637 lines
91 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import re
import time
import logging
import threading
from collections import defaultdict
from typing import Dict, Any, Optional
# Нам понадобятся библиотеки vk_api.
try:
import vk_api
from vk_api.bot_longpoll import VkBotLongPoll, VkBotEventType
from vk_api.keyboard import VkKeyboard, VkKeyboardColor
from vk_api.utils import get_random_id
except ImportError:
vk_api = None
from database import DatabaseManager
import config
import mailer
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - [%(levelname)s] - %(name)s - %(message)s"
)
logger = logging.getLogger("VK_Bot_IKP")
# Инициализация базы данных сессий
db = DatabaseManager()
# Анти-флуд: максимум сообщений от одного пользователя за окно времени
FLOOD_MAX_MSGS = 12
FLOOD_WINDOW_SEC = 10
# Кулдаун между уведомлениями оператора о заявке/отклике от одного пользователя (сек)
NOTIFY_COOLDOWN_SEC = 120
# Временный бан при повторном флуде: после BAN_STRIKES срабатываний за BAN_WINDOW_SEC — бан на BAN_SEC
BAN_STRIKES = 3
BAN_WINDOW_SEC = 60
BAN_SEC = 600
# Сколько секунд кэшировать список рантайм-админов из БД
ADMINS_CACHE_TTL = 30
from keyboards import * # все get_*_keyboard
# Хэндл lock-файла держим в глобали на весь процесс — пока он открыт, блокировка жива.
_INSTANCE_LOCK_HANDLE = None
def _acquire_single_instance_lock() -> bool:
"""Не даёт запустить второй экземпляр бота (два параллельных longpoll ломают UX).
Берём неблокирующую эксклюзивную блокировку на bot.lock в каталоге скрипта.
Кросс-платформенно: msvcrt на Windows, fcntl на POSIX. Если уже заблокирован
другим процессом — возвращаем False (выживает уже запущенный инстанс).
"""
global _INSTANCE_LOCK_HANDLE
import os
lock_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bot.lock")
handle = None
try:
handle = open(lock_path, "w")
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
if handle is not None:
handle.close() # лок занят (или сбой) — не оставляем хэндл открытым
return False
_INSTANCE_LOCK_HANDLE = handle # держим открытым до конца процесса
return True
# ==========================================
# КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ
# ==========================================
class VKBotApplication:
def __init__(self):
self.vk_session = None
self.vk = None
self.longpoll = None
# Тексты рассылок, ожидающих подтверждения админом: {admin_id: text}
self._pending_broadcasts: Dict[int, str] = {}
# Контекст админских мастеров (что редактируем): {admin_id: {...}}
self._admin_ctx: Dict[int, dict] = {}
# Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}}
self._photo_cache: Dict[str, Dict[str, str]] = {}
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
self._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда
self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен
self._last_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора
self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда
# Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение
self._admins_cache: Optional[set] = None
self._admins_cache_ts: float = 0.0
if vk_api and config.VK_TOKEN != "your_vk_community_token_here":
try:
self.vk_session = vk_api.VkApi(token=config.VK_TOKEN)
self.vk = self.vk_session.get_api()
self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID)
logger.info("Успешное подключение к VK API!")
# Предзагрузка картинок в фоне, чтобы карусель не тормозила при первом показе
threading.Thread(target=self._prewarm_media, daemon=True).start()
except Exception as e:
logger.error(f"Ошибка авторизации в VK API: {e}")
def send_message(self, peer_id: int, message: str, keyboard: Optional[str] = None,
attachment: Optional[str] = None, template: Optional[str] = None):
"""Отправка сообщения пользователю с сохранением в лог."""
db.log_message(peer_id, "bot", message)
if self.vk:
try:
params = {
"peer_id": peer_id,
"message": message,
"random_id": get_random_id()
}
if keyboard:
params["keyboard"] = keyboard
if attachment:
params["attachment"] = attachment
if template:
params["template"] = template
self.vk.messages.send(**params)
except Exception as e:
logger.error(f"Не удалось отправить сообщение пользователю {peer_id}: {e}")
else:
attachment_str = f", Attachment: {attachment}" if attachment else ""
tmpl_str = ", Carousel: Да" if template else ""
logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str}{tmpl_str})")
def _is_flooding(self, user_id: int) -> bool:
"""True, если пользователь шлёт сообщения слишком часто (защита от флуда/DDoS)."""
now = time.time()
times = [t for t in self._msg_times[user_id] if now - t < FLOOD_WINDOW_SEC]
times.append(now)
self._msg_times[user_id] = times
return len(times) > FLOOD_MAX_MSGS
def _is_super_admin(self, user_id: int) -> bool:
"""Супер-админ (определён только в config.SUPER_ADMIN_IDS, меняется в коде)."""
return user_id in getattr(config, "SUPER_ADMIN_IDS", [])
def _runtime_admins(self) -> set:
"""Кэшированный набor админов, добавленных в рантайме (из БД).
Перечитываем из SQLite не чаще раза в ADMINS_CACHE_TTL — _is_admin вызывается
многократно на каждое сообщение, и прямой запрос каждый раз был бы расточителен.
Кэш инвалидируется при добавлении/удалении админа.
"""
now = time.time()
if self._admins_cache is None or now - self._admins_cache_ts > ADMINS_CACHE_TTL:
try:
self._admins_cache = set(db.get_admins())
except Exception:
self._admins_cache = set()
self._admins_cache_ts = now
return self._admins_cache
def _is_admin(self, user_id: int) -> bool:
"""Админ: супер-админ, либо из config.ADMIN_IDS, либо добавленный в рантайме."""
if self._is_super_admin(user_id):
return True
if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS:
return True
return user_id in self._runtime_admins()
def _operator_ids(self) -> list:
"""Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID."""
ids = getattr(config, "OPERATOR_PEER_IDS", None)
return ids if ids else [config.OPERATOR_PEER_ID]
def _notify_operators(self, message: str):
"""Отправляет сообщение всем операторам из списка."""
for op_id in self._operator_ids():
self.send_message(op_id, message)
def _vk_name(self, user_id: int) -> Optional[str]:
"""Реальное имя пользователя из VK (для сверки с введёнными данными)."""
if not self.vk or user_id <= 0:
return None
try:
info = self.vk.users.get(user_ids=user_id)
if info:
u = info[0]
full = f"{u.get('first_name', '')} {u.get('last_name', '')}".strip()
return full or None
except Exception as e:
logger.error(f"Не удалось получить имя VK для {user_id}: {e}")
return None
def _resolve_vk_user_id(self, raw: str) -> Optional[int]:
"""Из ссылки / короткого имени / ID получает числовой VK user_id (или None).
Принимает что угодно: https://vk.com/gruzdevas, vk.com/id123, @gruzdevas,
gruzdevas, 123456. Короткое имя резолвится через VK API.
"""
s = (raw or "").strip()
s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол
s = re.sub(r"^(m\.)?vk\.com/", "", s, flags=re.I) # убираем домен
s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент
if not s:
return None
m = re.match(r"^id(\d+)$", s, flags=re.I) # id123456
if m:
return int(m.group(1))
if s.isdigit(): # чистое число
return int(s)
if self.vk: # короткое имя → через API
try:
info = self.vk.users.get(user_ids=s)
if info:
return int(info[0]["id"])
except Exception as e:
logger.error(f"Не удалось определить VK id по '{s}': {e}")
return None
def _within_work_hours(self) -> bool:
"""True, если сейчас рабочее время (config.WORK_HOUR_START..WORK_HOUR_END)."""
from datetime import datetime
h = datetime.now().hour
return getattr(config, "WORK_HOUR_START", 9) <= h < getattr(config, "WORK_HOUR_END", 21)
def _send_admin_panel(self, user_id: int):
"""Главный экран админ-панели с кнопками разделов."""
is_super = self._is_super_admin(user_id)
extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else ""
self.send_message(
user_id,
"⚙️ Админ-панель Кибершколы БАЗА\n\n"
"Выберите раздел на кнопках ниже:\n"
"• 📊 Статистика — заявки и диалоги\n"
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
"• 📣 Рассылка — сообщение подписчикам"
+ extra,
keyboard=get_admin_keyboard(is_super=is_super)
)
def _send_admins_panel(self, user_id: int):
"""Раздел управления администраторами (только супер-админ)."""
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Раздел доступен только супер-админу.")
return
runtime = db.get_admins()
supers = getattr(config, "SUPER_ADMIN_IDS", [])
cfg = [a for a in getattr(config, "ADMIN_IDS", []) if a not in supers]
lines = "👑 Супер-админы (меняются только в коде):\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in supers) or "\n")
lines += "\n🛡 Админы из .env (ADMIN_IDS):\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in cfg) or "\n")
lines += "\n➕ Добавленные из бота:\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in runtime) or "\n")
kb = VkKeyboard(inline=True)
kb.add_button(" Добавить админа", color=VkKeyboardColor.PRIMARY)
kb.add_line()
kb.add_button("🗑 Удалить админа", color=VkKeyboardColor.NEGATIVE)
self.send_message(
user_id,
"👑 Управление администраторами\n\n"
f"{lines}\n"
"Управляйте кнопками ниже 👇\n\n"
"ℹ️ Сменить/добавить СУПЕР-АДМИНА можно только в коде: откройте config.py, "
"впишите VK ID в список SUPER_ADMIN_IDS и перезапустите бота.",
keyboard=kb.get_keyboard()
)
def _handle_admin_add_admin(self, user_id: int, text: str):
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Добавлять админов может только супер-админ.")
return
raw = text
if text.lower().startswith("админ доб"):
raw = text[len("админ доб"):].strip()
new_id = self._resolve_vk_user_id(raw)
if not new_id:
self.send_message(
user_id,
"⚠️ Не смог определить пользователя.\n"
"Пришлите ссылку на страницу VK (например, https://vk.com/gruzdevas), "
"короткое имя (gruzdevas) или числовой ID."
)
return
if db.add_admin(new_id):
self._admins_cache = None # инвалидируем кэш админов
self.send_message(user_id, f"✅ Пользователь vk.com/id{new_id} теперь администратор.")
try:
self.send_message(new_id, "🛡 Вам выданы права администратора бота ИКП. "
"Откройте меню — появится кнопка «Админка ⚙️».")
except Exception:
pass
else:
self.send_message(user_id, f"ℹ️ Пользователь vk.com/id{new_id} уже администратор.")
self._send_admins_panel(user_id)
def _handle_admin_remove_admin(self, user_id: int, text: str):
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Удалять админов может только супер-админ.")
return
raw = text
if text.lower().startswith("админ удал"):
raw = text[len("админ удал"):].strip()
rem_id = self._resolve_vk_user_id(raw)
if not rem_id:
self.send_message(user_id, "⚠️ Не смог определить пользователя. Пришлите ссылку/имя/ID.")
return
if db.remove_admin(rem_id):
self._admins_cache = None # инвалидируем кэш админов
self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.")
else:
self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота "
"(админов из .env и супер-админов отсюда снять нельзя).")
self._send_admins_panel(user_id)
def _cleanup_antiflood(self):
"""Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно."""
now = time.time()
if now - self._last_antiflood_cleanup < BAN_SEC:
return
self._last_antiflood_cleanup = now
# Сообщения/страйки старше своих окон бесполезны — удаляем пустые ключи
for uid in [u for u, ts in self._msg_times.items()
if not ts or now - ts[-1] > FLOOD_WINDOW_SEC]:
del self._msg_times[uid]
for uid in [u for u, ts in self._flood_strikes.items()
if not ts or now - ts[-1] > BAN_WINDOW_SEC]:
del self._flood_strikes[uid]
for uid in [u for u, t in self._banned_until.items() if t <= now]:
del self._banned_until[uid]
for uid in [u for u, t in self._flood_warned.items() if now - t > FLOOD_WINDOW_SEC]:
del self._flood_warned[uid]
for uid in [u for u, t in self._last_notify.items() if now - t > NOTIFY_COOLDOWN_SEC]:
del self._last_notify[uid]
def _notify_allowed(self, user_id: int) -> bool:
"""Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе."""
now = time.time()
if now - self._last_notify.get(user_id, 0) < NOTIFY_COOLDOWN_SEC:
return False
self._last_notify[user_id] = now
return True
def handle_message(self, user_id: int, text: str, attachments: Optional[list] = None):
"""Маршрутизатор входящих сообщений на основе состояний пользователя."""
text_clean = text.strip()
text_lower = text_clean.lower()
attachments = attachments or []
# Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
self._cleanup_antiflood()
# Анти-флуд + временный бан (админы — исключение)
if not self._is_admin(user_id):
now = time.time()
# Уже забанен — молча игнорируем до конца бана
if self._banned_until.get(user_id, 0) > now:
return
if self._is_flooding(user_id):
# Фиксируем срабатывание флуда; при повторах — временный бан
strikes = [t for t in self._flood_strikes[user_id] if now - t < BAN_WINDOW_SEC]
strikes.append(now)
self._flood_strikes[user_id] = strikes
if len(strikes) >= BAN_STRIKES:
self._banned_until[user_id] = now + BAN_SEC
self._flood_strikes[user_id] = []
logger.warning(f"Временный бан пользователя {user_id} на {BAN_SEC} с (повторный флуд)")
self.send_message(
user_id,
f"🚫 Слишком много сообщений. Доступ к боту временно ограничен на {BAN_SEC // 60} минут."
)
return
# Иначе — мягкое предупреждение максимум раз за окно
if now - self._flood_warned.get(user_id, 0) > FLOOD_WINDOW_SEC:
self._flood_warned[user_id] = now
logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются")
self.send_message(
user_id,
"⏳ Слишком много сообщений подряд. Пожалуйста, подождите немного."
)
return
# Логируем входящее сообщение от пользователя
db.log_message(user_id, "user", text_clean)
# Получаем текущее состояние и заполненные данные
user_data = db.get_user_data(user_id)
state = user_data["state"]
# Глобальная обработка админ-команд и кнопок (только для ADMIN_IDS)
if self._is_admin(user_id):
# Мастера админки (ожидают ввод текста/выбор/фото) — ловим по состоянию
if state.startswith("ADMIN_"):
self._handle_admin_wizard(user_id, text_clean, text_lower, state, attachments)
return
# Инлайн-кнопки предпросмотра рассылки
if user_id in self._pending_broadcasts and text_lower in ["отправить ✅", "отправить", "отмена ❌", "отмена"]:
self._handle_admin_broadcast(user_id, "рассылка отмена" if "отмен" in text_lower else "рассылка подтвердить")
return
if text_lower in ["админка", "админка ⚙️", "/admin"]:
self._send_admin_panel(user_id)
return
# Кнопки-действия раздела «Быстрые ответы»
elif text_lower in [" добавить ответ", "добавить ответ"]:
db.update_user_state(user_id, "ADMIN_FAQ_ADD")
self.send_message(user_id, "➕ Новый быстрый ответ. Отправьте: ключевое слово | текст ответа\n"
"Пример: расписание | Тренировки ежедневно с 10:00 до 22:00.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["✏️ изменить ответ", "изменить ответ"]:
items = [(i, kw) for i, kw, _ in db.get_custom_faq()]
db.update_user_state(user_id, "ADMIN_FAQ_EDIT_PICK")
self.send_message(user_id, "✏️ Какой ответ изменить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🗑 удалить ответ", "удалить ответ"]:
items = [(i, kw) for i, kw, _ in db.get_custom_faq()]
db.update_user_state(user_id, "ADMIN_FAQ_DEL_PICK")
self.send_message(user_id, "🗑 Какой ответ удалить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["в админку ↩️", "в админку"]:
self._send_admin_panel(user_id)
return
elif text_lower in ["статистика 📊", "стата", "статистика", "/stats"]:
self._handle_admin_stats(user_id)
return
elif text_lower in ["рассылка 📣", "рассылка"]:
# Кнопка без текста — показываем инструкцию
self._handle_admin_broadcast(user_id, "рассылка")
return
elif text_lower.startswith("рассылка"):
self._handle_admin_broadcast(user_id, text_clean)
return
elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]:
self._send_faq_admin(user_id)
return
elif text_lower.startswith("фак доб "):
self._handle_faq_add(user_id, text_clean)
return
elif text_lower.startswith("фак удал "):
self._handle_faq_delete(user_id, text_clean)
return
elif text_lower in ["администраторы 👑", "администраторы", "админы"]:
self._send_admins_panel(user_id)
return
elif text_lower in [" добавить админа", "добавить админа"]:
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Только супер-админ.")
return
db.update_user_state(user_id, "ADMIN_ADMIN_ADD")
self.send_message(user_id, "➕ Пришлите ссылку на страницу нового администратора в VK\n"
"(например, https://vk.com/gruzdevas). Можно также короткое имя "
"или числовой ID — бот сам разберётся.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["🗑 удалить админа", "удалить админа"]:
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Только супер-админ.")
return
runtime = db.get_admins()
if not runtime:
self.send_message(user_id, "Список добавленных из бота админов пуст.")
self._send_admins_panel(user_id)
return
db.update_user_state(user_id, "ADMIN_ADMIN_DEL_PICK")
self.send_message(user_id, "🗑 Кого удалить из админов? Выберите:",
keyboard=get_pick_keyboard([(a, f"vk.com/id{a}") for a in runtime]))
return
elif text_lower.startswith("админ доб"):
self._handle_admin_add_admin(user_id, text_clean)
return
elif text_lower.startswith("админ удал"):
self._handle_admin_remove_admin(user_id, text_clean)
return
elif text_lower in ["в главное меню ↩️"]:
self.send_message(
user_id, "🏠 Главное меню.",
keyboard=get_main_keyboard(is_admin=True)
)
return
# Глобальная отписка от рассылки (доступна в любом состоянии)
if text_lower in ["отписаться от рассылки 🔕", "отписаться от рассылки", "отписаться"]:
db.update_user_field(user_id, "consent_2", "Не согласен")
self.send_message(
user_id,
"🔕 Вы отписаны от рассылки. Рекламные сообщения больше присылать не будем. "
"Это не влияет на ваши заявки и поддержку."
)
return
logger.info(f"User {user_id} in state {state} sent: {text_clean}")
# Маршрутизация по состояниям (FSM)
if state == "FREE_CHAT":
self._handle_free_chat(user_id, text_clean, text_lower)
# --- Воронка Кибершколы БАЗА ---
elif state == "BAZA_FORWHO":
self._handle_baza_forwho(user_id, text_clean, text_lower)
elif state == "BAZA_AGE":
self._handle_baza_age(user_id, text_clean, text_lower)
elif state == "BAZA_YOUNG":
self._handle_baza_young(user_id, text_clean, text_lower)
elif state == "BAZA_GAME":
self._handle_baza_game(user_id, text_clean, text_lower)
elif state == "BAZA_GAME_OTHER":
self._handle_baza_game_other(user_id, text_clean, text_lower)
elif state == "BAZA_LEVEL":
self._handle_baza_level(user_id, text_clean, text_lower)
elif state == "BAZA_FORMAT":
self._handle_baza_format(user_id, text_clean, text_lower)
elif state == "BAZA_GROUP":
self._handle_baza_group(user_id, text_clean, text_lower)
elif state == "BAZA_INDIVIDUAL":
self._handle_baza_individual(user_id, text_clean, text_lower)
elif state == "BAZA_HELP":
self._handle_baza_help(user_id, text_clean, text_lower)
elif state == "BAZA_PAY":
self._handle_baza_pay(user_id, text_clean, text_lower)
elif state == "BAZA_INT_INFO":
self._handle_baza_int_info(user_id, text_clean, text_lower)
elif state == "BAZA_INT_AGE":
self._handle_baza_int_age(user_id, text_clean, text_lower)
elif state == "BAZA_INT_YOUNG":
self._handle_baza_int_young(user_id, text_clean, text_lower)
elif state == "BAZA_INT_LEVEL":
self._handle_baza_int_level(user_id, text_clean, text_lower)
elif state == "BAZA_INT_PAY_INTRO":
self._handle_baza_int_pay_intro(user_id, text_clean, text_lower)
elif state == "BAZA_QUESTION":
self._handle_baza_question(user_id, text_clean, text_lower)
elif state == "OPERATOR_CHAT":
self._handle_operator_chat(user_id, text_clean, text_lower)
else:
# Предотвращение неопределенного состояния
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# ------------------------------------------
# ОБРАБОТЧИКИ СОСТОЯНИЙ (FSM)
# ------------------------------------------
def _handle_free_chat(self, user_id: int, text: str, text_lower: str):
"""Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос."""
# 1. Подобрать тренировку — запуск воронки
if text_lower in ["подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
self._baza_start_funnel(user_id)
return
# 2. Летний интенсив CS2 (кнопка меню)
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
self._baza_start_intensive(user_id, via_keyword=False)
return
# 2.1 Кодовые слова рекламной кампании (лето/интенсив и т.п.) — сразу в ветку интенсива
if any(t in text_lower for t in config.SUMMER_TRIGGERS):
self._baza_start_intensive(user_id, via_keyword=True)
return
# 3. Посмотреть цены
if text_lower in ["посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
self._baza_show_prices(user_id)
return
# 3.1 Кнопки под ценами
if text_lower in ["записаться на пробное", "пробное", "пробное занятие"]:
db.add_metka(user_id, "interest_trial")
self._baza_send_payment(user_id, "trial", "Пробное занятие")
return
if text_lower in ["выбрать абонемент", "абонемент", "абонементы"]:
# §8: кнопка ведёт на страницу с тарифами
self._baza_send_openlink(user_id, "tariffs",
"Открываю страницу с тарифами — там можно выбрать и оплатить абонемент 👇",
"Открыть тарифы 💳")
return
# 4. Задать вопрос — просим написать вопрос одним сообщением (§9)
if text_lower in ["задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬",
"оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]:
self._baza_ask_question(user_id)
return
# 5. Площадки — кнопки-ссылки
if text_lower in ["наши соцсети 🔗", "наши соцсети", "соцсети", "площадки", "ссылки"]:
self.send_message(
user_id,
"🔗 Мы на площадках Кибершколы БАЗА — заходите и подписывайтесь 👇",
keyboard=get_socials_keyboard()
)
return
# 6. Возврат в главное меню
if text_lower in ["назад в меню ↩️", "меню", "назад", "в главное меню ↩️", "главное меню"]:
self.send_message(
user_id,
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
return
# 7. Приветствие (+ основные триггеры запуска)
if text_lower in ["начать", "привет", "здравствуйте", "ку", "hi", "hello", "старт"] \
or any(t in text_lower for t in config.MAIN_TRIGGERS):
self._baza_send_greeting(user_id)
return
# 8. Быстрые ответы по ключевым словам (редактируются админом)
quick = db.match_custom_faq(text_lower)
if quick:
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
return
# 9. Пасхалка автора (запрет на удаление)
if text_lower in ["/favi", "/Favi", "/FAVI"]:
self.send_message(
user_id,
"Пасихалка для знающих\n\n"
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
"Желаю хороших впечатлений! От этого бота\n\n"
"https://vk.com/favilonia",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
)
return
# 10. Fallback — не поняли запрос (§15, дословно)
self.send_message(
user_id,
"Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# ==========================================================
# ВОРОНКА КИБЕРШКОЛЫ БАЗА (подбор тренировки, интенсив, оплата)
# ==========================================================
def _utm(self, url: str, campaign: str = "main") -> str:
"""Добавляет UTM-метку к ссылке (main — обычные, summer — интенсив)."""
utm = config.UTM_SUMMER if campaign == "summer" else config.UTM_MAIN
sep = "&" if "?" in url else "?"
return f"{url}{sep}{utm}"
def _baza_send_openlink(self, user_id: int, link_key: str, message: str, button: str,
campaign: str = "main"):
"""Отправляет сообщение с инлайн-кнопкой-ссылкой (без перевода на оплату/лид)."""
link = self._utm(config.LINKS.get(link_key, config.LINKS["tariffs"]), campaign)
kb = VkKeyboard(inline=True)
kb.add_openlink_button(button, link)
self.send_message(user_id, message, keyboard=kb.get_keyboard())
def _baza_started(self, user_id: int):
"""Фиксирует факт запуска бота: метка bot_started + дата первого запуска (один раз)."""
db.add_metka(user_id, "bot_started")
if not db.get_profile(user_id).get("started_at"):
from datetime import datetime
db.set_profile_field(user_id, "started_at", datetime.now().strftime("%Y-%m-%d %H:%M"))
def _baza_send_greeting(self, user_id: int):
self._baza_started(user_id)
self.send_message(
user_id,
config.GREETING_BAZA + "\n\nВыберите действие на кнопках ниже 👇",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
attachment=self.get_logo_attachment(user_id)
)
def _baza_nav(self, user_id: int, text_lower: str) -> bool:
"""Общие кнопки внутри воронки: меню / вопрос менеджеру. True — если обработали."""
if text_lower in ["в главное меню ↩️", "главное меню", "меню", "в меню", "выход",
"вернуться в меню ↩️", "вернуться в меню", "посмотреть другие тренировки"]:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
return True
if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру",
"задать вопрос 💬", "задать вопрос", "сначала задать вопрос 💬", "сначала задать вопрос",
"менеджер", "оператор"]:
self._baza_ask_question(user_id)
return True
return False
def _baza_start_funnel(self, user_id: int):
"""Старт воронки «Подобрать тренировку»."""
self._baza_started(user_id)
db.set_profile_field(user_id, "source", "Основное меню")
db.add_metka(user_id, "source_main_menu")
db.update_user_state(user_id, "BAZA_FORWHO")
self.send_message(
user_id,
"Отлично! Задам пару коротких вопросов и подберу подходящий формат 🎯\n\n"
"Для кого подбираем тренировки?",
keyboard=get_baza_forwho_keyboard()
)
def _handle_baza_forwho(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower in ["для себя", "себе", "для меня"]:
db.set_profile_field(user_id, "role", "Игрок (пишет сам)")
db.add_metka(user_id, "role_player")
elif text_lower in ["для подростка", "для ребёнка", "для ребенка", "для сына", "для дочери", "для подростка "]:
db.set_profile_field(user_id, "role", "Родитель (пишет за подростка)")
db.add_metka(user_id, "role_parent")
else:
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_forwho_keyboard())
return
db.update_user_state(user_id, "BAZA_AGE")
self.send_message(user_id, "Сколько лет будущему ученику?", keyboard=get_baza_age_keyboard())
def _handle_baza_age(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_FORWHO")
self.send_message(user_id, "Для кого подбираем тренировки?", keyboard=get_baza_forwho_keyboard())
return
if "младше 12" in text_lower or "меньше 12" in text_lower:
db.set_profile_field(user_id, "age", "Младше 12 лет")
db.add_metka(user_id, "age_under12")
db.update_user_state(user_id, "BAZA_YOUNG")
self.send_message(
user_id,
"Обычно мы занимаемся с ребятами от 12 лет. Если ученику чуть меньше — это не проблема, "
"лучше подобрать формат вместе с менеджером.",
keyboard=get_baza_younger12_keyboard()
)
return
if "12" in text_lower or "13" in text_lower:
age = "1213 лет"
elif "14" in text_lower or "15" in text_lower:
age = "1415 лет"
elif "16" in text_lower:
age = "16 лет и старше"
else:
self.send_message(user_id, "Выберите возраст на кнопках ниже 👇", keyboard=get_baza_age_keyboard())
return
db.set_profile_field(user_id, "age", age)
db.update_user_state(user_id, "BAZA_GAME")
self.send_message(user_id, "По какой игре хотите заниматься?", keyboard=get_baza_game_keyboard())
def _handle_baza_young(self, user_id: int, text: str, text_lower: str):
if text_lower.startswith("обсудить"):
self._baza_to_manager(user_id, reason="Ученик младше 12 лет")
return
if text_lower.startswith("назад") or text_lower.startswith("вернуться"):
db.update_user_state(user_id, "BAZA_AGE")
self.send_message(user_id, "Сколько лет будущему ученику?", keyboard=get_baza_age_keyboard())
return
if self._baza_nav(user_id, text_lower):
return
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_younger12_keyboard())
def _handle_baza_game(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_AGE")
self.send_message(user_id, "Сколько лет будущему ученику?", keyboard=get_baza_age_keyboard())
return
if "cs" in text_lower or "кс" in text_lower or "контр" in text_lower:
game, metka = "CS2", "game_cs2"
elif "dota" in text_lower or "дота" in text_lower:
game, metka = "Dota 2", "game_dota2"
elif "tank" in text_lower or "танк" in text_lower or "wot" in text_lower:
game, metka = "World of Tanks", "game_wot"
elif "друг" in text_lower:
db.update_user_state(user_id, "BAZA_GAME_OTHER")
self.send_message(
user_id,
"Сейчас основные направления БАЗЫ — CS2, Dota 2 и World of Tanks.\n"
"Напишите название игры одним сообщением. Мы передадим запрос менеджеру и сообщим, "
"можем ли предложить подходящего тренера.",
keyboard=get_baza_menu_only_keyboard()
)
return
else:
self.send_message(user_id, "Выберите игру на кнопках ниже 👇", keyboard=get_baza_game_keyboard())
return
db.set_profile_field(user_id, "game", game)
db.add_metka(user_id, metka)
db.update_user_state(user_id, "BAZA_LEVEL")
self.send_message(user_id, "Как бы вы описали текущий уровень игрока?", keyboard=get_baza_level_keyboard())
def _handle_baza_game_other(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_GAME")
self.send_message(user_id, "По какой игре хотите заниматься?", keyboard=get_baza_game_keyboard())
return
db.set_profile_field(user_id, "game", f"Другая игра: {text.strip()}")
db.add_metka(user_id, "game_other")
self.send_message(user_id, "Спасибо! Передаю запрос менеджеру.")
self._baza_to_manager(user_id, reason="Интересует другая игра",
client_text="Спасибо! Передаю запрос менеджеру. Он ответит здесь в ближайшее время.")
def _handle_baza_level(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_GAME")
self.send_message(user_id, "По какой игре хотите заниматься?", keyboard=get_baza_game_keyboard())
return
levels = {
"только начинает": "Только начинает",
"играет для себя": "Играет для себя",
"есть рейтинг или ранг": "Есть рейтинг или ранг",
"играет в команде или турнирах": "Играет в команде или турнирах",
}
match = levels.get(text_lower)
if not match:
self.send_message(user_id, "Выберите уровень на кнопках ниже 👇", keyboard=get_baza_level_keyboard())
return
db.set_profile_field(user_id, "level", match)
db.update_user_state(user_id, "BAZA_FORMAT")
self.send_message(user_id, "Какой формат рассматриваете?", keyboard=get_baza_format_keyboard())
def _handle_baza_format(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_LEVEL")
self.send_message(user_id, "Как бы вы описали текущий уровень игрока?", keyboard=get_baza_level_keyboard())
return
if "групп" in text_lower:
db.set_profile_field(user_id, "format", "Групповые тренировки")
db.add_metka(user_id, "format_group")
db.update_user_state(user_id, "BAZA_GROUP")
self.send_message(
user_id,
"Групповые тренировки подойдут тем, кто хочет развиваться вместе с командой: разбирать "
"ошибки, тренировать игровые навыки и учиться взаимодействовать с другими игроками.\n\n"
"Стоимость групповых тренировок:\n"
"• 4 тренировки — 2 790 ₽\n• 8 тренировок — 4 990 ₽\n\n"
"Можно сначала записаться на пробное занятие или сразу выбрать абонемент.",
keyboard=get_baza_group_keyboard()
)
elif "индивид" in text_lower:
db.set_profile_field(user_id, "format", "Индивидуальные тренировки")
db.add_metka(user_id, "format_individual")
db.update_user_state(user_id, "BAZA_INDIVIDUAL")
self.send_message(
user_id,
"На индивидуальных тренировках тренер работает с конкретными задачами игрока: разбирает "
"игру, помогает исправлять ошибки и составляет персональный план развития.\n\n"
"Стоимость индивидуальных тренировок:\n"
"• 1 тренировка — 870 ₽\n• 4 тренировки — 3 290 ₽\n• 8 тренировок — 5 990 ₽",
keyboard=get_baza_individual_keyboard()
)
elif "не знаю" in text_lower or "совет" in text_lower:
db.set_profile_field(user_id, "format", "Пока не определился, нужен совет")
db.add_metka(user_id, "format_help")
db.update_user_state(user_id, "BAZA_HELP")
p = db.get_profile(user_id)
known = (f"Игра: {p.get('game', '')}\n"
f"Возраст: {p.get('age', '')}\n"
f"Уровень: {p.get('level', '')}")
self.send_message(
user_id,
"Ничего страшного — поможем определиться.\n\n"
"Мы уже знаем:\n" + known + "\n\n"
"Напишите одним сообщением, что для вас особенно важно: расписание, стоимость, "
"выбор тренера, результат или формат занятий.",
keyboard=get_baza_menu_only_keyboard()
)
else:
self.send_message(user_id, "Выберите формат на кнопках ниже 👇", keyboard=get_baza_format_keyboard())
def _handle_baza_group(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_FORMAT")
self.send_message(user_id, "Какой формат тренировок предпочтительнее?", keyboard=get_baza_format_keyboard())
return
if "пробное" in text_lower:
db.add_metka(user_id, "interest_trial")
self._baza_send_payment(user_id, "trial", "Пробное занятие (группа)")
elif "4" in text_lower:
self._baza_send_payment(user_id, "group_4", "Групповой абонемент, 4 занятия")
elif "8" in text_lower:
self._baza_send_payment(user_id, "group_8", "Групповой абонемент, 8 занятий")
else:
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_group_keyboard())
def _handle_baza_individual(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_FORMAT")
self.send_message(user_id, "Какой формат тренировок предпочтительнее?", keyboard=get_baza_format_keyboard())
return
if "1" in text_lower:
self._baza_send_payment(user_id, "individual_1", "Индивидуальное, 1 занятие")
elif "4" in text_lower:
self._baza_send_payment(user_id, "individual_4", "Индивидуальные, 4 занятия")
elif "8" in text_lower:
self._baza_send_payment(user_id, "individual_8", "Индивидуальные, 8 занятий")
else:
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_individual_keyboard())
def _handle_baza_help(self, user_id: int, text: str, text_lower: str):
"""Ветка «Пока не знаю» (§7): пользователь пишет свободным текстом → менеджеру."""
if self._baza_nav(user_id, text_lower):
return
db.set_profile_field(user_id, "message", text.strip())
self.send_message(
user_id,
"Спасибо! Передаю информацию менеджеру. Он посмотрит ваш запрос и поможет подобрать "
"подходящий вариант."
)
self._baza_to_manager(user_id, reason="Нужен совет по формату",
client_text="Менеджер скоро подключится к диалогу. Можете дополнить запрос здесь.")
def _baza_send_payment(self, user_id: int, link_key: str, tariff_label: str, campaign: str = "main"):
"""Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру»."""
link = self._utm(config.LINKS.get(link_key, config.LINKS["tariffs"]), campaign)
db.set_profile_field(user_id, "tariff", tariff_label)
db.add_metka(user_id, "clicked_payment")
db.add_metka(user_id, "lead_hot")
db.update_user_state(user_id, "BAZA_PAY")
kb = VkKeyboard(inline=True)
kb.add_openlink_button("Перейти к оплате 💳", link)
kb.add_line()
kb.add_button("Проблема с оплатой", color=VkKeyboardColor.NEGATIVE)
kb.add_line()
kb.add_button("Задать вопрос", color=VkKeyboardColor.SECONDARY)
self.send_message(
user_id,
"Вы переходите на страницу оплаты. После оформления менеджер проверит заявку и свяжется "
"с вами в этом диалоге.\n\n"
"Если ссылка не откроется или возникнет вопрос — нажмите кнопку ниже.",
keyboard=kb.get_keyboard()
)
def _handle_baza_pay(self, user_id: int, text: str, text_lower: str):
if text_lower in ["проблема с оплатой", "проблема", "не получается", "ошибка оплаты"]:
db.add_metka(user_id, "payment_problem")
reason = ("Проблема с оплатой интенсива"
if "интенсив" in (db.get_profile(user_id).get("tariff", "").lower()) else "Проблема с оплатой")
self._baza_to_manager(user_id, reason=reason,
client_text="Передал вас менеджеру — поможем с оплатой. Опишите, что произошло.")
return
if self._baza_nav(user_id, text_lower):
return
# Любой другой текст — считаем вопросом, сохраняем и передаём менеджеру
db.set_profile_field(user_id, "message", text.strip())
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
self._baza_to_manager(user_id, reason="Вопрос на шаге оплаты",
client_text="Менеджер скоро ответит здесь.")
# ---------- Летний интенсив CS2 ----------
def _baza_start_intensive(self, user_id: int, via_keyword: bool = False):
self._baza_started(user_id)
db.set_profile_field(user_id, "source", "Кодовое слово «лето»" if via_keyword else "Основное меню")
db.add_metka(user_id, "source_keyword_summer" if via_keyword else "source_main_menu")
if via_keyword:
db.set_profile_field(user_id, "keyword", "лето")
db.set_profile_field(user_id, "interest_summer", True)
db.add_metka(user_id, "interest_summer_intensive")
db.update_user_state(user_id, "BAZA_INT_INFO")
self.send_message(
user_id,
config.INTENSIVE_INFO + "\n\nХотите записаться на интенсив?",
keyboard=get_baza_intensive_keyboard()
)
def _handle_baza_int_info(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if "участвовать" in text_lower or "записаться" in text_lower or "хочу" in text_lower or "да" == text_lower:
db.update_user_state(user_id, "BAZA_INT_AGE")
self.send_message(user_id, "Отлично! Сколько лет игроку?", keyboard=get_baza_age_keyboard())
return
self.send_message(user_id, "Выберите действие на кнопках ниже 👇", keyboard=get_baza_intensive_keyboard())
def _handle_baza_int_age(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_INT_INFO")
self.send_message(user_id, "Хотите записаться на интенсив?", keyboard=get_baza_intensive_keyboard())
return
if "младше 12" in text_lower or "меньше 12" in text_lower:
db.set_profile_field(user_id, "age", "Младше 12 лет")
db.update_user_state(user_id, "BAZA_INT_YOUNG")
self.send_message(
user_id,
"Основная программа интенсива рассчитана на участников от 12 лет.\n"
"Мы можем передать запрос менеджеру, чтобы он уточнил у тренера, возможен ли подходящий формат.",
keyboard=get_baza_int_young_keyboard()
)
return
if "12" in text_lower or "13" in text_lower:
age = "1213 лет"
elif "14" in text_lower or "15" in text_lower:
age = "1415 лет"
elif "16" in text_lower:
age = "16 лет и старше"
else:
self.send_message(user_id, "Выберите возраст на кнопках ниже 👇", keyboard=get_baza_age_keyboard())
return
db.set_profile_field(user_id, "age", age)
db.update_user_state(user_id, "BAZA_INT_LEVEL")
self.send_message(user_id, "Какой сейчас уровень в CS2?", keyboard=get_baza_int_level_keyboard())
def _handle_baza_int_young(self, user_id: int, text: str, text_lower: str):
if text_lower.startswith("передать"):
self._baza_to_manager(user_id, reason="Интенсив: участник младше 12 лет",
client_text="Передал запрос менеджеру — он уточнит формат и ответит здесь.")
return
if self._baza_nav(user_id, text_lower): # «Посмотреть другие тренировки» → меню
return
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_int_young_keyboard())
def _handle_baza_int_level(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower):
return
if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_INT_AGE")
self.send_message(user_id, "Сколько лет игроку?", keyboard=get_baza_age_keyboard())
return
levels = {
"только начинаю": "Только начинаю",
"уже знаю основы": "Уже знаю основы",
"играю регулярно": "Играю регулярно",
"есть рейтинг или командный опыт": "Есть рейтинг или командный опыт",
}
match = levels.get(text_lower)
if not match:
self.send_message(user_id, "Выберите уровень на кнопках ниже 👇", keyboard=get_baza_int_level_keyboard())
return
db.set_profile_field(user_id, "level", match)
db.set_profile_field(user_id, "game", "CS2 (интенсив)")
db.update_user_state(user_id, "BAZA_INT_PAY_INTRO")
self.send_message(
user_id,
"Отлично! Можно забронировать место и оплатить участие на сайте.\n\n"
f"Стоимость — {config.INTENSIVE_PRICE} за все 4 тренировки.\n\n"
"После оплаты менеджер подтвердит участие и пришлёт организационную информацию в этот диалог.",
keyboard=get_baza_int_pay_keyboard()
)
def _handle_baza_int_pay_intro(self, user_id: int, text: str, text_lower: str):
if "оплатить" in text_lower:
db.set_profile_field(user_id, "tariff", f"Летний интенсив CS2 — {config.INTENSIVE_PRICE}")
self._baza_send_payment(user_id, "summer", f"Летний интенсив CS2 — {config.INTENSIVE_PRICE}",
campaign="summer")
return
if self._baza_nav(user_id, text_lower): # «Сначала задать вопрос» / «Вернуться в меню»
return
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_int_pay_keyboard())
# ---------- Свободный вопрос менеджеру / цены / карточка ----------
def _baza_ask_question(self, user_id: int):
"""§9: просит написать вопрос одним сообщением, затем передаёт менеджеру."""
db.add_metka(user_id, "manager_requested")
db.update_user_state(user_id, "BAZA_QUESTION")
p = db.get_profile(user_id)
known = ""
pieces = [("Интерес", "летний интенсив CS2" if p.get("interest_summer") else None),
("Возраст", p.get("age")), ("Уровень", p.get("level"))]
known_lines = [f"{lbl}: {val}" for lbl, val in pieces if val]
if known_lines:
known = "\n\nМенеджер получит информацию, которую мы уже собрали:\n" + "\n".join(known_lines)
self.send_message(
user_id,
"Напишите вопрос одним сообщением. Менеджер увидит ваш запрос и подключится к диалогу.\n"
"Все ответы, которые вы уже дали боту, сохранятся — повторять их не потребуется." + known,
keyboard=get_baza_menu_only_keyboard()
)
def _handle_baza_question(self, user_id: int, text: str, text_lower: str):
if text_lower in ["в главное меню ↩️", "главное меню", "меню", "в меню",
"посмотреть другие тренировки", "вернуться в меню ↩️"]:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
return
db.set_profile_field(user_id, "message", text.strip())
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
self._baza_to_manager(user_id, reason="Вопрос от пользователя",
client_text="Менеджер скоро подключится к диалогу.")
# ---------- Цены / передача менеджеру / карточка ----------
def _baza_show_prices(self, user_id: int):
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, config.PRICES_TEXT + "\n\nЧто дальше?", keyboard=get_baza_prices_keyboard())
def _baza_manager_card(self, user_id: int, reason: str = "") -> str:
p = db.get_profile(user_id)
vk_name = self._vk_name(user_id) or ""
lines = ["🎯 Новый запрос из бота — Кибершкола БАЗА", ""]
if reason:
lines.append(f"❗ Повод: {reason}")
lines.append(f"👤 Клиент: {vk_name}")
lines.append(f"🔗 Профиль: vk.com/id{user_id}")
lines.append("")
# Пустые поля в карточку не выводим (§11)
interest = []
if p.get("interest_trial"):
interest.append("пробное")
if p.get("interest_summer"):
interest.append("летний интенсив")
fields = [
("source", "Источник"), ("keyword", "Кодовое слово"), ("role", "Кто обращается"),
("age", "Возраст"), ("game", "Игра"), ("level", "Уровень"),
("format", "Формат"), ("tariff", "Выбранный тариф"), ("message", "Сообщение пользователя"),
]
for key, label in fields:
val = p.get(key)
if val:
lines.append(f"{label}: {val}")
if interest:
lines.append("• Интерес: " + ", ".join(interest))
if p.get("started_at"):
lines.append(f"• Запуск бота: {p['started_at']}")
if p.get("manager_at"):
lines.append(f"• Передан менеджеру: {p['manager_at']}")
metki = p.get("metki")
if metki:
lines.append("")
lines.append("🏷 Метки: " + ", ".join(metki))
return "\n".join(lines)
def _baza_to_manager(self, user_id: int, reason: str = "", client_text: Optional[str] = None):
"""Передаёт лид менеджеру с карточкой профиля и переводит в чат с оператором."""
db.add_metka(user_id, "manager_requested")
from datetime import datetime
db.set_profile_field(user_id, "manager_at", datetime.now().strftime("%Y-%m-%d %H:%M"))
card = self._baza_manager_card(user_id, reason)
self._notify_operators("💬 Запрос менеджеру\n\n" + card)
try:
mailer.send_email(f"Лид из бота БАЗА — vk.com/id{user_id}", card)
except Exception as mail_err:
logger.error(f"Не удалось отправить email о лиде: {mail_err}")
db.update_user_state(user_id, "OPERATOR_CHAT")
if client_text is None:
if self._within_work_hours():
client_text = ("💬 Передал вас менеджеру — он ответит здесь в ближайшее время.\n"
"Можете написать свой вопрос прямо сейчас.")
else:
client_text = (f"🌙 Менеджер сейчас оффлайн (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 "
f"до {getattr(config, 'WORK_HOUR_END', 21)}:00).\n"
"Я передал ваш запрос — ответим, как начнётся рабочий день. "
"Можете оставить сообщение здесь.")
elif not self._within_work_hours():
client_text += (f"\n\n🌙 Сейчас нерабочее время (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 "
f"до {getattr(config, 'WORK_HOUR_END', 21)}:00) — ответим утром.")
self.send_message(user_id, client_text, keyboard=get_operator_keyboard())
def _upload_photo(self, peer_id: int, image_path: str) -> Optional[Dict[str, str]]:
"""Загружает фото в VK для прикрепления к сообщению. Возвращает {'attachment','raw'} или None."""
if image_path in self._photo_cache:
return self._photo_cache[image_path]
import os
if not self.vk or not os.path.exists(image_path):
return None
try:
import requests
upload_server = self.vk.photos.getMessagesUploadServer(peer_id=peer_id)
upload_url = upload_server["upload_url"]
with open(image_path, "rb") as f:
response = requests.post(upload_url, files={"photo": (os.path.basename(image_path), f)})
r = response.json()
saved = self.vk.photos.saveMessagesPhoto(photo=r["photo"], server=r["server"], hash=r["hash"])
info = saved[0] if isinstance(saved, list) else saved
raw = f"{info['owner_id']}_{info['id']}"
result = {"attachment": f"photo{raw}", "raw": raw}
self._photo_cache[image_path] = result
logger.info(f"Фото загружено в VK: {result['attachment']}")
return result
except Exception as e:
logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}")
return None
def get_logo_attachment(self, peer_id: int) -> Optional[str]:
"""Attachment-строка с логотипом ИКП для прикрепления к приветствию."""
import os
logo_path = os.path.join(os.path.dirname(__file__), "help_img", "Logo.jpg")
info = self._upload_photo(peer_id, logo_path)
return info["attachment"] if info else None
def _prewarm_media(self):
"""Заранее (при старте) загружает логотип и картинки дисциплин в VK,
чтобы первое приветствие и первая карусель открывались мгновенно."""
import os, glob
try:
peer = config.OPERATOR_PEER_ID
base = os.path.join(os.path.dirname(__file__), "help_img")
# Грузим ВСЕ картинки из папки (кроме фона веб-формы), чтобы ничего
# не подгружалось лениво в момент сообщения.
count = 0
for path in sorted(glob.glob(os.path.join(base, "*.jpg"))):
if os.path.basename(path).lower() == "bg.jpg":
continue
if self._upload_photo(peer, path):
count += 1
logger.info(f"Медиа предзагружены в VK ({count} картинок) — открываются мгновенно.")
except Exception as e:
logger.error(f"Не удалось предзагрузить медиа: {e}")
def _handle_operator_chat(self, user_id: int, text: str, text_lower: str):
"""Поведение бота в режиме активного диалога с живым человеком."""
# Пользователь хочет выйти из диалога с оператором
if text_lower in ["завершить диалог 🚪", "завершить", "/exit", "выход"]:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# Уведомляем операторов о закрытии диалога
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
return
# В остальное время бот МОЛЧИТ в диалог с пользователем.
logger.info(f"[OPERATOR MODE] Message from {user_id} bypasses bot: {text}")
self._forward_to_operator(user_id, text)
# ------------------------------------------
# СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ
# ------------------------------------------
# ------------------------------------------
# РАЗДЕЛ "КАРЬЕРА В ИКП" (НАЙМ ИГРОКОВ И СОТРУДНИКОВ)
# ------------------------------------------
# ----- Ветка игрока -----
# ----- Ветка сотрудника -----
def _forward_to_operator(self, user_id: int, text: str):
"""Пересылка сообщений от клиента всем операторам."""
self._notify_operators(f"💬 Сообщение от vk.com/id{user_id}:\n{text}")
# ------------------------------------------
# ЗАПУСК И ОБРАБОТКА LONGPOLL
# ------------------------------------------
def _handle_admin_wizard(self, user_id: int, text: str, text_lower: str, state: str, attachments: list):
"""Мастера админки: добавление/изменение/удаление услуг и быстрых ответов, смена картинок."""
if text_lower in ["отмена ❌", "отмена", "в админку ↩️", "в админку", "назад"]:
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
def back_to_faq(msg=None):
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
if msg:
self.send_message(user_id, msg)
self._send_faq_admin(user_id)
def pick_id(t):
t = t.strip()
return t.split(".")[0].split()[0] if t else ""
# ---------- Быстрые ответы ----------
if state == "ADMIN_FAQ_ADD":
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard())
return
kw, ans = [p.strip() for p in text.split("|", 1)]
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard())
return
db.add_custom_faq(kw, ans)
back_to_faq("✅ Быстрый ответ добавлен.")
return
if state == "ADMIN_FAQ_EDIT_PICK":
fid = pick_id(text)
ids = [str(i) for i, _, _ in db.get_custom_faq()]
if fid not in ids:
self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.")
return
self._admin_ctx[user_id] = {"faq_id": int(fid)}
db.update_user_state(user_id, "ADMIN_FAQ_EDIT_VAL")
self.send_message(user_id, "✏️ Отправьте новый вариант: ключевое слово | текст ответа",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_FAQ_EDIT_VAL":
fid = self._admin_ctx.get(user_id, {}).get("faq_id")
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard())
return
kw, ans = [p.strip() for p in text.split("|", 1)]
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard())
return
if fid:
db.delete_custom_faq(fid)
db.add_custom_faq(kw, ans)
back_to_faq("✅ Быстрый ответ обновлён.")
return
if state == "ADMIN_FAQ_DEL_PICK":
fid = pick_id(text)
if not fid.isdigit() or not db.delete_custom_faq(int(fid)):
self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.")
return
back_to_faq(f"✅ Ответ №{fid} удалён.")
return
# ---------- Администраторы (супер-админ) ----------
if state == "ADMIN_ADMIN_ADD":
if not self._is_super_admin(user_id):
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
self._handle_admin_add_admin(user_id, text) # передаём сырой текст (ссылка/имя/ID)
db.update_user_state(user_id, "FREE_CHAT")
return
if state == "ADMIN_ADMIN_DEL_PICK":
if not self._is_super_admin(user_id):
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
self._handle_admin_remove_admin(user_id, "админ удал " + pick_id(text))
db.update_user_state(user_id, "FREE_CHAT")
return
# Неизвестное ADMIN_-состояние — сброс
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
def _handle_admin_stats(self, user_id: int):
"""Формирует и отправляет админу сводную статистику по заявкам и диалогам."""
ls = db.get_lead_stats()
ds = db.get_dialogue_stats()
total = ls["total"]
marketing_pct = round(ls["marketing_yes"] / total * 100) if total else 0
service_lines = ""
if ls["by_service"]:
for svc_name, cnt in ls["by_service"]:
service_lines += f"{svc_name}: {cnt}\n"
else:
service_lines = " • пока нет данных\n"
msg = (
"📊 Статистика Кибершколы БАЗА\n\n"
"🗂 Заявки (всего): {total}\n"
" • сегодня: {today}\n"
" • за 7 дней: {last7}\n"
" • из бота: {from_bot}\n"
"с сайта: {from_web}\n\n"
"📨 Согласны на рассылку: {mk_yes} из {total} ({mk_pct}%)\n\n"
"🎮 По направлениям:\n{services}\n"
"💬 Диалоги:\n"
" • уникальных пользователей: {uniq}\n"
" • сообщений от клиентов: {umsg}"
).format(
total=total, today=ls["today"], last7=ls["last7"],
from_bot=ls["from_bot"], from_web=ls["from_web"],
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
)
self.send_message(user_id, msg)
def _send_faq_admin(self, user_id: int):
"""Раздел «Быстрые ответы»: список + инструкции."""
rows = db.get_custom_faq()
listing = ""
for _id, kw, ans in rows:
short = (ans[:50] + "") if len(ans) > 50 else ans
listing += f"🔹 [{_id}] «{kw}» → {short}\n"
if not listing:
listing = "Пока нет ни одного быстрого ответа.\n"
self.send_message(
user_id,
"💬 Быстрые ответы (по ключевым словам)\n\n"
"Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n"
f"📋 Текущие:\n{listing}\n"
"Управляйте кнопками ниже: ➕ добавить • ✏️ изменить • 🗑 удалить",
keyboard=get_faq_admin_keyboard()
)
def _handle_faq_add(self, user_id: int, text: str):
content = text[len("фак доб "):].strip()
if "|" not in content:
self.send_message(user_id, "⚠️ Формат: фак доб ключевое слово | текст ответа")
return
kw, ans = content.split("|", 1)
kw, ans = kw.strip(), ans.strip()
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.")
return
new_id = db.add_custom_faq(kw, ans)
self.send_message(user_id, f"✅ Быстрый ответ добавлен (№{new_id}): «{kw.lower()}».")
self._send_faq_admin(user_id)
def _handle_faq_delete(self, user_id: int, text: str):
arg = text[len("фак удал "):].strip()
if not arg.isdigit():
self.send_message(user_id, "⚠️ Укажите номер: фак удал 3")
return
if db.delete_custom_faq(int(arg)):
self.send_message(user_id, f"✅ Быстрый ответ №{arg} удалён.")
else:
self.send_message(user_id, f"⚠️ Ответ №{arg} не найден.")
self._send_faq_admin(user_id)
def _handle_admin_broadcast(self, user_id: int, text: str):
"""Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка."""
# Отрезаем ключевое слово 'рассылка'
content = text[len("рассылка"):].strip()
content_lower = content.lower()
# Отмена рассылки
if content_lower in ["отмена", "отменить", "отмена ❌"]:
self._pending_broadcasts.pop(user_id, None)
self.send_message(user_id, "❌ Рассылка отменена.")
self._send_admin_panel(user_id)
return
# Шаг 2: подтверждение и запуск
if content_lower in ["подтвердить", "да", "отправить", "!"]:
pending = self._pending_broadcasts.get(user_id)
if not pending:
self.send_message(
user_id,
"❌ Нет рассылки, ожидающей подтверждения.\n"
"Сначала отправьте: рассылка <текст сообщения>."
)
return
recipients = db.get_marketing_subscribers()
if not recipients:
self.send_message(user_id, "⚠️ Нет подписчиков с согласием на рассылку — отправлять некому.")
self._pending_broadcasts.pop(user_id, None)
return
self.send_message(
user_id,
f"🚀 Запускаю рассылку для {len(recipients)} получателей. "
"Отправка идёт с паузами, чтобы не нарушать лимиты VK. Сообщу по завершении."
)
self._pending_broadcasts.pop(user_id, None)
# Запускаем в отдельном потоке, чтобы не блокировать обработку сообщений
threading.Thread(
target=self._run_broadcast, args=(user_id, pending, recipients), daemon=True
).start()
return
# Шаг 1: приём текста и предпросмотр
if not content:
self.send_message(
user_id,
"📣 Рассылка по подписчикам\n\n"
"Отправьте: рассылка <текст сообщения>\n"
"После этого я покажу число получателей и попрошу подтверждение.\n\n"
"Рассылка уходит только тем, кто дал согласие (consent_2)."
)
return
recipients = db.get_marketing_subscribers()
self._pending_broadcasts[user_id] = content
preview = (
"📋 Предпросмотр рассылки\n\n"
f"Получателей (с согласием): {len(recipients)}\n\n"
"Текст сообщения:\n"
"— — —\n"
f"{content}\n"
"— — —\n\n"
"Отправить или отменить?"
)
kb = VkKeyboard(inline=True)
kb.add_button("Отправить ✅", color=VkKeyboardColor.PRIMARY, payload={"cmd": "bc_send"})
kb.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE, payload={"cmd": "bc_cancel"})
self.send_message(user_id, preview, keyboard=kb.get_keyboard())
def _run_broadcast(self, admin_id: int, text: str, recipients: list):
"""Фоновая отправка рассылки с троттлингом и подсказкой об отписке."""
body = (
f"{text}\n\n"
"— — —\n"
"🔕 Чтобы отписаться от рассылки, отправьте: Отписаться от рассылки"
)
sent = 0
for uid in recipients:
try:
# Повторно проверяем согласие на случай отписки во время рассылки
data = db.get_user_data(uid)
if data.get("consent_2") != "Согласен":
continue
self.send_message(uid, body)
sent += 1
except Exception as e:
logger.error(f"Ошибка рассылки пользователю {uid}: {e}")
time.sleep(0.4) # троттлинг против лимитов VK
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
# Эмулятор консольного ввода для тестирования на Windows локально
test_user_id = 99999
# Эмулируем приветствие при старте
self.handle_message(test_user_id, "начать")
while True:
try:
user_input = input("Вы: ")
if user_input.lower() == "выход":
break
self.handle_message(test_user_id, user_input)
except (KeyboardInterrupt, EOFError):
break
return
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
# Старт-пинг администратору — лёгкий сигнal «бот жив»
try:
from datetime import datetime
ts = datetime.now().strftime("%d.%m.%Y %H:%M")
for admin_id in getattr(config, "ADMIN_IDS", []):
self.send_message(admin_id, f"✅ Бот ИКП запущен и на связи ({ts}).")
except Exception as e:
logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
while True:
try:
for event in self.longpoll.listen():
if event.type == VkBotEventType.MESSAGE_NEW:
message_obj = event.obj.message
user_id = message_obj["from_id"]
text = message_obj["text"]
attachments = message_obj.get("attachments", [])
try:
self.handle_message(user_id, text, attachments)
except Exception as e:
# Ошибка обработки одного сообщения не должна останавливать бота
logger.error(f"Ошибка обработки сообщения от {user_id}: {e}")
except KeyboardInterrupt:
logger.info("Остановка бота по запросу (Ctrl+C).")
break
except Exception as e:
# Сетевой обрыв/протухшая сессия longpoll — ждём и переподключаемся
logger.warning(f"Обрыв LongPoll ({e}). Переподключение через 3 с…")
time.sleep(3)
try:
self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID)
logger.info("LongPoll переподключён.")
except Exception as e2:
logger.error(f"Не удалось переподключить LongPoll: {e2}")
time.sleep(5)
if __name__ == "__main__":
import sys
if not _acquire_single_instance_lock():
logger.error("Уже запущен другой экземпляр бота — выходим.")
sys.exit(0)
bot = VKBotApplication()
bot.run()