From 261e5c2da0187c8fb0f27b724ad9520fcefd3e05 Mon Sep 17 00:00:00 2001 From: Kenik Date: Thu, 23 Jul 2026 19:45:47 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D0=BA=D1=81=D1=82=D1=8B=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=A2?= =?UTF-8?q?=D0=97=20+=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BE=D0=BA=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тексты: - «Сколько лет игроку?» (было «…будущему ученику?») во всех местах; - сообщение для «Младше 12 лет» приведено дословно к ТЗ (§4); - убраны отсебятины-преамбулы, которых нет в ТЗ (приветствие, старт воронки, цены «Что дальше?», интенсив «Хотите записаться?»); кнопка младше-12 «Вернуться назад». Смена ссылок в админке: - таблица bot_links (key, url) + методы get_link/get_all_links/set_link; - _link(key): БД-переопределение → дефолт из config/.env; используется на шагах оплаты; - раздел «Ссылки 🔗» в админ-панели: список текущих адресов + «✏️ Изменить ссылку» (выбор из 8 кнопок → ввод URL с валидацией http/https). UTM добавляются автоматически. Тесты: добавлен test_admin_edit_link. pytest 31 passed. Co-Authored-By: Claude Opus 4.8 --- bot.py | 94 ++++++++++++++++++++++++++++++++++++++-------- config.py | 12 ++++++ database.py | 38 ++++++++++++++++++- keyboards.py | 3 +- tests/test_flow.py | 14 +++++++ 5 files changed, 144 insertions(+), 17 deletions(-) diff --git a/bot.py b/bot.py index 96baa66..496c695 100644 --- a/bot.py +++ b/bot.py @@ -247,7 +247,8 @@ class VKBotApplication: "Выберите раздел на кнопках ниже:\n" "• 📊 Статистика — заявки и диалоги\n" "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" - "• 📣 Рассылка — сообщение подписчикам" + "• 📣 Рассылка — сообщение подписчикам\n" + "• 🔗 Ссылки — куда ведут кнопки записи/оплаты" + extra, keyboard=get_admin_keyboard(is_super=is_super) ) @@ -444,6 +445,14 @@ class VKBotApplication: elif text_lower.startswith("рассылка"): self._handle_admin_broadcast(user_id, text_clean) return + elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]: + self._send_links_admin(user_id) + 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") + self.send_message(user_id, "✏️ Какую ссылку изменить? Выберите:", keyboard=get_pick_keyboard(items)) + return elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]: self._send_faq_admin(user_id) return @@ -663,10 +672,14 @@ class VKBotApplication: result = f"{base}{sep}{utm}" return f"{result}#{frag}" if sep_frag else result + def _link(self, key: str) -> str: + """Актуальный URL кнопки: переопределение из БД (админка) или дефолт из config/.env.""" + return db.get_link(key) or config.LINKS.get(key, config.LINKS["tariffs"]) + def _baza_send_openlink(self, user_id: int, link_key: str, message: str, button: str, campaign: str = "main"): """Отправляет сообщение с инлайн-кнопкой-ссылкой (без перевода на оплату/лид).""" - link = self._utm(config.LINKS.get(link_key, config.LINKS["tariffs"]), campaign) + link = self._utm(self._link(link_key), campaign) kb = VkKeyboard(inline=True) kb.add_openlink_button(button, link) self.send_message(user_id, message, keyboard=kb.get_keyboard()) @@ -682,7 +695,7 @@ class VKBotApplication: self._baza_started(user_id) self.send_message( user_id, - config.GREETING_BAZA + "\n\nВыберите действие на кнопках ниже 👇", + config.GREETING_BAZA, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)), attachment=self.get_logo_attachment(user_id) ) @@ -712,7 +725,6 @@ class VKBotApplication: db.update_user_state(user_id, "BAZA_FORWHO") self.send_message( user_id, - "Отлично! Задам пару коротких вопросов и подберу подходящий формат 🎯\n\n" "Для кого подбираем тренировки?", keyboard=get_baza_forwho_keyboard() ) @@ -730,7 +742,7 @@ class VKBotApplication: self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_forwho_keyboard()) return db.update_user_state(user_id, "BAZA_AGE") - self.send_message(user_id, "Сколько лет будущему ученику?", keyboard=get_baza_age_keyboard()) + self.send_message(user_id, "Сколько лет игроку?", keyboard=get_baza_age_keyboard()) def _handle_baza_age(self, user_id: int, text: str, text_lower: str): if self._baza_nav(user_id, text_lower): @@ -741,12 +753,12 @@ class VKBotApplication: return if "младше 12" in text_lower or "меньше 12" in text_lower: db.set_profile_field(user_id, "age", "Младше 12 лет") - db.add_metka(user_id, "age_under12") db.update_user_state(user_id, "BAZA_YOUNG") self.send_message( user_id, - "Обычно мы занимаемся с ребятами от 12 лет. Если ученику чуть меньше — это не проблема, " - "лучше подобрать формат вместе с менеджером.", + "Основные программы Кибершколы БАЗА рассчитаны на игроков от 12 лет. Но мы можем " + "отдельно посмотреть, получится ли подобрать подходящий формат.\n" + "Передать запрос менеджеру?", keyboard=get_baza_younger12_keyboard() ) return @@ -769,7 +781,7 @@ class VKBotApplication: return if text_lower.startswith("назад") or text_lower.startswith("вернуться"): db.update_user_state(user_id, "BAZA_AGE") - self.send_message(user_id, "Сколько лет будущему ученику?", keyboard=get_baza_age_keyboard()) + self.send_message(user_id, "Сколько лет игроку?", keyboard=get_baza_age_keyboard()) return if self._baza_nav(user_id, text_lower): return @@ -780,7 +792,7 @@ class VKBotApplication: return if text_lower.startswith("назад"): db.update_user_state(user_id, "BAZA_AGE") - self.send_message(user_id, "Сколько лет будущему ученику?", keyboard=get_baza_age_keyboard()) + self.send_message(user_id, "Сколько лет игроку?", keyboard=get_baza_age_keyboard()) return if "cs" in text_lower or "кс" in text_lower or "контр" in text_lower: game, metka = "CS2", "game_cs2" @@ -939,7 +951,7 @@ class VKBotApplication: def _baza_send_payment(self, user_id: int, link_key: str, tariff_label: str, campaign: str = "main"): """Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру».""" - link = self._utm(config.LINKS.get(link_key, config.LINKS["tariffs"]), campaign) + link = self._utm(self._link(link_key), campaign) db.set_profile_field(user_id, "tariff", tariff_label) db.add_metka(user_id, "clicked_payment") db.add_metka(user_id, "lead_hot") @@ -987,7 +999,7 @@ class VKBotApplication: db.update_user_state(user_id, "BAZA_INT_INFO") self.send_message( user_id, - config.INTENSIVE_INFO + "\n\nХотите записаться на интенсив?", + config.INTENSIVE_INFO, keyboard=get_baza_intensive_keyboard() ) @@ -996,7 +1008,7 @@ class VKBotApplication: return if "участвовать" in text_lower or "записаться" in text_lower or "хочу" in text_lower or "да" == text_lower: db.update_user_state(user_id, "BAZA_INT_AGE") - self.send_message(user_id, "Отлично! Сколько лет игроку?", keyboard=get_baza_age_keyboard()) + self.send_message(user_id, "Сколько лет игроку?", keyboard=get_baza_age_keyboard()) return self.send_message(user_id, "Выберите действие на кнопках ниже 👇", keyboard=get_baza_intensive_keyboard()) @@ -1005,7 +1017,7 @@ class VKBotApplication: return if text_lower.startswith("назад"): db.update_user_state(user_id, "BAZA_INT_INFO") - self.send_message(user_id, "Хотите записаться на интенсив?", keyboard=get_baza_intensive_keyboard()) + self.send_message(user_id, config.INTENSIVE_INFO, keyboard=get_baza_intensive_keyboard()) return if "младше 12" in text_lower or "меньше 12" in text_lower: db.set_profile_field(user_id, "age", "Младше 12 лет") @@ -1113,7 +1125,7 @@ class VKBotApplication: def _baza_show_prices(self, user_id: int): db.update_user_state(user_id, "FREE_CHAT") - self.send_message(user_id, config.PRICES_TEXT + "\n\nЧто дальше?", keyboard=get_baza_prices_keyboard()) + self.send_message(user_id, config.PRICES_TEXT, keyboard=get_baza_prices_keyboard()) def _baza_manager_card(self, user_id: int, reason: str = "") -> str: p = db.get_profile(user_id) @@ -1383,6 +1395,41 @@ class VKBotApplication: back_to_faq(f"✅ Ответ №{fid} удалён.") return + # ---------- Ссылки кнопок ---------- + if state == "ADMIN_LINK_PICK": + key = pick_id(text) + if key not in config.LINK_LABELS: + self.send_message(user_id, "⚠️ Выберите ссылку кнопкой ниже.") + return + self._admin_ctx[user_id] = {"link_key": key} + db.update_user_state(user_id, "ADMIN_LINK_VAL") + self.send_message( + user_id, + f"✏️ «{config.LINK_LABELS[key]}»\nТекущий адрес:\n{self._link(key)}\n\n" + "Пришлите новый URL (начинается с http:// или https://). UTM добавится автоматически.", + keyboard=get_cancel_admin_keyboard() + ) + return + + if state == "ADMIN_LINK_VAL": + key = self._admin_ctx.get(user_id, {}).get("link_key") + url = text.strip() + if not key or key not in config.LINK_LABELS: + self._admin_ctx.pop(user_id, None) + db.update_user_state(user_id, "FREE_CHAT") + self._send_links_admin(user_id) + return + if not re.match(r"^https?://", url, re.I): + self.send_message(user_id, "⚠️ Ссылка должна начинаться с http:// или https://", + keyboard=get_cancel_admin_keyboard()) + return + db.set_link(key, url) + self._admin_ctx.pop(user_id, None) + db.update_user_state(user_id, "FREE_CHAT") + self.send_message(user_id, f"✅ Ссылка «{config.LINK_LABELS[key]}» обновлена.") + self._send_links_admin(user_id) + return + # ---------- Администраторы (супер-админ) ---------- if state == "ADMIN_ADMIN_ADD": if not self._is_super_admin(user_id): @@ -1446,6 +1493,23 @@ class VKBotApplication: ) self.send_message(user_id, msg) + def _send_links_admin(self, user_id: int): + """Раздел «Ссылки»: текущие адреса кнопок записи/оплаты + кнопка изменения.""" + lines = [] + for key, label in config.LINK_LABELS.items(): + overridden = " (изменена)" if db.get_link(key) else "" + lines.append(f"🔗 {label}{overridden}\n {self._link(key)}") + kb = VkKeyboard(inline=True) + kb.add_button("✏️ Изменить ссылку", color=VkKeyboardColor.PRIMARY) + kb.add_line() + kb.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY) + self.send_message( + user_id, + "🔗 Ссылки кнопок записи/оплаты\n\n" + "\n\n".join(lines) + + "\n\nUTM-метки добавляются автоматически. Нажмите «Изменить ссылку», чтобы задать новый адрес.", + keyboard=kb.get_keyboard() + ) + def _send_faq_admin(self, user_id: int): """Раздел «Быстрые ответы»: список + инструкции.""" rows = db.get_custom_faq() diff --git a/config.py b/config.py index ed54e07..f821a8e 100644 --- a/config.py +++ b/config.py @@ -109,6 +109,18 @@ LINKS = { "summer": os.getenv("LINK_SUMMER", "https://infocyber.pro/"), # оплата летнего интенсива } +# Человеческие названия ссылок (для админки смены ссылок). Порядок = порядок показа. +LINK_LABELS = { + "trial": "Запись на пробное", + "tariffs": "Страница тарифов", + "group_4": "Групповой абонемент — 4 занятия", + "group_8": "Групповой абонемент — 8 занятий", + "individual_1": "Индивидуальное — 1 занятие", + "individual_4": "Индивидуальные — 4 занятия", + "individual_8": "Индивидуальные — 8 занятий", + "summer": "Летний интенсив CS2", +} + # --- Триггеры запуска (регистронезависимо) --- MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться", "привет", "здравствуйте", "старт", "меню"] diff --git a/database.py b/database.py index 7269407..5e249c1 100644 --- a/database.py +++ b/database.py @@ -188,7 +188,43 @@ class DatabaseManager: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + + # Table for editable bot links (переопределяют дефолты из config/.env) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS bot_links ( + key TEXT PRIMARY KEY, + url TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + conn.commit() + + # ---------- Редактируемые ссылки (кнопки записи/оплаты) ---------- + def get_link(self, key: str): + """URL из БД (если админ переопределил) или None.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT url FROM bot_links WHERE key = ?", (key,)) + row = cursor.fetchone() + return row[0] if row else None + + def get_all_links(self) -> Dict[str, str]: + """Все переопределённые ссылки из БД в виде {key: url}.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT key, url FROM bot_links") + return {k: u for k, u in cursor.fetchall()} + + def set_link(self, key: str, url: str): + """Задать/обновить ссылку (переопределяет дефолт из config).""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO bot_links (key, url) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET url = excluded.url, updated_at = CURRENT_TIMESTAMP", + (key, url) + ) conn.commit() def get_user_data(self, user_id: int) -> Dict[str, Any]: diff --git a/keyboards.py b/keyboards.py index 332a3f5..52cbfe9 100644 --- a/keyboards.py +++ b/keyboards.py @@ -63,7 +63,7 @@ def get_baza_younger12_keyboard() -> str: keyboard = VkKeyboard(one_time=False) keyboard.add_button("Обсудить с менеджером 💬", color=VkKeyboardColor.SECONDARY) keyboard.add_line() - keyboard.add_button("Назад ⬅️", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Вернуться назад ↩️", color=VkKeyboardColor.SECONDARY) keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.SECONDARY) return keyboard.get_keyboard() @@ -149,6 +149,7 @@ def get_admin_keyboard(is_super: bool = False) -> str: keyboard.add_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY) keyboard.add_line() keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Ссылки 🔗", color=VkKeyboardColor.SECONDARY) keyboard.add_line() if is_super: keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY) diff --git a/tests/test_flow.py b/tests/test_flow.py index c01174c..4ec233d 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -139,6 +139,20 @@ def _admin(): return bot.config.SUPER_ADMIN_IDS[0] +def test_admin_edit_link(app): + """Админ меняет ссылку кнопки → сохраняется в БД и подхватывается ботом.""" + a = _admin() + app.handle_message(a, "Ссылки 🔗") + app.handle_message(a, "✏️ Изменить ссылку") + app.handle_message(a, "group_4. Групповой абонемент — 4 занятия") + app.handle_message(a, "не-ссылка") # отклоняется валидацией + assert bot.db.get_link("group_4") is None + app.handle_message(a, "https://infocyber.pro/pay/group4") + assert bot.db.get_link("group_4") == "https://infocyber.pro/pay/group4" + # бот отдаёт обновлённую ссылку (с UTM) + assert app._link("group_4") == "https://infocyber.pro/pay/group4" + + def test_admin_faq_crud(app): a = _admin() app.handle_message(a, "Быстрые ответы 💬")