Админка: показ/скрытие кнопок меню + экстренный стоп бота (/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_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 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 (кнопка меню)
|
||||
# 2. Летний интенсив CS2 (кнопка меню + кодовые слова) — только если раздел включён
|
||||
if db.get_flag("intensive", True):
|
||||
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
|
||||
|
||||
# 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} завершил сессию общения с оператором.")
|
||||
|
||||
@@ -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 = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться",
|
||||
"привет", "здравствуйте", "старт", "меню"]
|
||||
|
||||
+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()
|
||||
|
||||
# ---------- Редактируемые ссылки (кнопки записи/оплаты) ----------
|
||||
@@ -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:
|
||||
|
||||
+26
-9
@@ -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)
|
||||
has = False
|
||||
|
||||
def row(label, color=VkKeyboardColor.SECONDARY):
|
||||
nonlocal has
|
||||
if has:
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user