fc79da8f83
- В разделе «Напоминалки» добавлена кнопка «🗑 Удалить напоминание» с выбором из списка (как в разделе «Быстрые ответы»), не только текстовая команда. - При выборе получателей и в списке напоминаний теперь показывается ФИО админа из VK рядом со ссылкой (если удалось получить), чтобы не кликать по каждой ссылке для опознания. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2096 lines
121 KiB
Python
2096 lines
121 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 hmac
|
||
import hashlib
|
||
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
|
||
from reminders import parse_reminder_datetime, compute_next_occurrence
|
||
from datetime import datetime
|
||
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_PATH (нужно для Docker: база в volume).
|
||
db = DatabaseManager(os.getenv("DB_PATH") or "bot_database.db")
|
||
|
||
# Анти-флуд: максимум сообщений от одного пользователя за окно времени
|
||
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
|
||
# Как часто фоновый поток проверяет, не пора ли отправить напоминание
|
||
REMINDER_CHECK_INTERVAL_SEC = 60
|
||
|
||
REMINDER_PERIOD_LABELS = {
|
||
"once": "разово",
|
||
"weekly": "каждую неделю",
|
||
"monthly": "каждый месяц",
|
||
"yearly": "каждый год",
|
||
}
|
||
REMINDER_PERIOD_BY_LABEL = {v: k for k, v in REMINDER_PERIOD_LABELS.items()}
|
||
|
||
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._favi_pending: Dict[int, dict] = {} # супер-админы, вводящие пароль /favi-stop
|
||
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():
|
||
if db.get_profile(op_id).get("notify_muted"):
|
||
continue
|
||
self.send_message(op_id, message)
|
||
|
||
def _menu_flags(self) -> dict:
|
||
"""Текущие флаги показа кнопок главного меню (из БД, дефолты — из config)."""
|
||
return {k: db.get_flag(k, default) for k, (_label, default) in config.MENU_FEATURES.items()}
|
||
|
||
def _menu_kb(self, user_id: int) -> str:
|
||
"""Главное меню с учётом включённых/выключенных кнопок."""
|
||
return get_main_keyboard(is_admin=self._is_admin(user_id), flags=self._menu_flags())
|
||
|
||
# ---------- Экстренное отключение бота (/favi-stop) ----------
|
||
@staticmethod
|
||
def _verify_favi_password(attempt: str) -> bool:
|
||
"""Сверяет введённый пароль с хэшем PBKDF2 из config (константное сравнение)."""
|
||
stored = getattr(config, "FAVI_STOP_HASH", "") or ""
|
||
try:
|
||
algo, iters, salt_hex, hash_hex = stored.split("$")
|
||
if algo != "pbkdf2_sha256":
|
||
return False
|
||
dk = hashlib.pbkdf2_hmac("sha256", attempt.encode("utf-8"),
|
||
bytes.fromhex(salt_hex), int(iters))
|
||
return hmac.compare_digest(dk.hex(), hash_hex)
|
||
except Exception:
|
||
return False
|
||
|
||
def _start_favi_flow(self, user_id: int, action: str):
|
||
"""Запуск сценария ввода пароля для экстренного стоп/старт (супер-админ)."""
|
||
if not getattr(config, "FAVI_STOP_HASH", ""):
|
||
self.send_message(user_id, "⚠️ Экстренный выключатель не настроен (нет FAVI_STOP_HASH в .env).")
|
||
return
|
||
self._favi_pending[user_id] = {"action": action, "tries": 0}
|
||
verb = "ОТКЛЮЧЕНИЯ" if action == "stop" else "включения"
|
||
self.send_message(user_id, f"🔒 Введите пароль для {verb} бота одним сообщением.\n"
|
||
"Отмена — напишите «отмена».")
|
||
|
||
def _handle_favi_password(self, user_id: int, text: str):
|
||
ctx = self._favi_pending.get(user_id)
|
||
if not ctx:
|
||
return
|
||
if text.strip().lower() in ("отмена", "cancel", "/cancel"):
|
||
self._favi_pending.pop(user_id, None)
|
||
self.send_message(user_id, "Отменено.")
|
||
return
|
||
if self._verify_favi_password(text.strip()):
|
||
self._favi_pending.pop(user_id, None)
|
||
if ctx["action"] == "stop":
|
||
db.set_flag("bot_disabled", True)
|
||
logger.warning(f"Бот ЭКСТРЕННО ОТКЛЮЧЁН супер-админом {user_id}")
|
||
self.send_message(user_id, "🛑 Бот ЭКСТРЕННО ОТКЛЮЧЁН — клиентам он больше не отвечает.\n"
|
||
"Включить обратно: команда /favi-start")
|
||
else:
|
||
db.set_flag("bot_disabled", False)
|
||
logger.warning(f"Бот снова включён супер-админом {user_id}")
|
||
self.send_message(user_id, "✅ Бот снова включён и отвечает клиентам.")
|
||
return
|
||
ctx["tries"] += 1
|
||
if ctx["tries"] >= 3:
|
||
self._favi_pending.pop(user_id, None)
|
||
self.send_message(user_id, "⛔ Неверный пароль. Попытки исчерпаны — повторите команду заново.")
|
||
else:
|
||
self.send_message(user_id, f"❌ Неверный пароль. Осталось попыток: {3 - ctx['tries']}")
|
||
|
||
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, https://vk.ru/gruzdevas,
|
||
m.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"^(www\.|m\.)?vk\.(com|ru)/", "", s, flags=re.I) # убираем домен (vk.com / vk.ru)
|
||
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)
|
||
muted = bool(db.get_profile(user_id).get("notify_muted"))
|
||
extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else ""
|
||
self.send_message(
|
||
user_id,
|
||
"⚙️ Админ-панель Кибершколы БАЗА\n\n"
|
||
"Выберите раздел на кнопках ниже:\n"
|
||
"• 📊 Статистика — заявки и диалоги\n"
|
||
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
|
||
"• 📣 Рассылка — сообщение подписчикам\n"
|
||
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты\n"
|
||
"• 🧩 Кнопки меню — показать/скрыть кнопки (интенсив, вебинар и др.)\n"
|
||
"• 🔔 Уведомления — вкл/выкл уведомления о заявках лично для вас"
|
||
+ extra,
|
||
keyboard=get_admin_keyboard(is_super=is_super, muted=muted)
|
||
)
|
||
|
||
def _toggle_admin_notifications(self, user_id: int):
|
||
"""Переключает лично для этого админа приём уведомлений о заявках/клиентах."""
|
||
muted = not bool(db.get_profile(user_id).get("notify_muted"))
|
||
db.set_profile_field(user_id, "notify_muted", muted)
|
||
if muted:
|
||
txt = ("🔕 Уведомления от клиентов ОТКЛЮЧЕНЫ лично для вас.\n"
|
||
"Заявки и сообщения от клиентов приходить не будут (у других операторов — как раньше).\n"
|
||
"Включить обратно — кнопкой «Уведомления» в админке.")
|
||
else:
|
||
txt = "🔔 Уведомления от клиентов ВКЛЮЧЕНЫ — заявки снова будут приходить вам."
|
||
self.send_message(user_id, txt,
|
||
keyboard=get_admin_keyboard(is_super=self._is_super_admin(user_id), muted=muted))
|
||
|
||
def _send_features_admin(self, user_id: int):
|
||
"""Раздел «Кнопки меню»: показать/скрыть кнопки главного меню (нажатие — переключение)."""
|
||
kb = VkKeyboard(inline=True)
|
||
lines = []
|
||
for i, (key, (label, default)) in enumerate(config.MENU_FEATURES.items()):
|
||
on = db.get_flag(key, default)
|
||
btn = f"{label} — {'вкл ✅' if on else 'выкл ⛔'}"
|
||
lines.append(btn)
|
||
if i:
|
||
kb.add_line()
|
||
kb.add_button(btn, color=VkKeyboardColor.POSITIVE if on else VkKeyboardColor.NEGATIVE)
|
||
kb.add_line()
|
||
kb.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
|
||
self.send_message(
|
||
user_id,
|
||
"🧩 Кнопки главного меню\n\n"
|
||
"Нажмите на пункт, чтобы показать/скрыть его в меню у клиентов:\n\n"
|
||
+ "\n".join(lines),
|
||
keyboard=kb.get_keyboard()
|
||
)
|
||
|
||
def _toggle_feature(self, user_id: int, text: str):
|
||
"""Переключает показ кнопки меню по её подписи."""
|
||
base = text.split(" — ")[0].strip()
|
||
for key, (label, default) in config.MENU_FEATURES.items():
|
||
if base == label:
|
||
new = not db.get_flag(key, default)
|
||
db.set_flag(key, new)
|
||
self.send_message(user_id, f"«{label}» теперь {'показывается в меню ✅' if new else 'скрыта из меню ⛔'}.")
|
||
self._send_features_admin(user_id)
|
||
return
|
||
self.send_message(user_id, "Не понял, какую кнопку переключить.")
|
||
self._send_features_admin(user_id)
|
||
|
||
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 _all_admin_ids(self) -> list:
|
||
"""Полный список текущих админов: супер-админы + .env + добавленные в рантайме,
|
||
без дублей, отсортирован (используется для «все админы» и нумерации в мастере)."""
|
||
ids = set(getattr(config, "SUPER_ADMIN_IDS", []))
|
||
ids |= set(getattr(config, "ADMIN_IDS", []))
|
||
ids |= self._runtime_admins()
|
||
return sorted(ids)
|
||
|
||
def _admin_display(self, uid: int) -> str:
|
||
"""ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
|
||
(используется в мастере напоминалок, чтобы не кликать по каждому id)."""
|
||
name = self._vk_name(uid)
|
||
return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"
|
||
|
||
def _send_reminders_admin(self, user_id: int):
|
||
"""Раздел «Напоминалки»: список активных + инструкции."""
|
||
rows = db.get_active_reminders()
|
||
listing = ""
|
||
for r in rows:
|
||
dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
|
||
who = "всем админам" if r["recipients"] == "all" else \
|
||
", ".join(self._admin_display(uid) for uid in r["recipients"])
|
||
period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
|
||
short = (r["text"][:50] + "…") if len(r["text"]) > 50 else r["text"]
|
||
listing += (f"🔹 [{r['id']}] {dt.strftime('%d.%m.%Y %H:%M')} ({period}) → {who}\n"
|
||
f" «{short}»\n")
|
||
if not listing:
|
||
listing = "Активных напоминаний нет.\n"
|
||
kb = VkKeyboard(inline=True)
|
||
kb.add_button("➕ Добавить", color=VkKeyboardColor.PRIMARY)
|
||
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"
|
||
f"{listing}\n"
|
||
"Управляйте кнопками ниже: ➕ добавить • 🗑 удалить",
|
||
keyboard=kb.get_keyboard()
|
||
)
|
||
|
||
def _handle_reminder_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_reminder(int(arg)):
|
||
self.send_message(user_id, f"✅ Напоминание №{arg} удалено.")
|
||
else:
|
||
self.send_message(user_id, f"⚠️ Напоминание №{arg} не найдено.")
|
||
self._send_reminders_admin(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 []
|
||
|
||
# Сообщения без текста (стикер/фото/голосовое/гифка/сервисное) не распознаются
|
||
# и раньше вызывали фолбэк «не понял запрос». Бот текстовый — просто игнорируем.
|
||
if not text_clean:
|
||
return
|
||
|
||
# === Экстренный выключатель бота (только супер-админ) ===
|
||
if self._is_super_admin(user_id):
|
||
if user_id in self._favi_pending:
|
||
self._handle_favi_password(user_id, text_clean)
|
||
return
|
||
if text_lower == "/favi-stop":
|
||
self._start_favi_flow(user_id, "stop")
|
||
return
|
||
if text_lower == "/favi-start":
|
||
self._start_favi_flow(user_id, "start")
|
||
return
|
||
# Если бот в аварийном режиме — не отвечаем никому (супер-админ обработан выше)
|
||
if db.get_flag("bot_disabled", False):
|
||
return
|
||
|
||
# Периодическая чистка 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 ["⏰ напоминалки", "напоминалки"]:
|
||
self._send_reminders_admin(user_id)
|
||
return
|
||
elif text_lower == "➕ добавить":
|
||
db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
|
||
self.send_message(
|
||
user_id,
|
||
"⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
|
||
"Например: 15.03.2027 09:00",
|
||
keyboard=get_cancel_admin_keyboard()
|
||
)
|
||
return
|
||
elif text_lower.startswith("напоминалка удалить"):
|
||
self._handle_reminder_delete(user_id, text_clean)
|
||
return
|
||
elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
|
||
rows = db.get_active_reminders()
|
||
if not rows:
|
||
self.send_message(user_id, "Активных напоминаний нет.")
|
||
self._send_reminders_admin(user_id)
|
||
return
|
||
items = [(r["id"], r["text"]) for r in rows]
|
||
db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
|
||
self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
|
||
keyboard=get_pick_keyboard(items))
|
||
return
|
||
elif text_lower in ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]:
|
||
self._toggle_admin_notifications(user_id)
|
||
return
|
||
elif text_lower in ["кнопки меню 🧩", "кнопки меню", "разделы", "разделы 🧩"]:
|
||
self._send_features_admin(user_id)
|
||
return
|
||
elif (" — вкл" in text_lower or " — выкл" in text_lower) and text.split(" — ")[0].strip() in [
|
||
lbl for lbl, _ in config.MENU_FEATURES.values()]:
|
||
self._toggle_feature(user_id, text_clean)
|
||
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=self._menu_kb(user_id)
|
||
)
|
||
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=self._menu_kb(user_id)
|
||
)
|
||
|
||
# ------------------------------------------
|
||
# ОБРАБОТЧИКИ СОСТОЯНИЙ (FSM)
|
||
# ------------------------------------------
|
||
|
||
def _handle_free_chat(self, user_id: int, text: str, text_lower: str):
|
||
"""Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос."""
|
||
|
||
# 1. Подобрать тренировку — запуск воронки
|
||
if db.get_flag("pickup", True) and text_lower in [
|
||
"подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
|
||
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
|
||
self._baza_start_funnel(user_id)
|
||
return
|
||
|
||
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
|
||
if db.get_flag("webinar", False) and text_lower in [
|
||
"записаться на вебинар 🎓", "записаться на вебинар", "вебинар",
|
||
"хочу на вебинар", "регистрация на вебинар"]:
|
||
self._handle_webinar_register(user_id)
|
||
return
|
||
|
||
# 2. Летний интенсив CS2 (кнопка меню + кодовые слова) — только если раздел включён
|
||
if db.get_flag("intensive", True):
|
||
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
|
||
self._baza_start_intensive(user_id, via_keyword=False)
|
||
return
|
||
if any(t in text_lower for t in config.SUMMER_TRIGGERS):
|
||
self._baza_start_intensive(user_id, via_keyword=True)
|
||
return
|
||
|
||
# 3. Посмотреть цены
|
||
if db.get_flag("prices", True) and 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. Связь с человеком — всегда доступна (оператор/менеджер)
|
||
if text_lower in ["оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]:
|
||
self._baza_ask_question(user_id)
|
||
return
|
||
# 4.1 Кнопка «Задать вопрос» — если раздел включён
|
||
if db.get_flag("question", True) and text_lower in [
|
||
"задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬"]:
|
||
self._baza_ask_question(user_id)
|
||
return
|
||
|
||
# 6. Возврат в главное меню
|
||
if text_lower in ["назад в меню ↩️", "меню", "назад", "в главное меню ↩️", "главное меню"]:
|
||
self.send_message(
|
||
user_id,
|
||
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
||
keyboard=self._menu_kb(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=self._menu_kb(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=self._menu_kb(user_id),
|
||
)
|
||
return
|
||
|
||
# 10. Fallback — не поняли запрос (§15, дословно)
|
||
self.send_message(
|
||
user_id,
|
||
"Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.",
|
||
keyboard=self._menu_kb(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=self._menu_kb(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=self._menu_kb(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 "counter" in text_lower or "strike" in text_lower or "cs" in text_lower or "кс" in text_lower or "контр" in text_lower:
|
||
game, metka = "Counter-Strike 2", "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,
|
||
"Сейчас основные направления БАЗЫ — Counter-Strike 2, 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, "Какой сейчас уровень в Counter-Strike 2?", 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, "Какой сейчас уровень в Counter-Strike 2?", 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", "Counter-Strike 2 (интенсив)")
|
||
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=self._menu_kb(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:
|
||
# Кэшируем неудачу, чтобы не долбить VK и лог на каждое приветствие
|
||
# (напр. у токена нет прав на фото — [15] Access denied).
|
||
self._photo_cache[image_path] = None
|
||
logger.warning(f"Фото {os.path.basename(image_path)} не загружено (шлём без него): {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
|
||
try:
|
||
logo = os.path.join(os.path.dirname(__file__), "help_img", "Logo.jpg")
|
||
if self._upload_photo(config.OPERATOR_PEER_ID, logo):
|
||
logger.info("Логотип предзагружен в VK — приветствие открывается мгновенно.")
|
||
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=self._menu_kb(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=self._menu_kb(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
|
||
|
||
# ---------- Напоминалки ----------
|
||
if state == "ADMIN_REMINDER_DEL_PICK":
|
||
rid = pick_id(text)
|
||
if not rid.isdigit() or not db.delete_reminder(int(rid)):
|
||
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
|
||
return
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
|
||
self._send_reminders_admin(user_id)
|
||
return
|
||
|
||
if state == "ADMIN_REMINDER_DATE":
|
||
dt = parse_reminder_datetime(text)
|
||
if not dt:
|
||
self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
|
||
keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
if dt <= datetime.now():
|
||
self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
|
||
keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
self._admin_ctx[user_id] = {"remind_at": dt.strftime("%Y-%m-%d %H:%M:%S")}
|
||
db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
|
||
self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
|
||
if state == "ADMIN_REMINDER_TEXT":
|
||
if not text.strip():
|
||
self.send_message(user_id, "⚠️ Текст не может быть пустым.", keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
self._admin_ctx.setdefault(user_id, {})["text"] = text.strip()
|
||
admins = self._all_admin_ids()
|
||
self._admin_ctx[user_id]["admins_list"] = admins
|
||
listing = "\n".join(f"{i + 1}. {self._admin_display(a)}" for i, a in enumerate(admins))
|
||
db.update_user_state(user_id, "ADMIN_REMINDER_RECIPIENTS")
|
||
self.send_message(
|
||
user_id,
|
||
"👥 Кому отправлять напоминание?\n"
|
||
f"{listing}\n\n"
|
||
"Введите номер (например, 2), несколько через запятую (1,3) или 0 — все админы.",
|
||
keyboard=get_cancel_admin_keyboard()
|
||
)
|
||
return
|
||
|
||
if state == "ADMIN_REMINDER_RECIPIENTS":
|
||
ctx = self._admin_ctx.get(user_id, {})
|
||
admins = ctx.get("admins_list", [])
|
||
raw = text.strip()
|
||
if raw == "0":
|
||
recipients = "all"
|
||
else:
|
||
picks = [p.strip() for p in raw.split(",") if p.strip()]
|
||
if not picks or not all(p.isdigit() for p in picks):
|
||
self.send_message(user_id, "⚠️ Введите номера через запятую или 0 для всех админов.",
|
||
keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
indices = [int(p) for p in picks]
|
||
if any(i < 1 or i > len(admins) for i in indices):
|
||
self.send_message(user_id, "⚠️ Есть номер вне списка. Проверьте и введите снова.",
|
||
keyboard=get_cancel_admin_keyboard())
|
||
return
|
||
recipients = sorted({admins[i - 1] for i in indices})
|
||
ctx["recipients"] = recipients
|
||
db.update_user_state(user_id, "ADMIN_REMINDER_PERIOD")
|
||
self.send_message(user_id, "🔁 Периодичность напоминания:", keyboard=get_reminder_period_keyboard())
|
||
return
|
||
|
||
if state == "ADMIN_REMINDER_PERIOD":
|
||
recurrence = REMINDER_PERIOD_BY_LABEL.get(text_lower)
|
||
if not recurrence:
|
||
self.send_message(user_id, "⚠️ Выберите период кнопкой ниже.",
|
||
keyboard=get_reminder_period_keyboard())
|
||
return
|
||
ctx = self._admin_ctx.pop(user_id, {})
|
||
remind_at = ctx.get("remind_at")
|
||
rtext = ctx.get("text")
|
||
recipients = ctx.get("recipients")
|
||
if not (remind_at and rtext and recipients is not None):
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self.send_message(user_id, "⚠️ Что-то пошло не так, начните заново кнопкой «➕ Добавить».")
|
||
self._send_reminders_admin(user_id)
|
||
return
|
||
db.add_reminder(rtext, remind_at, recurrence, recipients, user_id)
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self.send_message(user_id, "✅ Напоминание создано.")
|
||
self._send_reminders_admin(user_id)
|
||
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 _check_due_reminders(self):
|
||
"""Проверяет и рассылает напоминания, чьё время наступило.
|
||
Вызывается фоновым потоком `_reminders_loop`, но выделена отдельно,
|
||
чтобы её можно было дёргать напрямую из тестов без запуска потока."""
|
||
now = datetime.now()
|
||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
for r in db.get_due_reminders(now_str):
|
||
targets = self._all_admin_ids() if r["recipients"] == "all" else r["recipients"]
|
||
body = f"⏰ Напоминание:\n{r['text']}"
|
||
for uid in targets:
|
||
try:
|
||
self.send_message(uid, body)
|
||
except Exception as e:
|
||
logger.error(f"Не удалось отправить напоминание {r['id']} пользователю {uid}: {e}")
|
||
if r["recurrence"] == "once":
|
||
db.deactivate_reminder(r["id"])
|
||
else:
|
||
anchor = datetime.strptime(r["remind_at"], "%Y-%m-%d %H:%M:%S")
|
||
next_dt = compute_next_occurrence(anchor, r["recurrence"], now)
|
||
db.update_reminder_next_at(r["id"], next_dt.strftime("%Y-%m-%d %H:%M:%S"))
|
||
|
||
def _reminders_loop(self):
|
||
"""Фоновый цикл: раз в REMINDER_CHECK_INTERVAL_SEC секунд проверяет и шлёт напоминания.
|
||
Обрыв на одной итерации (например, сбой отправки) не должен останавливать цикл."""
|
||
while True:
|
||
try:
|
||
self._check_due_reminders()
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обработки напоминаний: {e}")
|
||
time.sleep(REMINDER_CHECK_INTERVAL_SEC)
|
||
|
||
def run(self):
|
||
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
|
||
threading.Thread(target=self._reminders_loop, daemon=True).start()
|
||
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:
|
||
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"]
|
||
peer_id = message_obj.get("peer_id", user_id)
|
||
# Работаем только в личке. Сообщения из бесед/групповых чатов
|
||
# (peer_id != from_id) игнорируем — иначе бот шлёт людям в ЛС
|
||
# «не понял запрос» на каждое их сообщение в беседе.
|
||
if peer_id != user_id:
|
||
continue
|
||
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()
|