From 7a59b035ce8570eec50619831c7bf4ff4dc0817f Mon Sep 17 00:00:00 2001 From: Kenik Date: Sun, 19 Jul 2026 13:55:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D1=80=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=91=D0=90=D0=97=D0=90:=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20=D0=A2=D0=97=20(?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B8,=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5,=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=8B,=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрыты расхождения с ТЗ: - Метки (§12) переименованы под спецификацию: role_player, game_dota2, format_help, interest_trial, interest_summer_intensive, manager_requested, source_main_menu, source_keyword_summer; убраны служебные age_*/level_* метки. - Сохранённые данные (§13): кодовое слово, дата запуска бота (started_at), дата передачи менеджеру (manager_at), интересы (пробное/интенсив). Карточка менеджеру дополнена этими полями, заголовок «Новый запрос из бота». - «Пока не знаю, нужен совет» (§7): показ собранных данных + свободный текст → менеджеру. - «Задать вопрос» (§9): промпт «напишите одним сообщением» → BAZA_QUESTION → передача. - Летний интенсив: свой набор уровней (§10.3), экран младше-12 (Передать менеджеру/ Другие тренировки), двухшаговая оплата (Оплатить интенсив → ссылка) (§10.4), различение запуска по кодовому слову vs меню. - Индивидуальная ветка без «пробного» (§6). Fallback-текст дословно по §15. - «Выбрать абонемент» ведёт на страницу тарифов ссылкой (§8). Тексты веток — по ТЗ. Тесты воронки обновлены и дополнены (интенсив-оплата, младше-12 интенсив, свободный вопрос, «нужен совет»). pytest 30 passed. Co-Authored-By: Claude Opus 4.8 --- bot.py | 327 +++++++++++++++++++++++++++++++-------------- keyboards.py | 38 +++++- tests/test_flow.py | 42 +++++- 3 files changed, 298 insertions(+), 109 deletions(-) diff --git a/bot.py b/bot.py index bfd97a3..173b3e0 100644 --- a/bot.py +++ b/bot.py @@ -528,8 +528,14 @@ class VKBotApplication: self._handle_baza_int_info(user_id, text_clean, text_lower) elif state == "BAZA_INT_AGE": self._handle_baza_int_age(user_id, text_clean, text_lower) + elif state == "BAZA_INT_YOUNG": + self._handle_baza_int_young(user_id, text_clean, text_lower) elif state == "BAZA_INT_LEVEL": self._handle_baza_int_level(user_id, text_clean, text_lower) + elif state == "BAZA_INT_PAY_INTRO": + self._handle_baza_int_pay_intro(user_id, text_clean, text_lower) + elif state == "BAZA_QUESTION": + self._handle_baza_question(user_id, text_clean, text_lower) elif state == "OPERATOR_CHAT": self._handle_operator_chat(user_id, text_clean, text_lower) else: @@ -554,10 +560,13 @@ class VKBotApplication: self._baza_start_funnel(user_id) return - # 2. Летний интенсив CS2 (+ кодовые слова «лето», «интенсив» и т.п.) - if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив", "интенсив"] \ - or any(t in text_lower for t in config.SUMMER_TRIGGERS): - self._baza_start_intensive(user_id) + # 2. Летний интенсив CS2 (кнопка меню) + if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]: + self._baza_start_intensive(user_id, via_keyword=False) + return + # 2.1 Кодовые слова рекламной кампании (лето/интенсив и т.п.) — сразу в ветку интенсива + if any(t in text_lower for t in config.SUMMER_TRIGGERS): + self._baza_start_intensive(user_id, via_keyword=True) return # 3. Посмотреть цены @@ -567,17 +576,20 @@ class VKBotApplication: # 3.1 Кнопки под ценами if text_lower in ["записаться на пробное", "пробное", "пробное занятие"]: + db.add_metka(user_id, "interest_trial") self._baza_send_payment(user_id, "trial", "Пробное занятие") return if text_lower in ["выбрать абонемент", "абонемент", "абонементы"]: - # Подбираем формат через воронку, затем предложим точный тариф - self._baza_start_funnel(user_id) + # §8: кнопка ведёт на страницу с тарифами + self._baza_send_openlink(user_id, "tariffs", + "Открываю страницу с тарифами — там можно выбрать и оплатить абонемент 👇", + "Открыть тарифы 💳") return - # 4. Задать вопрос / оператор / менеджер — передаём менеджеру + # 4. Задать вопрос — просим написать вопрос одним сообщением (§9) if text_lower in ["задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬", "оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]: - self._baza_to_manager(user_id, reason="Прямой вопрос из меню") + self._baza_ask_question(user_id) return # 5. Площадки — кнопки-ссылки @@ -622,11 +634,10 @@ class VKBotApplication: ) return - # 10. Fallback — не поняли запрос + # 10. Fallback — не поняли запрос (§15, дословно) self.send_message( user_id, - "🤔 Не совсем понял запрос. Давайте я помогу подобрать тренировку или отвечу на вопрос — " - "выберите на кнопках ниже 👇", + "Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) @@ -640,8 +651,23 @@ class VKBotApplication: sep = "&" if "?" in url else "?" return f"{url}{sep}{utm}" - def _baza_send_greeting(self, user_id: int): + 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) + kb = VkKeyboard(inline=True) + kb.add_openlink_button(button, link) + self.send_message(user_id, message, keyboard=kb.get_keyboard()) + + def _baza_started(self, user_id: int): + """Фиксирует факт запуска бота: метка bot_started + дата первого запуска (один раз).""" db.add_metka(user_id, "bot_started") + if not db.get_profile(user_id).get("started_at"): + from datetime import datetime + db.set_profile_field(user_id, "started_at", datetime.now().strftime("%Y-%m-%d %H:%M")) + + def _baza_send_greeting(self, user_id: int): + self._baza_started(user_id) self.send_message( user_id, config.GREETING_BAZA + "\n\nВыберите действие на кнопках ниже 👇", @@ -651,7 +677,8 @@ class VKBotApplication: def _baza_nav(self, user_id: int, text_lower: str) -> bool: """Общие кнопки внутри воронки: меню / вопрос менеджеру. True — если обработали.""" - if text_lower in ["в главное меню ↩️", "главное меню", "меню", "в меню", "выход"]: + if text_lower in ["в главное меню ↩️", "главное меню", "меню", "в меню", "выход", + "вернуться в меню ↩️", "вернуться в меню", "посмотреть другие тренировки"]: db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?", @@ -659,17 +686,17 @@ class VKBotApplication: ) return True if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру", + "задать вопрос 💬", "задать вопрос", "сначала задать вопрос 💬", "сначала задать вопрос", "менеджер", "оператор"]: - self._baza_to_manager(user_id, reason="Вопрос из подбора тренировки") + self._baza_ask_question(user_id) return True return False def _baza_start_funnel(self, user_id: int): """Старт воронки «Подобрать тренировку».""" - db.clear_profile(user_id) - db.set_profile_field(user_id, "source", "Подбор тренировки") - db.add_metka(user_id, "bot_started") - db.add_metka(user_id, "funnel_started") + self._baza_started(user_id) + db.set_profile_field(user_id, "source", "Основное меню") + db.add_metka(user_id, "source_main_menu") db.update_user_state(user_id, "BAZA_FORWHO") self.send_message( user_id, @@ -682,10 +709,10 @@ class VKBotApplication: if self._baza_nav(user_id, text_lower): return if text_lower in ["для себя", "себе", "для меня"]: - db.set_profile_field(user_id, "role", "Для себя (занимается сам)") - db.add_metka(user_id, "role_self") + db.set_profile_field(user_id, "role", "Игрок (пишет сам)") + db.add_metka(user_id, "role_player") elif text_lower in ["для подростка", "для ребёнка", "для ребенка", "для сына", "для дочери", "для подростка "]: - db.set_profile_field(user_id, "role", "Для подростка (пишет родитель)") + db.set_profile_field(user_id, "role", "Родитель (пишет за подростка)") db.add_metka(user_id, "role_parent") else: self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_forwho_keyboard()) @@ -712,18 +739,17 @@ class VKBotApplication: ) return if "12" in text_lower or "13" in text_lower: - age, metka = "12–13 лет", "age_12_13" + age = "12–13 лет" elif "14" in text_lower or "15" in text_lower: - age, metka = "14–15 лет", "age_14_15" + age = "14–15 лет" elif "16" in text_lower: - age, metka = "16 лет и старше", "age_16plus" + age = "16 лет и старше" else: self.send_message(user_id, "Выберите возраст на кнопках ниже 👇", keyboard=get_baza_age_keyboard()) return db.set_profile_field(user_id, "age", age) - db.add_metka(user_id, metka) db.update_user_state(user_id, "BAZA_GAME") - self.send_message(user_id, "Какая игра интересует?", keyboard=get_baza_game_keyboard()) + self.send_message(user_id, "По какой игре хотите заниматься?", keyboard=get_baza_game_keyboard()) def _handle_baza_young(self, user_id: int, text: str, text_lower: str): if text_lower.startswith("обсудить"): @@ -747,14 +773,17 @@ class VKBotApplication: if "cs" in text_lower or "кс" in text_lower or "контр" in text_lower: game, metka = "CS2", "game_cs2" elif "dota" in text_lower or "дота" in text_lower: - game, metka = "Dota 2", "game_dota" + game, metka = "Dota 2", "game_dota2" elif "tank" in text_lower or "танк" in text_lower or "wot" in text_lower: game, metka = "World of Tanks", "game_wot" elif "друг" in text_lower: db.update_user_state(user_id, "BAZA_GAME_OTHER") self.send_message( - user_id, "Напишите, какая игра интересует — передам менеджеру, он подскажет по возможностям.", - keyboard=get_baza_game_keyboard() + user_id, + "Сейчас основные направления БАЗЫ — CS2, Dota 2 и World of Tanks.\n" + "Напишите название игры одним сообщением. Мы передадим запрос менеджеру и сообщим, " + "можем ли предложить подходящего тренера.", + keyboard=get_baza_menu_only_keyboard() ) return else: @@ -763,47 +792,48 @@ class VKBotApplication: db.set_profile_field(user_id, "game", game) db.add_metka(user_id, metka) db.update_user_state(user_id, "BAZA_LEVEL") - self.send_message(user_id, "Какой сейчас уровень игры у ученика?", keyboard=get_baza_level_keyboard()) + self.send_message(user_id, "Как бы вы описали текущий уровень игрока?", keyboard=get_baza_level_keyboard()) def _handle_baza_game_other(self, user_id: int, text: str, text_lower: str): if self._baza_nav(user_id, text_lower): return if text_lower.startswith("назад"): db.update_user_state(user_id, "BAZA_GAME") - self.send_message(user_id, "Какая игра интересует?", keyboard=get_baza_game_keyboard()) + self.send_message(user_id, "По какой игре хотите заниматься?", keyboard=get_baza_game_keyboard()) return db.set_profile_field(user_id, "game", f"Другая игра: {text.strip()}") db.add_metka(user_id, "game_other") - self._baza_to_manager(user_id, reason="Интересует другая игра") + self.send_message(user_id, "Спасибо! Передаю запрос менеджеру.") + self._baza_to_manager(user_id, reason="Интересует другая игра", + client_text="Спасибо! Передаю запрос менеджеру. Он ответит здесь в ближайшее время.") def _handle_baza_level(self, user_id: int, text: str, text_lower: str): if self._baza_nav(user_id, text_lower): return if text_lower.startswith("назад"): db.update_user_state(user_id, "BAZA_GAME") - self.send_message(user_id, "Какая игра интересует?", keyboard=get_baza_game_keyboard()) + self.send_message(user_id, "По какой игре хотите заниматься?", keyboard=get_baza_game_keyboard()) return levels = { - "только начинает": ("Только начинает", "level_novice"), - "играет для себя": ("Играет для себя", "level_casual"), - "есть рейтинг или ранг": ("Есть рейтинг/ранг", "level_ranked"), - "играет в команде или турнирах": ("Команда/турниры", "level_pro"), + "только начинает": "Только начинает", + "играет для себя": "Играет для себя", + "есть рейтинг или ранг": "Есть рейтинг или ранг", + "играет в команде или турнирах": "Играет в команде или турнирах", } match = levels.get(text_lower) if not match: self.send_message(user_id, "Выберите уровень на кнопках ниже 👇", keyboard=get_baza_level_keyboard()) return - db.set_profile_field(user_id, "level", match[0]) - db.add_metka(user_id, match[1]) + db.set_profile_field(user_id, "level", match) db.update_user_state(user_id, "BAZA_FORMAT") - self.send_message(user_id, "Какой формат тренировок предпочтительнее?", keyboard=get_baza_format_keyboard()) + self.send_message(user_id, "Какой формат рассматриваете?", keyboard=get_baza_format_keyboard()) def _handle_baza_format(self, user_id: int, text: str, text_lower: str): if self._baza_nav(user_id, text_lower): return if text_lower.startswith("назад"): db.update_user_state(user_id, "BAZA_LEVEL") - self.send_message(user_id, "Какой сейчас уровень игры у ученика?", keyboard=get_baza_level_keyboard()) + self.send_message(user_id, "Как бы вы описали текущий уровень игрока?", keyboard=get_baza_level_keyboard()) return if "групп" in text_lower: db.set_profile_field(user_id, "format", "Групповые тренировки") @@ -811,9 +841,11 @@ class VKBotApplication: db.update_user_state(user_id, "BAZA_GROUP") self.send_message( user_id, - "Групповые тренировки — занятия в мини-группе с тренером.\n\n" - "🧑‍🤝‍🧑 Групповые:\n• 4 занятия — 2 790 ₽\n• 8 занятий — 4 990 ₽\n\n" - "Можно начать с пробного занятия. Что выберете?", + "Групповые тренировки подойдут тем, кто хочет развиваться вместе с командой: разбирать " + "ошибки, тренировать игровые навыки и учиться взаимодействовать с другими игроками.\n\n" + "Стоимость групповых тренировок:\n" + "• 4 тренировки — 2 790 ₽\n• 8 тренировок — 4 990 ₽\n\n" + "Можно сначала записаться на пробное занятие или сразу выбрать абонемент.", keyboard=get_baza_group_keyboard() ) elif "индивид" in text_lower: @@ -822,20 +854,27 @@ class VKBotApplication: db.update_user_state(user_id, "BAZA_INDIVIDUAL") self.send_message( user_id, - "Индивидуальные тренировки — персональная работа с тренером один на один.\n\n" - "👤 Индивидуальные:\n• 1 занятие — 870 ₽\n• 4 занятия — 3 290 ₽\n• 8 занятий — 5 990 ₽\n\n" - "Можно начать с пробного занятия. Что выберете?", + "На индивидуальных тренировках тренер работает с конкретными задачами игрока: разбирает " + "игру, помогает исправлять ошибки и составляет персональный план развития.\n\n" + "Стоимость индивидуальных тренировок:\n" + "• 1 тренировка — 870 ₽\n• 4 тренировки — 3 290 ₽\n• 8 тренировок — 5 990 ₽", keyboard=get_baza_individual_keyboard() ) elif "не знаю" in text_lower or "совет" in text_lower: - db.set_profile_field(user_id, "format", "Нужен совет по формату") - db.add_metka(user_id, "need_advice") + db.set_profile_field(user_id, "format", "Пока не определился, нужен совет") + db.add_metka(user_id, "format_help") db.update_user_state(user_id, "BAZA_HELP") + p = db.get_profile(user_id) + known = (f"Игра: {p.get('game', '—')}\n" + f"Возраст: {p.get('age', '—')}\n" + f"Уровень: {p.get('level', '—')}") self.send_message( user_id, - "Без проблем — подскажем 🙌 Можно записаться на пробное занятие и определиться на практике " - "или задать вопрос менеджеру, он поможет выбрать формат.", - keyboard=get_baza_help_keyboard() + "Ничего страшного — поможем определиться.\n\n" + "Мы уже знаем:\n" + known + "\n\n" + "Напишите одним сообщением, что для вас особенно важно: расписание, стоимость, " + "выбор тренера, результат или формат занятий.", + keyboard=get_baza_menu_only_keyboard() ) else: self.send_message(user_id, "Выберите формат на кнопках ниже 👇", keyboard=get_baza_format_keyboard()) @@ -848,6 +887,7 @@ class VKBotApplication: self.send_message(user_id, "Какой формат тренировок предпочтительнее?", keyboard=get_baza_format_keyboard()) return if "пробное" in text_lower: + db.add_metka(user_id, "interest_trial") self._baza_send_payment(user_id, "trial", "Пробное занятие (группа)") elif "4" in text_lower: self._baza_send_payment(user_id, "group_4", "Групповой абонемент, 4 занятия") @@ -863,9 +903,7 @@ class VKBotApplication: db.update_user_state(user_id, "BAZA_FORMAT") self.send_message(user_id, "Какой формат тренировок предпочтительнее?", keyboard=get_baza_format_keyboard()) return - if "пробное" in text_lower: - self._baza_send_payment(user_id, "trial", "Пробное занятие (индивид.)") - elif "1" in text_lower: + if "1" in text_lower: self._baza_send_payment(user_id, "individual_1", "Индивидуальное, 1 занятие") elif "4" in text_lower: self._baza_send_payment(user_id, "individual_4", "Индивидуальные, 4 занятия") @@ -875,15 +913,17 @@ class VKBotApplication: self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_individual_keyboard()) def _handle_baza_help(self, user_id: int, text: str, text_lower: str): + """Ветка «Пока не знаю» (§7): пользователь пишет свободным текстом → менеджеру.""" if self._baza_nav(user_id, text_lower): return - if "пробное" in text_lower: - self._baza_send_payment(user_id, "trial", "Пробное занятие") - return - if "подобрать" in text_lower: - self._baza_start_funnel(user_id) - return - self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_help_keyboard()) + db.set_profile_field(user_id, "message", text.strip()) + self.send_message( + user_id, + "Спасибо! Передаю информацию менеджеру. Он посмотрит ваш запрос и поможет подобрать " + "подходящий вариант." + ) + self._baza_to_manager(user_id, reason="Нужен совет по формату", + client_text="Менеджер скоро подключится к диалогу. Можете дополнить запрос здесь.") def _baza_send_payment(self, user_id: int, link_key: str, tariff_label: str, campaign: str = "main"): """Показывает ссылку на оплату/запись + кнопки «проблема с оплатой» / «вопрос менеджеру».""" @@ -897,33 +937,41 @@ class VKBotApplication: kb.add_line() kb.add_button("Проблема с оплатой", color=VkKeyboardColor.NEGATIVE) kb.add_line() - kb.add_button("Задать вопрос менеджеру 💬", color=VkKeyboardColor.SECONDARY) + kb.add_button("Задать вопрос", color=VkKeyboardColor.SECONDARY) self.send_message( user_id, - f"Вы выбрали: {tariff_label}.\n\n" - "Нажмите «Перейти к оплате 💳» — откроется страница оплаты. " - "После оплаты менеджер свяжется с вами и подскажет время занятия.\n\n" - "Если что-то не получается — нажмите «Проблема с оплатой», и мы поможем.", + "Вы переходите на страницу оплаты. После оформления менеджер проверит заявку и свяжется " + "с вами в этом диалоге.\n\n" + "Если ссылка не откроется или возникнет вопрос — нажмите кнопку ниже.", keyboard=kb.get_keyboard() ) def _handle_baza_pay(self, user_id: int, text: str, text_lower: str): if text_lower in ["проблема с оплатой", "проблема", "не получается", "ошибка оплаты"]: db.add_metka(user_id, "payment_problem") - self._baza_to_manager(user_id, reason="Проблема с оплатой") + reason = ("Проблема с оплатой интенсива" + if "интенсив" in (db.get_profile(user_id).get("tariff", "").lower()) else "Проблема с оплатой") + self._baza_to_manager(user_id, reason=reason, + client_text="Передал вас менеджеру — поможем с оплатой. Опишите, что произошло.") return if self._baza_nav(user_id, text_lower): return - # Любой другой текст — считаем вопросом и передаём менеджеру + # Любой другой текст — считаем вопросом, сохраняем и передаём менеджеру db.set_profile_field(user_id, "message", text.strip()) - self._baza_to_manager(user_id, reason="Вопрос на шаге оплаты") + self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.") + self._baza_to_manager(user_id, reason="Вопрос на шаге оплаты", + client_text="Менеджер скоро ответит здесь.") # ---------- Летний интенсив CS2 ---------- - def _baza_start_intensive(self, user_id: int): - db.set_profile_field(user_id, "source", "Летний интенсив CS2") - db.add_metka(user_id, "bot_started") - db.add_metka(user_id, "intensive_interest") + def _baza_start_intensive(self, user_id: int, via_keyword: bool = False): + self._baza_started(user_id) + db.set_profile_field(user_id, "source", "Кодовое слово «лето»" if via_keyword else "Основное меню") + db.add_metka(user_id, "source_keyword_summer" if via_keyword else "source_main_menu") + if via_keyword: + db.set_profile_field(user_id, "keyword", "лето") + db.set_profile_field(user_id, "interest_summer", True) + db.add_metka(user_id, "interest_summer_intensive") db.update_user_state(user_id, "BAZA_INT_INFO") self.send_message( user_id, @@ -936,7 +984,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()) @@ -949,8 +997,13 @@ 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") - self._baza_to_manager(user_id, reason="Интенсив: участник младше 12 лет") + db.update_user_state(user_id, "BAZA_INT_YOUNG") + self.send_message( + user_id, + "Основная программа интенсива рассчитана на участников от 12 лет.\n" + "Мы можем передать запрос менеджеру, чтобы он уточнил у тренера, возможен ли подходящий формат.", + keyboard=get_baza_int_young_keyboard() + ) return if "12" in text_lower or "13" in text_lower: age = "12–13 лет" @@ -963,28 +1016,86 @@ class VKBotApplication: return db.set_profile_field(user_id, "age", age) db.update_user_state(user_id, "BAZA_INT_LEVEL") - self.send_message(user_id, "Какой сейчас уровень игры в CS2?", keyboard=get_baza_level_keyboard()) + self.send_message(user_id, "Какой сейчас уровень в CS2?", keyboard=get_baza_int_level_keyboard()) + + def _handle_baza_int_young(self, user_id: int, text: str, text_lower: str): + if text_lower.startswith("передать"): + self._baza_to_manager(user_id, reason="Интенсив: участник младше 12 лет", + client_text="Передал запрос менеджеру — он уточнит формат и ответит здесь.") + return + if self._baza_nav(user_id, text_lower): # «Посмотреть другие тренировки» → меню + return + self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_int_young_keyboard()) def _handle_baza_int_level(self, user_id: int, text: str, text_lower: str): if self._baza_nav(user_id, text_lower): return if text_lower.startswith("назад"): 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 levels = { - "только начинает": "Только начинает", - "играет для себя": "Играет для себя", - "есть рейтинг или ранг": "Есть рейтинг/ранг", - "играет в команде или турнирах": "Команда/турниры", + "только начинаю": "Только начинаю", + "уже знаю основы": "Уже знаю основы", + "играю регулярно": "Играю регулярно", + "есть рейтинг или командный опыт": "Есть рейтинг или командный опыт", } match = levels.get(text_lower) if not match: - self.send_message(user_id, "Выберите уровень на кнопках ниже 👇", keyboard=get_baza_level_keyboard()) + self.send_message(user_id, "Выберите уровень на кнопках ниже 👇", keyboard=get_baza_int_level_keyboard()) return db.set_profile_field(user_id, "level", match) db.set_profile_field(user_id, "game", "CS2 (интенсив)") - self._baza_send_payment(user_id, "summer", f"Летний интенсив CS2 — {config.INTENSIVE_PRICE}", campaign="summer") + db.update_user_state(user_id, "BAZA_INT_PAY_INTRO") + self.send_message( + user_id, + "Отлично! Можно забронировать место и оплатить участие на сайте.\n\n" + f"Стоимость — {config.INTENSIVE_PRICE} за все 4 тренировки.\n\n" + "После оплаты менеджер подтвердит участие и пришлёт организационную информацию в этот диалог.", + keyboard=get_baza_int_pay_keyboard() + ) + + def _handle_baza_int_pay_intro(self, user_id: int, text: str, text_lower: str): + if "оплатить" in text_lower: + db.set_profile_field(user_id, "tariff", f"Летний интенсив CS2 — {config.INTENSIVE_PRICE}") + self._baza_send_payment(user_id, "summer", f"Летний интенсив CS2 — {config.INTENSIVE_PRICE}", + campaign="summer") + return + if self._baza_nav(user_id, text_lower): # «Сначала задать вопрос» / «Вернуться в меню» + return + self.send_message(user_id, "Выберите вариант на кнопках ниже 👇", keyboard=get_baza_int_pay_keyboard()) + + # ---------- Свободный вопрос менеджеру / цены / карточка ---------- + + def _baza_ask_question(self, user_id: int): + """§9: просит написать вопрос одним сообщением, затем передаёт менеджеру.""" + db.add_metka(user_id, "manager_requested") + db.update_user_state(user_id, "BAZA_QUESTION") + p = db.get_profile(user_id) + known = "" + pieces = [("Интерес", "летний интенсив CS2" if p.get("interest_summer") else None), + ("Возраст", p.get("age")), ("Уровень", p.get("level"))] + known_lines = [f"{lbl}: {val}" for lbl, val in pieces if val] + if known_lines: + known = "\n\nМенеджер получит информацию, которую мы уже собрали:\n" + "\n".join(known_lines) + self.send_message( + user_id, + "Напишите вопрос одним сообщением. Менеджер увидит ваш запрос и подключится к диалогу.\n" + "Все ответы, которые вы уже дали боту, сохранятся — повторять их не потребуется." + known, + keyboard=get_baza_menu_only_keyboard() + ) + + def _handle_baza_question(self, user_id: int, text: str, text_lower: str): + if text_lower in ["в главное меню ↩️", "главное меню", "меню", "в меню", + "посмотреть другие тренировки", "вернуться в меню ↩️"]: + db.update_user_state(user_id, "FREE_CHAT") + self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?", + keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) + return + db.set_profile_field(user_id, "message", text.strip()) + self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.") + self._baza_to_manager(user_id, reason="Вопрос от пользователя", + client_text="Менеджер скоро подключится к диалогу.") # ---------- Цены / передача менеджеру / карточка ---------- @@ -995,30 +1106,44 @@ class VKBotApplication: def _baza_manager_card(self, user_id: int, reason: str = "") -> str: p = db.get_profile(user_id) vk_name = self._vk_name(user_id) or "—" - lines = ["🎯 Новый лид — Кибершкола БАЗА", ""] + lines = ["🎯 Новый запрос из бота — Кибершкола БАЗА", ""] if reason: lines.append(f"❗ Повод: {reason}") lines.append(f"👤 Клиент: {vk_name}") lines.append(f"🔗 Профиль: vk.com/id{user_id}") lines.append("") + # Пустые поля в карточку не выводим (§11) + interest = [] + if p.get("interest_trial"): + interest.append("пробное") + if p.get("interest_summer"): + interest.append("летний интенсив") fields = [ - ("source", "Источник"), ("role", "Для кого"), ("age", "Возраст"), - ("game", "Игра"), ("level", "Уровень"), ("format", "Формат"), - ("tariff", "Интерес / тариф"), ("message", "Сообщение"), + ("source", "Источник"), ("keyword", "Кодовое слово"), ("role", "Кто обращается"), + ("age", "Возраст"), ("game", "Игра"), ("level", "Уровень"), + ("format", "Формат"), ("tariff", "Выбранный тариф"), ("message", "Сообщение пользователя"), ] for key, label in fields: val = p.get(key) if val: lines.append(f"• {label}: {val}") + if interest: + lines.append("• Интерес: " + ", ".join(interest)) + if p.get("started_at"): + lines.append(f"• Запуск бота: {p['started_at']}") + if p.get("manager_at"): + lines.append(f"• Передан менеджеру: {p['manager_at']}") metki = p.get("metki") if metki: lines.append("") lines.append("🏷 Метки: " + ", ".join(metki)) return "\n".join(lines) - def _baza_to_manager(self, user_id: int, reason: str = ""): + def _baza_to_manager(self, user_id: int, reason: str = "", client_text: Optional[str] = None): """Передаёт лид менеджеру с карточкой профиля и переводит в чат с оператором.""" - db.add_metka(user_id, "lead_manager") + 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")) card = self._baza_manager_card(user_id, reason) self._notify_operators("💬 Запрос менеджеру\n\n" + card) try: @@ -1026,14 +1151,18 @@ class VKBotApplication: except Exception as mail_err: logger.error(f"Не удалось отправить email о лиде: {mail_err}") db.update_user_state(user_id, "OPERATOR_CHAT") - if self._within_work_hours(): - client_text = ("💬 Передал вас менеджеру — он ответит здесь в ближайшее время.\n" - "Можете написать свой вопрос прямо сейчас.") - else: - client_text = (f"🌙 Менеджер сейчас оффлайн (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 " - f"до {getattr(config, 'WORK_HOUR_END', 21)}:00).\n" - "Я передал ваш запрос — ответим, как начнётся рабочий день. " - "Можете оставить сообщение здесь.") + if client_text is None: + if self._within_work_hours(): + client_text = ("💬 Передал вас менеджеру — он ответит здесь в ближайшее время.\n" + "Можете написать свой вопрос прямо сейчас.") + else: + client_text = (f"🌙 Менеджер сейчас оффлайн (работаем с {getattr(config, 'WORK_HOUR_START', 9)}:00 " + f"до {getattr(config, 'WORK_HOUR_END', 21)}:00).\n" + "Я передал ваш запрос — ответим, как начнётся рабочий день. " + "Можете оставить сообщение здесь.") + 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) — ответим утром.") self.send_message(user_id, client_text, keyboard=get_operator_keyboard()) diff --git a/keyboards.py b/keyboards.py index 15f240f..332a3f5 100644 --- a/keyboards.py +++ b/keyboards.py @@ -88,17 +88,47 @@ def get_baza_group_keyboard() -> str: def get_baza_individual_keyboard() -> str: - return _kb([["Записаться на пробное"], ["Купить 1 тренировку"], + return _kb([["Купить 1 тренировку"], ["Купить 4 тренировки", "Купить 8 тренировок"]], back=True, ask_manager=True) -def get_baza_help_keyboard() -> str: - return _kb([["Записаться на пробное"], ["Подобрать заново 🎯"]], ask_manager=True) +def get_baza_menu_only_keyboard() -> str: + """Только «В главное меню» — для шагов со свободным вводом (совет/вопрос).""" + return _kb([]) def get_baza_intensive_keyboard() -> str: - return _kb([["Хочу участвовать 🔥"]], ask_manager=True) + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Хочу участвовать 🔥", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Посмотреть другие тренировки", color=VkKeyboardColor.SECONDARY) + return keyboard.get_keyboard() + + +def get_baza_int_level_keyboard() -> str: + return _kb([["Только начинаю"], ["Уже знаю основы"], + ["Играю регулярно"], ["Есть рейтинг или командный опыт"]], back=True) + + +def get_baza_int_pay_keyboard() -> str: + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Оплатить интенсив 💳", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Сначала задать вопрос 💬", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Вернуться в меню ↩️", color=VkKeyboardColor.SECONDARY) + return keyboard.get_keyboard() + + +def get_baza_int_young_keyboard() -> str: + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Передать менеджеру 💬", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Посмотреть другие тренировки", color=VkKeyboardColor.SECONDARY) + return keyboard.get_keyboard() def get_baza_prices_keyboard() -> str: diff --git a/tests/test_flow.py b/tests/test_flow.py index d7d8946..c01174c 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -20,8 +20,9 @@ def test_baza_group_funnel_to_payment(app): app.handle_message(u, "Групповые тренировки"); assert _state(u) == "BAZA_GROUP" app.handle_message(u, "Купить 4 тренировки"); assert _state(u) == "BAZA_PAY" p = _prof(u) - assert p["role"].startswith("Для подростка") and p["game"] == "CS2" + assert p["role"].startswith("Родитель") and p["game"] == "CS2" assert p["format"] == "Групповые тренировки" and "4" in p["tariff"] + assert p.get("started_at") # дата запуска сохранена (§13) assert {"role_parent", "game_cs2", "format_group", "clicked_payment", "lead_hot"} <= set(p["metki"]) @@ -47,7 +48,8 @@ def test_baza_under12_goes_to_manager(app): app.handle_message(u, "Младше 12 лет"); assert _state(u) == "BAZA_YOUNG" app.handle_message(u, "Обсудить с менеджером 💬") assert _state(u) == "OPERATOR_CHAT" - assert "age_under12" in _prof(u)["metki"] + assert _prof(u)["age"] == "Младше 12 лет" + assert "manager_requested" in _prof(u)["metki"] def test_baza_other_game_goes_to_manager(app): @@ -66,14 +68,42 @@ def test_baza_intensive_by_keyword(app): app.handle_message(u, "лето"); assert _state(u) == "BAZA_INT_INFO" app.handle_message(u, "Хочу участвовать 🔥"); assert _state(u) == "BAZA_INT_AGE" app.handle_message(u, "14–15 лет"); assert _state(u) == "BAZA_INT_LEVEL" - app.handle_message(u, "Играет в команде или турнирах"); assert _state(u) == "BAZA_PAY" - assert "intensive_interest" in _prof(u)["metki"] + app.handle_message(u, "Играю регулярно"); assert _state(u) == "BAZA_INT_PAY_INTRO" + app.handle_message(u, "Оплатить интенсив 💳"); assert _state(u) == "BAZA_PAY" + p = _prof(u) + assert p["keyword"] == "лето" and p.get("interest_summer") + assert {"source_keyword_summer", "interest_summer_intensive", "clicked_payment"} <= set(p["metki"]) + + +def test_baza_under12_intensive_to_manager(app): + u = 70017 + app.handle_message(u, "Летний интенсив CS2 🔥") + app.handle_message(u, "Хочу участвовать 🔥") + app.handle_message(u, "Младше 12 лет"); assert _state(u) == "BAZA_INT_YOUNG" + app.handle_message(u, "Передать менеджеру 💬"); assert _state(u) == "OPERATOR_CHAT" def test_baza_question_button_handoff(app): + """«Задать вопрос» (§9): сначала промпт, затем сообщение → менеджеру.""" u = 70015 - app.handle_message(u, "Задать вопрос 💬"); assert _state(u) == "OPERATOR_CHAT" - assert "lead_manager" in _prof(u)["metki"] + app.handle_message(u, "Задать вопрос 💬"); assert _state(u) == "BAZA_QUESTION" + app.handle_message(u, "Когда занятия?"); assert _state(u) == "OPERATOR_CHAT" + assert _prof(u)["message"] == "Когда занятия?" + assert "manager_requested" in _prof(u)["metki"] + + +def test_baza_help_free_text_to_manager(app): + """§7: «Пока не знаю» → свободный текст → менеджеру, format_help.""" + u = 70018 + app.handle_message(u, "Подобрать тренировку 🎯") + app.handle_message(u, "Для себя") + app.handle_message(u, "14–15 лет") + app.handle_message(u, "CS2") + app.handle_message(u, "Только начинает") + app.handle_message(u, "Пока не знаю, нужен совет"); assert _state(u) == "BAZA_HELP" + app.handle_message(u, "важно расписание"); assert _state(u) == "OPERATOR_CHAT" + assert _prof(u)["message"] == "важно расписание" + assert "format_help" in _prof(u)["metki"] def test_baza_prices_and_menu(app):