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