diff --git a/.env.example b/.env.example index e7cda77..dbb8e82 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,27 @@ OPERATOR_PEER_ID=12345678 # Список ID администраторов (через запятую) для управления услугами через чат ADMIN_IDS=12345678 + +# Разрешённый источник (Origin) для CORS веб-формы (домен сайта). Для локальной отладки можно '*' +ALLOWED_ORIGIN=https://infocyber.pro + +# Промокоды на скидку. Пока выключены. Поставь true, когда захочешь их выдавать клиентам. +PROMO_ENABLED=false + +# ===== Email (SMTP) для уведомлений о заявках ===== +# Адрес SMTP-сервера почты (например, smtp.yandex.ru / smtp.gmail.com / smtp.mail.ru) +SMTP_HOST= +# Порт: 465 для SSL, 587 для STARTTLS +SMTP_PORT=465 +# Логин (полный email) и пароль (для Яндекс/Gmail — пароль приложения, не основной!) +SMTP_USER= +SMTP_PASSWORD= +# От кого отправлять (по умолчанию = SMTP_USER) +MAIL_FROM= +# Куда отправлять заявки (можно несколько через запятую) +MAIL_TO=group-zakaz@infocyber.pro +# Куда отправлять отклики на вакансии/набор в состав. Пусто = на тот же адрес, что MAIL_TO. +# Можно вынести на отдельный HR-адрес, напр. hr@infocyber.pro +HR_MAIL_TO= +# true — SSL (порт 465), false — STARTTLS (порт 587) +SMTP_USE_SSL=true diff --git a/.gitignore b/.gitignore index d0cbd5c..e44fe6a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ __pycache__/ # Logs *.log + +# Резервные копии исходных картинок (до кадрирования) +help_img/_orig/ diff --git a/bot.py b/bot.py index c8e6dbb..1149b5d 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,11 @@ import json import re +import time +import random +import string import logging +import threading +from collections import defaultdict from typing import Dict, Any, Optional # Нам понадобятся библиотеки vk_api. @@ -14,6 +19,7 @@ except ImportError: from database import DatabaseManager import config +import mailer # Настройка логирования logging.basicConfig( @@ -25,8 +31,40 @@ logger = logging.getLogger("VK_Bot_IKP") # Инициализация базы данных сессий db = DatabaseManager() -# Регулярное выражение для базовой валидации номера телефона -PHONE_REGEX = re.compile(r"^\+?[78]\d{10}$") +# Анти-флуд: максимум сообщений от одного пользователя за окно времени +FLOOD_MAX_MSGS = 12 +FLOOD_WINDOW_SEC = 10 +# Кулдаун между уведомлениями оператора о заявке/отклике от одного пользователя (сек) +NOTIFY_COOLDOWN_SEC = 120 + +# Регулярные выражения для валидации +# Российский номер для нормализации 8XXX / 7XXX -> +7XXX +RU_PHONE_REGEX = re.compile(r"^\+?[78]\d{10}$") +# Международный формат (E.164): + и 10–15 цифр. Принимаем и иностранные номера. +INTL_PHONE_REGEX = re.compile(r"^\+?\d{10,15}$") +# Имя: буквы (кириллица/латиница), пробел, дефис, апостроф; длина 2–50 +NAME_REGEX = re.compile(r"^[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё \-']{1,49}$") + + +def normalize_phone(text: str) -> Optional[str]: + """Очищает и нормализует телефон. Возвращает нормализованный номер или None, если формат неверен.""" + phone_cleaned = re.sub(r"[\s\-\(\)]", "", text) + + # Российские номера приводим к виду +7XXXXXXXXXX + if phone_cleaned.startswith("8") and len(phone_cleaned) == 11: + phone_cleaned = "+7" + phone_cleaned[1:] + elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11: + phone_cleaned = "+" + phone_cleaned + + # Принимаем как российский, так и любой международный номер в формате E.164 + if RU_PHONE_REGEX.match(phone_cleaned) or INTL_PHONE_REGEX.match(phone_cleaned): + return phone_cleaned + return None + + +def is_valid_name(text: str) -> bool: + """Имя должно состоять только из букв, пробела, дефиса или апострофа (без цифр и спецсимволов).""" + return bool(NAME_REGEX.match(text.strip())) # ========================================== @@ -39,12 +77,66 @@ def get_main_keyboard() -> str: # Кнопка начать опрос (зеленая) keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY) keyboard.add_line() + # Карьера в ИКП (набор игроков и сотрудников) + keyboard.add_button("Карьера в ИКП 💼", color=VkKeyboardColor.POSITIVE) + keyboard.add_line() # Кнопки FAQ и оператора keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Позвать оператора 🧑💼", color=VkKeyboardColor.NEGATIVE) return keyboard.get_keyboard() +def get_vacancy_choice_keyboard() -> str: + """Выбор направления в разделе 'Карьера в ИКП'.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Игроком в состав 🎮", color=VkKeyboardColor.PRIMARY) + keyboard.add_line() + keyboard.add_button("Сотрудником 💼", color=VkKeyboardColor.PRIMARY) + keyboard.add_line() + keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY) + return keyboard.get_keyboard() + + +def get_vacancy_discipline_keyboard() -> str: + """Выбор дисциплины для набора в состав.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Counter-Strike 2 🔫", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Dota 2 🎮", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("World of Tanks 🛡️", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_vacancy_position_keyboard() -> str: + """Выбор должности для найма сотрудника.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("SMM / контент", 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_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE) + 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) @@ -54,6 +146,22 @@ def get_faq_keyboard() -> str: 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.PRIMARY) + return keyboard.get_keyboard() + + +def get_socials_keyboard() -> str: + """Клавиатура с кнопками-ссылками на площадки ИКП (открываются в один тап).""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_openlink_button("VK Live 📺", "https://live.vkvideo.ru/ikp") + keyboard.add_line() + keyboard.add_openlink_button("Telegram ✈️", "https://t.me/ikp_team") + keyboard.add_openlink_button("Discord 🎧", "https://discord.gg/z92mAw7KEe") + keyboard.add_line() + keyboard.add_openlink_button("ВКонтакте 🌐", "https://vk.com/ikpro") + keyboard.add_line() keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY) return keyboard.get_keyboard() @@ -85,6 +193,42 @@ def get_binary_keyboard(show_skip: bool = False) -> str: 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.SECONDARY) + keyboard.add_button("Сменить телефон 📞", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Заполнить заново 🔄", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_calltime_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.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) @@ -126,6 +270,8 @@ def get_edit_menu_keyboard() -> str: 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.PRIMARY) return keyboard.get_keyboard() @@ -140,6 +286,14 @@ class VKBotApplication: self.vk_session = None self.vk = None self.longpoll = None + # Тексты рассылок, ожидающих подтверждения админом: {admin_id: text} + self._pending_broadcasts: Dict[int, str] = {} + # Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}} + self._photo_cache: Dict[str, Dict[str, str]] = {} + # Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске) + self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений + self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде + self._last_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора # Инициализируем динамические услуги из базы данных config.SERVICES = db.get_services() @@ -150,13 +304,16 @@ class VKBotApplication: self.vk = self.vk_session.get_api() self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID) logger.info("Успешное подключение к VK API!") + # Предзагрузка картинок в фоне, чтобы карусель не тормозила при первом показе + threading.Thread(target=self._prewarm_media, daemon=True).start() except Exception as e: logger.error(f"Ошибка авторизации в VK API: {e}") - def send_message(self, peer_id: int, message: str, keyboard: Optional[str] = None, attachment: Optional[str] = None): + def send_message(self, peer_id: int, message: str, keyboard: Optional[str] = None, + attachment: Optional[str] = None, template: Optional[str] = None): """Отправка сообщения пользователю с сохранением в лог.""" db.log_message(peer_id, "bot", message) - + if self.vk: try: params = { @@ -168,18 +325,51 @@ class VKBotApplication: params["keyboard"] = keyboard if attachment: params["attachment"] = attachment + if template: + params["template"] = template self.vk.messages.send(**params) except Exception as e: logger.error(f"Не удалось отправить сообщение пользователю {peer_id}: {e}") else: attachment_str = f", Attachment: {attachment}" if attachment else "" - logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str})") + tmpl_str = ", Carousel: Да" if template else "" + logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str}{tmpl_str})") + + def _is_flooding(self, user_id: int) -> bool: + """True, если пользователь шлёт сообщения слишком часто (защита от флуда/DDoS).""" + now = time.time() + times = [t for t in self._msg_times[user_id] if now - t < FLOOD_WINDOW_SEC] + times.append(now) + self._msg_times[user_id] = times + return len(times) > FLOOD_MAX_MSGS + + def _notify_allowed(self, user_id: int) -> bool: + """Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе.""" + now = time.time() + if now - self._last_notify.get(user_id, 0) < NOTIFY_COOLDOWN_SEC: + return False + self._last_notify[user_id] = now + return True def handle_message(self, user_id: int, text: str): """Маршрутизатор входящих сообщений на основе состояний пользователя.""" text_clean = text.strip() text_lower = text_clean.lower() - + + # Анти-флуд: шквал сообщений от одного пользователя не обрабатываем (админы — исключение) + if not (hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS): + if self._is_flooding(user_id): + now = time.time() + # Предупреждаем максимум раз за окно, чтобы само предупреждение не стало спамом + if now - self._flood_warned.get(user_id, 0) > FLOOD_WINDOW_SEC: + self._flood_warned[user_id] = now + logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются") + self.send_message( + user_id, + "⏳ Слишком много сообщений подряд. Пожалуйста, подождите немного." + ) + return + # Логируем входящее сообщение от пользователя db.log_message(user_id, "user", text_clean) @@ -201,6 +391,22 @@ class VKBotApplication: elif text_lower.startswith("удал "): self._handle_admin_delete(user_id, text_clean) return + elif text_lower in ["стата", "статистика", "/stats"]: + self._handle_admin_stats(user_id) + return + elif text_lower.startswith("рассылка"): + self._handle_admin_broadcast(user_id, text_clean) + return + + # Глобальная отписка от рассылки (доступна в любом состоянии) + if text_lower in ["отписаться от рассылки 🔕", "отписаться от рассылки", "отписаться"]: + db.update_user_field(user_id, "consent_2", "Не согласен") + self.send_message( + user_id, + "🔕 Вы отписаны от рассылки. Рекламные сообщения больше присылать не будем. " + "Это не влияет на ваши заявки и поддержку." + ) + return logger.info(f"User {user_id} in state {state} sent: {text_clean}") @@ -214,15 +420,36 @@ class VKBotApplication: ) 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() + ) + return + # Маршрутизация по состояниям (FSM) if state == "FREE_CHAT": self._handle_free_chat(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_CALLTIME": + self._handle_form_calltime(user_id, text_clean) + elif state == "FORM_EDIT_CALLTIME": + self._handle_form_edit_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": @@ -241,6 +468,26 @@ class VKBotApplication: 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: @@ -261,12 +508,27 @@ class VKBotApplication: # 1. Запуск формы опроса if text_lower in ["заполнить заявку 📝", "заявка", "начать опрос", "опрос"]: - db.update_user_state(user_id, "FORM_NAME") + user_data = db.get_user_data(user_id) + # Если клиент уже оставлял данные — предлагаем подтвердить их, а не вводить заново + if user_data.get("name") and user_data.get("phone"): + db.update_user_state(user_id, "FORM_RETURNING") + self._send_returning_prompt(user_id, user_data) + else: + db.update_user_state(user_id, "FORM_NAME") + self.send_message( + user_id, + "📝 Начинаем заполнение заявки!\n\n" + "Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.", + keyboard=get_cancel_keyboard() + ) + return + + # 1.5 Площадки ИКП — кнопки-ссылки + if text_lower in ["наши соцсети 🔗", "наши соцсети", "соцсети", "площадки", "ссылки"]: self.send_message( user_id, - "📝 Начинаем заполнение заявки!\n\n" - "Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.", - keyboard=get_cancel_keyboard() + "🔗 Мы на площадках ИКП — заходите, подписывайтесь и смотрите трансляции 👇", + keyboard=get_socials_keyboard() ) return @@ -288,6 +550,13 @@ class VKBotApplication: ) return + # 3.5 Раздел "Карьера в ИКП" (набор игроков и сотрудников) + if text_lower in ["карьера в икп 💼", "карьера", "вакансии", "вакансия", "работа", "набор", "хочу в команду"]: + db.clear_vac_data(user_id) + db.update_user_state(user_id, "VAC_CHOOSE") + self._send_career_choice(user_id) + return + # 4. Вызов оператора if text_lower in ["позвать оператора 🧑💼", "позвать оператора", "оператор", "менеджер", "человек", "позови оператора", "связь с оператором"]: self._handoff_to_operator(user_id) @@ -303,10 +572,12 @@ class VKBotApplication: if text_lower in ["начать", "привет", "здравствуйте", "ку", "hi", "hello"]: self.send_message( user_id, - "👋 Здравствуйте! Я умный чат-бот школы киберспорта 'Cyber Pro'.\n\n" - "Я могу помочь вам быстро оформить заявку на наши услуги или ответить на частые вопросы. " - "Выберите действие на кнопках ниже или просто напишите ваш вопрос!", - keyboard=get_main_keyboard() + "👋 Здравствуйте! Это чат-бот киберспортивной организации ИКП — Игра. Киберспорт. Профессионалы.\n\n" + "Я помогу быстро оформить заявку на тренировки, рассказать об услугах или связать вас с командой. " + "Также у нас открыт набор игроков и сотрудников — загляните в раздел «Карьера в ИКП 💼».\n\n" + "Выберите действие на кнопках ниже 👇", + keyboard=get_main_keyboard(), + attachment=self.get_logo_attachment(user_id) ) return @@ -318,13 +589,90 @@ class VKBotApplication: keyboard=get_main_keyboard() ) - def _handle_form_name(self, user_id: int, text: str): - """Сохранение имени и переход к номеру телефона.""" - # Валидация имени: длина от 2 до 50 символов - if len(text) < 2 or len(text) > 50: + def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]): + """Приветствие для клиента, чьи имя и телефон уже сохранены.""" + name = user_data.get("name") + phone = user_data.get("phone") + self.send_message( + user_id, + f"Здравствуйте! Мы вас помним, {name}. 👋\n\n" + f"Ваш номер телефона по-прежнему {phone}?\n\n" + "Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги. " + "Либо измените данные или заполните заявку заново.", + keyboard=get_returning_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" + "Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.", + 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 @@ -342,20 +690,14 @@ class VKBotApplication: def _handle_form_phone(self, user_id: int, text: str): """Сохранение телефона и переход к выбору услуги.""" - # Очищаем телефон от пробелов, скобок и дефисов для валидации - phone_cleaned = re.sub(r"[\s\-\(\)]", "", text) - - # Если телефон начинается с 8, заменяем на +7 для стандартизации - if phone_cleaned.startswith("8") and len(phone_cleaned) == 11: - phone_cleaned = "+7" + phone_cleaned[1:] - elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11: - phone_cleaned = "+" + phone_cleaned + phone_cleaned = normalize_phone(text) - # Валидация регулярным выражением - if not PHONE_REGEX.match(phone_cleaned): + # Валидация (российский или международный формат) + if not phone_cleaned: self.send_message( user_id, - "⚠️ Некорректный формат телефона. Пожалуйста, отправьте номер в формате +79991234567.", + "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " + "либо международный в формате +<код страны><номер>.", keyboard=get_cancel_keyboard() ) return @@ -363,24 +705,9 @@ class VKBotApplication: # Сохраняем телефон в БД db.update_user_field(user_id, "phone", phone_cleaned) db.update_user_state(user_id, "FORM_QUESTION") - - # Формируем список услуг с ценами - services_text = ( - "✅ Телефон принят!\n\n" - "Шаг 3 из 3: Какая услуга вас интересует?\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" - services_text += "Пожалуйста, выберите нужную услугу, нажав на кнопку на клавиатуре или отправив её номер/название." - self.send_message( - user_id, - services_text, - keyboard=get_services_keyboard() - ) + # Показываем услуги каруселью (с фолбэком на текст) + self._send_services(user_id, "✅ Телефон принят!\n\nШаг 3 из 3: какое направление вас интересует?\n\n") def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]): """Обработка выбора услуги с прямым переходом к проверке данных.""" @@ -389,8 +716,9 @@ class VKBotApplication: # 1. Прямое сопоставление синонимов дисциплин для надежности synonyms = { - "1": ["дота", "dota", "дотку", "дотка", "dota2"], - "2": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter"] + "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): @@ -417,10 +745,14 @@ class VKBotApplication: if selected_service: # Сохраняем временный выбор в поле question 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_review_message(user_id, updated_data) + # Переходим к выбору удобного времени для звонка + db.update_user_state(user_id, "FORM_CALLTIME") + self.send_message( + user_id, + f"Отличный выбор — {selected_service['name']}!\n\n" + "🕒 Когда вам удобно, чтобы менеджер с вами связался?", + keyboard=get_calltime_keyboard() + ) else: # Формируем список услуг с ценами для повторного показа services_text = ( @@ -428,7 +760,7 @@ class VKBotApplication: "Пожалуйста, выберите одну из доступных услуг на клавиатуре или отправьте её номер/название:\n\n" ) for key, svc in config.SERVICES.items(): - services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f"{key}️⃣ {svc['name']}\n" services_text += f" 💰 Стоимость: {svc['price']}\n" services_text += f" 📝 {svc['description']}\n\n" @@ -443,17 +775,48 @@ class VKBotApplication: db.update_user_state(user_id, "FORM_REVIEW") self._send_review_message(user_id, user_data) + def _handle_form_calltime(self, user_id: int, text: str): + """Сохранение удобного времени звонка и переход к финальной проверке.""" + call_time = text.strip() + if len(call_time) < 2 or len(call_time) > 100: + self.send_message( + user_id, + "🕒 Пожалуйста, выберите время на клавиатуре или коротко напишите, когда вам удобно.", + keyboard=get_calltime_keyboard() + ) + return + db.update_user_field(user_id, "call_time", call_time) + db.update_user_state(user_id, "FORM_REVIEW") + self._send_review_message(user_id, db.get_user_data(user_id)) + + def _handle_form_edit_calltime(self, user_id: int, text: str): + """Сохранение нового времени звонка и возврат к проверке.""" + call_time = text.strip() + if len(call_time) < 2 or len(call_time) > 100: + self.send_message( + user_id, + "🕒 Пожалуйста, выберите время на клавиатуре или коротко напишите, когда вам удобно.", + keyboard=get_calltime_keyboard() + ) + return + db.update_user_field(user_id, "call_time", call_time) + db.update_user_state(user_id, "FORM_REVIEW") + self.send_message(user_id, "✅ Время звонка обновлено!") + 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 = 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\n" + "🔍 Пожалуйста, проверьте правильность заполненных данных:\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()) @@ -472,7 +835,7 @@ class VKBotApplication: # Отправляем значительно сокращенное и дружелюбное сообщение-согласие self.send_message( user_id, - "📄 **Согласие с условиями и офертой**\n\n" + "📄 Согласие с условиями и офертой\n\n" "Для завершения отправки заявки подтвердите, пожалуйста, ваше согласие с условиями Публичной оферты, " "Политикой обработки персональных данных и записью разговора.\n\n" "«Я подтверждаю, что ознакомлен(а) с договором оферты (прикреплен ниже), принимаю его условия, " @@ -486,7 +849,7 @@ class VKBotApplication: db.update_user_state(user_id, "FORM_EDIT_MENU") self.send_message( user_id, - "🔄 **Меню изменения данных**\n\n" + "🔄 Меню изменения данных\n\n" "Пожалуйста, выберите на клавиатуре ниже, какое поле вы хотите изменить:", keyboard=get_edit_menu_keyboard() ) @@ -520,10 +883,10 @@ class VKBotApplication: db.update_user_state(user_id, "FORM_EDIT_QUESTION") services_text = ( - "🎮 **Выберите новую услугу из списка:**\n\n" + "🎮 Выберите новую услугу из списка:\n\n" ) for key, svc in config.SERVICES.items(): - services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f"{key}️⃣ {svc['name']}\n" services_text += f" 💰 Стоимость: {svc['price']}\n\n" self.send_message( @@ -531,7 +894,14 @@ class VKBotApplication: services_text, keyboard=get_services_keyboard() ) - elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "4"]: + elif text_lower in ["изменить время 🕒", "изменить время", "время", "4"]: + db.update_user_state(user_id, "FORM_EDIT_CALLTIME") + self.send_message( + user_id, + "🕒 Когда вам удобно, чтобы менеджер с вами связался?", + keyboard=get_calltime_keyboard() + ) + elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "5"]: db.update_user_state(user_id, "FORM_REVIEW") self._send_review_message(user_id, user_data) else: @@ -543,10 +913,11 @@ class VKBotApplication: def _handle_form_edit_name(self, user_id: int, text: str): """Сохранение нового имени и возврат к проверке.""" - if len(text) < 2 or len(text) > 50: + if not is_valid_name(text): self.send_message( user_id, - "⚠️ Имя выглядит слишком коротким или длинным. Пожалуйста, введите корректное имя.", + "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " + "Пожалуйста, введите корректное имя.", keyboard=get_cancel_keyboard() ) return @@ -560,17 +931,13 @@ class VKBotApplication: def _handle_form_edit_phone(self, user_id: int, text: str): """Сохранение нового телефона и возврат к проверке.""" - phone_cleaned = re.sub(r"[\s\-\(\)]", "", text) - - if phone_cleaned.startswith("8") and len(phone_cleaned) == 11: - phone_cleaned = "+7" + phone_cleaned[1:] - elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11: - phone_cleaned = "+" + phone_cleaned + phone_cleaned = normalize_phone(text) - if not PHONE_REGEX.match(phone_cleaned): + if not phone_cleaned: self.send_message( user_id, - "⚠️ Некорректный формат телефона. Пожалуйста, отправьте номер в формате +79991234567.", + "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " + "либо международный в формате +<код страны><номер>.", keyboard=get_cancel_keyboard() ) return @@ -589,8 +956,9 @@ class VKBotApplication: # 1. Прямое сопоставление синонимов дисциплин для надежности synonyms = { - "1": ["дота", "dota", "дотку", "дотка", "dota2"], - "2": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter"] + "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): @@ -626,7 +994,7 @@ class VKBotApplication: "Пожалуйста, выберите одну из доступных услуг на клавиатуре:\n\n" ) for key, svc in config.SERVICES.items(): - services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f"{key}️⃣ {svc['name']}\n" services_text += f" 💰 Стоимость: {svc['price']}\n\n" self.send_message( @@ -669,7 +1037,10 @@ class VKBotApplication: 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}") @@ -678,48 +1049,203 @@ class VKBotApplication: 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.""" + if image_path in self._photo_cache: + return self._photo_cache[image_path] + import os + if not self.vk or not os.path.exists(image_path): + return None + try: + import requests + upload_server = self.vk.photos.getMessagesUploadServer(peer_id=peer_id) + upload_url = upload_server["upload_url"] + with open(image_path, "rb") as f: + response = requests.post(upload_url, files={"photo": (os.path.basename(image_path), f)}) + r = response.json() + saved = self.vk.photos.saveMessagesPhoto(photo=r["photo"], server=r["server"], hash=r["hash"]) + info = saved[0] if isinstance(saved, list) else saved + raw = f"{info['owner_id']}_{info['id']}" + result = {"attachment": f"photo{raw}", "raw": raw} + self._photo_cache[image_path] = result + logger.info(f"Фото загружено в VK: {result['attachment']}") + return result + except Exception as e: + logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}") + return None + + def get_logo_attachment(self, peer_id: int) -> Optional[str]: + """Attachment-строка с логотипом ИКП для прикрепления к приветствию.""" + import os + logo_path = os.path.join(os.path.dirname(__file__), "help_img", "Logo.jpg") + info = self._upload_photo(peer_id, logo_path) + return info["attachment"] if info else None + + def _prewarm_media(self): + """Заранее (при старте) загружает логотип и картинки дисциплин в VK, + чтобы первое приветствие и первая карусель открывались мгновенно.""" + import os + try: + peer = config.OPERATOR_PEER_ID + base = os.path.join(os.path.dirname(__file__), "help_img") + for fn in ("Logo.jpg", "cs2.jpg", "dota2.jpg", "wot.jpg"): + path = os.path.join(base, fn) + if os.path.exists(path): + self._upload_photo(peer, path) + logger.info("Медиа предзагружены в VK — карусель и логотип будут открываться быстро.") + 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(): + disc_img = os.path.join(base, img_by_id.get(key, "")) + 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_2", "Согласен") db.update_user_field(user_id, "consent_3", "Согласен") - - # Получаем актуальные данные пользователя - updated_data = db.get_user_data(user_id) - phone = updated_data["phone"] - - # Возвращаем пользователя в главное меню - db.update_user_state(user_id, "FREE_CHAT") - # Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00) - from datetime import datetime - now = datetime.now() - if 9 <= now.hour < 21: - callback_time = "в течение 15 минут" - else: - callback_time = "сегодня утром, начиная с 9:00" - - # Отправляем подтверждение пользователю + # Переходим к ОТДЕЛЬНОМУ необязательному согласию на рассылку (требование 152-ФЗ) + db.update_user_state(user_id, "FORM_CONSENT_2") self.send_message( user_id, - "🎉 Заявка и все необходимые согласия успешно оформлены!\n\n" - f"Наш менеджер уже изучает детали и свяжется с вами {callback_time} по номеру " - f"{phone}. Спасибо за обращение!\n\n" - "Вы вернулись в главное меню бота.", - keyboard=get_main_keyboard() + "✅ Спасибо! Обязательные согласия приняты.\n\n" + "📨 Последний шаг (по желанию): хотите получать новости, акции и спецпредложения ИКП?\n\n" + "Это необязательно и никак не влияет на обработку вашей заявки.", + keyboard=get_marketing_keyboard() ) - - # Уведомляем оператора о новой заявке со всеми согласиями - self._notify_operator_about_lead(user_id, updated_data) elif is_disagree: self.send_message( user_id, - "⚠️ **Для продолжения оформления заявки необходимо принять условия Публичной оферты и дать согласие на обработку данных и запись разговора.**\n\n" + "⚠️ Для оформления заявки необходимо принять условия оферты, согласие на обработку персональных данных и запись консультации.\n\n" "Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.", keyboard=get_binary_keyboard(show_skip=False) ) @@ -731,37 +1257,84 @@ class VKBotApplication: ) def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str): - """Резервный обработчик для совместимости с устаревшими состояниями в БД.""" - db.update_user_field(user_id, "consent_2", "Согласен") - db.update_user_field(user_id, "consent_3", "Согласен") - - updated_data = db.get_user_data(user_id) - phone = updated_data["phone"] - db.update_user_state(user_id, "FREE_CHAT") - - self.send_message( - user_id, - "🎉 Заявка успешно оформлена!\n\n" - f"Наш менеджер свяжется с вами по номеру {phone}. Спасибо за обращение!", - keyboard=get_main_keyboard() - ) - self._notify_operator_about_lead(user_id, updated_data) + """Шаг 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") - - self.send_message( - user_id, + + # Персональный промокод на скидку (пока выключен флагом 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"Наш менеджер свяжется с вами по номеру {phone}. Спасибо за обращение!", - keyboard=get_main_keyboard() + f"Наш менеджер свяжется с вами ({callback_time}) по номеру {phone}, " + f"удобное время: {call_time}.\n\n" ) - self._notify_operator_about_lead(user_id, updated_data) + if promo: + confirm_msg += ( + "🎁 Ваш персональный промокод на скидку 10% на первое занятие:\n" + f"{promo}\n" + "Назовите его менеджеру при записи. " + ) + confirm_msg += "Спасибо за обращение!\n\nВы вернулись в главное меню бота." + + self.send_message(user_id, confirm_msg, keyboard=get_main_keyboard()) + + # Журналируем заявку для статистики и истории + 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): """Поведение бота в режиме активного диалога с живым человеком.""" @@ -788,15 +1361,251 @@ class VKBotApplication: # СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ # ------------------------------------------ + # ------------------------------------------ + # РАЗДЕЛ "КАРЬЕРА В ИКП" (НАЙМ ИГРОКОВ И СОТРУДНИКОВ) + # ------------------------------------------ + + @staticmethod + def _valid_free_text(text: str) -> bool: + """Базовая проверка свободного ответа в отклике (не пусто, разумной длины).""" + return 1 <= len(text.strip()) <= 300 + + 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_message( + user_id, + "🎮 Набор в состав\n\nШаг 1 из 5: по какой дисциплине вы играете?", + keyboard=get_vacancy_discipline_keyboard() + ) + elif text_lower in ["сотрудником 💼", "сотрудником", "сотрудник", "работником", "в команду"]: + db.update_user_state(user_id, "VAC_W_POSITION") + self.send_message( + user_id, + "💼 Набор в команду\n\nШаг 1 из 3: на какую должность вы претендуете?", + keyboard=get_vacancy_position_keyboard() + ) + 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()) + 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 + 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_message( + user_id, + "Пожалуйста, выберите дисциплину на клавиатуре ниже.", + keyboard=get_vacancy_discipline_keyboard() + ) + 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): + known = { + "smm / контент": "SMM / контент", + "монтажёр / дизайнер": "Монтажёр / дизайнер", + "менеджер": "Менеджер", + "разработчик": "Разработчик", + } + 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 = known.get(text_lower) + if not position: + if not self._valid_free_text(text): + self.send_message( + user_id, + "Выберите должность на клавиатуре или нажмите «Другое ✍️».", + keyboard=get_vacancy_position_keyboard() + ) + 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() + ) + # Антиспам: ограничиваем частоту уведомлений об откликах от одного пользователя + 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.send_message(config.OPERATOR_PEER_ID, 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") self.send_message( user_id, - "🧑💼 Переключаю вас на оператора...\n\n" - "Я отправил уведомление специалисту поддержки. Он ответит вам прямо в этом диалоге в ближайшее время.\n" - "Все ваши сообщения сейчас направляются напрямую человеку. Чтобы вернуться к боту, нажмите кнопку ниже.", + "🧑💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n" + "Чтобы вернуться к боту, нажмите кнопку ниже.", keyboard=get_operator_keyboard() ) @@ -809,28 +1618,32 @@ class VKBotApplication: # Отправляем алерт оператору с историей диалога operator_msg = ( - f"🚨 **Запрос оператора!**\n" + f"🚨 Запрос оператора!\n" f"Пользователь: vk.com/id{user_id}\n" f"Тип запроса: Прямой вызов из меню\n\n" - f"📋 **Последние сообщения для контекста:**\n{formatted_history}" + f"📋 Последние сообщения для контекста:\n{formatted_history}" ) self.send_message(config.OPERATOR_PEER_ID, operator_msg) - def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any]): + 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 "" operator_msg = ( - f"⚡ **Получена новая заявка!**\n\n" - f"👤 **Имя:** {name}\n" - f"📞 **Телефон:** {phone}\n" - f"🎯 **Выбранная услуга:** {question}\n\n" - f"⚖️ **Согласия пользователя:**\n" + f"⚡ Получена новая заявка!\n\n" + f"👤 Имя: {name}\n" + f"📞 Телефон: {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" @@ -839,10 +1652,29 @@ class VKBotApplication: ) self.send_message(config.OPERATOR_PEER_ID, 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): """Пересылка сообщений от клиента в чат оператора.""" forward_msg = ( - f"💬 **Сообщение от vk.com/id{user_id}:**\n{text}" + f"💬 Сообщение от vk.com/id{user_id}:\n{text}" ) self.send_message(config.OPERATOR_PEER_ID, forward_msg) @@ -856,26 +1688,29 @@ class VKBotApplication: services_list_text = "" for key, svc in services.items(): - services_list_text += f"🔹 **[{key}] {svc['name']}**\n" + 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" "Здесь вы можете редактировать существующие услуги или добавлять новые.\n\n" - "📋 **Текущий список активных услуг:**\n\n" + "📋 Текущий список активных услуг:\n\n" f"{services_list_text}" - "🔧 **Доступные команды управления:**\n\n" - "1. **Редактирование услуги**:\n" - " 👉 Отправьте: `ред [номер] [поле] [новое значение]`\n" - " Доступные поля: `имя`, `цена`, `описание`.\n" - " *Пример:* `ред 1 цена 1 800 ₽/час`\n\n" - "2. **Добавление услуги**:\n" - " 👉 Отправьте: `доб [Имя] | [Цена] | [Описание]`\n" - " *Пример:* `доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.`\n\n" - "3. **Удаление услуги**:\n" - " 👉 Отправьте: `удал [номер]`\n" - " *Пример:* `удал 4`" + "🔧 Доступные команды управления:\n\n" + "1. Редактирование услуги:\n" + " 👉 Отправьте: ред [номер] [поле] [новое значение]\n" + " Доступные поля: имя, цена, описание.\n" + " *Пример:* ред 1 цена 1 800 ₽/час\n\n" + "2. Добавление услуги:\n" + " 👉 Отправьте: доб [Имя] | [Цена] | [Описание]\n" + " *Пример:* доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.\n\n" + "3. Удаление услуги:\n" + " 👉 Отправьте: удал [номер]\n" + " *Пример:* удал 4\n\n" + "📊 Статистика: отправьте стата\n\n" + "📣 Рассылка по подписчикам:\n" + " 👉 рассылка [текст] → предпросмотр → рассылка подтвердить" ) self.send_message(user_id, admin_text) @@ -886,8 +1721,8 @@ class VKBotApplication: self.send_message( user_id, "⚠️ Некорректный формат команды редактирования.\n" - "Правильный формат: `ред [номер] [поле] [новое значение]`\n" - "Пример: `ред 1 цена 1 800 ₽/час`" + "Правильный формат: ред [номер] [поле] [новое значение]\n" + "Пример: ред 1 цена 1 800 ₽/час" ) return @@ -906,7 +1741,7 @@ class VKBotApplication: if field_name not in field_mapping: self.send_message( user_id, - "⚠️ Неизвестное поле для редактирования. Допустимые поля: `имя`, `цена`, `описание`." + "⚠️ Неизвестное поле для редактирования. Допустимые поля: имя, цена, описание." ) return @@ -927,7 +1762,7 @@ class VKBotApplication: self.send_message( user_id, f"✅ Услуга [{service_id}] успешно обновлена!\n" - f"Поле `{field_name}` изменено на: `{new_value}`." + f"Поле {field_name} изменено на: {new_value}." ) self._send_admin_menu(user_id) except Exception as e: @@ -943,8 +1778,8 @@ class VKBotApplication: self.send_message( user_id, "⚠️ Некорректный формат команды добавления.\n" - "Правильный формат: `доб [Имя] | [Цена] | [Описание]`\n" - "Пример: `доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.`" + "Правильный формат: доб [Имя] | [Цена] | [Описание]\n" + "Пример: доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim." ) return @@ -960,7 +1795,7 @@ class VKBotApplication: self.send_message( user_id, - f"✅ Новая услуга успешно добавлена с номером **[{new_id}]**!" + f"✅ Новая услуга успешно добавлена с номером [{new_id}]!" ) self._send_admin_menu(user_id) except Exception as e: @@ -974,7 +1809,7 @@ class VKBotApplication: self.send_message( user_id, "⚠️ Укажите номер услуги для удаления.\n" - "Пример: `удал 4`" + "Пример: удал 4" ) return @@ -1001,6 +1836,120 @@ class VKBotApplication: logger.error(f"Ошибка при деактивации услуги в БД: {e}") self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") + def _handle_admin_stats(self, user_id: int): + """Формирует и отправляет админу сводную статистику по заявкам и диалогам.""" + ls = db.get_lead_stats() + ds = db.get_dialogue_stats() + + total = ls["total"] + marketing_pct = round(ls["marketing_yes"] / total * 100) if total else 0 + + service_lines = "" + if ls["by_service"]: + for svc_name, cnt in ls["by_service"]: + service_lines += f" • {svc_name}: {cnt}\n" + else: + service_lines = " • пока нет данных\n" + + msg = ( + "📊 Статистика ИКП\n\n" + "🗂 Заявки (всего): {total}\n" + " • сегодня: {today}\n" + " • за 7 дней: {last7}\n" + " • из бота: {from_bot}\n" + " • с сайта: {from_web}\n\n" + "📨 Согласны на рассылку: {mk_yes} из {total} ({mk_pct}%)\n\n" + "🎮 По направлениям:\n{services}\n" + "💬 Диалоги:\n" + " • уникальных пользователей: {uniq}\n" + " • сообщений от клиентов: {umsg}" + ).format( + total=total, today=ls["today"], last7=ls["last7"], + from_bot=ls["from_bot"], from_web=ls["from_web"], + mk_yes=ls["marketing_yes"], mk_pct=marketing_pct, + services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"] + ) + self.send_message(user_id, msg) + + def _handle_admin_broadcast(self, user_id: int, text: str): + """Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка.""" + # Отрезаем ключевое слово 'рассылка' + content = text[len("рассылка"):].strip() + content_lower = content.lower() + + # Шаг 2: подтверждение и запуск + if content_lower in ["подтвердить", "да", "отправить", "!"]: + pending = self._pending_broadcasts.get(user_id) + if not pending: + self.send_message( + user_id, + "❌ Нет рассылки, ожидающей подтверждения.\n" + "Сначала отправьте: рассылка <текст сообщения>." + ) + return + recipients = db.get_marketing_subscribers() + if not recipients: + self.send_message(user_id, "⚠️ Нет подписчиков с согласием на рассылку — отправлять некому.") + self._pending_broadcasts.pop(user_id, None) + return + self.send_message( + user_id, + f"🚀 Запускаю рассылку для {len(recipients)} получателей. " + "Отправка идёт с паузами, чтобы не нарушать лимиты VK. Сообщу по завершении." + ) + self._pending_broadcasts.pop(user_id, None) + # Запускаем в отдельном потоке, чтобы не блокировать обработку сообщений + threading.Thread( + target=self._run_broadcast, args=(user_id, pending, recipients), daemon=True + ).start() + return + + # Шаг 1: приём текста и предпросмотр + if not content: + self.send_message( + user_id, + "📣 Рассылка по подписчикам\n\n" + "Отправьте: рассылка <текст сообщения>\n" + "После этого я покажу число получателей и попрошу подтверждение.\n\n" + "Рассылка уходит только тем, кто дал согласие (consent_2)." + ) + return + + recipients = db.get_marketing_subscribers() + self._pending_broadcasts[user_id] = content + preview = ( + "📋 Предпросмотр рассылки\n\n" + f"Получателей (с согласием): {len(recipients)}\n\n" + "Текст сообщения:\n" + "— — —\n" + f"{content}\n" + "— — —\n\n" + "Чтобы отправить — напишите: рассылка подтвердить\n" + "Чтобы отменить — просто проигнорируйте." + ) + self.send_message(user_id, preview) + + def _run_broadcast(self, admin_id: int, text: str, recipients: list): + """Фоновая отправка рассылки с троттлингом и подсказкой об отписке.""" + body = ( + f"{text}\n\n" + "— — —\n" + "🔕 Чтобы отписаться от рассылки, отправьте: Отписаться от рассылки" + ) + sent = 0 + for uid in recipients: + try: + # Повторно проверяем согласие на случай отписки во время рассылки + data = db.get_user_data(uid) + if data.get("consent_2") != "Согласен": + continue + self.send_message(uid, body) + sent += 1 + except Exception as e: + logger.error(f"Ошибка рассылки пользователю {uid}: {e}") + time.sleep(0.4) # троттлинг против лимитов VK + self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.") + def run(self): """Запуск бесконечного цикла прослушивания событий VK LongPoll.""" if not self.longpoll: diff --git a/config.py b/config.py index fa3cf37..410b342 100644 --- a/config.py +++ b/config.py @@ -40,35 +40,80 @@ if admin_ids_raw: else: ADMIN_IDS = [OPERATOR_PEER_ID] +# Разрешённый источник (Origin) для CORS веб-формы. '*' допустим только для локальной отладки. +ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro") + +# Промокоды на скидку. Пока ВЫКЛЮЧЕНЫ — включить можно, задав PROMO_ENABLED=true в .env. +# Когда выключено: бот и форма не показывают промокод, в уведомлениях/письмах его нет. +PROMO_ENABLED = os.getenv("PROMO_ENABLED", "false").strip().lower() in ("1", "true", "yes") + +# Email (SMTP) configuration для уведомлений о заявках +SMTP_HOST = os.getenv("SMTP_HOST", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +MAIL_FROM = os.getenv("MAIL_FROM", "") or SMTP_USER +# Куда отправлять уведомления о новых заявках (можно несколько через запятую) +MAIL_TO = os.getenv("MAIL_TO", "group-zakaz@infocyber.pro") +# Куда отправлять отклики на вакансии / набор в состав. +# TODO: пока совпадает с MAIL_TO; возможно вынести на отдельный HR-адрес (напр. hr@infocyber.pro) — задать HR_MAIL_TO в .env. +HR_MAIL_TO = os.getenv("HR_MAIL_TO", "") or MAIL_TO +# True — порт 465 (SSL), False — STARTTLS (обычно порт 587) +SMTP_USE_SSL = os.getenv("SMTP_USE_SSL", "true").strip().lower() in ("1", "true", "yes") + # Standard replies and patterns FAQ_ANSWERS = { - "цены": "💰 Наши тарифы на киберспортивные тренировки:\n• Тренировка по Dota 2 🎮 — 1 500 ₽/час\n• Тренировка по CS2 🔫 — 1 500 ₽/час\n• Пакет 'Стандарт' 📦 (5 тренировок) — 7 500 ₽\n• Пакет 'Pro' 🌟 (10 тренировок + постоянная поддержка тренера) — 14 000 ₽\n\n🎁 ПОДАРОК: Всем новым ученикам дарим скидку 10% на первую индивидуальную тренировку! Заполните заявку через меню прямо сейчас!", - "контакты": "📞 Связаться с нами:\n• Телефон: +7 (999) 123-45-67\n• Email: info@cyberpro.com\n• Адрес: г. Москва, Киберспортивная Арена 'Cyber Pro'\n• Режим работы: Ежедневно с 10:00 до 22:00", - "услуги": "⚙️ Мы предлагаем профессиональное обучение по киберспортивным дисциплинам:\n1. Индивидуальные тренировки по Dota 2 с разбором реплеев и позиционки\n2. Персональные занятия по CS2 (aim, раскидки гранат, командная тактика)\n3. Выгодные абонементы на 5 и 10 занятий с постоянным сопровождением тренера", - "о компании": "🏢 Мы — школа киберспорта 'Cyber Pro'. Помогаем игрокам любого уровня повышать свой MMR/Faceit Elo, развивать игровое мышление и побеждать на турнирах с 2022 года!\n\nНаши тренеры — действующие и экс-профессиональные игроки с 7000+ MMR и опытом игры на PRO-сцене." + "цены": ( + "💰 Тарифы ИКП на тренировки (направления: 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: @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" + "• Discord: https://discord.gg/z92mAw7KEe" + ) } -# Доступные услуги и тарифы для выбора в форме заявки +# Доступные услуги (направления) для выбора в форме заявки SERVICES = { "1": { - "name": "Тренировка по Dota 2 🎮", - "price": "1 500 ₽/час", - "description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." + "name": "Counter-Strike 2 🔫", + "price": "от 870 ₽ / занятие", + "description": "Тренировки по CS2: aim, раскидки гранат, командная тактика. Разовое занятие 870 ₽, абонементы от 2 790 ₽." }, "2": { - "name": "Тренировка по CS2 🔫", - "price": "1 500 ₽/час", - "description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." + "name": "Dota 2 🎮", + "price": "от 870 ₽ / занятие", + "description": "Тренировки по Dota 2: разбор реплеев, позиционка, фарм и тимплей. Разовое занятие 870 ₽, абонементы от 2 790 ₽." }, "3": { - "name": "Пакет услуг 'Стандарт' 📦", - "price": "7 500 ₽", - "description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." - }, - "4": { - "name": "Пакет услуг 'Pro' 🌟", - "price": "14 000 ₽", - "description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера." + "name": "World of Tanks 🛡️", + "price": "от 870 ₽ / занятие", + "description": "Тренировки по World of Tanks: позиционирование, сведение, тактика боёв. Разовое занятие 870 ₽, абонементы от 2 790 ₽." } } diff --git a/database.py b/database.py index 35ada56..95c2b68 100644 --- a/database.py +++ b/database.py @@ -1,5 +1,6 @@ import sqlite3 import os +import json from typing import Optional, Dict, Any class DatabaseManager: @@ -29,7 +30,7 @@ class DatabaseManager: """) # Migration checks for existing databases - for col in ["consent_1", "consent_2", "consent_3"]: + for col in ["consent_1", "consent_2", "consent_3", "call_time", "vac_data"]: try: cursor.execute(f"ALTER TABLE user_states ADD COLUMN {col} TEXT") except sqlite3.OperationalError: @@ -63,24 +64,19 @@ class DatabaseManager: if cursor.fetchone()[0] == 0: default_services = { "1": { - "name": "Тренировка по Dota 2 🎮", - "price": "1 500 ₽/час", - "description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." + "name": "Counter-Strike 2 🔫", + "price": "от 870 ₽ / занятие", + "description": "Тренировки по CS2: aim, раскидки гранат, командная тактика. Разовое занятие 870 ₽, абонементы от 2 790 ₽." }, "2": { - "name": "Тренировка по CS2 🔫", - "price": "1 500 ₽/час", - "description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." + "name": "Dota 2 🎮", + "price": "от 870 ₽ / занятие", + "description": "Тренировки по Dota 2: разбор реплеев, позиционка, фарм и тимплей. Разовое занятие 870 ₽, абонементы от 2 790 ₽." }, "3": { - "name": "Пакет услуг 'Стандарт' 📦", - "price": "7 500 ₽", - "description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." - }, - "4": { - "name": "Пакет услуг 'Pro' 🌟", - "price": "14 000 ₽", - "description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера." + "name": "World of Tanks 🛡️", + "price": "от 870 ₽ / занятие", + "description": "Тренировки по World of Tanks: позиционирование, сведение, тактика боёв. Разовое занятие 870 ₽, абонементы от 2 790 ₽." } } for key, svc in default_services.items(): @@ -89,6 +85,38 @@ class DatabaseManager: (key, svc["name"], svc["price"], svc["description"]) ) + # Table for completed leads (заявки) — единый источник для статистики + cursor.execute(""" + CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + source TEXT, -- 'bot' | 'web' + name TEXT, + phone TEXT, + service TEXT, + call_time TEXT, + promo TEXT, + marketing INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Table for HR/recruitment applications (отклики на вакансии и набор в состав) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + track TEXT, -- 'player' | 'worker' + discipline TEXT, + position TEXT, + rank TEXT, + role TEXT, + experience TEXT, + contact TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + # Table for web registrations cursor.execute(""" CREATE TABLE IF NOT EXISTS web_registrations ( @@ -112,7 +140,7 @@ class DatabaseManager: conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute( - "SELECT state, name, phone, question, consent_1, consent_2, consent_3 FROM user_states WHERE user_id = ?", + "SELECT state, name, phone, question, consent_1, consent_2, consent_3, call_time FROM user_states WHERE user_id = ?", (user_id,) ) row = cursor.fetchone() @@ -126,13 +154,14 @@ class DatabaseManager: ) conn.commit() return { - "state": "FREE_CHAT", - "name": None, - "phone": None, + "state": "FREE_CHAT", + "name": None, + "phone": None, "question": None, "consent_1": None, "consent_2": None, - "consent_3": None + "consent_3": None, + "call_time": None } def update_user_state(self, user_id: int, state: str): @@ -148,7 +177,7 @@ class DatabaseManager: def update_user_field(self, user_id: int, field: str, value: Optional[str]): """Update a specific field in the form.""" - allowed_fields = ["name", "phone", "question", "state", "consent_1", "consent_2", "consent_3"] + allowed_fields = ["name", "phone", "question", "state", "consent_1", "consent_2", "consent_3", "call_time"] if field not in allowed_fields: raise ValueError(f"Invalid field name: {field}") @@ -162,12 +191,25 @@ class DatabaseManager: conn.commit() def reset_form(self, user_id: int): - """Reset form fields and return to free chat state.""" + """Reset form progress and return to free chat, but KEEP name/phone for returning users.""" with self._get_connection() as conn: cursor = conn.cursor() cursor.execute( "UPDATE user_states SET state = 'FREE_CHAT', " - "name = NULL, phone = NULL, question = NULL, " + "question = NULL, call_time = NULL, vac_data = NULL, " + "consent_1 = NULL, consent_2 = NULL, consent_3 = NULL " + "WHERE user_id = ?", + (user_id,) + ) + conn.commit() + + def reset_form_full(self, user_id: int): + """Полностью очистить данные пользователя (включая имя/телефон) — для 'Заполнить заново'.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE user_states SET state = 'FREE_CHAT', " + "name = NULL, phone = NULL, question = NULL, call_time = NULL, " "consent_1 = NULL, consent_2 = NULL, consent_3 = NULL " "WHERE user_id = ?", (user_id,) @@ -254,6 +296,120 @@ class DatabaseManager: ) conn.commit() + def count_web_registrations(self) -> int: + """Количество заявок с сайта (для генерации следующего ID независимо от Excel).""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM web_registrations") + return cursor.fetchone()[0] + + def add_lead(self, user_id: int, source: str, name: str, phone: str, + service: Optional[str] = None, call_time: Optional[str] = None, + promo: Optional[str] = None, marketing: bool = False): + """Записать завершённую заявку в единый журнал leads (для статистики и истории).""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO leads (user_id, source, name, phone, service, call_time, promo, marketing) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (user_id, source, name, phone, service, call_time, promo, 1 if marketing else 0) + ) + conn.commit() + + def get_marketing_subscribers(self) -> list: + """Список user_id, давших согласие на маркетинговую рассылку (consent_2 = 'Согласен').""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT user_id FROM user_states WHERE consent_2 = 'Согласен'") + return [row[0] for row in cursor.fetchall()] + + def get_lead_stats(self) -> Dict[str, Any]: + """Сводная статистика по заявкам из журнала leads.""" + with self._get_connection() as conn: + cursor = conn.cursor() + + def scalar(query, params=()): + cursor.execute(query, params) + return cursor.fetchone()[0] + + stats = { + "total": scalar("SELECT COUNT(*) FROM leads"), + "today": scalar("SELECT COUNT(*) FROM leads WHERE date(created_at) = date('now', 'localtime')"), + "last7": scalar("SELECT COUNT(*) FROM leads WHERE created_at >= datetime('now', '-7 days')"), + "from_bot": scalar("SELECT COUNT(*) FROM leads WHERE source = 'bot'"), + "from_web": scalar("SELECT COUNT(*) FROM leads WHERE source = 'web'"), + "marketing_yes": scalar("SELECT COUNT(*) FROM leads WHERE marketing = 1"), + } + + cursor.execute( + "SELECT service, COUNT(*) FROM leads WHERE service IS NOT NULL AND service != '' " + "GROUP BY service ORDER BY COUNT(*) DESC" + ) + stats["by_service"] = cursor.fetchall() + return stats + + def get_dialogue_stats(self) -> Dict[str, Any]: + """Статистика диалогов: уникальные пользователи и объём сообщений.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(DISTINCT user_id) FROM dialogue_logs") + unique_users = cursor.fetchone()[0] + cursor.execute("SELECT COUNT(*) FROM dialogue_logs WHERE sender = 'user'") + user_messages = cursor.fetchone()[0] + return {"unique_users": unique_users, "user_messages": user_messages} + + def get_vac_data(self, user_id: int) -> Dict[str, Any]: + """Черновик отклика на вакансию (хранится как JSON в user_states.vac_data).""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT vac_data FROM user_states WHERE user_id = ?", (user_id,)) + row = cursor.fetchone() + if row and row[0]: + try: + return json.loads(row[0]) + except (ValueError, TypeError): + return {} + return {} + + def set_vac_field(self, user_id: int, key: str, value: Optional[str]): + """Обновить одно поле в черновике отклика.""" + data = self.get_vac_data(user_id) + data[key] = value + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO user_states (user_id, vac_data) VALUES (?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET vac_data = excluded.vac_data, last_updated = CURRENT_TIMESTAMP", + (user_id, json.dumps(data, ensure_ascii=False)) + ) + conn.commit() + + def clear_vac_data(self, user_id: int): + """Очистить черновик отклика.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO user_states (user_id, vac_data) VALUES (?, NULL) " + "ON CONFLICT(user_id) DO UPDATE SET vac_data = NULL, last_updated = CURRENT_TIMESTAMP", + (user_id,) + ) + conn.commit() + + def add_application(self, user_id: int, track: str, data: dict): + """Сохранить завершённый отклик (игрок/сотрудник) в журнал applications.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO applications (user_id, track, discipline, position, rank, role, experience, contact) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + user_id, track, + data.get("discipline"), data.get("position"), data.get("rank"), + data.get("role"), data.get("experience"), data.get("contact") + ) + ) + conn.commit() + def add_web_registration(self, reg_id: str, data: dict): """Сохранить заявку с веб-формы в базу данных SQLite.""" with self._get_connection() as conn: diff --git a/form/index.html b/form/index.html index 7c38f5f..f7e8c68 100644 --- a/form/index.html +++ b/form/index.html @@ -33,6 +33,12 @@
Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП