Админка: показ/скрытие кнопок меню + экстренный стоп бота (/favi-stop)

1) Кнопки меню: раздел «Кнопки меню 🧩» в админке — показать/скрыть любую кнопку
   главного меню (Подобрать/Интенсив/Цены/Вопрос/Вебинар). Флаги в БД (bot_flags),
   дефолты в config.MENU_FEATURES (вебинар по умолчанию скрыт). Скрытый раздел не
   реагирует и на кодовые слова (интенсив выкл → «лето» игнорируется). Вебинар
   получил кнопку в меню (для теста ветки) + вход по callback из поста работает всегда.

2) Экстренное отключение (только супер-админ): /favi-stop → запрос пароля → бот
   уходит в аварийный режим и молчит для всех; /favi-start возвращает. Пароль хранится
   как PBKDF2-хэш в .env (FAVI_STOP_HASH), не в коде; сравнение константное, 3 попытки.

pytest 43 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kenik
2026-07-26 10:58:54 +03:00
parent 24daec1c09
commit d2ca750a2d
6 changed files with 279 additions and 36 deletions
+154 -26
View File
@@ -7,6 +7,8 @@ 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
@@ -99,6 +101,7 @@ class VKBotApplication:
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
@@ -208,6 +211,66 @@ class VKBotApplication:
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:
@@ -268,6 +331,7 @@ class VKBotApplication:
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
"• 📣 Рассылка — сообщение подписчикам\n"
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты\n"
"• 🧩 Кнопки меню — показать/скрыть кнопки (интенсив, вебинар и др.)\n"
"• 🔔 Уведомления — вкл/выкл уведомления о заявках лично для вас"
+ extra,
keyboard=get_admin_keyboard(is_super=is_super, muted=muted)
@@ -286,6 +350,40 @@ class VKBotApplication:
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):
@@ -385,6 +483,21 @@ class VKBotApplication:
text_lower = text_clean.lower()
attachments = attachments or []
# === Экстренный выключатель бота (только супер-админ) ===
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()
@@ -474,6 +587,13 @@ class VKBotApplication:
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")
@@ -523,7 +643,7 @@ class VKBotApplication:
elif text_lower in ["в главное меню ↩️"]:
self.send_message(
user_id, "🏠 Главное меню.",
keyboard=get_main_keyboard(is_admin=True)
keyboard=self._menu_kb(user_id)
)
return
@@ -585,7 +705,7 @@ class VKBotApplication:
self.send_message(
user_id,
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
keyboard=self._menu_kb(user_id)
)
# ------------------------------------------
@@ -596,27 +716,31 @@ class VKBotApplication:
"""Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос."""
# 1. Подобрать тренировку — запуск воронки
if text_lower in ["подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
if db.get_flag("pickup", True) and text_lower in [
"подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
self._baza_start_funnel(user_id)
return
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
if text_lower in ["записаться на вебинар", "вебинар", "хочу на вебинар", "регистрация на вебинар"]:
if db.get_flag("webinar", False) and text_lower in [
"записаться на вебинар 🎓", "записаться на вебинар", "вебинар",
"хочу на вебинар", "регистрация на вебинар"]:
self._handle_webinar_register(user_id)
return
# 2. Летний интенсив CS2 (кнопка меню)
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
self._baza_start_intensive(user_id, via_keyword=False)
return
# 2.1 Кодовые слова рекламной кампании (лето/интенсив и т.п.) — сразу в ветку интенсива
if any(t in text_lower for t in config.SUMMER_TRIGGERS):
self._baza_start_intensive(user_id, via_keyword=True)
return
# 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 text_lower in ["посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
if db.get_flag("prices", True) and text_lower in [
"посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
self._baza_show_prices(user_id)
return
@@ -632,9 +756,13 @@ class VKBotApplication:
"Открыть тарифы 💳")
return
# 4. Задать вопрос — просим написать вопрос одним сообщением (§9)
if text_lower in ["задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬",
"оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]:
# 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
@@ -643,7 +771,7 @@ class VKBotApplication:
self.send_message(
user_id,
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
keyboard=self._menu_kb(user_id)
)
return
@@ -656,7 +784,7 @@ class VKBotApplication:
# 8. Быстрые ответы по ключевым словам (редактируются админом)
quick = db.match_custom_faq(text_lower)
if quick:
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
self.send_message(user_id, quick, keyboard=self._menu_kb(user_id))
return
# 9. Пасхалка автора (запрет на удаление)
@@ -667,7 +795,7 @@ class VKBotApplication:
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
"Желаю хороших впечатлений! От этого бота\n\n"
"https://vk.com/favilonia",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
keyboard=self._menu_kb(user_id),
)
return
@@ -675,7 +803,7 @@ class VKBotApplication:
self.send_message(
user_id,
"Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
keyboard=self._menu_kb(user_id)
)
# ==========================================================
@@ -718,7 +846,7 @@ class VKBotApplication:
self.send_message(
user_id,
config.GREETING_BAZA,
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
keyboard=self._menu_kb(user_id),
attachment=self.get_logo_attachment(user_id)
)
@@ -729,7 +857,7 @@ class VKBotApplication:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
keyboard=self._menu_kb(user_id)
)
return True
if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру",
@@ -1190,7 +1318,7 @@ class VKBotApplication:
"посмотреть другие тренировки", "вернуться в меню ↩️"]:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
keyboard=self._menu_kb(user_id))
return
db.set_profile_field(user_id, "message", text.strip())
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
@@ -1334,7 +1462,7 @@ class VKBotApplication:
f"{config.WEBINAR_TIME}\n\n"
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n"
"А пока можешь посмотреть направления и цены — кнопки ниже 👇",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
keyboard=self._menu_kb(user_id)
)
logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
@@ -1346,7 +1474,7 @@ class VKBotApplication:
self.send_message(
user_id,
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
keyboard=self._menu_kb(user_id)
)
# Уведомляем операторов о закрытии диалога
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")