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, \ server_utc_offset_hours, convert_time 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 _parse_tz_offset(self, raw: str) -> Optional[int]: """Парсит смещение от UTC: либо чистое число '+3'/'-5'/'0' (ручной ввод), либо 'Город, UTC+N' (клик по кнопке get_timezone_keyboard). None, если не удалось распознать или число вне диапазона реальных поясов (-12..+14).""" raw = raw.strip() m = re.search(r"UTC\s*([+-]?\d{1,2})\s*$", raw, re.I) if not m: m = re.match(r"^([+-]?\d{1,2})$", raw) if not m: return None val = int(m.group(1)) return val if -12 <= val <= 14 else None def _require_reminder_tz(self, user_id: int) -> bool: """Гейт часового пояса: если у админа он ещё не задан — переводит его в мастер ввода пояса и возвращает False (вызывающий должен просто return). Если пояс уже задан — возвращает True, можно продолжать.""" if db.get_admin_tz_offset(user_id) is not None: return True db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP") self.send_message( user_id, "🌐 Прежде чем пользоваться напоминалками, укажите свой часовой пояс.\n" "Выберите город кнопкой ниже, либо введите смещение от UTC вручную " "(например, +3), если своего города в списке нет.", keyboard=get_timezone_keyboard() ) return False def _handle_set_timezone(self, user_id: int, text: str): """Команда 'часовой пояс <±N>' — сменить уже заданный пояс в любой момент.""" raw = text[len("часовой пояс"):].strip() offset = self._parse_tz_offset(raw) if offset is None: self.send_message(user_id, "⚠️ Формат: часовой пояс +3 (число от -12 до +14).") return db.set_admin_tz_offset(user_id, offset) self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.") def _send_reminders_admin(self, user_id: int): """Раздел «Напоминалки»: список активных + инструкции. Вызывается только после прохождения гейта часового пояса (см. _require_reminder_tz) — тут он гарантированно уже задан.""" rows = db.get_active_reminders() viewer_offset = db.get_admin_tz_offset(user_id) if viewer_offset is None: viewer_offset = 0 server_offset = server_utc_offset_hours() listing = "" for r in rows: server_dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S") viewer_dt = convert_time(server_dt, server_offset, viewer_offset) 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"] pause_mark = "⏸ (на паузе) " if r["paused"] else "" listing += (f"🔹 [{r['id']}] {pause_mark}{viewer_dt.strftime('%d.%m.%Y %H:%M')} " f"(UTC{viewer_offset:+d}, {period}) → {who}\n" f" «{short}»\n") if not listing: listing = "Активных напоминаний нет.\n" self.send_message( user_id, "⏰ Напоминалки\n\n" f"{listing}\n" "ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз " "зайти сюда и указать свой часовой пояс.\n\n" "Управляйте кнопками ниже: ➕ добавить • 🗑 удалить • ⏸/▶ пауза", keyboard=get_reminders_admin_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 ["⏰ напоминалки", "напоминалки"]: if self._require_reminder_tz(user_id): self._send_reminders_admin(user_id) return elif text_lower == "➕ добавить": if not self._require_reminder_tz(user_id): return 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("напоминалка удалить"): if not self._require_reminder_tz(user_id): return self._handle_reminder_delete(user_id, text_clean) return elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]: if not self._require_reminder_tz(user_id): return 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 ["⏸/▶ пауза", "пауза"]: if not self._require_reminder_tz(user_id): return rows = db.get_active_reminders() if not rows: self.send_message(user_id, "Активных напоминаний нет.") self._send_reminders_admin(user_id) return items = [ (r["id"], ("▶ Возобновить: " if r["paused"] else "⏸ На паузу: ") + r["text"]) for r in rows ] db.update_user_state(user_id, "ADMIN_REMINDER_PAUSE_PICK") self.send_message(user_id, "⏸/▶ Какое напоминание переключить? Выберите:", keyboard=get_pick_keyboard(items)) return elif text_lower.startswith("часовой пояс"): self._handle_set_timezone(user_id, text_clean) 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_TZ_SETUP": offset = self._parse_tz_offset(text) if offset is None: self.send_message(user_id, "⚠️ Выберите город кнопкой ниже, либо введите число " "от -12 до +14 вручную, например +3 или -5.", keyboard=get_timezone_keyboard()) return db.set_admin_tz_offset(user_id, offset) db.update_user_state(user_id, "FREE_CHAT") self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.") self._send_reminders_admin(user_id) 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_PAUSE_PICK": rid = pick_id(text) rows_by_id = {r["id"]: r for r in db.get_active_reminders()} if not rid.isdigit() or int(rid) not in rows_by_id: self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.") return new_paused = not rows_by_id[int(rid)]["paused"] db.set_reminder_paused(int(rid), new_paused) db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, f"✅ Напоминание №{rid} " + ("поставлено на паузу." if new_paused else "снято с паузы.") ) 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 admin_offset = db.get_admin_tz_offset(user_id) if admin_offset is None: admin_offset = 0 server_dt = convert_time(dt, admin_offset, server_utc_offset_hours()) if server_dt <= datetime.now(): self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.", keyboard=get_cancel_admin_keyboard()) return self._admin_ctx[user_id] = {"remind_at": server_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()