Админка: показ/скрытие кнопок меню + экстренный стоп бота (/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:
@@ -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} завершил сессию общения с оператором.")
|
||||
|
||||
Reference in New Issue
Block a user