Админка: показ/скрытие кнопок меню + экстренный стоп бота (/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
+5
View File
@@ -46,3 +46,8 @@ LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4
LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5 LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5
# Летний интенсив: отдельной страницы на сайте нет — ведём на общую форму (замените, когда появится) # Летний интенсив: отдельной страницы на сайте нет — ведём на общую форму (замените, когда появится)
LINK_SUMMER=https://infocyber.pro/form/index.html LINK_SUMMER=https://infocyber.pro/form/index.html
# Экстренное отключение бота (/favi-stop) — ХЭШ пароля (PBKDF2), не сам пароль.
# Сгенерировать: py -3.11 -c "import hashlib,os; s=os.urandom(16); import sys; pw=sys.argv[1].encode(); print('pbkdf2_sha256$240000$'+s.hex()+'$'+hashlib.pbkdf2_hmac('sha256',pw,s,240000).hex())" "ВАШ_ПАРОЛЬ"
# Пусто — команда /favi-stop отключена.
FAVI_STOP_HASH=
+147 -19
View File
@@ -7,6 +7,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json import json
import re import re
import time import time
import hmac
import hashlib
import logging import logging
import threading import threading
from collections import defaultdict from collections import defaultdict
@@ -99,6 +101,7 @@ class VKBotApplication:
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
self._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда self._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда
self._banned_until: Dict[int, float] = {} # 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 # когда последний раз чистили словари анти-флуда self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда
# Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение # Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение
self._admins_cache: Optional[set] = None self._admins_cache: Optional[set] = None
@@ -208,6 +211,66 @@ class VKBotApplication:
continue continue
self.send_message(op_id, message) 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]: def _vk_name(self, user_id: int) -> Optional[str]:
"""Реальное имя пользователя из VK (для сверки с введёнными данными).""" """Реальное имя пользователя из VK (для сверки с введёнными данными)."""
if not self.vk or user_id <= 0: if not self.vk or user_id <= 0:
@@ -268,6 +331,7 @@ class VKBotApplication:
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
"• 📣 Рассылка — сообщение подписчикам\n" "• 📣 Рассылка — сообщение подписчикам\n"
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты\n" "• 🔗 Ссылки — куда ведут кнопки записи/оплаты\n"
"• 🧩 Кнопки меню — показать/скрыть кнопки (интенсив, вебинар и др.)\n"
"• 🔔 Уведомления — вкл/выкл уведомления о заявках лично для вас" "• 🔔 Уведомления — вкл/выкл уведомления о заявках лично для вас"
+ extra, + extra,
keyboard=get_admin_keyboard(is_super=is_super, muted=muted) keyboard=get_admin_keyboard(is_super=is_super, muted=muted)
@@ -286,6 +350,40 @@ class VKBotApplication:
self.send_message(user_id, txt, self.send_message(user_id, txt,
keyboard=get_admin_keyboard(is_super=self._is_super_admin(user_id), muted=muted)) 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): def _send_admins_panel(self, user_id: int):
"""Раздел управления администраторами (только супер-админ).""" """Раздел управления администраторами (только супер-админ)."""
if not self._is_super_admin(user_id): if not self._is_super_admin(user_id):
@@ -385,6 +483,21 @@ class VKBotApplication:
text_lower = text_clean.lower() text_lower = text_clean.lower()
attachments = attachments or [] 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 словарей анти-флуда (не на каждое сообщение) # Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
self._cleanup_antiflood() self._cleanup_antiflood()
@@ -474,6 +587,13 @@ class VKBotApplication:
elif text_lower in ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]: elif text_lower in ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]:
self._toggle_admin_notifications(user_id) self._toggle_admin_notifications(user_id)
return 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 ["✏️ изменить ссылку", "изменить ссылку"]: elif text_lower in ["✏️ изменить ссылку", "изменить ссылку"]:
items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS] items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS]
db.update_user_state(user_id, "ADMIN_LINK_PICK") db.update_user_state(user_id, "ADMIN_LINK_PICK")
@@ -523,7 +643,7 @@ class VKBotApplication:
elif text_lower in ["в главное меню ↩️"]: elif text_lower in ["в главное меню ↩️"]:
self.send_message( self.send_message(
user_id, "🏠 Главное меню.", user_id, "🏠 Главное меню.",
keyboard=get_main_keyboard(is_admin=True) keyboard=self._menu_kb(user_id)
) )
return return
@@ -585,7 +705,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, 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. Подобрать тренировку — запуск воронки # 1. Подобрать тренировку — запуск воронки
if text_lower in ["подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯", if db.get_flag("pickup", True) and text_lower in [
"подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
"подобрать", "подбор", "записаться", "тренировки", "занятия"]: "подобрать", "подбор", "записаться", "тренировки", "занятия"]:
self._baza_start_funnel(user_id) self._baza_start_funnel(user_id)
return return
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста) # 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
if text_lower in ["записаться на вебинар", "вебинар", "хочу на вебинар", "регистрация на вебинар"]: if db.get_flag("webinar", False) and text_lower in [
"записаться на вебинар 🎓", "записаться на вебинар", "вебинар",
"хочу на вебинар", "регистрация на вебинар"]:
self._handle_webinar_register(user_id) self._handle_webinar_register(user_id)
return return
# 2. Летний интенсив CS2 (кнопка меню) # 2. Летний интенсив CS2 (кнопка меню + кодовые слова) — только если раздел включён
if db.get_flag("intensive", True):
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]: if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
self._baza_start_intensive(user_id, via_keyword=False) self._baza_start_intensive(user_id, via_keyword=False)
return return
# 2.1 Кодовые слова рекламной кампании (лето/интенсив и т.п.) — сразу в ветку интенсива
if any(t in text_lower for t in config.SUMMER_TRIGGERS): if any(t in text_lower for t in config.SUMMER_TRIGGERS):
self._baza_start_intensive(user_id, via_keyword=True) self._baza_start_intensive(user_id, via_keyword=True)
return return
# 3. Посмотреть цены # 3. Посмотреть цены
if text_lower in ["посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]: if db.get_flag("prices", True) and text_lower in [
"посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
self._baza_show_prices(user_id) self._baza_show_prices(user_id)
return return
@@ -632,9 +756,13 @@ class VKBotApplication:
"Открыть тарифы 💳") "Открыть тарифы 💳")
return return
# 4. Задать вопрос — просим написать вопрос одним сообщением (§9) # 4. Связь с человеком — всегда доступна (оператор/менеджер)
if text_lower in ["задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬", 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) self._baza_ask_question(user_id)
return return
@@ -643,7 +771,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, user_id,
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?", "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
return return
@@ -656,7 +784,7 @@ class VKBotApplication:
# 8. Быстрые ответы по ключевым словам (редактируются админом) # 8. Быстрые ответы по ключевым словам (редактируются админом)
quick = db.match_custom_faq(text_lower) quick = db.match_custom_faq(text_lower)
if quick: 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 return
# 9. Пасхалка автора (запрет на удаление) # 9. Пасхалка автора (запрет на удаление)
@@ -667,7 +795,7 @@ class VKBotApplication:
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n" "Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
"Желаю хороших впечатлений! От этого бота\n\n" "Желаю хороших впечатлений! От этого бота\n\n"
"https://vk.com/favilonia", "https://vk.com/favilonia",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)), keyboard=self._menu_kb(user_id),
) )
return return
@@ -675,7 +803,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, 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( self.send_message(
user_id, user_id,
config.GREETING_BAZA, 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) attachment=self.get_logo_attachment(user_id)
) )
@@ -729,7 +857,7 @@ class VKBotApplication:
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
self.send_message( self.send_message(
user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?", user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
return True return True
if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру", if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру",
@@ -1190,7 +1318,7 @@ class VKBotApplication:
"посмотреть другие тренировки", "вернуться в меню ↩️"]: "посмотреть другие тренировки", "вернуться в меню ↩️"]:
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?", self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) keyboard=self._menu_kb(user_id))
return return
db.set_profile_field(user_id, "message", text.strip()) db.set_profile_field(user_id, "message", text.strip())
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.") self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
@@ -1334,7 +1462,7 @@ class VKBotApplication:
f"{config.WEBINAR_TIME}\n\n" f"{config.WEBINAR_TIME}\n\n"
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\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'})") logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
@@ -1346,7 +1474,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, 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} завершил сессию общения с оператором.") self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
+15
View File
@@ -49,6 +49,11 @@ else:
SUPER_ADMIN_IDS = [350793273] SUPER_ADMIN_IDS = [350793273]
# ============================================================ # ============================================================
# Экстренное отключение бота супер-админом командой /favi-stop.
# Хранится ТОЛЬКО хэш пароля (PBKDF2), не сам пароль. Задаётся в .env: FAVI_STOP_HASH.
# Сгенерировать новый хэш: см. README/USER_GUIDE. Пусто — команда отключена.
FAVI_STOP_HASH = os.getenv("FAVI_STOP_HASH", "")
# Ссылки на юридические документы (инлайн-кнопки в боте) # Ссылки на юридические документы (инлайн-кнопки в боте)
OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf") OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf")
POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf") POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf")
@@ -121,6 +126,16 @@ LINK_LABELS = {
"summer": "Летний интенсив CS2", "summer": "Летний интенсив CS2",
} }
# --- Кнопки/разделы главного меню: показать/скрыть (админка) ---
# key: (человеческое название, показывать ли по умолчанию)
MENU_FEATURES = {
"pickup": ("Подобрать тренировку 🎯", True),
"intensive": ("Летний интенсив CS2 🔥", True),
"prices": ("Посмотреть цены 💰", True),
"question": ("Задать вопрос 💬", True),
"webinar": ("Записаться на вебинар 🎓", False),
}
# --- Триггеры запуска (регистронезависимо) --- # --- Триггеры запуска (регистронезависимо) ---
MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться", MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться",
"привет", "здравствуйте", "старт", "меню"] "привет", "здравствуйте", "старт", "меню"]
+29
View File
@@ -183,6 +183,15 @@ class DatabaseManager:
) )
""") """)
# Table for feature flags (показ/скрытие кнопок меню и разделов)
cursor.execute("""
CREATE TABLE IF NOT EXISTS bot_flags (
key TEXT PRIMARY KEY,
enabled INTEGER NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit() conn.commit()
# ---------- Редактируемые ссылки (кнопки записи/оплаты) ---------- # ---------- Редактируемые ссылки (кнопки записи/оплаты) ----------
@@ -212,6 +221,26 @@ class DatabaseManager:
) )
conn.commit() conn.commit()
# ---------- Фиче-флаги (показ/скрытие кнопок меню) ----------
def get_flag(self, key: str, default: bool = True) -> bool:
"""Включён ли раздел/кнопка. Если в БД нет записи — возвращает default."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT enabled FROM bot_flags WHERE key = ?", (key,))
row = cursor.fetchone()
return bool(row[0]) if row is not None else default
def set_flag(self, key: str, enabled: bool):
"""Включить/выключить раздел или кнопку."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO bot_flags (key, enabled) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET enabled = excluded.enabled, updated_at = CURRENT_TIMESTAMP",
(key, 1 if enabled else 0)
)
conn.commit()
def get_user_data(self, user_id: int) -> Dict[str, Any]: def get_user_data(self, user_id: int) -> Dict[str, Any]:
"""Retrieve user state and collected data.""" """Retrieve user state and collected data."""
with self._get_connection() as conn: with self._get_connection() as conn:
+26 -9
View File
@@ -7,18 +7,34 @@ import config
# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте) # КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте)
# ========================================== # ==========================================
def get_main_keyboard(is_admin: bool = False) -> str: def get_main_keyboard(is_admin: bool = False, flags: dict = None) -> str:
"""Главное меню Кибершколы БАЗА. Для админа добавляется кнопка «Админка ⚙️».""" """Главное меню Кибершколы БАЗА. flags — какие кнопки показывать (по умолчанию — все,
кроме вебинара). Для админа добавляется кнопка «Админка »."""
f = {"pickup": True, "intensive": True, "prices": True, "question": True, "webinar": False}
if flags:
f.update(flags)
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY) has = False
def row(label, color=VkKeyboardColor.SECONDARY):
nonlocal has
if has:
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Летний интенсив CS2 🔥", color=VkKeyboardColor.SECONDARY) keyboard.add_button(label, color=color)
keyboard.add_line() has = True
keyboard.add_button("Посмотреть цены 💰", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY) if f.get("pickup"):
row("Подобрать тренировку 🎯", VkKeyboardColor.PRIMARY)
if f.get("intensive"):
row("Летний интенсив CS2 🔥")
if f.get("webinar"):
row("Записаться на вебинар 🎓")
if f.get("prices"):
row("Посмотреть цены 💰")
if f.get("question"):
row("Задать вопрос 💬")
if is_admin: if is_admin:
keyboard.add_line() row("Админка ⚙️", VkKeyboardColor.POSITIVE)
keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.POSITIVE)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -156,6 +172,7 @@ def get_admin_keyboard(is_super: bool = False, muted: bool = False) -> str:
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Ссылки 🔗", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Ссылки 🔗", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Кнопки меню 🧩", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔", keyboard.add_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔",
color=VkKeyboardColor.SECONDARY) color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
+49
View File
@@ -88,6 +88,7 @@ def test_baza_question_ticket(app):
def test_webinar_registration(app): def test_webinar_registration(app):
"""Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно).""" """Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно)."""
u = 70041 u = 70041
bot.db.set_flag("webinar", True) # вебинар по умолчанию скрыт — включаем
app.handle_message(u, "записаться на вебинар") app.handle_message(u, "записаться на вебинар")
assert "webinar_registered" in bot.db.get_profile(u).get("metki", []) assert "webinar_registered" in bot.db.get_profile(u).get("metki", [])
assert _state(u) == "FREE_CHAT" assert _state(u) == "FREE_CHAT"
@@ -247,6 +248,54 @@ def test_resolve_vk_links(app):
assert app._resolve_vk_user_id("999") == 999 assert app._resolve_vk_user_id("999") == 999
def test_menu_feature_toggle(app):
"""Скрытие кнопки меню: раздел «интенсив» выключается и не запускается по слову."""
import json
a = _admin()
# выключаем интенсив
app.handle_message(a, "Кнопки меню 🧩")
app.handle_message(a, "Летний интенсив CS2 🔥 — вкл ✅") # нажатие -> выкл
assert bot.db.get_flag("intensive", True) is False
# кнопки нет в меню
labels = [b["action"].get("label") for row in json.loads(app._menu_kb(a))["buttons"] for b in row]
assert not any("Летний интенсив" in x for x in labels)
# кодовое слово «лето» больше не запускает интенсив
u = 70050
app.handle_message(u, "лето")
assert _state(u) == "FREE_CHAT"
def test_favi_stop_start(app):
"""Экстренное отключение супер-админом по паролю и обратное включение."""
import config as cfg
# тестовый хэш пароля 'secret'
import hashlib, os
salt = os.urandom(16)
cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \
hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex()
a = bot.config.SUPER_ADMIN_IDS[0]
app.handle_message(a, "/favi-stop")
app.handle_message(a, "secret")
assert bot.db.get_flag("bot_disabled", False) is True
# обычный юзер не получает ответа
app._sent.clear(); app.handle_message(70051, "привет")
assert len(app._sent) == 0
# включаем обратно
app.handle_message(a, "/favi-start"); app.handle_message(a, "secret")
assert bot.db.get_flag("bot_disabled", False) is False
def test_favi_wrong_password(app):
import config as cfg, hashlib, os
salt = os.urandom(16)
cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \
hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex()
a = bot.config.SUPER_ADMIN_IDS[0]
app.handle_message(a, "/favi-stop")
app.handle_message(a, "wrong")
assert bot.db.get_flag("bot_disabled", False) is False # не отключился
def test_admin_mute_notifications(app): def test_admin_mute_notifications(app):
"""Админ может отключить себе уведомления от клиентов — _notify_operators его пропускает.""" """Админ может отключить себе уведомления от клиентов — _notify_operators его пропускает."""
op = _admin() op = _admin()