From d2ca750a2d49169bc6991ed4e944c64ec5c3a3e5 Mon Sep 17 00:00:00 2001 From: Kenik Date: Sun, 26 Jul 2026 10:58:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0:=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7/=D1=81=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8E=20+=20=D1=8D=D0=BA=D1=81=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=BE=D0=BF=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=20(/favi-stop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 ++ bot.py | 180 ++++++++++++++++++++++++++++++++++++++------- config.py | 15 ++++ database.py | 29 ++++++++ keyboards.py | 37 +++++++--- tests/test_flow.py | 49 ++++++++++++ 6 files changed, 279 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index 44247eb..1deaba4 100644 --- a/.env.example +++ b/.env.example @@ -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_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= diff --git a/bot.py b/bot.py index 4b7c16b..193a84c 100644 --- a/bot.py +++ b/bot.py @@ -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} завершил сессию общения с оператором.") diff --git a/config.py b/config.py index 75b2ffb..d706295 100644 --- a/config.py +++ b/config.py @@ -49,6 +49,11 @@ else: 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") POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf") @@ -121,6 +126,16 @@ LINK_LABELS = { "summer": "Летний интенсив CS2", } +# --- Кнопки/разделы главного меню: показать/скрыть (админка) --- +# key: (человеческое название, показывать ли по умолчанию) +MENU_FEATURES = { + "pickup": ("Подобрать тренировку 🎯", True), + "intensive": ("Летний интенсив CS2 🔥", True), + "prices": ("Посмотреть цены 💰", True), + "question": ("Задать вопрос 💬", True), + "webinar": ("Записаться на вебинар 🎓", False), +} + # --- Триггеры запуска (регистронезависимо) --- MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться", "привет", "здравствуйте", "старт", "меню"] diff --git a/database.py b/database.py index 98a1316..767a1c7 100644 --- a/database.py +++ b/database.py @@ -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() # ---------- Редактируемые ссылки (кнопки записи/оплаты) ---------- @@ -212,6 +221,26 @@ class DatabaseManager: ) 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]: """Retrieve user state and collected data.""" with self._get_connection() as conn: diff --git a/keyboards.py b/keyboards.py index fd61c24..1b344aa 100644 --- a/keyboards.py +++ b/keyboards.py @@ -7,18 +7,34 @@ import config # КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (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.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY) - keyboard.add_line() - keyboard.add_button("Летний интенсив CS2 🔥", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Посмотреть цены 💰", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY) + has = False + + def row(label, color=VkKeyboardColor.SECONDARY): + nonlocal has + if has: + keyboard.add_line() + keyboard.add_button(label, color=color) + has = True + + 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: - keyboard.add_line() - keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.POSITIVE) + row("Админка ⚙️", VkKeyboardColor.POSITIVE) 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_line() + keyboard.add_button("Кнопки меню 🧩", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔", color=VkKeyboardColor.SECONDARY) keyboard.add_line() diff --git a/tests/test_flow.py b/tests/test_flow.py index 54e3dde..1d9b862 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -88,6 +88,7 @@ def test_baza_question_ticket(app): def test_webinar_registration(app): """Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно).""" u = 70041 + bot.db.set_flag("webinar", True) # вебинар по умолчанию скрыт — включаем app.handle_message(u, "записаться на вебинар") assert "webinar_registered" in bot.db.get_profile(u).get("metki", []) assert _state(u) == "FREE_CHAT" @@ -247,6 +248,54 @@ def test_resolve_vk_links(app): 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): """Админ может отключить себе уведомления от клиентов — _notify_operators его пропускает.""" op = _admin()