7caecb1c72
- Тикеты: add_lead возвращает id заявки; при передаче менеджеру клиент получает «🎫 Номер вашей заявки: №N», тот же номер — в карточке оператору и в письме. Один тикет на заход (сбрасывается при новом старте воронки/интенсива). - Кнопка «Записаться на пробное» → «Записаться на пробное занятие» (лейблы + распознавание). - Ветка вебинара: вход по callback-кнопке и по тексту «записаться на вебинар», текст выровнен по ТЗ, после регистрации состояние FREE_CHAT — основное меню остаётся доступным (не блокирует продающую ветку). Дата/время — в config. Тесты: test_baza_question_ticket, test_webinar_registration. pytest 36 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1732 lines
100 KiB
Python
1732 lines
100 KiB
Python
import os
|
||
import sys
|
||
# Гарантируем, что папка бота в sys.path — иначе при запуске некоторыми сборками
|
||
# Python (например, sandbox-версиями) не находятся database.py/config.py и т.п.
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
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
|
||
# Временный бан при повторном флуде: после 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_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 _handle_message_event(self, event):
|
||
"""Обработка callback-событий от inline-кнопок (MESSAGE_EVENT)."""
|
||
user_id = event.obj.user_id
|
||
payload = event.obj.payload
|
||
event_id = event.obj.event_id
|
||
cmd = payload.get("cmd") if isinstance(payload, dict) else None
|
||
# Отвечаем VK, чтобы убрать «часики» у кнопки
|
||
try:
|
||
self.vk.messages.sendMessageEventAnswer(
|
||
user_id=user_id,
|
||
event_id=event_id,
|
||
peer_id=user_id,
|
||
event_data='{"type":"show_snackbar","text":"Обработка..."}'
|
||
)
|
||
except Exception:
|
||
pass
|
||
if cmd == config.WEBINAR_PAYLOAD:
|
||
self._handle_webinar_register(user_id)
|
||
|
||
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"
|
||
"• 📣 Рассылка — сообщение подписчикам\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]
|
||
|
||
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_links_admin(user_id)
|
||
return
|
||
elif text_lower in ["✏️ изменить ссылку", "изменить ссылку"]:
|
||
items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS]
|
||
db.update_user_state(user_id, "ADMIN_LINK_PICK")
|
||
self.send_message(user_id, "✏️ Какую ссылку изменить? Выберите:", keyboard=get_pick_keyboard(items))
|
||
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
|
||
|
||
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
|
||
if text_lower in ["записаться на вебинар", "вебинар", "хочу на вебинар", "регистрация на вебинар"]:
|
||
self._handle_webinar_register(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
|
||
|
||
# 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 вставляется ДО него, иначе браузер не
|
||
доскроллит до раздела (напр. https://infocyber.pro/?utm...#prices).
|
||
"""
|
||
utm = config.UTM_SUMMER if campaign == "summer" else config.UTM_MAIN
|
||
base, sep_frag, frag = url.partition("#")
|
||
sep = "&" if "?" in base else "?"
|
||
result = f"{base}{sep}{utm}"
|
||
return f"{result}#{frag}" if sep_frag else result
|
||
|
||
def _link(self, key: str) -> str:
|
||
"""Актуальный URL кнопки: переопределение из БД (админка) или дефолт из config/.env."""
|
||
return db.get_link(key) or config.LINKS.get(key, config.LINKS["tariffs"])
|
||
|
||
def _baza_send_openlink(self, user_id: int, link_key: str, message: str, button: str,
|
||
campaign: str = "main"):
|
||
"""Отправляет сообщение с инлайн-кнопкой-ссылкой (без перевода на оплату/лид)."""
|
||
link = self._utm(self._link(link_key), 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,
|
||
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
|
||
|
||
# Шаги воронки: порядок и какое поле профиля заполняет каждый (format — без поля,
|
||
# это выбор действия, поэтому спрашивается всегда).
|
||
_FUNNEL_ORDER = ["BAZA_FORWHO", "BAZA_AGE", "BAZA_GAME", "BAZA_LEVEL", "BAZA_FORMAT"]
|
||
_FUNNEL_FIELD = {"BAZA_FORWHO": "role", "BAZA_AGE": "age", "BAZA_GAME": "game", "BAZA_LEVEL": "level"}
|
||
|
||
def _baza_ask(self, user_id: int, state: str):
|
||
"""Задаёт вопрос конкретного шага воронки и ставит соответствующее состояние."""
|
||
prompts = {
|
||
"BAZA_FORWHO": ("Для кого подбираем тренировки?", get_baza_forwho_keyboard()),
|
||
"BAZA_AGE": ("Сколько лет игроку?", get_baza_age_keyboard()),
|
||
"BAZA_GAME": ("По какой игре хотите заниматься?", get_baza_game_keyboard()),
|
||
"BAZA_LEVEL": ("Как бы вы описали текущий уровень игрока?", get_baza_level_keyboard()),
|
||
"BAZA_FORMAT": ("Какой формат рассматриваете?", get_baza_format_keyboard()),
|
||
}
|
||
msg, kb = prompts[state]
|
||
db.update_user_state(user_id, state)
|
||
self.send_message(user_id, msg, keyboard=kb)
|
||
|
||
def _baza_advance(self, user_id: int, from_state=None):
|
||
"""Переходит к следующему шагу воронки, ПРОПУСКАЯ уже заполненные поля (§15)."""
|
||
p = db.get_profile(user_id)
|
||
start = 0 if from_state is None else self._FUNNEL_ORDER.index(from_state) + 1
|
||
for state in self._FUNNEL_ORDER[start:]:
|
||
field = self._FUNNEL_FIELD.get(state)
|
||
if not field or not p.get(field):
|
||
self._baza_ask(user_id, state)
|
||
return
|
||
self._baza_ask(user_id, "BAZA_FORMAT")
|
||
|
||
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.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
|
||
db.set_profile_field(user_id, "ticket", None)
|
||
p = db.get_profile(user_id)
|
||
if any(p.get(f) for f in ("role", "age", "game", "level")):
|
||
self.send_message(
|
||
user_id,
|
||
"С возвращением! Что вы уже указывали, переспрашивать не буду. "
|
||
"Если нужно изменить прошлый ответ — нажмите «Назад ⬅️»."
|
||
)
|
||
self._baza_advance(user_id, None)
|
||
|
||
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
|
||
self._baza_advance(user_id, "BAZA_FORWHO")
|
||
|
||
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.update_user_state(user_id, "BAZA_YOUNG")
|
||
self.send_message(
|
||
user_id,
|
||
"Основные программы Кибершколы БАЗА рассчитаны на игроков от 12 лет. Но мы можем "
|
||
"отдельно посмотреть, получится ли подобрать подходящий формат.\n"
|
||
"Передать запрос менеджеру?",
|
||
keyboard=get_baza_younger12_keyboard()
|
||
)
|
||
return
|
||
if "12" in text_lower or "13" in text_lower:
|
||
age = "12–13 лет"
|
||
elif "14" in text_lower or "15" in text_lower:
|
||
age = "14–15 лет"
|
||
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)
|
||
self._baza_advance(user_id, "BAZA_AGE")
|
||
|
||
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)
|
||
self._baza_advance(user_id, "BAZA_GAME")
|
||
|
||
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)
|
||
self._baza_advance(user_id, "BAZA_LEVEL")
|
||
|
||
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_record_lead(self, user_id: int) -> Optional[int]:
|
||
"""Фиксирует заявку в журнале leads (один раз на заход). Возвращает номер тикета."""
|
||
p = db.get_profile(user_id)
|
||
if p.get("lead_recorded"):
|
||
return p.get("ticket")
|
||
name = self._vk_name(user_id) or "—"
|
||
service = p.get("tariff") or p.get("format") or p.get("game") or "—"
|
||
try:
|
||
ticket = db.add_lead(user_id, source="bot", name=name, phone="", service=service)
|
||
db.set_profile_field(user_id, "lead_recorded", True)
|
||
db.set_profile_field(user_id, "ticket", ticket)
|
||
return ticket
|
||
except Exception as e:
|
||
logger.error(f"Не удалось записать лид в статистику: {e}")
|
||
return None
|
||
|
||
def _baza_send_payment(self, user_id: int, link_key: str, tariff_label: str, campaign: str = "main"):
|
||
"""Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру»."""
|
||
link = self._utm(self._link(link_key), campaign)
|
||
db.set_profile_field(user_id, "tariff", tariff_label)
|
||
db.add_metka(user_id, "clicked_payment")
|
||
db.add_metka(user_id, "lead_hot")
|
||
self._baza_record_lead(user_id)
|
||
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.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
|
||
db.set_profile_field(user_id, "ticket", None)
|
||
db.update_user_state(user_id, "BAZA_INT_INFO")
|
||
self.send_message(
|
||
user_id,
|
||
config.INTENSIVE_INFO,
|
||
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:
|
||
age = (db.get_profile(user_id).get("age") or "").lower()
|
||
if age and "младше 12" not in age:
|
||
# возраст уже известен из подбора — не переспрашиваем (§15)
|
||
db.update_user_state(user_id, "BAZA_INT_LEVEL")
|
||
self.send_message(user_id, "Какой сейчас уровень в CS2?", keyboard=get_baza_int_level_keyboard())
|
||
else:
|
||
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, config.INTENSIVE_INFO, 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 = "12–13 лет"
|
||
elif "14" in text_lower or "15" in text_lower:
|
||
age = "14–15 лет"
|
||
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, 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 p.get("ticket"):
|
||
lines.append(f"🎫 Заявка №{p['ticket']}")
|
||
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"))
|
||
ticket = self._baza_record_lead(user_id)
|
||
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) — ответим утром.")
|
||
if ticket:
|
||
client_text += f"\n\n🎫 Номер вашей заявки: №{ticket} — сохраните его для связи."
|
||
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_webinar_register(self, user_id: int):
|
||
"""Регистрация на вебинар «Точка входа в киберспорт» (кнопка из поста/клипа)."""
|
||
self._baza_started(user_id)
|
||
already = "webinar_registered" in db.get_profile(user_id).get("metki", [])
|
||
db.add_metka(user_id, "webinar_registered")
|
||
db.set_profile_field(user_id, "source", "Вебинар: " + config.WEBINAR_TITLE)
|
||
db.update_user_state(user_id, "FREE_CHAT") # остаётся в боте, основное меню доступно
|
||
prefix = "Ты уже записан(а) на вебинар 🙌\n\n" if already else ""
|
||
self.send_message(
|
||
user_id,
|
||
prefix +
|
||
"Привет! 👋\n"
|
||
f"Будем рады видеть тебя на вебинаре «{config.WEBINAR_TITLE}».\n\n"
|
||
f"📅 {config.WEBINAR_DATE}\n"
|
||
f"⏰ {config.WEBINAR_TIME}\n\n"
|
||
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n"
|
||
"А пока можешь посмотреть направления и цены — кнопки ниже 👇",
|
||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
||
)
|
||
logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
|
||
|
||
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_LINK_PICK":
|
||
key = pick_id(text)
|
||
if key not in config.LINK_LABELS:
|
||
self.send_message(user_id, "⚠️ Выберите ссылку кнопкой ниже.")
|
||
return
|
||
self._admin_ctx[user_id] = {"link_key": key}
|
||
db.update_user_state(user_id, "ADMIN_LINK_VAL")
|
||
self.send_message(
|
||
user_id,
|
||
f"✏️ «{config.LINK_LABELS[key]}»\nТекущий адрес:\n{self._link(key)}\n\n"
|
||
"Пришлите новый URL (начинается с http:// или https://). UTM добавится автоматически.",
|
||
keyboard=get_cancel_admin_keyboard()
|
||
)
|
||
return
|
||
|
||
if state == "ADMIN_LINK_VAL":
|
||
key = self._admin_ctx.get(user_id, {}).get("link_key")
|
||
url = text.strip()
|
||
if not key or key not in config.LINK_LABELS:
|
||
self._admin_ctx.pop(user_id, None)
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self._send_links_admin(user_id)
|
||
return
|
||
if not re.match(r"^https?://", url, re.I):
|
||
self.send_message(user_id, "⚠️ Ссылка должна начинаться с http:// или https://",
|
||
keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
db.set_link(key, url)
|
||
self._admin_ctx.pop(user_id, None)
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self.send_message(user_id, f"✅ Ссылка «{config.LINK_LABELS[key]}» обновлена.")
|
||
self._send_links_admin(user_id)
|
||
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()
|
||
|
||
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\n"
|
||
"🎯 По выбранному тарифу/формату:\n{services}\n"
|
||
"💬 Диалоги:\n"
|
||
" • уникальных пользователей: {uniq}\n"
|
||
" • сообщений от клиентов: {umsg}"
|
||
).format(
|
||
total=ls["total"], today=ls["today"], last7=ls["last7"],
|
||
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
|
||
)
|
||
self.send_message(user_id, msg)
|
||
|
||
def _send_links_admin(self, user_id: int):
|
||
"""Раздел «Ссылки»: текущие адреса кнопок записи/оплаты + кнопка изменения."""
|
||
lines = []
|
||
for key, label in config.LINK_LABELS.items():
|
||
overridden = " (изменена)" if db.get_link(key) else ""
|
||
lines.append(f"🔗 {label}{overridden}\n {self._link(key)}")
|
||
kb = VkKeyboard(inline=True)
|
||
kb.add_button("✏️ Изменить ссылку", color=VkKeyboardColor.PRIMARY)
|
||
kb.add_line()
|
||
kb.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
|
||
self.send_message(
|
||
user_id,
|
||
"🔗 Ссылки кнопок записи/оплаты\n\n" + "\n\n".join(lines) +
|
||
"\n\nUTM-метки добавляются автоматически. Нажмите «Изменить ссылку», чтобы задать новый адрес.",
|
||
keyboard=kb.get_keyboard()
|
||
)
|
||
|
||
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}")
|
||
elif event.type == VkBotEventType.MESSAGE_EVENT:
|
||
try:
|
||
self._handle_message_event(event)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обработки MESSAGE_EVENT: {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()
|