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. try: import vk_api from vk_api.bot_longpoll import VkBotLongPoll, VkBotEventType from vk_api.keyboard import VkKeyboard, VkKeyboardColor from vk_api.utils import get_random_id except ImportError: vk_api = None from database import DatabaseManager import config import mailer # Настройка логирования logging.basicConfig( level=logging.INFO, format="%(asctime)s - [%(levelname)s] - %(name)s - %(message)s" ) logger = logging.getLogger("VK_Bot_IKP") # Инициализация базы данных сессий db = DatabaseManager() # Анти-флуд: максимум сообщений от одного пользователя за окно времени 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())) # ========================================== # КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте) # ========================================== def get_main_keyboard() -> str: """Главное меню бота.""" keyboard = VkKeyboard(one_time=False) # Кнопка начать опрос (зеленая) 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) 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.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() 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) keyboard.add_button("Завершить диалог 🚪", color=VkKeyboardColor.NEGATIVE) 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.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) 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.PRIMARY) return keyboard.get_keyboard() # ========================================== # КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ # ========================================== class VKBotApplication: def __init__(self): 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() if vk_api and config.VK_TOKEN != "your_vk_community_token_here": try: self.vk_session = vk_api.VkApi(token=config.VK_TOKEN) 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, template: Optional[str] = None): """Отправка сообщения пользователю с сохранением в лог.""" db.log_message(peer_id, "bot", message) if self.vk: try: params = { "peer_id": peer_id, "message": message, "random_id": get_random_id() } if keyboard: 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 "" 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) # Получаем текущее состояние и заполненные данные user_data = db.get_user_data(user_id) state = user_data["state"] # Глобальная обработка админ-команд (доступна только пользователям из ADMIN_IDS) if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS: if text_lower in ["админка", "/admin", "услуги"]: self._send_admin_menu(user_id) 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 ["стата", "статистика", "/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}") # Глобальная обработка отмены формы в любых состояниях заполнения опроса if state.startswith("FORM_") and text_lower in ["отменить заполнение ❌", "отмена", "отменить"]: db.reset_form(user_id) self.send_message( user_id, "Заполнение заявки отменено. Мы вернулись в главное меню. Чем я могу помочь?", keyboard=get_main_keyboard() ) 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": 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: # Предотвращение неопределенного состояния db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.", keyboard=get_main_keyboard() ) # ------------------------------------------ # ОБРАБОТЧИКИ СОСТОЯНИЙ (FSM) # ------------------------------------------ def _handle_free_chat(self, user_id: int, text: str, text_lower: str): """Логика Свободного Чата (Главное меню, FAQ, переключение на форму).""" # 1. Запуск формы опроса if text_lower in ["заполнить заявку 📝", "заявка", "начать опрос", "опрос"]: 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, "🔗 Мы на площадках ИКП — заходите, подписывайтесь и смотрите трансляции 👇", keyboard=get_socials_keyboard() ) return # 2. Переход к списку частых вопросов (FAQ) if text_lower in ["частые вопросы ❓", "faq", "вопросы", "помощь"]: self.send_message( user_id, "❓ Выберите тему, которая вас интересует, на клавиатуре ниже:", keyboard=get_faq_keyboard() ) return # 3. Возврат в главное меню if text_lower in ["назад в меню ↩️", "меню", "назад"]: self.send_message( user_id, "🏠 Вы вернулись в главное меню. Выберите интересующее действие:", keyboard=get_main_keyboard() ) 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) return # 5. Обработка конкретных ответов FAQ for key, answer in config.FAQ_ANSWERS.items(): if key in text_lower: self.send_message(user_id, answer, keyboard=get_faq_keyboard()) return # 6. Приветственные сообщения if text_lower in ["начать", "привет", "здравствуйте", "ку", "hi", "hello"]: self.send_message( user_id, "👋 Здравствуйте! Это чат-бот киберспортивной организации ИКП — Игра. Киберспорт. Профессионалы.\n\n" "Я помогу быстро оформить заявку на тренировки, рассказать об услугах или связать вас с командой. " "Также у нас открыт набор игроков и сотрудников — загляните в раздел «Карьера в ИКП 💼».\n\n" "Выберите действие на кнопках ниже 👇", keyboard=get_main_keyboard(), attachment=self.get_logo_attachment(user_id) ) return # 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру) self.send_message( user_id, "🤔 Я пока только учусь и не смог полностью распознать ваш запрос.\n\n" "Пожалуйста, воспользуйтесь кнопками меню ниже или напишите 'оператор', чтобы я пригласил специалиста.", keyboard=get_main_keyboard() ) 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 # Сохраняем имя в БД 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" "Шаг 2 из 3: Введите ваш контактный номер телефона (например, +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, "✅ Телефон принят!\n\nШаг 3 из 3: какое направление вас интересует?\n\n") def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]): """Обработка выбора услуги с прямым переходом к проверке данных.""" selected_service = None text_lower = text.lower().strip() # 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): selected_service = config.SERVICES.get(key_opt) break # 2. Поиск по названию или ключу (если синонимы не сработали) if not selected_service: 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 (text_lower == key or text_lower == f"{key}." or text_lower == f"{key}️⃣" or 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))): selected_service = svc break 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_CALLTIME") self.send_message( user_id, f"Отличный выбор — {selected_service['name']}!\n\n" "🕒 Когда вам удобно, чтобы менеджер с вами связался?", keyboard=get_calltime_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_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" 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") # Загружаем документ оферты для ознакомления doc_attachment = self.get_offer_document_attachment(user_id) # Отправляем значительно сокращенное и дружелюбное сообщение-согласие self.send_message( user_id, "📄 Согласие с условиями и офертой\n\n" "Для завершения отправки заявки подтвердите, пожалуйста, ваше согласие с условиями Публичной оферты, " "Политикой обработки персональных данных и записью разговора.\n\n" "«Я подтверждаю, что ознакомлен(а) с договором оферты (прикреплен ниже), принимаю его условия, " "соглашаюсь на обработку персональных данных и запись консультации.»", keyboard=get_binary_keyboard(show_skip=False), attachment=doc_attachment ) 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_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: 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 = None text_lower = text.lower().strip() # 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): selected_service = config.SERVICES.get(key_opt) break # 2. Поиск по названию или ключу (если синонимы не сработали) if not selected_service: 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 (text_lower == key or text_lower == f"{key}." or text_lower == f"{key}️⃣" or 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))): selected_service = svc break 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.""" 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", "career_player.jpg", "career_worker.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_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}) по номеру {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()) # Журналируем заявку для статистики и истории 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): """Поведение бота в режиме активного диалога с живым человеком.""" # Пользователь хочет выйти из диалога с оператором if text_lower in ["завершить диалог 🚪", "завершить", "/exit", "выход"]: db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.", keyboard=get_main_keyboard() ) # Уведомляем оператора о закрытии диалога self.send_message( config.OPERATOR_PEER_ID, f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором." ) return # В остальное время бот МОЛЧИТ в диалог с пользователем. logger.info(f"[OPERATOR MODE] Message from {user_id} bypasses bot: {text}") self._forward_to_operator(user_id, text) # ------------------------------------------ # СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ # ------------------------------------------ # ------------------------------------------ # РАЗДЕЛ "КАРЬЕРА В ИКП" (НАЙМ ИГРОКОВ И СОТРУДНИКОВ) # ------------------------------------------ @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" "Чтобы вернуться к боту, нажмите кнопку ниже.", 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.send_message(config.OPERATOR_PEER_ID, 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 "" operator_msg = ( 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" f"🔗 Профиль клиента: vk.com/id{user_id}\n" f"Переписка сохранена в базе данных." ) 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}" ) self.send_message(config.OPERATOR_PEER_ID, forward_msg) # ------------------------------------------ # ЗАПУСК И ОБРАБОТКА LONGPOLL # ------------------------------------------ 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" "📋 Текущий список активных услуг:\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" "📊 Статистика: отправьте стата\n\n" "📣 Рассылка по подписчикам:\n" " 👉 рассылка [текст] → предпросмотр → рассылка подтвердить" ) self.send_message(user_id, admin_text) 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): """Формирует и отправляет админу сводную статистику по заявкам и диалогам.""" 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: logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).") print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.") # Эмулятор консольного ввода для тестирования на Windows локально test_user_id = 99999 # Эмулируем приветствие при старте self.handle_message(test_user_id, "начать") while True: try: user_input = input("Вы: ") if user_input.lower() == "выход": break self.handle_message(test_user_id, user_input) except (KeyboardInterrupt, EOFError): break return logger.info("Бот успешно запущен в режиме прослушивания LongPoll...") # Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота. while True: try: for event in self.longpoll.listen(): if event.type == VkBotEventType.MESSAGE_NEW: message_obj = event.obj.message user_id = message_obj["from_id"] text = message_obj["text"] try: self.handle_message(user_id, text) except Exception as e: # Ошибка обработки одного сообщения не должна останавливать бота logger.error(f"Ошибка обработки сообщения от {user_id}: {e}") except KeyboardInterrupt: logger.info("Остановка бота по запросу (Ctrl+C).") break except Exception as e: # Сетевой обрыв/протухшая сессия longpoll — ждём и переподключаемся logger.warning(f"Обрыв LongPoll ({e}). Переподключение через 3 с…") time.sleep(3) try: self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID) logger.info("LongPoll переподключён.") except Exception as e2: logger.error(f"Не удалось переподключить LongPoll: {e2}") time.sleep(5) if __name__ == "__main__": bot = VKBotApplication() bot.run()