Тикеты заявок, «пробное занятие», доработка ветки вебинара
- Тикеты: 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:
@@ -141,6 +141,25 @@ class VKBotApplication:
|
|||||||
tmpl_str = ", Carousel: Да" if template else ""
|
tmpl_str = ", Carousel: Да" if template else ""
|
||||||
logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str}{tmpl_str})")
|
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:
|
def _is_flooding(self, user_id: int) -> bool:
|
||||||
"""True, если пользователь шлёт сообщения слишком часто (защита от флуда/DDoS)."""
|
"""True, если пользователь шлёт сообщения слишком часто (защита от флуда/DDoS)."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -562,6 +581,11 @@ class VKBotApplication:
|
|||||||
self._baza_start_funnel(user_id)
|
self._baza_start_funnel(user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
|
||||||
|
if text_lower in ["записаться на вебинар", "вебинар", "хочу на вебинар", "регистрация на вебинар"]:
|
||||||
|
self._handle_webinar_register(user_id)
|
||||||
|
return
|
||||||
|
|
||||||
# 2. Летний интенсив CS2 (кнопка меню)
|
# 2. Летний интенсив CS2 (кнопка меню)
|
||||||
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
|
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
|
||||||
self._baza_start_intensive(user_id, via_keyword=False)
|
self._baza_start_intensive(user_id, via_keyword=False)
|
||||||
@@ -577,7 +601,7 @@ class VKBotApplication:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 3.1 Кнопки под ценами
|
# 3.1 Кнопки под ценами
|
||||||
if text_lower in ["записаться на пробное", "пробное", "пробное занятие"]:
|
if text_lower in ["записаться на пробное занятие", "записаться на пробное", "пробное", "пробное занятие"]:
|
||||||
db.add_metka(user_id, "interest_trial")
|
db.add_metka(user_id, "interest_trial")
|
||||||
self._baza_send_payment(user_id, "trial", "Пробное занятие")
|
self._baza_send_payment(user_id, "trial", "Пробное занятие")
|
||||||
return
|
return
|
||||||
@@ -730,6 +754,7 @@ class VKBotApplication:
|
|||||||
db.set_profile_field(user_id, "source", "Основное меню")
|
db.set_profile_field(user_id, "source", "Основное меню")
|
||||||
db.add_metka(user_id, "source_main_menu")
|
db.add_metka(user_id, "source_main_menu")
|
||||||
db.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
|
db.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
|
||||||
|
db.set_profile_field(user_id, "ticket", None)
|
||||||
p = db.get_profile(user_id)
|
p = db.get_profile(user_id)
|
||||||
if any(p.get(f) for f in ("role", "age", "game", "level")):
|
if any(p.get(f) for f in ("role", "age", "game", "level")):
|
||||||
self.send_message(
|
self.send_message(
|
||||||
@@ -955,18 +980,21 @@ class VKBotApplication:
|
|||||||
self._baza_to_manager(user_id, reason="Нужен совет по формату",
|
self._baza_to_manager(user_id, reason="Нужен совет по формату",
|
||||||
client_text="Менеджер скоро подключится к диалогу. Можете дополнить запрос здесь.")
|
client_text="Менеджер скоро подключится к диалогу. Можете дополнить запрос здесь.")
|
||||||
|
|
||||||
def _baza_record_lead(self, user_id: int):
|
def _baza_record_lead(self, user_id: int) -> Optional[int]:
|
||||||
"""Фиксирует заявку в журнале leads (для статистики) — один раз на заход воронки."""
|
"""Фиксирует заявку в журнале leads (один раз на заход). Возвращает номер тикета."""
|
||||||
p = db.get_profile(user_id)
|
p = db.get_profile(user_id)
|
||||||
if p.get("lead_recorded"):
|
if p.get("lead_recorded"):
|
||||||
return
|
return p.get("ticket")
|
||||||
name = self._vk_name(user_id) or "—"
|
name = self._vk_name(user_id) or "—"
|
||||||
service = p.get("tariff") or p.get("format") or p.get("game") or "—"
|
service = p.get("tariff") or p.get("format") or p.get("game") or "—"
|
||||||
try:
|
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, "lead_recorded", True)
|
||||||
|
db.set_profile_field(user_id, "ticket", ticket)
|
||||||
|
return ticket
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Не удалось записать лид в статистику: {e}")
|
logger.error(f"Не удалось записать лид в статистику: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
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"):
|
||||||
"""Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру»."""
|
"""Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру»."""
|
||||||
@@ -1017,6 +1045,7 @@ class VKBotApplication:
|
|||||||
db.set_profile_field(user_id, "interest_summer", True)
|
db.set_profile_field(user_id, "interest_summer", True)
|
||||||
db.add_metka(user_id, "interest_summer_intensive")
|
db.add_metka(user_id, "interest_summer_intensive")
|
||||||
db.set_profile_field(user_id, "lead_recorded", False) # новый заход = новая возможная заявка
|
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")
|
db.update_user_state(user_id, "BAZA_INT_INFO")
|
||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -1158,6 +1187,8 @@ class VKBotApplication:
|
|||||||
p = db.get_profile(user_id)
|
p = db.get_profile(user_id)
|
||||||
vk_name = self._vk_name(user_id) or "—"
|
vk_name = self._vk_name(user_id) or "—"
|
||||||
lines = ["🎯 Новый запрос из бота — Кибершкола БАЗА", ""]
|
lines = ["🎯 Новый запрос из бота — Кибершкола БАЗА", ""]
|
||||||
|
if p.get("ticket"):
|
||||||
|
lines.append(f"🎫 Заявка №{p['ticket']}")
|
||||||
if reason:
|
if reason:
|
||||||
lines.append(f"❗ Повод: {reason}")
|
lines.append(f"❗ Повод: {reason}")
|
||||||
lines.append(f"👤 Клиент: {vk_name}")
|
lines.append(f"👤 Клиент: {vk_name}")
|
||||||
@@ -1195,7 +1226,7 @@ class VKBotApplication:
|
|||||||
db.add_metka(user_id, "manager_requested")
|
db.add_metka(user_id, "manager_requested")
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
db.set_profile_field(user_id, "manager_at", datetime.now().strftime("%Y-%m-%d %H:%M"))
|
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)
|
card = self._baza_manager_card(user_id, reason)
|
||||||
self._notify_operators("💬 Запрос менеджеру\n\n" + card)
|
self._notify_operators("💬 Запрос менеджеру\n\n" + card)
|
||||||
try:
|
try:
|
||||||
@@ -1215,6 +1246,8 @@ class VKBotApplication:
|
|||||||
elif not self._within_work_hours():
|
elif not self._within_work_hours():
|
||||||
client_text += (f"\n\n🌙 Сейчас нерабочее время (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 "
|
client_text += (f"\n\n🌙 Сейчас нерабочее время (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 "
|
||||||
f"до {getattr(config, 'WORK_HOUR_END', 21)}: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())
|
self.send_message(user_id, client_text, keyboard=get_operator_keyboard())
|
||||||
|
|
||||||
|
|
||||||
@@ -1271,6 +1304,29 @@ class VKBotApplication:
|
|||||||
logger.error(f"Не удалось предзагрузить медиа: {e}")
|
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):
|
def _handle_operator_chat(self, user_id: int, text: str, text_lower: str):
|
||||||
"""Поведение бота в режиме активного диалога с живым человеком."""
|
"""Поведение бота в режиме активного диалога с живым человеком."""
|
||||||
# Пользователь хочет выйти из диалога с оператором
|
# Пользователь хочет выйти из диалога с оператором
|
||||||
@@ -1644,8 +1700,13 @@ class VKBotApplication:
|
|||||||
try:
|
try:
|
||||||
self.handle_message(user_id, text, attachments)
|
self.handle_message(user_id, text, attachments)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Ошибка обработки одного сообщения не должна останавливать бота
|
# Ошибка обработки одного сообщения не должна ронять бота
|
||||||
logger.error(f"Ошибка обработки сообщения от {user_id}: {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:
|
except KeyboardInterrupt:
|
||||||
logger.info("Остановка бота по запросу (Ctrl+C).")
|
logger.info("Остановка бота по запросу (Ctrl+C).")
|
||||||
break
|
break
|
||||||
|
|||||||
+19
-1
@@ -377,7 +377,7 @@ class DatabaseManager:
|
|||||||
def add_lead(self, user_id: int, source: str, name: str, phone: str,
|
def add_lead(self, user_id: int, source: str, name: str, phone: str,
|
||||||
service: Optional[str] = None, call_time: Optional[str] = None,
|
service: Optional[str] = None, call_time: Optional[str] = None,
|
||||||
promo: Optional[str] = None, marketing: bool = False):
|
promo: Optional[str] = None, marketing: bool = False):
|
||||||
"""Записать завершённую заявку в единый журнал leads (для статистики и истории)."""
|
"""Записать завершённую заявку в единый журнал leads. Возвращает id заявки (тикет)."""
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
@@ -386,6 +386,7 @@ class DatabaseManager:
|
|||||||
(user_id, source, name, phone, service, call_time, promo, 1 if marketing else 0)
|
(user_id, source, name, phone, service, call_time, promo, 1 if marketing else 0)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
def get_marketing_subscribers(self) -> list:
|
def get_marketing_subscribers(self) -> list:
|
||||||
"""Список user_id, давших согласие на маркетинговую рассылку (consent_2 = 'Согласен')."""
|
"""Список user_id, давших согласие на маркетинговую рассылку (consent_2 = 'Согласен')."""
|
||||||
@@ -606,6 +607,23 @@ class DatabaseManager:
|
|||||||
return answer
|
return answer
|
||||||
return None
|
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:
|
def set_application_status(self, app_id: int, status: str) -> bool:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
|||||||
+2
-2
@@ -83,7 +83,7 @@ def get_baza_format_keyboard() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_baza_group_keyboard() -> str:
|
def get_baza_group_keyboard() -> str:
|
||||||
return _kb([["Записаться на пробное"], ["Купить 4 тренировки", "Купить 8 тренировок"]],
|
return _kb([["Записаться на пробное занятие"], ["Купить 4 тренировки", "Купить 8 тренировок"]],
|
||||||
back=True, ask_manager=True)
|
back=True, ask_manager=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ def get_baza_prices_keyboard() -> str:
|
|||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
keyboard.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY)
|
keyboard.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY)
|
||||||
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)
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY)
|
||||||
|
|||||||
@@ -74,6 +74,28 @@ def test_baza_records_lead_once(app):
|
|||||||
assert any("Групповой" in name for name, _ in s["by_service"])
|
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):
|
def test_baza_smart_skip_known_fields(app):
|
||||||
"""§15: уже сохранённые ответы не переспрашиваются."""
|
"""§15: уже сохранённые ответы не переспрашиваются."""
|
||||||
u = 70020
|
u = 70020
|
||||||
|
|||||||
Reference in New Issue
Block a user