From 74a855819d5b23c7dc24efc6a4dca425b14b9315 Mon Sep 17 00:00:00 2001 From: Kenik Date: Sat, 18 Jul 2026 18:36:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20=D0=98=D0=9A=D0=9F-?= =?UTF-8?q?=D1=84=D0=BB=D0=BE=D1=83:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0-=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BA=D0=B0,=20=D0=BA=D0=B0=D1=80=D1=8C=D0=B5?= =?UTF-8?q?=D1=80=D0=B0/=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D0=B8,?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=BD=D1=8E=20FAQ=20=D0=B8=20=D0=BE=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=B5=D0=B2=D1=88=D0=B0=D1=8F=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Из ТЗ Кибершколы БАЗА эти сценарии заменены воронкой подбора, поэтому убран мёртвый код: - клиентские FSM: FORM_* (форма-заявка, возвращающийся клиент, редактирование, согласия, выбор услуги, карусель) и VAC_* (карьера: игроки/сотрудники); - меню «Частые вопросы» и config.FAQ_ANSWERS; - осиротевшая админка: управление услугами + карусель, вакансии, кандидаты (в панели и в мастере ADMIN_SVC_*/ADMIN_VAC_*), блок статистики по откликам; - старый _handoff_to_operator и _notify_operator_about_lead (заменены _baza_to_manager); - неиспользуемые клавиатуры (форма/услуги/вакансии/согласия) и импорты (validators, random, string). Оставлено (используется/по ТЗ): воронка БАЗА, быстрые ответы (custom_faq), стата по лидам, рассылка, управление админами, чат с оператором, логотип. Слой БД не тронут — существующие установки не ломаются (таблицы services/applications/vacancy_options просто не используются). bot.py: -1490 строк. Тесты: pytest 28 passed. Co-Authored-By: Claude Opus 4.8 --- bot.py | 1520 +------------------------------------------- config.py | 39 +- keyboards.py | 198 +----- tests/test_flow.py | 23 - 4 files changed, 21 insertions(+), 1759 deletions(-) diff --git a/bot.py b/bot.py index 4618ce9..bfd97a3 100644 --- a/bot.py +++ b/bot.py @@ -1,8 +1,6 @@ import json import re import time -import random -import string import logging import threading from collections import defaultdict @@ -43,10 +41,6 @@ BAN_SEC = 600 # Сколько секунд кэшировать список рантайм-админов из БД ADMINS_CACHE_TTL = 30 -from validators import ( - RU_PHONE_REGEX, INTL_PHONE_REGEX, NAME_REGEX, - normalize_phone, format_phone_display, is_valid_name, -) from keyboards import * # все get_*_keyboard @@ -107,9 +101,6 @@ class VKBotApplication: self._admins_cache: Optional[set] = None self._admins_cache_ts: float = 0.0 - # Инициализируем динамические услуги из базы данных - config.SERVICES = db.get_services() - if vk_api and config.VK_TOKEN != "your_vk_community_token_here": try: self.vk_session = vk_api.VkApi(token=config.VK_TOKEN) @@ -233,10 +224,6 @@ class VKBotApplication: logger.error(f"Не удалось определить VK id по '{s}': {e}") return None - @staticmethod - def _progress_bar(step: int, total: int = 3) -> str: - """Визуальный прогресс шага формы: ▰▰▱ Шаг 2 из 3.""" - return "▰" * step + "▱" * (total - step) + f" Шаг {step} из {total}" def _within_work_hours(self) -> bool: """True, если сейчас рабочее время (config.WORK_HOUR_START..WORK_HOUR_END).""" @@ -250,11 +237,9 @@ class VKBotApplication: extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else "" self.send_message( user_id, - "⚙️ Админ-панель ИКП\n\n" + "⚙️ Админ-панель Кибершколы БАЗА\n\n" "Выберите раздел на кнопках ниже:\n" - "• 📊 Статистика — заявки, отклики, диалоги\n" - "• 👥 Кандидаты — отклики и их статусы\n" - "• ⚙️ Управление услугами — добавить/изменить/удалить\n" + "• 📊 Статистика — заявки и диалоги\n" "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" "• 📣 Рассылка — сообщение подписчикам" + extra, @@ -423,28 +408,6 @@ class VKBotApplication: if text_lower in ["админка", "админка ⚙️", "/admin"]: self._send_admin_panel(user_id) return - # Кнопки-действия раздела «Услуги» - elif text_lower in ["➕ добавить услугу", "добавить услугу"]: - db.update_user_state(user_id, "ADMIN_SVC_ADD") - self.send_message(user_id, "➕ Новая услуга. Отправьте: Название | Цена | Описание\n" - "Пример: Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы.", - keyboard=get_cancel_admin_keyboard()) - return - elif text_lower in ["✏️ изменить услугу", "изменить услугу"]: - items = [(k, s["name"]) for k, s in db.get_services().items()] - db.update_user_state(user_id, "ADMIN_SVC_EDIT_PICK") - self.send_message(user_id, "✏️ Какую услугу изменить? Выберите:", keyboard=get_pick_keyboard(items)) - return - elif text_lower in ["🖼 сменить картинку", "сменить картинку"]: - items = [(k, s["name"]) for k, s in db.get_services().items()] - db.update_user_state(user_id, "ADMIN_SVC_IMG_PICK") - self.send_message(user_id, "🖼 Для какой услуги сменить картинку? Выберите:", keyboard=get_pick_keyboard(items)) - return - elif text_lower in ["🗑 удалить услугу", "удалить услугу"]: - items = [(k, s["name"]) for k, s in db.get_services().items()] - db.update_user_state(user_id, "ADMIN_SVC_DEL_PICK") - self.send_message(user_id, "🗑 Какую услугу удалить? Выберите:", keyboard=get_pick_keyboard(items)) - return # Кнопки-действия раздела «Быстрые ответы» elif text_lower in ["➕ добавить ответ", "добавить ответ"]: db.update_user_state(user_id, "ADMIN_FAQ_ADD") @@ -468,9 +431,6 @@ class VKBotApplication: elif text_lower in ["статистика 📊", "стата", "статистика", "/stats"]: self._handle_admin_stats(user_id) return - elif text_lower in ["управление услугами ⚙️", "управление услугами", "услуги"]: - self._send_admin_menu(user_id) - return elif text_lower in ["рассылка 📣", "рассылка"]: # Кнопка без текста — показываем инструкцию self._handle_admin_broadcast(user_id, "рассылка") @@ -478,29 +438,6 @@ class VKBotApplication: elif text_lower.startswith("рассылка"): self._handle_admin_broadcast(user_id, text_clean) return - elif text_lower in ["вакансии 💼", "вакансии"]: - self._send_vacancy_admin(user_id) - return - elif text_lower in ["➕ вакансия игрок", "вакансия игрок"]: - db.update_user_state(user_id, "ADMIN_VAC_ADD_PLAYER") - self.send_message(user_id, "➕ Название нового направления для ИГРОКОВ (например, Valorant 🎯):", - keyboard=get_cancel_admin_keyboard()) - return - elif text_lower in ["➕ вакансия сотрудник", "вакансия сотрудник"]: - db.update_user_state(user_id, "ADMIN_VAC_ADD_WORKER") - self.send_message(user_id, "➕ Название новой должности для СОТРУДНИКОВ (например, Аналитик):", - keyboard=get_cancel_admin_keyboard()) - return - elif text_lower in ["🗑 удалить вакансию", "удалить вакансию"]: - items = [(i, f"🎮 {l}") for i, l in db.get_vacancy_options("player")] - items += [(i, f"💼 {l}") for i, l in db.get_vacancy_options("worker")] - if not items: - self.send_message(user_id, "Список вакансий пуст.") - self._send_vacancy_admin(user_id) - return - db.update_user_state(user_id, "ADMIN_VAC_DEL_PICK") - self.send_message(user_id, "🗑 Какую вакансию удалить? Выберите:", keyboard=get_pick_keyboard(items)) - return elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]: self._send_faq_admin(user_id) return @@ -510,12 +447,6 @@ class VKBotApplication: elif text_lower.startswith("фак удал "): self._handle_faq_delete(user_id, text_clean) return - elif text_lower in ["кандидаты 👥", "кандидаты"]: - self._send_candidates(user_id) - return - elif text_lower.startswith("статус "): - self._handle_candidate_status(user_id, text_clean) - return elif text_lower in ["администраторы 👑", "администраторы", "админы"]: self._send_admins_panel(user_id) return @@ -548,15 +479,6 @@ class VKBotApplication: elif text_lower.startswith("админ удал"): self._handle_admin_remove_admin(user_id, text_clean) return - elif text_lower.startswith("ред "): - self._handle_admin_edit(user_id, text_clean) - return - elif text_lower.startswith("доб "): - self._handle_admin_add(user_id, text_clean) - return - elif text_lower.startswith("удал "): - self._handle_admin_delete(user_id, text_clean) - return elif text_lower in ["в главное меню ↩️"]: self.send_message( user_id, "🏠 Главное меню.", @@ -576,27 +498,6 @@ class VKBotApplication: logger.info(f"User {user_id} in state {state} sent: {text_clean}") - # Глобальная обработка отмены формы в любых состояниях заполнения опроса - if state.startswith("FORM_") and text_lower in ["отменить заполнение ❌", "отмена", "отменить"]: - db.reset_form(user_id) - self.send_message( - user_id, - "Заполнение заявки отменено. Мы вернулись в главное меню. Чем я могу помочь?", - keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) - ) - return - - # Глобальная отмена отклика на вакансию - if state.startswith("VAC_") and text_lower in ["отменить ❌", "отмена", "отменить"]: - db.clear_vac_data(user_id) - 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 - # Маршрутизация по состояниям (FSM) if state == "FREE_CHAT": self._handle_free_chat(user_id, text_clean, text_lower) @@ -629,60 +530,6 @@ class VKBotApplication: self._handle_baza_int_age(user_id, text_clean, text_lower) elif state == "BAZA_INT_LEVEL": self._handle_baza_int_level(user_id, text_clean, text_lower) - elif state == "FORM_RETURNING": - self._handle_form_returning(user_id, text_clean, text_lower, user_data) - elif state == "FORM_RET_NAME": - self._handle_form_ret_name(user_id, text_clean) - elif state == "FORM_RET_PHONE": - self._handle_form_ret_phone(user_id, text_clean) - elif state == "FORM_NAME": - self._handle_form_name(user_id, text_clean) - elif state == "FORM_PHONE": - self._handle_form_phone(user_id, text_clean) - elif state == "FORM_QUESTION": - self._handle_form_question(user_id, text_clean, user_data) - elif state == "FORM_CALLDATE": - self._handle_form_calldate(user_id, text_clean) - elif state == "FORM_CALLTIME": - self._handle_form_calltime(user_id, text_clean) - elif state == "FORM_CONFIRM_QUESTION": - self._handle_form_confirm_question(user_id, text_clean, user_data) - elif state == "FORM_REVIEW": - self._handle_form_review(user_id, text_clean, user_data) - elif state == "FORM_EDIT_MENU": - self._handle_form_edit_menu(user_id, text_clean, user_data) - elif state == "FORM_EDIT_NAME": - self._handle_form_edit_name(user_id, text_clean) - elif state == "FORM_EDIT_PHONE": - self._handle_form_edit_phone(user_id, text_clean) - elif state == "FORM_EDIT_QUESTION": - self._handle_form_edit_question(user_id, text_clean) - elif state == "FORM_CONSENT_1": - self._handle_form_consent_1(user_id, text_clean, text_lower) - elif state == "FORM_CONSENT_2": - self._handle_form_consent_2(user_id, text_clean, text_lower) - elif state == "FORM_CONSENT_3": - self._handle_form_consent_3(user_id, text_clean, text_lower, user_data) - elif state == "VAC_CHOOSE": - self._handle_vac_choose(user_id, text_clean, text_lower) - elif state == "VAC_P_DISCIPLINE": - self._handle_vac_p_discipline(user_id, text_clean, text_lower) - elif state == "VAC_P_RANK": - self._handle_vac_p_rank(user_id, text_clean) - elif state == "VAC_P_ROLE": - self._handle_vac_p_role(user_id, text_clean) - elif state == "VAC_P_EXP": - self._handle_vac_p_exp(user_id, text_clean) - elif state == "VAC_P_CONTACT": - self._handle_vac_p_contact(user_id, text_clean) - elif state == "VAC_W_POSITION": - self._handle_vac_w_position(user_id, text_clean, text_lower) - elif state == "VAC_W_POSITION_OTHER": - self._handle_vac_w_position_other(user_id, text_clean) - elif state == "VAC_W_EXP": - self._handle_vac_w_exp(user_id, text_clean) - elif state == "VAC_W_CONTACT": - self._handle_vac_w_contact(user_id, text_clean) elif state == "OPERATOR_CHAT": self._handle_operator_chat(user_id, text_clean, text_lower) else: @@ -1190,459 +1037,25 @@ class VKBotApplication: self.send_message(user_id, client_text, keyboard=get_operator_keyboard()) - def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]): - """Приветствие для клиента, чьи имя и телефон уже сохранены.""" - name = user_data.get("name") - phone = format_phone_display(user_data.get("phone", "")) - self.send_message( - user_id, - f"Здравствуйте! Мы вас помним, {name}. 👋\n\n" - f"Ваш номер телефона по-прежнему {phone}?\n\n" - "Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги.", - keyboard=get_returning_keyboard() - ) - # Кнопки изменения данных — инлайн, под отдельным сообщением - self.send_message( - user_id, - "Нужно поправить данные? 👇", - keyboard=get_returning_changes_keyboard() - ) - - def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]): - """Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново.""" - if text_lower in ["да, всё верно ✅", "да, все верно", "да", "верно", "всё верно"]: - # Данные подтверждены — переходим сразу к выбору услуги - db.update_user_state(user_id, "FORM_QUESTION") - self._send_services_prompt(user_id, prefix="Отлично! Перейдём к выбору услуги.\n\n") - elif text_lower in ["сменить имя 👤", "сменить имя", "имя"]: - db.update_user_state(user_id, "FORM_RET_NAME") - self.send_message(user_id, "👤 Введите ваше новое имя:", keyboard=get_cancel_keyboard()) - elif text_lower in ["сменить телефон 📞", "сменить телефон", "телефон"]: - db.update_user_state(user_id, "FORM_RET_PHONE") - self.send_message( - user_id, - "📞 Введите ваш новый номер телефона:", - keyboard=get_cancel_keyboard() - ) - elif text_lower in ["заполнить заново 🔄", "заполнить заново", "заново"]: - db.reset_form_full(user_id) - db.update_user_state(user_id, "FORM_NAME") - self.send_message( - user_id, - "📝 Хорошо, заполним заявку с нуля!\n\n" - f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.", - keyboard=get_cancel_keyboard() - ) - else: - self._send_returning_prompt(user_id, user_data) - - def _handle_form_ret_name(self, user_id: int, text: str): - """Смена имени у возвращающегося клиента → обратно к подтверждению.""" - if not is_valid_name(text): - self.send_message( - user_id, - "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " - "Пожалуйста, введите корректное имя.", - keyboard=get_cancel_keyboard() - ) - return - db.update_user_field(user_id, "name", text.strip()) - db.update_user_state(user_id, "FORM_RETURNING") - self.send_message(user_id, "✅ Имя обновлено!") - self._send_returning_prompt(user_id, db.get_user_data(user_id)) - - def _handle_form_ret_phone(self, user_id: int, text: str): - """Смена телефона у возвращающегося клиента → обратно к подтверждению.""" - phone_cleaned = normalize_phone(text) - if not phone_cleaned: - self.send_message( - user_id, - "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " - "либо международный в формате +<код страны><номер>.", - keyboard=get_cancel_keyboard() - ) - return - db.update_user_field(user_id, "phone", phone_cleaned) - db.update_user_state(user_id, "FORM_RETURNING") - self.send_message(user_id, "✅ Телефон обновлён!") - self._send_returning_prompt(user_id, db.get_user_data(user_id)) - - def _send_services_prompt(self, user_id: int, prefix: str = ""): - """Отправляет услуги каруселью (с фолбэком на текст).""" - self._send_services(user_id, prefix) - - def _handle_form_name(self, user_id: int, text: str): - """Сохранение имени и переход к номеру телефона.""" - # Валидация имени: только буквы/пробел/дефис, длина 2–50 - if not is_valid_name(text): - self.send_message( - user_id, - "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " - "Пожалуйста, введите корректное имя.", - keyboard=get_cancel_keyboard() - ) - return - - # Сохраняем имя в БД - db.update_user_field(user_id, "name", text) - db.update_user_state(user_id, "FORM_PHONE") - - self.send_message( - user_id, - f"👍 Приятно познакомиться, {text}!\n\n" - f"{self._progress_bar(2)}\nВведите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.", - keyboard=get_cancel_keyboard() - ) - - def _handle_form_phone(self, user_id: int, text: str): - """Сохранение телефона и переход к выбору услуги.""" - phone_cleaned = normalize_phone(text) - - # Валидация (российский или международный формат) - if not phone_cleaned: - self.send_message( - user_id, - "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " - "либо международный в формате +<код страны><номер>.", - keyboard=get_cancel_keyboard() - ) - return - - # Сохраняем телефон в БД - db.update_user_field(user_id, "phone", phone_cleaned) - db.update_user_state(user_id, "FORM_QUESTION") - - # Показываем услуги каруселью (с фолбэком на текст) - self._send_services(user_id, f"✅ Телефон принят!\n\n{self._progress_bar(3)}\nКакое направление вас интересует?\n\n") - - @staticmethod - def _match_service(text: str) -> Optional[Dict[str, str]]: - """Сопоставляет введённый текст с услугой: сначала по синонимам дисциплин, - затем по ключу/названию (с очисткой эмодзи). Возвращает svc или None.""" - text_lower = text.lower().strip() - - # 0. Точное совпадение по номеру/ключу услуги — высший приоритет. - # Иначе нечёткий поиск ниже ловит «2» внутри «counter-strike 2» и ломает выбор по номеру. - for key, svc in config.SERVICES.items(): - if text_lower in (key, f"{key}.", f"{key}️⃣"): - return svc - - # 1. Прямое сопоставление синонимов дисциплин для надёжности - synonyms = { - "1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"], - "2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"], - "3": ["танки", "танк", "wot", "world of tanks", "ворлд оф танкс", "вот", "tanks"] - } - for key_opt, words in synonyms.items(): - if any(w in text_lower for w in words): - svc = config.SERVICES.get(key_opt) - if svc: - return svc - - # 2. Нечёткий поиск по названию (если точный ключ и синонимы не сработали) - for key, svc in config.SERVICES.items(): - name_clean = svc["name"].lower().strip() - # Убираем эмодзи и спецсимволы для гибкого сравнения - name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip() - text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip() - - if (name_clean in text_lower or - text_lower in name_clean or - (name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))): - return svc - return None - - def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]): - """Обработка выбора услуги с прямым переходом к проверке данных.""" - selected_service = self._match_service(text) - - if selected_service: - # Сохраняем временный выбор в поле question - db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") - # Сбрасываем возможную старую дату и переходим к выбору ДНЯ звонка - db.update_user_field(user_id, "call_time", None) - db.update_user_state(user_id, "FORM_CALLDATE") - self.send_message( - user_id, - f"Отличный выбор — {selected_service['name']}!\n\n" - "📅 В какой день вам удобно, чтобы менеджер с вами связался?", - keyboard=get_calldate_keyboard() - ) - else: - # Формируем список услуг с ценами для повторного показа - services_text = ( - "⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n" - "Пожалуйста, выберите одну из доступных услуг на клавиатуре или отправьте её номер/название:\n\n" - ) - for key, svc in config.SERVICES.items(): - services_text += f"{key}️⃣ {svc['name']}\n" - services_text += f" 💰 Стоимость: {svc['price']}\n" - services_text += f" 📝 {svc['description']}\n\n" - - self.send_message( - user_id, - services_text, - keyboard=get_services_keyboard() - ) - - def _handle_form_confirm_question(self, user_id: int, text: str, user_data: Dict[str, Any]): - """Резервный обработчик для совместимости с устаревшим шагом подтверждения.""" - db.update_user_state(user_id, "FORM_REVIEW") - self._send_review_message(user_id, user_data) - - def _handle_form_calldate(self, user_id: int, text: str): - """Сохранение выбранного ДНЯ и переход к выбору времени.""" - day = text.strip() - if len(day) < 2 or len(day) > 40: - self.send_message( - user_id, - "📅 Выберите день на клавиатуре ниже или коротко напишите дату.", - keyboard=get_calldate_keyboard() - ) - return - # Временно храним выбранный день в call_time (время добавится следующим шагом) - db.update_user_field(user_id, "call_time", day) - db.update_user_state(user_id, "FORM_CALLTIME") - self.send_message( - user_id, - f"📅 День: {day}.\n\n🕒 В какое время удобно? Выберите слот или напишите своё время:", - keyboard=get_calltime_keyboard() - ) - - def _handle_form_calltime(self, user_id: int, text: str): - """Сохранение времени (день+время) и переход к финальной проверке.""" - time_part = text.strip() - if len(time_part) < 2 or len(time_part) > 40: - self.send_message( - user_id, - "🕒 Выберите время на клавиатуре или коротко напишите его (например, 15:30).", - keyboard=get_calltime_keyboard() - ) - return - day = (db.get_user_data(user_id).get("call_time") or "").strip() - combined = f"{day}, {time_part}" if day else time_part - db.update_user_field(user_id, "call_time", combined) - db.update_user_state(user_id, "FORM_REVIEW") - self._send_review_message(user_id, db.get_user_data(user_id)) - - def _send_review_message(self, user_id: int, user_data: Dict[str, Any]): - """Отправляет сводную карточку проверки данных перед финальным шагом.""" - name = user_data.get("name", "Не указано") - phone = format_phone_display(user_data.get("phone", "Не указано")) - question = user_data.get("question", "Не указано") - call_time = user_data.get("call_time") or "Не указано" - - review_text = ( - "🔍 Пожалуйста, проверьте правильность заполненных данных:\n\n" - f"👤 Имя: {name}\n" - f"📞 Телефон: {phone}\n" - f"🎮 Выбранная услуга: {question}\n" - f"🕒 Удобное время звонка: {call_time}\n\n" - "Если всё верно, нажмите «Да, всё верно! ✅» для продолжения. Если хотите исправить — нажмите «Изменить данные 🔄»." - ) - self.send_message(user_id, review_text, keyboard=get_review_keyboard()) - - def _handle_form_review(self, user_id: int, text: str, user_data: Dict[str, Any]): - """Обработка решений на экране финальной проверки данных.""" - text_lower = text.lower().strip() - - if text_lower in ["да, всё верно! ✅", "да, всё верно", "все верно", "да", "верно"]: - # Переходим к шагу оферты и согласий - db.update_user_state(user_id, "FORM_CONSENT_1") - - # Сообщение 1 — документы инлайн-кнопками (открываются по ссылке) - self.send_message( - user_id, - "📄 Перед отправкой ознакомьтесь, пожалуйста, с документами (кнопки ниже):", - keyboard=get_consent_docs_keyboard() - ) - # Сообщение 2 — подтверждение согласия (кнопки снизу) - self.send_message( - user_id, - "Подтвердите согласие с условиями Публичной оферты, Политикой обработки персональных данных " - "и записью консультации:\n\n" - "«Я ознакомлен(а) с документами, принимаю условия оферты, соглашаюсь на обработку " - "персональных данных и запись консультации.»", - keyboard=get_binary_keyboard(show_skip=False) - ) - - elif text_lower in ["изменить данные 🔄", "изменить данные", "изменить", "редактировать", "поменять"]: - # Переходим в меню изменения полей - db.update_user_state(user_id, "FORM_EDIT_MENU") - self.send_message( - user_id, - "🔄 Меню изменения данных\n\n" - "Пожалуйста, выберите на клавиатуре ниже, какое поле вы хотите изменить:", - keyboard=get_edit_menu_keyboard() - ) - - else: - self.send_message( - user_id, - "❓ Пожалуйста, подтвердите ваши данные кнопкой «Да, всё верно! ✅» или нажмите «Изменить данные 🔄» для редактирования.", - keyboard=get_review_keyboard() - ) - - def _handle_form_edit_menu(self, user_id: int, text: str, user_data: Dict[str, Any]): - """Обработка меню выбора поля для редактирования.""" - text_lower = text.lower().strip() - - if text_lower in ["изменить имя 👤", "изменить имя", "имя", "1"]: - db.update_user_state(user_id, "FORM_EDIT_NAME") - self.send_message( - user_id, - "👤 Введите ваше новое имя:", - keyboard=get_cancel_keyboard() - ) - elif text_lower in ["изменить телефон 📞", "изменить телефон", "телефон", "2"]: - db.update_user_state(user_id, "FORM_EDIT_PHONE") - self.send_message( - user_id, - "📞 Введите ваш новый номер телефона в формате +79991234567:", - keyboard=get_cancel_keyboard() - ) - elif text_lower in ["изменить услугу 🎮", "изменить услугу", "услугу", "услуга", "3"]: - db.update_user_state(user_id, "FORM_EDIT_QUESTION") - - services_text = ( - "🎮 Выберите новую услугу из списка:\n\n" - ) - for key, svc in config.SERVICES.items(): - services_text += f"{key}️⃣ {svc['name']}\n" - services_text += f" 💰 Стоимость: {svc['price']}\n\n" - - self.send_message( - user_id, - services_text, - keyboard=get_services_keyboard() - ) - elif text_lower in ["изменить время 🕒", "изменить время", "время", "4"]: - db.update_user_field(user_id, "call_time", None) - db.update_user_state(user_id, "FORM_CALLDATE") - self.send_message( - user_id, - "📅 В какой день вам удобно, чтобы менеджер с вами связался?", - keyboard=get_calldate_keyboard() - ) - elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "5"]: - db.update_user_state(user_id, "FORM_REVIEW") - self._send_review_message(user_id, user_data) - else: - self.send_message( - user_id, - "❓ Выберите нужный пункт меню на клавиатуре или нажмите «Назад к проверке ↩️» для возврата к карточке данных.", - keyboard=get_edit_menu_keyboard() - ) - - def _handle_form_edit_name(self, user_id: int, text: str): - """Сохранение нового имени и возврат к проверке.""" - if not is_valid_name(text): - self.send_message( - user_id, - "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " - "Пожалуйста, введите корректное имя.", - keyboard=get_cancel_keyboard() - ) - return - - db.update_user_field(user_id, "name", text) - db.update_user_state(user_id, "FORM_REVIEW") - - updated_data = db.get_user_data(user_id) - self.send_message(user_id, "✅ Имя успешно обновлено!") - self._send_review_message(user_id, updated_data) - - def _handle_form_edit_phone(self, user_id: int, text: str): - """Сохранение нового телефона и возврат к проверке.""" - phone_cleaned = normalize_phone(text) - - if not phone_cleaned: - self.send_message( - user_id, - "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " - "либо международный в формате +<код страны><номер>.", - keyboard=get_cancel_keyboard() - ) - return - - db.update_user_field(user_id, "phone", phone_cleaned) - db.update_user_state(user_id, "FORM_REVIEW") - - updated_data = db.get_user_data(user_id) - self.send_message(user_id, "✅ Номер телефона успешно обновлен!") - self._send_review_message(user_id, updated_data) - - def _handle_form_edit_question(self, user_id: int, text: str): - """Сохранение новой услуги и возврат к проверке.""" - selected_service = self._match_service(text) - - if selected_service: - db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") - db.update_user_state(user_id, "FORM_REVIEW") - - updated_data = db.get_user_data(user_id) - self.send_message(user_id, "✅ Услуга успешно обновлена!") - self._send_review_message(user_id, updated_data) - else: - services_text = ( - "⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n" - "Пожалуйста, выберите одну из доступных услуг на клавиатуре:\n\n" - ) - for key, svc in config.SERVICES.items(): - services_text += f"{key}️⃣ {svc['name']}\n" - services_text += f" 💰 Стоимость: {svc['price']}\n\n" - - self.send_message( - user_id, - services_text, - keyboard=get_services_keyboard() - ) - def get_offer_document_attachment(self, peer_id: int) -> Optional[str]: - """Загружает документ оферты на сервер VK и возвращает attachment-строку.""" - if hasattr(self, "_cached_doc_attachment") and self._cached_doc_attachment: - return self._cached_doc_attachment - - import os - doc_path = os.path.join(os.path.dirname(__file__), "Dogovor_oferty_kibersport_versia_izmenennaya.docx") - if not os.path.exists(doc_path): - logger.error(f"Файл оферты не найден по пути: {doc_path}") - return None - - if not self.vk: - # Режим эмуляции (консольный) - logger.info(f"[EMULATOR] Файл {os.path.basename(doc_path)} прикреплен к сообщению.") - return None - - try: - import requests - logger.info("Загрузка документа оферты в VK API...") - upload_server = self.vk.docs.getMessagesUploadServer(peer_id=peer_id, type="doc") - upload_url = upload_server["upload_url"] - - title = "Dogovor_oferty_kibersport.docx" - with open(doc_path, "rb") as f: - response = requests.post(upload_url, files={"file": (title, f)}) - - response_json = response.json() - if "file" not in response_json: - logger.error(f"Ошибка загрузки файла в VK: {response_json}") - return None - - saved_doc = self.vk.docs.save(file=response_json["file"], title=title) - doc_info = saved_doc[0] if isinstance(saved_doc, list) else saved_doc - # docs.save может вернуть {'type': 'doc', 'doc': {...}} — берём вложенный объект - if isinstance(doc_info, dict) and "doc" in doc_info: - doc_info = doc_info["doc"] - attachment = f"doc{doc_info['owner_id']}_{doc_info['id']}" - self._cached_doc_attachment = attachment - logger.info(f"Документ загружен успешно: {attachment}") - return attachment - except Exception as e: - logger.error(f"Ошибка при загрузке документа в VK API: {e}") - return None + + + + + + + + + + + + + + + + def _upload_photo(self, peer_id: int, image_path: str) -> Optional[Dict[str, str]]: """Загружает фото в VK для прикрепления к сообщению. Возвращает {'attachment','raw'} или None.""" @@ -1669,42 +1082,7 @@ class VKBotApplication: logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}") return None - @staticmethod - def _first_photo(attachments: list) -> Optional[dict]: - """Возвращает первое фото-вложение из сообщения (или None).""" - for att in attachments or []: - if isinstance(att, dict) and att.get("type") == "photo": - return att.get("photo") - return None - def _save_service_image(self, service_id: str, photo: dict) -> bool: - """Скачивает присланное фото, обрезает под 13:8 (650×400) и сохраняет как service_.jpg.""" - import os - try: - sizes = photo.get("sizes", []) - if not sizes: - return False - url = max(sizes, key=lambda s: s.get("width", 0)).get("url") - import requests - from PIL import Image - from io import BytesIO - data = requests.get(url, timeout=15).content - im = Image.open(BytesIO(data)).convert("RGB") - TARGET = 13 / 8 - w, h = im.size - if w / h > TARGET: - nw = int(h * TARGET); x = (w - nw) // 2; box = (x, 0, x + nw, h) - else: - nh = int(w / TARGET); y = (h - nh) // 2; box = (0, y, w, y + nh) - im = im.crop(box).resize((650, 400), Image.LANCZOS) - path = os.path.join(os.path.dirname(__file__), "help_img", f"service_{service_id}.jpg") - im.save(path, "JPEG", quality=88) - self._photo_cache.pop(path, None) # сбросить кэш, чтобы перезалить в VK - logger.info(f"Картинка услуги {service_id} обновлена: {path}") - return True - except Exception as e: - logger.error(f"Ошибка сохранения картинки услуги {service_id}: {e}") - return False def get_logo_attachment(self, peer_id: int) -> Optional[str]: """Attachment-строка с логотипом ИКП для прикрепления к приветствию.""" @@ -1732,248 +1110,15 @@ class VKBotApplication: except Exception as e: logger.error(f"Не удалось предзагрузить медиа: {e}") - @staticmethod - def _card_description(svc: Dict[str, str]) -> str: - """Компактное описание карточки карусели: цена + краткая суть (лимит VK — 80 символов).""" - price = str(svc.get("price", "")).strip() - # Берём первую фразу описания (до точки) как короткую суть - blurb = str(svc.get("description", "")).strip().split(".")[0].strip() - text = f"{price} · {blurb}" if blurb else price - if len(text) > 80: - text = text[:79].rstrip(" ,;·") + "…" - return text - def _build_services_carousel(self, peer_id: int) -> Optional[str]: - """Собирает VK-карусель из доступных услуг. Возвращает JSON-строку шаблона или None.""" - import os - base = os.path.join(os.path.dirname(__file__), "help_img") - # Картинки дисциплин по id услуги. ВАЖНО: VK требует пропорцию 13:8 (напр. 650×400). - # Логотип квадратный и не подходит — поэтому фото берём ТОЛЬКО если есть спец-файл дисциплины. - img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"} - elements = [] - for key, svc in config.SERVICES.items(): - # Сначала ищем загруженную админом картинку service_.jpg, затем легаси-имя - uploaded = os.path.join(base, f"service_{key}.jpg") - legacy = os.path.join(base, img_by_id.get(key, "")) - disc_img = uploaded if os.path.exists(uploaded) else legacy - info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None - element = { - "title": svc["name"][:80], - "description": self._card_description(svc), - "buttons": [{ - "action": {"type": "text", "label": f"Выбрать {svc['name']}"[:40]}, - "color": "primary" - }] - } - if info: - element["photo_id"] = info["raw"] - elements.append(element) - if not elements: - return None - # VK требует фото либо у всех карточек, либо ни у одной - if any("photo_id" not in e for e in elements): - for e in elements: - e.pop("photo_id", None) - return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False) - def _send_services(self, user_id: int, intro: str): - """Отправляет услуги каруселью (если возможно), иначе текстом-фолбэком.""" - # Полный текстовый список — для фолбэка (и лога) - full_text = intro + "📋 Доступные направления и расценки:\n\n" - for key, svc in config.SERVICES.items(): - full_text += f"{key}️⃣ {svc['name']}\n 💰 {svc['price']}\n 📝 {svc['description']}\n\n" - full_text += "Выберите направление кнопкой ниже или отправьте его номер/название." - if self.vk: - template = self._build_services_carousel(user_id) - if template: - try: - # 1) Короткий текст + кнопка отмены. Это сразу убирает «зависшие» - # кнопки прошлого шага и даёт нормальную кнопку «Отменить». - self.send_message( - user_id, - intro + "Листайте карточки ниже 👇 и нажмите «Выбрать» под нужным направлением.", - keyboard=get_cancel_keyboard() - ) - # 2) Сама карусель (без клавиатуры — кнопка отмены сохраняется). - db.log_message(user_id, "bot", "[Карусель направлений]") - self.vk.messages.send( - peer_id=user_id, message="🎮 Выберите направление:", - template=template, random_id=get_random_id() - ) - return - except Exception as e: - logger.error(f"Карусель не отправилась, фолбэк на текст: {e}") - # Фолбэк / режим эмуляции - self.send_message(user_id, full_text, keyboard=get_services_keyboard()) - def _build_career_carousel(self, peer_id: int) -> Optional[str]: - """Карусель выбора в разделе «Карьера»: карточки «Игроком» и «Сотрудником».""" - import os - base = os.path.join(os.path.dirname(__file__), "help_img") - cards = [ - ("career_player.jpg", "Игроком в состав 🎮", - "Покажи скилл и попади в ростер ИКП", "Игроком в состав 🎮"), - ("career_worker.jpg", "Сотрудником 💼", - "Тренер, SMM, монтаж, менеджмент, разработка", "Сотрудником 💼"), - ] - elements = [] - for img_name, title, desc, label in cards: - img_path = os.path.join(base, img_name) - info = self._upload_photo(peer_id, img_path) if os.path.exists(img_path) else None - element = { - "title": title[:80], - "description": desc[:80], - "buttons": [{"action": {"type": "text", "label": label[:40]}, "color": "primary"}] - } - if info: - element["photo_id"] = info["raw"] - elements.append(element) - if any("photo_id" not in e for e in elements): - for e in elements: - e.pop("photo_id", None) - return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False) - def _send_career_choice(self, user_id: int): - """Отправляет выбор в «Карьере» каруселью (с фолбэком на обычную клавиатуру).""" - intro = ("💼 Карьера в ИКП\n\n" - "Мы растём и ищем как игроков в состав, так и специалистов в команду.") - if self.vk: - template = self._build_career_carousel(user_id) - if template: - try: - self.send_message( - user_id, - intro + "\nВыберите на карточке ниже 👇, кем хотите присоединиться.", - keyboard=get_vacancy_back_keyboard() - ) - db.log_message(user_id, "bot", "[Карусель карьеры]") - self.vk.messages.send( - peer_id=user_id, message="Кем хотите присоединиться?", - template=template, random_id=get_random_id() - ) - return - except Exception as e: - logger.error(f"Карусель карьеры не отправилась, фолбэк: {e}") - # Фолбэк / эмуляция - self.send_message( - user_id, - intro + "\nКем вы хотите к нам присоединиться?", - keyboard=get_vacancy_choice_keyboard() - ) - def _handle_form_consent_1(self, user_id: int, text: str, text_lower: str): - """Шаг 1 — ОБЯЗАТЕЛЬНЫЕ согласия: оферта, обработка ПД и запись консультации.""" - is_agree = text_lower in ["согласен(а) и подтверждаю ✅", "согласен и подтверждаю", "согласен(а) и подтверждаю", "согласен", "согласна", "да", "принимаю", "согласен(а) ✅"] - is_disagree = text_lower in ["не согласен(а) ❌", "не согласен", "не согласна", "нет", "не принимаю"] - if is_agree: - # Фиксируем обязательные согласия (оферта/ПД и запись консультации) - db.update_user_field(user_id, "consent_1", "Согласен") - db.update_user_field(user_id, "consent_3", "Согласен") - # Переходим к ОТДЕЛЬНОМУ необязательному согласию на рассылку (требование 152-ФЗ) - db.update_user_state(user_id, "FORM_CONSENT_2") - self.send_message( - user_id, - "✅ Спасибо! Обязательные согласия приняты.\n\n" - "📨 Последний шаг (по желанию): хотите получать новости, акции и спецпредложения ИКП?\n\n" - "Это необязательно и никак не влияет на обработку вашей заявки.", - keyboard=get_marketing_keyboard() - ) - elif is_disagree: - self.send_message( - user_id, - "⚠️ Для оформления заявки необходимо принять условия оферты, согласие на обработку персональных данных и запись консультации.\n\n" - "Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.", - keyboard=get_binary_keyboard(show_skip=False) - ) - else: - self.send_message( - user_id, - "❓ Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или выберите «Не согласен(а) ❌» / «Отменить заполнение ❌».", - keyboard=get_binary_keyboard(show_skip=False) - ) - def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str): - """Шаг 2 — НЕОБЯЗАТЕЛЬНОЕ согласие на маркетинговую рассылку. Любой выбор завершает заявку.""" - is_agree = text_lower in ["согласен на рассылку ✅", "согласен на рассылку", "согласен", "согласна", "да", "хочу"] - is_decline = text_lower in ["без рассылки ➡️", "без рассылки", "не согласен", "не согласна", "нет", "не хочу", "пропустить ➡️", "пропустить"] - - if is_agree: - db.update_user_field(user_id, "consent_2", "Согласен") - self._finalize_lead(user_id) - elif is_decline: - db.update_user_field(user_id, "consent_2", "Не согласен") - self._finalize_lead(user_id) - else: - self.send_message( - user_id, - "❓ Хотите получать рассылку ИКП? Нажмите «Согласен на рассылку ✅» или «Без рассылки ➡️».", - keyboard=get_marketing_keyboard() - ) - - def _handle_form_consent_3(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]): - """Резервный обработчик для совместимости с устаревшими состояниями в БД.""" - db.update_user_field(user_id, "consent_3", "Согласен") - self._finalize_lead(user_id) - - @staticmethod - def _generate_promo() -> str: - """Генерирует персональный промокод вида ИКП-AB12CD.""" - code = "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) - return f"ИКП-{code}" - - def _finalize_lead(self, user_id: int): - """Завершает оформление заявки: подтверждение клиенту + уведомление оператору и на почту.""" - updated_data = db.get_user_data(user_id) - phone = updated_data["phone"] - call_time = updated_data.get("call_time") or "не указано" - marketing = (updated_data.get("consent_2") == "Согласен") - db.update_user_state(user_id, "FREE_CHAT") - - # Персональный промокод на скидку (пока выключен флагом config.PROMO_ENABLED) - promo = self._generate_promo() if getattr(config, "PROMO_ENABLED", False) else None - - # Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00) - from datetime import datetime - now = datetime.now() - if 9 <= now.hour < 21: - callback_time = "в течение 15 минут" - else: - callback_time = "сегодня утром, начиная с 9:00" - - confirm_msg = ( - "🎉 Заявка успешно оформлена!\n\n" - f"Наш менеджер свяжется с вами ({callback_time}) по номеру {format_phone_display(phone)}, " - f"удобное время: {call_time}.\n\n" - ) - if promo: - confirm_msg += ( - "🎁 Ваш персональный промокод на скидку 10% на первое занятие:\n" - f"{promo}\n" - "Назовите его менеджеру при записи. " - ) - confirm_msg += "Спасибо за обращение!\n\nВы вернулись в главное меню бота." - - self.send_message(user_id, confirm_msg, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) - - # Журналируем заявку для статистики и истории - try: - db.add_lead( - user_id=user_id, source="bot", - name=updated_data.get("name"), phone=phone, - service=updated_data.get("question"), call_time=updated_data.get("call_time"), - promo=promo, marketing=marketing - ) - except Exception as e: - logger.error(f"Не удалось записать лид в журнал: {e}") - - # Антиспам: не заваливаем оператора повторными уведомлениями от одного пользователя - if self._notify_allowed(user_id): - self._notify_operator_about_lead(user_id, updated_data, promo=promo) - else: - logger.warning(f"Подавлено повторное уведомление о заявке от {user_id} (антиспам)") def _handle_operator_chat(self, user_id: int, text: str, text_lower: str): """Поведение бота в режиме активного диалога с живым человеком.""" @@ -2001,337 +1146,25 @@ class VKBotApplication: # РАЗДЕЛ "КАРЬЕРА В ИКП" (НАЙМ ИГРОКОВ И СОТРУДНИКОВ) # ------------------------------------------ - @staticmethod - def _valid_free_text(text: str) -> bool: - """Базовая проверка свободного ответа в отклике (не пусто, разумной длины).""" - return 1 <= len(text.strip()) <= 300 - def _send_vac_options(self, user_id: int, track: str, question: str, include_other: bool = False): - """Шлёт вопрос (с кнопкой отмены) и инлайн-кнопки доступных опций вакансии из БД.""" - self.send_message(user_id, question, keyboard=get_vacancy_cancel_keyboard()) - kb = VkKeyboard(inline=True) - opts = db.get_vacancy_options(track) - n = 0 - for _id, label in opts[:5]: - if n and n % 2 == 0: - kb.add_line() - kb.add_button(label[:40], color=VkKeyboardColor.SECONDARY) - n += 1 - if include_other: - kb.add_line() - kb.add_button("Другое ✍️", color=VkKeyboardColor.SECONDARY) - self.send_message(user_id, "Выберите вариант 👇", keyboard=kb.get_keyboard()) - def _handle_vac_choose(self, user_id: int, text: str, text_lower: str): - """Выбор ветки: игрок в состав или сотрудник.""" - if text_lower in ["игроком в состав 🎮", "игроком", "игрок", "в состав", "состав"]: - db.update_user_state(user_id, "VAC_P_DISCIPLINE") - self._send_vac_options(user_id, "player", - "🎮 Набор в состав\n\nШаг 1 из 5: по какой дисциплине вы играете?") - elif text_lower in ["сотрудником 💼", "сотрудником", "сотрудник", "работником", "в команду"]: - db.update_user_state(user_id, "VAC_W_POSITION") - self._send_vac_options(user_id, "worker", - "💼 Набор в команду\n\nШаг 1 из 3: на какую должность вы претендуете?", - include_other=True) - elif text_lower in ["назад в меню ↩️", "назад", "меню", "в меню"]: - db.clear_vac_data(user_id) - db.update_user_state(user_id, "FREE_CHAT") - self.send_message(user_id, "🏠 Вы вернулись в главное меню.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) - else: - self.send_message( - user_id, - "Пожалуйста, выберите на клавиатуре: «Игроком в состав 🎮» или «Сотрудником 💼».", - keyboard=get_vacancy_choice_keyboard() - ) # ----- Ветка игрока ----- - def _handle_vac_p_discipline(self, user_id: int, text: str, text_lower: str): - discipline = None - # 1) Сопоставление с опциями из БД (нажата инлайн-кнопка или введено название) - for _id, label in db.get_vacancy_options("player"): - ll = label.lower() - if text.strip() == label or text_lower == ll or ll in text_lower or text_lower in ll: - discipline = label - break - # 2) Фолбэк-синонимы для свободного ввода - if not discipline: - syn = { - "Counter-Strike 2": ["кс", "cs", "cs2", "csgo", "ксго", "контра", "counter"], - "Dota 2": ["дота", "dota", "дотка", "доту"], - "World of Tanks": ["танк", "wot", "tanks", "ворлд оф танкс", "вот"], - } - for name, words in syn.items(): - if any(w in text_lower for w in words): - discipline = name - break - if not discipline: - self._send_vac_options(user_id, "player", "Пожалуйста, выберите дисциплину на кнопках ниже:") - return - db.set_vac_field(user_id, "discipline", discipline) - db.update_user_state(user_id, "VAC_P_RANK") - self.send_message( - user_id, - f"Отлично, {discipline}!\n\n" - "Шаг 2 из 5: укажите ваш текущий ранг / MMR / ELO " - "(например, «Faceit lvl 8», «5000 MMR» или «Легенда»).", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_p_rank(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, коротко укажите ваш ранг / MMR / ELO.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "rank", text.strip()) - db.update_user_state(user_id, "VAC_P_ROLE") - self.send_message( - user_id, - "Шаг 3 из 5: ваша роль / позиция в игре? (например, AWPer, мид, саппорт, командир)", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_p_role(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, укажите вашу роль / позицию.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "role", text.strip()) - db.update_user_state(user_id, "VAC_P_EXP") - self.send_message( - user_id, - "Шаг 4 из 5: расскажите коротко об опыте — в каких командах играли, турниры, достижения.", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_p_exp(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "experience", text.strip()) - db.update_user_state(user_id, "VAC_P_CONTACT") - self.send_message( - user_id, - "Шаг 5 из 5: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_p_contact(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "contact", text.strip()) - self._finalize_application(user_id, "player") # ----- Ветка сотрудника ----- - def _handle_vac_w_position(self, user_id: int, text: str, text_lower: str): - if text_lower in ["другое ✍️", "другое", "иное"]: - db.update_user_state(user_id, "VAC_W_POSITION_OTHER") - self.send_message( - user_id, - "✍️ Напишите, на какую должность вы претендуете.", - keyboard=get_vacancy_cancel_keyboard() - ) - return - # Сопоставление с должностями из БД (инлайн-кнопка), иначе — произвольный текст - position = None - for _id, label in db.get_vacancy_options("worker"): - if text.strip() == label or text_lower == label.lower(): - position = label - break - if not position: - if not self._valid_free_text(text): - self._send_vac_options(user_id, "worker", - "Выберите должность на кнопках ниже или нажмите «Другое ✍️»:", - include_other=True) - return - position = text.strip() - db.set_vac_field(user_id, "position", position) - db.update_user_state(user_id, "VAC_W_EXP") - self.send_message( - user_id, - f"Должность: {position}.\n\n" - "Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_w_position_other(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, напишите желаемую должность.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "position", text.strip()) - db.update_user_state(user_id, "VAC_W_EXP") - self.send_message( - user_id, - "Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_w_exp(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "experience", text.strip()) - db.update_user_state(user_id, "VAC_W_CONTACT") - self.send_message( - user_id, - "Шаг 3 из 3: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).", - keyboard=get_vacancy_cancel_keyboard() - ) - def _handle_vac_w_contact(self, user_id: int, text: str): - if not self._valid_free_text(text): - self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard()) - return - db.set_vac_field(user_id, "contact", text.strip()) - self._finalize_application(user_id, "worker") - def _finalize_application(self, user_id: int, track: str): - """Завершает отклик: подтверждение кандидату + уведомление HR/оператору и письмо.""" - data = db.get_vac_data(user_id) - try: - db.add_application(user_id, track, data) - except Exception as e: - logger.error(f"Не удалось записать отклик в журнал: {e}") - db.clear_vac_data(user_id) - db.update_user_state(user_id, "FREE_CHAT") - self.send_message( - user_id, - "✅ Спасибо за отклик!\n\n" - "Мы изучим вашу анкету и свяжемся, если кандидатура подойдёт. Удачи! 🎮\n\n" - "Вы вернулись в главное меню.", - keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) - ) - # Антиспам: ограничиваем частоту уведомлений об откликах от одного пользователя - if self._notify_allowed(user_id): - self._notify_hr_about_application(user_id, track, data) - else: - logger.warning(f"Подавлено повторное уведомление об отклике от {user_id} (антиспам)") - def _notify_hr_about_application(self, user_id: int, track: str, data: Dict[str, Any]): - """Уведомление об отклике оператору в VK и на HR-почту.""" - if track == "player": - title = "Набор в состав (игрок)" - details = ( - f"Дисциплина: {data.get('discipline', '—')}\n" - f"Ранг/MMR/ELO: {data.get('rank', '—')}\n" - f"Роль/позиция: {data.get('role', '—')}\n" - f"Опыт: {data.get('experience', '—')}\n" - f"Контакт: {data.get('contact', '—')}" - ) - else: - title = "Вакансия (сотрудник)" - details = ( - f"Должность: {data.get('position', '—')}\n" - f"Опыт/навыки: {data.get('experience', '—')}\n" - f"Контакт: {data.get('contact', '—')}" - ) - operator_msg = ( - f"🧩 Новый отклик — {title}!\n\n" - f"{details}\n\n" - f"🔗 Профиль кандидата: vk.com/id{user_id}" - ) - self._notify_operators(operator_msg) - - # Письмо на HR-адрес (пока совпадает с group-zakaz, см. config.HR_MAIL_TO) - try: - email_body = ( - f"Новый отклик из чат-бота ВКонтакте — {title}.\n\n" - f"{details}\n\n" - f"Профиль кандидата: https://vk.com/id{user_id}\n" - ) - mailer.send_email(f"Новый отклик — {title}", email_body, mail_to=getattr(config, "HR_MAIL_TO", None)) - except Exception as mail_err: - logger.error(f"Не удалось отправить email об отклике: {mail_err}") - - def _handoff_to_operator(self, user_id: int): - """Переводит пользователя в режим оператора и уведомляет команду поддержки.""" - 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" - "Я передал ваш запрос — вам ответят утром, как начнётся рабочий день. " - "Можете оставить сообщение здесь. Чтобы вернуться к боту, нажмите кнопку ниже.") - self.send_message(user_id, client_text, keyboard=get_operator_keyboard()) - - # Получаем историю последних 10 сообщений для контекста оператора - history = db.get_dialogue_history(user_id, limit=10) - formatted_history = "" - for sender, msg, ts in history: - role = "Вы" if sender == "user" else "Бот" - formatted_history += f"[{ts}] {role}: {msg}\n" - - # Отправляем алерт оператору с историей диалога - operator_msg = ( - f"🚨 Запрос оператора!\n" - f"Пользователь: vk.com/id{user_id}\n" - f"Тип запроса: Прямой вызов из меню\n\n" - f"📋 Последние сообщения для контекста:\n{formatted_history}" - ) - self._notify_operators(operator_msg) - - def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any], promo: Optional[str] = None): - """Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий.""" - name = user_data["name"] - phone = user_data["phone"] - question = user_data["question"] - call_time = user_data.get("call_time") or "не указано" - consent_1 = user_data.get("consent_1", "Согласен") - consent_2 = user_data.get("consent_2", "Согласен") - consent_3 = user_data.get("consent_3", "Согласен") - promo_line = f"🎁 Промокод клиента: {promo}\n" if promo else "" - - # Реальное имя из VK + флаг несовпадения (помогает отсеять «рандомные» данные) - vk_name = self._vk_name(user_id) - if vk_name: - mismatch = "" - if name and name.strip().lower() not in vk_name.lower() and vk_name.lower() not in name.strip().lower(): - mismatch = " ⚠️ не совпадает с введённым!" - vk_name_line = f"🪪 Имя в VK: {vk_name}{mismatch}\n" - else: - vk_name_line = "" - - operator_msg = ( - f"⚡ Получена новая заявка!\n\n" - f"👤 Имя (введено): {name}\n" - f"{vk_name_line}" - f"📞 Телефон: {format_phone_display(phone)}\n" - f"🎯 Выбранная услуга: {question}\n" - f"🕒 Удобное время звонка: {call_time}\n" - f"{promo_line}\n" - f"⚖️ Согласия пользователя:\n" - f"1. Оферта и обработка ПД (Обяз.): {consent_1}\n" - f"2. Маркетинговые рассылки (Необяз.): {consent_2}\n" - f"3. Запись консультации (Обяз.): {consent_3}\n\n" - f"🔗 Профиль клиента: vk.com/id{user_id}\n" - f"Переписка сохранена в базе данных." - ) - self._notify_operators(operator_msg) - - # Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro) - try: - email_body = ( - "Новая заявка из чат-бота ВКонтакте.\n\n" - f"Имя: {name}\n" - f"Телефон: {phone}\n" - f"Выбранная услуга: {question}\n" - f"Удобное время звонка: {call_time}\n" - + (f"Промокод клиента: {promo}\n" if promo else "") - + "\nСогласия:\n" - f" - Оферта и обработка ПД (обяз.): {consent_1}\n" - f" - Маркетинговые рассылки (необяз.): {consent_2}\n" - f" - Запись консультации (обяз.): {consent_3}\n\n" - f"Профиль клиента: https://vk.com/id{user_id}\n" - ) - mailer.send_email(f"Новая заявка из бота — {name}", email_body) - except Exception as mail_err: - logger.error(f"Не удалось отправить email о заявке: {mail_err}") def _forward_to_operator(self, user_id: int, text: str): """Пересылка сообщений от клиента всем операторам.""" @@ -2341,24 +1174,6 @@ class VKBotApplication: # ЗАПУСК И ОБРАБОТКА LONGPOLL # ------------------------------------------ - def _send_vacancy_admin(self, user_id: int): - """Раздел управления вакансиями (направления игроков / должности сотрудников).""" - players = db.get_vacancy_options("player") - workers = db.get_vacancy_options("worker") - lines = "🎮 Направления (игроки):\n" - lines += ("".join(f" • [{i}] {l}\n" for i, l in players) or " —\n") - lines += "\n💼 Должности (сотрудники):\n" - lines += ("".join(f" • [{i}] {l}\n" for i, l in workers) or " —\n") - kb = VkKeyboard(inline=True) - kb.add_button("➕ Вакансия игрок", color=VkKeyboardColor.PRIMARY) - kb.add_button("➕ Вакансия сотрудник", color=VkKeyboardColor.PRIMARY) - kb.add_line() - kb.add_button("🗑 Удалить вакансию", color=VkKeyboardColor.NEGATIVE) - self.send_message( - user_id, - "💼 Управление вакансиями\n\n" + lines + "\nУправляйте кнопками ниже 👇", - keyboard=kb.get_keyboard() - ) def _handle_admin_wizard(self, user_id: int, text: str, text_lower: str, state: str, attachments: list): """Мастера админки: добавление/изменение/удаление услуг и быстрых ответов, смена картинок.""" @@ -2368,13 +1183,6 @@ class VKBotApplication: self._send_admin_panel(user_id) return - def back_to_services(msg=None): - self._admin_ctx.pop(user_id, None) - db.update_user_state(user_id, "FREE_CHAT") - if msg: - self.send_message(user_id, msg) - self._send_admin_menu(user_id) - def back_to_faq(msg=None): self._admin_ctx.pop(user_id, None) db.update_user_state(user_id, "FREE_CHAT") @@ -2386,85 +1194,6 @@ class VKBotApplication: t = t.strip() return t.split(".")[0].split()[0] if t else "" - # ---------- Услуги ---------- - if state == "ADMIN_SVC_ADD": - parts = [p.strip() for p in text.split("|")] - if len(parts) < 3 or not all(parts[:3]): - self.send_message(user_id, "⚠️ Формат: Название | Цена | Описание", keyboard=get_cancel_admin_keyboard()) - return - new_id = db.add_service(parts[0], parts[1], parts[2]) - config.SERVICES = db.get_services() - back_to_services(f"✅ Услуга добавлена (№{new_id}). Картинку можно задать через «🖼 Сменить картинку».") - return - - if state == "ADMIN_SVC_EDIT_PICK": - sid = pick_id(text) - services = db.get_services() - if sid not in services: - self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.") - return - self._admin_ctx[user_id] = {"svc_id": sid} - db.update_user_state(user_id, "ADMIN_SVC_EDIT_VAL") - self.send_message(user_id, - f"✏️ Услуга «{services[sid]['name']}». Отправьте: поле | значение\n" - "Поля: имя, цена, описание\nПример: цена | 1 900 ₽/час", - keyboard=get_cancel_admin_keyboard()) - return - - if state == "ADMIN_SVC_EDIT_VAL": - sid = self._admin_ctx.get(user_id, {}).get("svc_id") - if not sid or sid not in db.get_services(): - back_to_services("⚠️ Услуга не найдена.") - return - if "|" not in text: - self.send_message(user_id, "⚠️ Формат: поле | значение", keyboard=get_cancel_admin_keyboard()) - return - field, value = [p.strip() for p in text.split("|", 1)] - mapping = {"имя": "name", "название": "name", "цена": "price", "стоимость": "price", "описание": "description"} - if field.lower() not in mapping or not value: - self.send_message(user_id, "⚠️ Поле должно быть: имя / цена / описание.", keyboard=get_cancel_admin_keyboard()) - return - db.update_service(sid, mapping[field.lower()], value) - config.SERVICES = db.get_services() - back_to_services(f"✅ Услуга №{sid} обновлена.") - return - - if state == "ADMIN_SVC_IMG_PICK": - sid = pick_id(text) - if sid not in db.get_services(): - self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.") - return - self._admin_ctx[user_id] = {"svc_id": sid} - db.update_user_state(user_id, "ADMIN_SVC_IMG_WAIT") - self.send_message(user_id, - "🖼 Пришлите картинку (именно фото). Лучше широкую (~13:8) — я сам аккуратно обрежу.", - keyboard=get_cancel_admin_keyboard()) - return - - if state == "ADMIN_SVC_IMG_WAIT": - photo = self._first_photo(attachments) - if not photo: - self.send_message(user_id, "⚠️ Прикрепите именно фото (картинкой), не файлом.", keyboard=get_cancel_admin_keyboard()) - return - sid = self._admin_ctx.get(user_id, {}).get("svc_id") - ok = self._save_service_image(sid, photo) if sid else False - if ok: - import os - self._upload_photo(config.OPERATOR_PEER_ID, - os.path.join(os.path.dirname(__file__), "help_img", f"service_{sid}.jpg")) - back_to_services("✅ Картинка обновлена." if ok else "⚠️ Не удалось сохранить картинку.") - return - - if state == "ADMIN_SVC_DEL_PICK": - sid = pick_id(text) - if sid not in db.get_services(): - self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.") - return - db.delete_service(sid) - config.SERVICES = db.get_services() - back_to_services(f"✅ Услуга №{sid} удалена.") - return - # ---------- Быстрые ответы ---------- if state == "ADMIN_FAQ_ADD": if "|" not in text: @@ -2513,38 +1242,6 @@ class VKBotApplication: back_to_faq(f"✅ Ответ №{fid} удалён.") return - # ---------- Вакансии ---------- - def back_to_vacancies(msg=None): - self._admin_ctx.pop(user_id, None) - db.update_user_state(user_id, "FREE_CHAT") - if msg: - self.send_message(user_id, msg) - self._send_vacancy_admin(user_id) - - if state == "ADMIN_VAC_ADD_PLAYER": - if not self._valid_free_text(text): - self.send_message(user_id, "⚠️ Введите название направления.", keyboard=get_cancel_admin_keyboard()) - return - db.add_vacancy_option("player", text.strip()) - back_to_vacancies("✅ Направление для игроков добавлено.") - return - - if state == "ADMIN_VAC_ADD_WORKER": - if not self._valid_free_text(text): - self.send_message(user_id, "⚠️ Введите название должности.", keyboard=get_cancel_admin_keyboard()) - return - db.add_vacancy_option("worker", text.strip()) - back_to_vacancies("✅ Должность для сотрудников добавлена.") - return - - if state == "ADMIN_VAC_DEL_PICK": - vid = pick_id(text) - if not vid.isdigit() or not db.delete_vacancy_option(int(vid)): - self.send_message(user_id, "⚠️ Выберите вакансию кнопкой ниже.") - return - back_to_vacancies(f"✅ Вакансия №{vid} удалена.") - return - # ---------- Администраторы (супер-админ) ---------- if state == "ADMIN_ADMIN_ADD": if not self._is_super_admin(user_id): @@ -2569,146 +1266,9 @@ class VKBotApplication: db.update_user_state(user_id, "FREE_CHAT") self._send_admin_panel(user_id) - def _send_admin_menu(self, user_id: int): - """Отправляет администратору панель управления услугами с текущим списком.""" - services = db.get_services() - - services_list_text = "" - for key, svc in services.items(): - services_list_text += f"🔹 [{key}] {svc['name']}\n" - services_list_text += f" 💰 Стоимость: {svc['price']}\n" - services_list_text += f" 📝 Описание: {svc['description']}\n\n" - - admin_text = ( - "⚙️ Управление услугами (направлениями)\n\n" - "📋 Текущие направления:\n\n" - f"{services_list_text}" - "Управляйте кнопками ниже:\n" - "• ➕ Добавить • ✏️ Изменить • 🖼 Сменить картинку • 🗑 Удалить" - ) - self.send_message(user_id, admin_text, keyboard=get_svc_admin_keyboard()) - def _handle_admin_edit(self, user_id: int, text: str): - """Обрабатывает редактирование услуги администратором.""" - parts = text.split(" ", 3) - if len(parts) < 4: - self.send_message( - user_id, - "⚠️ Некорректный формат команды редактирования.\n" - "Правильный формат: ред [номер] [поле] [новое значение]\n" - "Пример: ред 1 цена 1 800 ₽/час" - ) - return - - _, service_id, field_name, new_value = parts - field_name = field_name.lower().strip() - new_value = new_value.strip() - - field_mapping = { - "имя": "name", - "название": "name", - "цена": "price", - "стоимость": "price", - "описание": "description" - } - - if field_name not in field_mapping: - self.send_message( - user_id, - "⚠️ Неизвестное поле для редактирования. Допустимые поля: имя, цена, описание." - ) - return - - db_field = field_mapping[field_name] - services = db.get_services() - - if service_id not in services: - self.send_message( - user_id, - f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг." - ) - return - - try: - db.update_service(service_id, db_field, new_value) - config.SERVICES = db.get_services() - - self.send_message( - user_id, - f"✅ Услуга [{service_id}] успешно обновлена!\n" - f"Поле {field_name} изменено на: {new_value}." - ) - self._send_admin_menu(user_id) - except Exception as e: - logger.error(f"Ошибка при редактировании услуги в БД: {e}") - self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") - def _handle_admin_add(self, user_id: int, text: str): - """Обрабатывает добавление новой услуги администратором.""" - content = text[4:].strip() - parts = [p.strip() for p in content.split("|")] - - if len(parts) < 3: - self.send_message( - user_id, - "⚠️ Некорректный формат команды добавления.\n" - "Правильный формат: доб [Имя] | [Цена] | [Описание]\n" - "Пример: доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim." - ) - return - - name, price, description = parts - - if not name or not price or not description: - self.send_message(user_id, "⚠️ Все три поля (имя, цена, описание) должны быть заполнены!") - return - - try: - new_id = db.add_service(name, price, description) - config.SERVICES = db.get_services() - - self.send_message( - user_id, - f"✅ Новая услуга успешно добавлена с номером [{new_id}]!" - ) - self._send_admin_menu(user_id) - except Exception as e: - logger.error(f"Ошибка при добавлении услуги в БД: {e}") - self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") - def _handle_admin_delete(self, user_id: int, text: str): - """Обрабатывает мягкое удаление услуги администратором.""" - parts = text.split(" ") - if len(parts) < 2: - self.send_message( - user_id, - "⚠️ Укажите номер услуги для удаления.\n" - "Пример: удал 4" - ) - return - - service_id = parts[1].strip() - services = db.get_services() - - if service_id not in services: - self.send_message( - user_id, - f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг." - ) - return - - try: - db.delete_service(service_id) - config.SERVICES = db.get_services() - - self.send_message( - user_id, - f"✅ Услуга [{service_id}] успешно удалена из активного списка!" - ) - self._send_admin_menu(user_id) - except Exception as e: - logger.error(f"Ошибка при деактивации услуги в БД: {e}") - self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") def _handle_admin_stats(self, user_id: int): """Формирует и отправляет админу сводную статистику по заявкам и диалогам.""" @@ -2726,7 +1286,7 @@ class VKBotApplication: service_lines = " • пока нет данных\n" msg = ( - "📊 Статистика ИКП\n\n" + "📊 Статистика Кибершколы БАЗА\n\n" "🗂 Заявки (всего): {total}\n" " • сегодня: {today}\n" " • за 7 дней: {last7}\n" @@ -2743,19 +1303,6 @@ class VKBotApplication: mk_yes=ls["marketing_yes"], mk_pct=marketing_pct, services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"] ) - - # Блок откликов (карьера) - aps = db.get_application_stats() - msg += f"\n\n🧩 Отклики (карьера): {aps['total']}\n" - msg += f" • игроки: {aps['players']}\n" - if aps["by_discipline"]: - for disc, cnt in aps["by_discipline"]: - msg += f" ◦ {disc}: {cnt}\n" - msg += f" • сотрудники: {aps['workers']}\n" - if aps["by_position"]: - for pos, cnt in aps["by_position"]: - msg += f" ◦ {pos}: {cnt}\n" - self.send_message(user_id, msg) def _send_faq_admin(self, user_id: int): @@ -2801,36 +1348,7 @@ class VKBotApplication: self.send_message(user_id, f"⚠️ Ответ №{arg} не найден.") self._send_faq_admin(user_id) - def _send_candidates(self, user_id: int): - """Раздел «Кандидаты»: последние отклики со статусами + инструкция смены статуса.""" - rows = db.get_recent_applications(limit=10) - if not rows: - self.send_message(user_id, "👥 Откликов пока нет.") - return - listing = "" - for r in rows: - who = r["discipline"] if r["track"] == "player" else r["position"] - tag = "🎮 игрок" if r["track"] == "player" else "💼 сотрудник" - listing += f"🔹 [{r['id']}] {tag} — {who or '—'}\n контакт: {r['contact'] or '—'} | статус: {r['status'] or 'новый'}\n" - self.send_message( - user_id, - "👥 Последние отклики:\n\n" - f"{listing}\n" - "Сменить статус: статус [номер] [новый статус]\n" - "Пример: статус 3 на собесе\n" - "Рекомендуемые статусы: новый, на собесе, отказ, принят" - ) - def _handle_candidate_status(self, user_id: int, text: str): - parts = text.split(" ", 2) - if len(parts) < 3 or not parts[1].isdigit(): - self.send_message(user_id, "⚠️ Формат: статус [номер] [новый статус]\nПример: статус 3 принят") - return - app_id, new_status = int(parts[1]), parts[2].strip() - if db.set_application_status(app_id, new_status): - self.send_message(user_id, f"✅ Статус отклика №{app_id} → «{new_status}».") - else: - self.send_message(user_id, f"⚠️ Отклик №{app_id} не найден.") def _handle_admin_broadcast(self, user_id: int, text: str): """Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка.""" diff --git a/config.py b/config.py index c894460..ed54e07 100644 --- a/config.py +++ b/config.py @@ -145,44 +145,7 @@ INTENSIVE_INFO = ( f"Стоимость участия — {INTENSIVE_PRICE} за все 4 тренировки." ) -# Standard replies and patterns -FAQ_ANSWERS = { - "цены": ( - "💰 Тарифы ИКП на тренировки (направления: CS2, Dota 2, World of Tanks):\n\n" - "• Разовая тренировка — 870 ₽\n" - "• Абонемент на 4 занятия (групповой) — 2 790 ₽\n" - "• Абонемент на 8 занятий (групповой) — 4 990 ₽\n" - "• Абонемент на 4 занятия (индивидуальный) — 3 290 ₽\n" - "• Абонемент на 8 занятий (индивидуальный) — 5 990 ₽\n\n" - "Оставьте заявку через меню — менеджер подберёт оптимальный формат и направление!" - ), - "контакты": ( - "📞 Связаться с ИКП:\n" - "• Email (поддержка): support@infocyber.pro\n" - "• Заявки: group-zakaz@infocyber.pro\n" - "• Telegram: https://t.me/ikp_team\n" - "• Сайт: https://infocyber.pro/" - ), - "услуги": ( - "⚙️ ИКП — тренировки по киберспортивным дисциплинам:\n\n" - "1. Counter-Strike 2 — aim, раскидки гранат, командная тактика\n" - "2. Dota 2 — разбор реплеев, позиционка, фарм и тимплей\n" - "3. World of Tanks — позиционирование, сведение, тактика боёв\n\n" - "Форматы: разовые занятия, групповые и индивидуальные абонементы (4 и 8 занятий)." - ), - "о компании": ( - "🏢 ИКП — Игра. Киберспорт. Профессионалы.\n\n" - "Мощная экосистема для взрывного роста игроков: лучшие тренеры и глубокая data-аналитика. " - "Организация основана 1 сентября 2025 года.\n\n" - "Мы на площадках:\n" - "• ВКонтакте: https://vk.com/ikpro\n" - "• VK Live (трансляции): https://live.vkvideo.ru/ikp\n" - "• Telegram: https://t.me/ikp_team\n" - "• Lolka: https://vk.com/away.php?to=https%3A%2F%2Flolka.gg%2FlNfM3F9DD&utf=1" - ) -} - -# Доступные услуги (направления) для выбора в форме заявки +# Доступные услуги (направления) — используются для сидирования БД (services) SERVICES = { "1": { "name": "Counter-Strike 2 🔫", diff --git a/keyboards.py b/keyboards.py index 16ce750..15f240f 100644 --- a/keyboards.py +++ b/keyboards.py @@ -116,12 +116,8 @@ def get_admin_keyboard(is_super: bool = False) -> str: """Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы».""" keyboard = VkKeyboard(one_time=False) keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Кандидаты 👥", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Управление услугами ⚙️", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Вакансии 💼", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() keyboard.add_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) keyboard.add_line() if is_super: @@ -131,20 +127,6 @@ def get_admin_keyboard(is_super: bool = False) -> str: return keyboard.get_keyboard() -def get_svc_admin_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_line() - keyboard.add_button("🗑 Удалить услугу", color=VkKeyboardColor.NEGATIVE) - keyboard.add_line() - keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY) - return keyboard.get_keyboard() - - def get_faq_admin_keyboard() -> str: """Клавиатура раздела управления быстрыми ответами.""" keyboard = VkKeyboard(one_time=False) @@ -179,55 +161,6 @@ def get_cancel_admin_keyboard() -> str: return keyboard.get_keyboard() -def get_vacancy_choice_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_vacancy_cancel_keyboard() -> str: - """Клавиатура отмены отклика на свободном текстовом шаге.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_vacancy_back_keyboard() -> str: - """Клавиатура с одной кнопкой «Назад в меню» — под карусель выбора в «Карьере».""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY) - return keyboard.get_keyboard() - - -def get_faq_keyboard() -> str: - """Клавиатура раздела Частых Вопросов (FAQ).""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Цены 💰", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Услуги ⚙️", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Контакты 📞", color=VkKeyboardColor.SECONDARY) - 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_consent_docs_keyboard() -> str: - """Инлайн-кнопки со ссылками на оферту и политику обработки ПД.""" - keyboard = VkKeyboard(inline=True) - keyboard.add_openlink_button("📄 Оферта", getattr(config, "OFFER_URL", "https://infocyber.pro/")) - keyboard.add_line() - keyboard.add_openlink_button("🔐 Политика обработки ПД", getattr(config, "POLICY_URL", "https://infocyber.pro/")) - return keyboard.get_keyboard() - - def get_socials_keyboard() -> str: """Клавиатура с кнопками-ссылками на площадки ИКП (открываются в один тап).""" keyboard = VkKeyboard(one_time=False) @@ -244,13 +177,6 @@ def get_socials_keyboard() -> str: return keyboard.get_keyboard() -def get_cancel_keyboard() -> str: - """Клавиатура отмены заполнения формы.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - def get_operator_keyboard() -> str: """Клавиатура при активном диалоге с оператором.""" keyboard = VkKeyboard(one_time=False) @@ -258,125 +184,3 @@ def get_operator_keyboard() -> str: return keyboard.get_keyboard() -def get_binary_keyboard(show_skip: bool = False) -> str: - """Клавиатура с кнопками Согласен / Не согласен.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Согласен(а) и подтверждаю ✅", color=VkKeyboardColor.PRIMARY) - keyboard.add_button("Не согласен(а) ❌", color=VkKeyboardColor.NEGATIVE) - if show_skip: - keyboard.add_line() - keyboard.add_button("Пропустить ➡️", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_returning_keyboard() -> str: - """Нижняя клавиатура возвращающегося клиента: подтвердить или отменить.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Да, всё верно ✅", color=VkKeyboardColor.PRIMARY) - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_returning_changes_keyboard() -> str: - """Инлайн-кнопки под сообщением: сменить имя / телефон / заполнить заново.""" - keyboard = VkKeyboard(inline=True) - keyboard.add_button("Сменить имя 👤", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Сменить телефон 📞", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Заполнить заново 🔄", color=VkKeyboardColor.SECONDARY) - return keyboard.get_keyboard() - - -def get_calldate_keyboard() -> str: - """Клавиатура выбора ДНЯ для звонка (сегодня/завтра + ближайшие даты).""" - from datetime import datetime, timedelta - wd = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] - today = datetime.now() - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Сегодня", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Завтра", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - for i in (2, 3, 4): - d = today + timedelta(days=i) - keyboard.add_button(f"{wd[d.weekday()]} {d.strftime('%d.%m')}", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_calltime_keyboard() -> str: - """Клавиатура выбора ВРЕМЕНИ (слоты в рабочие часы).""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("10:00", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("12:00", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("14:00", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("16:00", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("18:00", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("20:00", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Любое время", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_marketing_keyboard() -> str: - """Клавиатура для необязательного согласия на маркетинговую рассылку.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Согласен на рассылку ✅", color=VkKeyboardColor.PRIMARY) - keyboard.add_button("Без рассылки ➡️", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_services_keyboard() -> str: - """Клавиатура со списком доступных услуг.""" - keyboard = VkKeyboard(one_time=False) - first = True - for key, svc in config.SERVICES.items(): - if not first: - keyboard.add_line() - keyboard.add_button(svc["name"], color=VkKeyboardColor.SECONDARY) - first = False - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_confirm_service_keyboard() -> str: - """Клавиатура для подтверждения выбранной услуги.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Да, подтверждаю ✅", color=VkKeyboardColor.PRIMARY) - keyboard.add_button("Нет, выбрать другую 🔄", color=VkKeyboardColor.NEGATIVE) - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_review_keyboard() -> str: - """Клавиатура для финальной проверки данных пользователем.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Да, всё верно! ✅", color=VkKeyboardColor.PRIMARY) - keyboard.add_button("Изменить данные 🔄", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_edit_menu_keyboard() -> str: - """Клавиатура меню изменения полей.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Изменить Имя 👤", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Изменить Телефон 📞", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Изменить Время 🕒", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.SECONDARY) - return keyboard.get_keyboard() - - diff --git a/tests/test_flow.py b/tests/test_flow.py index 02ea176..d7d8946 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -109,21 +109,6 @@ def _admin(): return bot.config.SUPER_ADMIN_IDS[0] -def test_admin_services_crud(app): - a = _admin() - app.handle_message(a, "Управление услугами ⚙️") - app.handle_message(a, "➕ Добавить услугу") - app.handle_message(a, "Тест-у | 1 ₽ | описание") - nid = [k for k, v in bot.db.get_services().items() if v["name"] == "Тест-у"] - assert nid - n = nid[0] - app.handle_message(a, "✏️ Изменить услугу"); app.handle_message(a, f"{n}. Тест-у") - app.handle_message(a, "цена | 999 ₽") - assert bot.db.get_services()[n]["price"] == "999 ₽" - app.handle_message(a, "🗑 Удалить услугу"); app.handle_message(a, f"{n}. Тест-у") - assert n not in bot.db.get_services() - - def test_admin_faq_crud(app): a = _admin() app.handle_message(a, "Быстрые ответы 💬") @@ -132,14 +117,6 @@ def test_admin_faq_crud(app): assert any(kw == "вопрос" for _, kw, _ in bot.db.get_custom_faq()) -def test_admin_vacancy_crud(app): - a = _admin() - app.handle_message(a, "Вакансии 💼") - app.handle_message(a, "➕ Вакансия игрок") - app.handle_message(a, "Valorant 🎯") - assert any(l == "Valorant 🎯" for _, l in bot.db.get_vacancy_options("player")) - - def test_admin_add_remove_admin(app): a = _admin() app.handle_message(a, "Администраторы 👑")