Тикеты заявок, «пробное занятие», доработка ветки вебинара

- Тикеты: add_lead возвращает id заявки; при передаче менеджеру клиент получает
  «🎫 Номер вашей заявки: №N», тот же номер — в карточке оператору и в письме.
  Один тикет на заход (сбрасывается при новом старте воронки/интенсива).
- Кнопка «Записаться на пробное» → «Записаться на пробное занятие» (лейблы + распознавание).
- Ветка вебинара: вход по callback-кнопке и по тексту «записаться на вебинар»,
  текст выровнен по ТЗ, после регистрации состояние FREE_CHAT — основное меню
  остаётся доступным (не блокирует продающую ветку). Дата/время — в config.

Тесты: test_baza_question_ticket, test_webinar_registration. pytest 36 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kenik
2026-07-26 10:04:19 +03:00
parent 7889a19744
commit 7caecb1c72
4 changed files with 111 additions and 10 deletions
+68 -7
View File
@@ -141,6 +141,25 @@ class VKBotApplication:
tmpl_str = ", Carousel: Да" if template else ""
logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str}{tmpl_str})")
def _handle_message_event(self, event):
"""Обработка callback-событий от inline-кнопок (MESSAGE_EVENT)."""
user_id = event.obj.user_id
payload = event.obj.payload
event_id = event.obj.event_id
cmd = payload.get("cmd") if isinstance(payload, dict) else None
# Отвечаем VK, чтобы убрать «часики» у кнопки
try:
self.vk.messages.sendMessageEventAnswer(
user_id=user_id,
event_id=event_id,
peer_id=user_id,
event_data='{"type":"show_snackbar","text":"Обработка..."}'
)
except Exception:
pass
if cmd == config.WEBINAR_PAYLOAD:
self._handle_webinar_register(user_id)
def _is_flooding(self, user_id: int) -> bool:
"""True, если пользователь шлёт сообщения слишком часто (защита от флуда/DDoS)."""
now = time.time()
@@ -562,6 +581,11 @@ class VKBotApplication:
self._baza_start_funnel(user_id)
return
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
if 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)
@@ -577,7 +601,7 @@ class VKBotApplication:
return
# 3.1 Кнопки под ценами
if text_lower in ["записаться на пробное", "пробное", "пробное занятие"]:
if text_lower in ["записаться на пробное занятие", "записаться на пробное", "пробное", "пробное занятие"]:
db.add_metka(user_id, "interest_trial")
self._baza_send_payment(user_id, "trial", "Пробное занятие")
return
@@ -730,6 +754,7 @@ class VKBotApplication:
db.set_profile_field(user_id, "source", "Основное меню")
db.add_metka(user_id, "source_main_menu")
db.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
db.set_profile_field(user_id, "ticket", None)
p = db.get_profile(user_id)
if any(p.get(f) for f in ("role", "age", "game", "level")):
self.send_message(
@@ -955,18 +980,21 @@ class VKBotApplication:
self._baza_to_manager(user_id, reason="Нужен совет по формату",
client_text="Менеджер скоро подключится к диалогу. Можете дополнить запрос здесь.")
def _baza_record_lead(self, user_id: int):
"""Фиксирует заявку в журнале leads (для статистики) — один раз на заход воронки."""
def _baza_record_lead(self, user_id: int) -> Optional[int]:
"""Фиксирует заявку в журнале leads (один раз на заход). Возвращает номер тикета."""
p = db.get_profile(user_id)
if p.get("lead_recorded"):
return
return p.get("ticket")
name = self._vk_name(user_id) or ""
service = p.get("tariff") or p.get("format") or p.get("game") or ""
try:
db.add_lead(user_id, source="bot", name=name, phone="", service=service)
ticket = db.add_lead(user_id, source="bot", name=name, phone="", service=service)
db.set_profile_field(user_id, "lead_recorded", True)
db.set_profile_field(user_id, "ticket", ticket)
return ticket
except Exception as e:
logger.error(f"Не удалось записать лид в статистику: {e}")
return None
def _baza_send_payment(self, user_id: int, link_key: str, tariff_label: str, campaign: str = "main"):
"""Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру»."""
@@ -1017,6 +1045,7 @@ class VKBotApplication:
db.set_profile_field(user_id, "interest_summer", True)
db.add_metka(user_id, "interest_summer_intensive")
db.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
db.set_profile_field(user_id, "ticket", None)
db.update_user_state(user_id, "BAZA_INT_INFO")
self.send_message(
user_id,
@@ -1158,6 +1187,8 @@ class VKBotApplication:
p = db.get_profile(user_id)
vk_name = self._vk_name(user_id) or ""
lines = ["🎯 Новый запрос из бота — Кибершкола БАЗА", ""]
if p.get("ticket"):
lines.append(f"🎫 Заявка №{p['ticket']}")
if reason:
lines.append(f"❗ Повод: {reason}")
lines.append(f"👤 Клиент: {vk_name}")
@@ -1195,7 +1226,7 @@ class VKBotApplication:
db.add_metka(user_id, "manager_requested")
from datetime import datetime
db.set_profile_field(user_id, "manager_at", datetime.now().strftime("%Y-%m-%d %H:%M"))
self._baza_record_lead(user_id)
ticket = self._baza_record_lead(user_id)
card = self._baza_manager_card(user_id, reason)
self._notify_operators("💬 Запрос менеджеру\n\n" + card)
try:
@@ -1215,6 +1246,8 @@ class VKBotApplication:
elif not self._within_work_hours():
client_text += (f"\n\n🌙 Сейчас нерабочее время (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 "
f"до {getattr(config, 'WORK_HOUR_END', 21)}:00) — ответим утром.")
if ticket:
client_text += f"\n\n🎫 Номер вашей заявки: №{ticket} — сохраните его для связи."
self.send_message(user_id, client_text, keyboard=get_operator_keyboard())
@@ -1271,6 +1304,29 @@ class VKBotApplication:
logger.error(f"Не удалось предзагрузить медиа: {e}")
# ---------- Вебинар «Точка входа в киберспорт» ----------
def _handle_webinar_register(self, user_id: int):
"""Регистрация на вебинар «Точка входа в киберспорт» (кнопка из поста/клипа)."""
self._baza_started(user_id)
already = "webinar_registered" in db.get_profile(user_id).get("metki", [])
db.add_metka(user_id, "webinar_registered")
db.set_profile_field(user_id, "source", "Вебинар: " + config.WEBINAR_TITLE)
db.update_user_state(user_id, "FREE_CHAT") # остаётся в боте, основное меню доступно
prefix = "Ты уже записан(а) на вебинар 🙌\n\n" if already else ""
self.send_message(
user_id,
prefix +
"Привет! 👋\n"
f"Будем рады видеть тебя на вебинаре «{config.WEBINAR_TITLE}».\n\n"
f"📅 {config.WEBINAR_DATE}\n"
f"{config.WEBINAR_TIME}\n\n"
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n"
"А пока можешь посмотреть направления и цены — кнопки ниже 👇",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
def _handle_operator_chat(self, user_id: int, text: str, text_lower: str):
"""Поведение бота в режиме активного диалога с живым человеком."""
# Пользователь хочет выйти из диалога с оператором
@@ -1644,8 +1700,13 @@ class VKBotApplication:
try:
self.handle_message(user_id, text, attachments)
except Exception as e:
# Ошибка обработки одного сообщения не должна останавливать бота
# Ошибка обработки одного сообщения не должна ронять бота
logger.error(f"Ошибка обработки сообщения от {user_id}: {e}")
elif event.type == VkBotEventType.MESSAGE_EVENT:
try:
self._handle_message_event(event)
except Exception as e:
logger.error(f"Ошибка обработки MESSAGE_EVENT: {e}")
except KeyboardInterrupt:
logger.info("Остановка бота по запросу (Ctrl+C).")
break
+19 -1
View File
@@ -377,7 +377,7 @@ class DatabaseManager:
def add_lead(self, user_id: int, source: str, name: str, phone: str,
service: Optional[str] = None, call_time: Optional[str] = None,
promo: Optional[str] = None, marketing: bool = False):
"""Записать завершённую заявку в единый журнал leads (для статистики и истории)."""
"""Записать завершённую заявку в единый журнал leads. Возвращает id заявки (тикет)."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -386,6 +386,7 @@ class DatabaseManager:
(user_id, source, name, phone, service, call_time, promo, 1 if marketing else 0)
)
conn.commit()
return cursor.lastrowid
def get_marketing_subscribers(self) -> list:
"""Список user_id, давших согласие на маркетинговую рассылку (consent_2 = 'Согласен')."""
@@ -606,6 +607,23 @@ class DatabaseManager:
return answer
return None
# --- Вебинар: список зарегистрированных ---
def get_webinar_registered_users(self) -> list:
"""Список user_id с меткой 'webinar_registered' (профиль JSON)."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT user_id, profile FROM user_states WHERE profile IS NOT NULL")
result = []
for uid, profile_json in cursor.fetchall():
try:
profile = json.loads(profile_json) if profile_json else {}
except (ValueError, TypeError):
profile = {}
metki = profile.get("metki", [])
if "webinar_registered" in metki:
result.append(uid)
return result
# --- Отклики: статусы и статистика ---
def set_application_status(self, app_id: int, status: str) -> bool:
with self._get_connection() as conn:
+2 -2
View File
@@ -83,7 +83,7 @@ def get_baza_format_keyboard() -> str:
def get_baza_group_keyboard() -> str:
return _kb([["Записаться на пробное"], ["Купить 4 тренировки", "Купить 8 тренировок"]],
return _kb([["Записаться на пробное занятие"], ["Купить 4 тренировки", "Купить 8 тренировок"]],
back=True, ask_manager=True)
@@ -135,7 +135,7 @@ def get_baza_prices_keyboard() -> str:
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY)
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_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY)
+22
View File
@@ -74,6 +74,28 @@ def test_baza_records_lead_once(app):
assert any("Групповой" in name for name, _ in s["by_service"])
def test_baza_question_ticket(app):
"""§9: клиенту и в карточку оператору уходит номер заявки (тикет)."""
u = 70040
app._sent.clear()
app.handle_message(u, "Задать вопрос 💬")
app.handle_message(u, "Есть ли рассрочка?")
ticket = bot.db.get_profile(u).get("ticket")
assert ticket # тикет присвоен
assert any(f"{ticket}" in s["msg"] for s in app._sent) # клиент видит номер
def test_webinar_registration(app):
"""Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно)."""
u = 70041
app.handle_message(u, "записаться на вебинар")
assert "webinar_registered" in bot.db.get_profile(u).get("metki", [])
assert _state(u) == "FREE_CHAT"
# основное меню доступно — обычная продающая ветка запускается
app.handle_message(u, "Подобрать тренировку 🎯")
assert _state(u) == "BAZA_FORWHO"
def test_baza_smart_skip_known_fields(app):
"""§15: уже сохранённые ответы не переспрашиваются."""
u = 70020