Тексты воронки по ТЗ + смена ссылок из админки

Тексты:
- «Сколько лет игроку?» (было «…будущему ученику?») во всех местах;
- сообщение для «Младше 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 <noreply@anthropic.com>
This commit is contained in:
Kenik
2026-07-23 19:45:47 +03:00
parent fac2e3808b
commit 261e5c2da0
5 changed files with 144 additions and 17 deletions
+79 -15
View File
@@ -247,7 +247,8 @@ class VKBotApplication:
"Выберите раздел на кнопках ниже:\n" "Выберите раздел на кнопках ниже:\n"
"• 📊 Статистика — заявки и диалоги\n" "• 📊 Статистика — заявки и диалоги\n"
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
"• 📣 Рассылка — сообщение подписчикам" "• 📣 Рассылка — сообщение подписчикам\n"
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты"
+ extra, + extra,
keyboard=get_admin_keyboard(is_super=is_super) keyboard=get_admin_keyboard(is_super=is_super)
) )
@@ -444,6 +445,14 @@ class VKBotApplication:
elif text_lower.startswith("рассылка"): elif text_lower.startswith("рассылка"):
self._handle_admin_broadcast(user_id, text_clean) self._handle_admin_broadcast(user_id, text_clean)
return 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 ["быстрые ответы 💬", "быстрые ответы", "фак"]: elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]:
self._send_faq_admin(user_id) self._send_faq_admin(user_id)
return return
@@ -663,10 +672,14 @@ class VKBotApplication:
result = f"{base}{sep}{utm}" result = f"{base}{sep}{utm}"
return f"{result}#{frag}" if sep_frag else result 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, def _baza_send_openlink(self, user_id: int, link_key: str, message: str, button: str,
campaign: str = "main"): 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 = VkKeyboard(inline=True)
kb.add_openlink_button(button, link) kb.add_openlink_button(button, link)
self.send_message(user_id, message, keyboard=kb.get_keyboard()) self.send_message(user_id, message, keyboard=kb.get_keyboard())
@@ -682,7 +695,7 @@ class VKBotApplication:
self._baza_started(user_id) self._baza_started(user_id)
self.send_message( self.send_message(
user_id, user_id,
config.GREETING_BAZA + "\n\nВыберите действие на кнопках ниже 👇", config.GREETING_BAZA,
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)), keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
attachment=self.get_logo_attachment(user_id) attachment=self.get_logo_attachment(user_id)
) )
@@ -712,7 +725,6 @@ class VKBotApplication:
db.update_user_state(user_id, "BAZA_FORWHO") db.update_user_state(user_id, "BAZA_FORWHO")
self.send_message( self.send_message(
user_id, user_id,
"Отлично! Задам пару коротких вопросов и подберу подходящий формат 🎯\n\n"
"Для кого подбираем тренировки?", "Для кого подбираем тренировки?",
keyboard=get_baza_forwho_keyboard() keyboard=get_baza_forwho_keyboard()
) )
@@ -730,7 +742,7 @@ class VKBotApplication:
self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_forwho_keyboard()) self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_forwho_keyboard())
return return
db.update_user_state(user_id, "BAZA_AGE") 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): def _handle_baza_age(self, user_id: int, text: str, text_lower: str):
if self._baza_nav(user_id, text_lower): if self._baza_nav(user_id, text_lower):
@@ -741,12 +753,12 @@ class VKBotApplication:
return return
if "младше 12" in text_lower or "меньше 12" in text_lower: if "младше 12" in text_lower or "меньше 12" in text_lower:
db.set_profile_field(user_id, "age", "Младше 12 лет") db.set_profile_field(user_id, "age", "Младше 12 лет")
db.add_metka(user_id, "age_under12")
db.update_user_state(user_id, "BAZA_YOUNG") db.update_user_state(user_id, "BAZA_YOUNG")
self.send_message( self.send_message(
user_id, user_id,
"Обычно мы занимаемся с ребятами от 12 лет. Если ученику чуть меньше — это не проблема, " "Основные программы Кибершколы БАЗА рассчитаны на игроков от 12 лет. Но мы можем "
"лучше подобрать формат вместе с менеджером.", "отдельно посмотреть, получится ли подобрать подходящий формат.\n"
"Передать запрос менеджеру?",
keyboard=get_baza_younger12_keyboard() keyboard=get_baza_younger12_keyboard()
) )
return return
@@ -769,7 +781,7 @@ class VKBotApplication:
return return
if text_lower.startswith("назад") or text_lower.startswith("вернуться"): if text_lower.startswith("назад") or text_lower.startswith("вернуться"):
db.update_user_state(user_id, "BAZA_AGE") 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 return
if self._baza_nav(user_id, text_lower): if self._baza_nav(user_id, text_lower):
return return
@@ -780,7 +792,7 @@ class VKBotApplication:
return return
if text_lower.startswith("назад"): if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_AGE") 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 return
if "cs" in text_lower or "кс" in text_lower or "контр" in text_lower: if "cs" in text_lower or "кс" in text_lower or "контр" in text_lower:
game, metka = "CS2", "game_cs2" 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"): 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.set_profile_field(user_id, "tariff", tariff_label)
db.add_metka(user_id, "clicked_payment") db.add_metka(user_id, "clicked_payment")
db.add_metka(user_id, "lead_hot") db.add_metka(user_id, "lead_hot")
@@ -987,7 +999,7 @@ class VKBotApplication:
db.update_user_state(user_id, "BAZA_INT_INFO") db.update_user_state(user_id, "BAZA_INT_INFO")
self.send_message( self.send_message(
user_id, user_id,
config.INTENSIVE_INFO + "\n\nХотите записаться на интенсив?", config.INTENSIVE_INFO,
keyboard=get_baza_intensive_keyboard() keyboard=get_baza_intensive_keyboard()
) )
@@ -996,7 +1008,7 @@ class VKBotApplication:
return return
if "участвовать" in text_lower or "записаться" in text_lower or "хочу" in text_lower or "да" == text_lower: if "участвовать" in text_lower or "записаться" in text_lower or "хочу" in text_lower or "да" == text_lower:
db.update_user_state(user_id, "BAZA_INT_AGE") 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 return
self.send_message(user_id, "Выберите действие на кнопках ниже 👇", keyboard=get_baza_intensive_keyboard()) self.send_message(user_id, "Выберите действие на кнопках ниже 👇", keyboard=get_baza_intensive_keyboard())
@@ -1005,7 +1017,7 @@ class VKBotApplication:
return return
if text_lower.startswith("назад"): if text_lower.startswith("назад"):
db.update_user_state(user_id, "BAZA_INT_INFO") 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 return
if "младше 12" in text_lower or "меньше 12" in text_lower: if "младше 12" in text_lower or "меньше 12" in text_lower:
db.set_profile_field(user_id, "age", "Младше 12 лет") db.set_profile_field(user_id, "age", "Младше 12 лет")
@@ -1113,7 +1125,7 @@ class VKBotApplication:
def _baza_show_prices(self, user_id: int): def _baza_show_prices(self, user_id: int):
db.update_user_state(user_id, "FREE_CHAT") 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: def _baza_manager_card(self, user_id: int, reason: str = "") -> str:
p = db.get_profile(user_id) p = db.get_profile(user_id)
@@ -1383,6 +1395,41 @@ class VKBotApplication:
back_to_faq(f"✅ Ответ №{fid} удалён.") back_to_faq(f"✅ Ответ №{fid} удалён.")
return 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 state == "ADMIN_ADMIN_ADD":
if not self._is_super_admin(user_id): if not self._is_super_admin(user_id):
@@ -1446,6 +1493,23 @@ class VKBotApplication:
) )
self.send_message(user_id, msg) 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): def _send_faq_admin(self, user_id: int):
"""Раздел «Быстрые ответы»: список + инструкции.""" """Раздел «Быстрые ответы»: список + инструкции."""
rows = db.get_custom_faq() rows = db.get_custom_faq()
+12
View File
@@ -109,6 +109,18 @@ LINKS = {
"summer": os.getenv("LINK_SUMMER", "https://infocyber.pro/"), # оплата летнего интенсива "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 = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться", MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться",
"привет", "здравствуйте", "старт", "меню"] "привет", "здравствуйте", "старт", "меню"]
+36
View File
@@ -189,6 +189,42 @@ class DatabaseManager:
) )
""") """)
# 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() conn.commit()
def get_user_data(self, user_id: int) -> Dict[str, Any]: def get_user_data(self, user_id: int) -> Dict[str, Any]:
+2 -1
View File
@@ -63,7 +63,7 @@ def get_baza_younger12_keyboard() -> str:
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
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("Вернуться назад ", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.SECONDARY) keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() 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_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Ссылки 🔗", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
if is_super: if is_super:
keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY)
+14
View File
@@ -139,6 +139,20 @@ def _admin():
return bot.config.SUPER_ADMIN_IDS[0] 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): def test_admin_faq_crud(app):
a = _admin() a = _admin()
app.handle_message(a, "Быстрые ответы 💬") app.handle_message(a, "Быстрые ответы 💬")