Files
bot_vk_ikp_prodagi/bot.py
T
Kenik e41db35f13 Напоминалки: выбор часового пояса кнопками городов + ручной ввод
При гейте часового пояса теперь показывается клавиатура с частыми
российскими городами-поясами (Калининград..Камчатка). Клик по кнопке
шлёт 'Город, UTC+N' — _parse_tz_offset распознаёт и это, и обычный
ручной ввод вида '+3'/'-5', если своего города в списке нет.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:18:29 +03:00

2201 lines
127 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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 = "1213 лет"
elif "14" in text_lower or "15" in text_lower:
age = "1415 лет"
elif "16" in text_lower:
age = "16 лет и старше"
else:
self.send_message(user_id, "Выберите возраст на кнопках ниже 👇", keyboard=get_baza_age_keyboard())
return
db.set_profile_field(user_id, "age", age)
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 = "1213 лет"
elif "14" in text_lower or "15" in text_lower:
age = "1415 лет"
elif "16" in text_lower:
age = "16 лет и старше"
else:
self.send_message(user_id, "Выберите возраст на кнопках ниже 👇", keyboard=get_baza_age_keyboard())
return
db.set_profile_field(user_id, "age", age)
db.update_user_state(user_id, "BAZA_INT_LEVEL")
self.send_message(user_id, "Какой сейчас уровень в 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()