From 7caecb1c7277e95d7d6b1e32503827d0188058d8 Mon Sep 17 00:00:00 2001 From: Kenik Date: Sun, 26 Jul 2026 10:04:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B8=D0=BA=D0=B5=D1=82=D1=8B=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA,=20=C2=AB=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=C2=BB,=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B2=D0=B5?= =?UTF-8?q?=D0=B1=D0=B8=D0=BD=D0=B0=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Тикеты: 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 --- bot.py | 75 +++++++++++++++++++++++++++++++++++++++++----- database.py | 20 ++++++++++++- keyboards.py | 4 +-- tests/test_flow.py | 22 ++++++++++++++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index 0fb967d..cf2b7c0 100644 --- a/bot.py +++ b/bot.py @@ -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 diff --git a/database.py b/database.py index fcbc763..98a1316 100644 --- a/database.py +++ b/database.py @@ -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: diff --git a/keyboards.py b/keyboards.py index 23d417b..2c75731 100644 --- a/keyboards.py +++ b/keyboards.py @@ -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) diff --git a/tests/test_flow.py b/tests/test_flow.py index 3259571..930033c 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -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