commit c8d769ee1a4e3c75382c5bf797210526ce10988c Author: a.gruzdev Date: Mon Jun 15 04:56:06 2026 +0000 main Начальный проект чат бота (Для тестирования и исправления) diff --git a/.env b/.env new file mode 100644 index 0000000..e7cda77 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +# VK API Configuration +# Токен сообщества ВКонтакте (с правами на управление сообщениями) +VK_TOKEN=your_vk_community_token_here + +# Идентификатор (ID) сообщества ВКонтакте (только цифры) +VK_GROUP_ID=0 + +# ID оператора / администратора ВКонтакте для отправки лидов и оповещений (только цифры) +OPERATOR_PEER_ID=12345678 + +# Список ID администраторов (через запятую) для управления услугами через чат +ADMIN_IDS=12345678 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e7cda77 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# VK API Configuration +# Токен сообщества ВКонтакте (с правами на управление сообщениями) +VK_TOKEN=your_vk_community_token_here + +# Идентификатор (ID) сообщества ВКонтакте (только цифры) +VK_GROUP_ID=0 + +# ID оператора / администратора ВКонтакте для отправки лидов и оповещений (только цифры) +OPERATOR_PEER_ID=12345678 + +# Список ID администраторов (через запятую) для управления услугами через чат +ADMIN_IDS=12345678 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0cbd5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Configuration files containing credentials +.env + +# Python byte-compiled files +__pycache__/ +*.py[cod] +*$py.class + +# Local Databases and dynamic data files +*.db +*.xlsx + +# Environment/IDE configurations +.idea/ +.vscode/ + +# Logs +*.log diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..997d552 --- /dev/null +++ b/bot.py @@ -0,0 +1,1036 @@ +import json +import re +import logging +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 + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - [%(levelname)s] - %(name)s - %(message)s" +) +logger = logging.getLogger("VK_Bot_IKP") + +# Инициализация базы данных сессий +db = DatabaseManager() + +# Регулярное выражение для базовой валидации номера телефона +PHONE_REGEX = re.compile(r"^\+?[78]\d{10}$") + + +# ========================================== +# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте) +# ========================================== + +def get_main_keyboard() -> str: + """Главное меню бота.""" + keyboard = VkKeyboard(one_time=False) + # Кнопка начать опрос (зеленая) + keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY) + keyboard.add_line() + # Кнопки FAQ и оператора + keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Позвать оператора 🧑‍💼", color=VkKeyboardColor.NEGATIVE) + 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.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_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.PRIMARY) + return keyboard.get_keyboard() + + + +# ========================================== +# КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ +# ========================================== + +class VKBotApplication: + def __init__(self): + self.vk_session = None + self.vk = None + self.longpoll = None + + # Инициализируем динамические услуги из базы данных + 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!") + 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): + """Отправка сообщения пользователю с сохранением в лог.""" + 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 + 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})") + + def handle_message(self, user_id: int, text: str): + """Маршрутизатор входящих сообщений на основе состояний пользователя.""" + text_clean = text.strip() + text_lower = text_clean.lower() + + # Логируем входящее сообщение от пользователя + 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 + + 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 + + # Маршрутизация по состояниям (FSM) + if state == "FREE_CHAT": + self._handle_free_chat(user_id, text_clean, text_lower) + 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_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 == "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 ["заполнить заявку 📝", "заявка", "начать опрос", "опрос"]: + db.update_user_state(user_id, "FORM_NAME") + self.send_message( + user_id, + "📝 Начинаем заполнение заявки!\n\n" + "Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.", + keyboard=get_cancel_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 + + # 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, + "👋 Здравствуйте! Я умный чат-бот школы киберспорта 'Cyber Pro'.\n\n" + "Я могу помочь вам быстро оформить заявку на наши услуги или ответить на частые вопросы. " + "Выберите действие на кнопках ниже или просто напишите ваш вопрос!", + keyboard=get_main_keyboard() + ) + return + + # 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру) + self.send_message( + user_id, + "🤔 Я пока только учусь и не смог полностью распознать ваш запрос.\n\n" + "Пожалуйста, воспользуйтесь кнопками меню ниже или напишите 'оператор', чтобы я пригласил специалиста.", + keyboard=get_main_keyboard() + ) + + def _handle_form_name(self, user_id: int, text: str): + """Сохранение имени и переход к номеру телефона.""" + # Валидация имени: длина от 2 до 50 символов + if len(text) < 2 or len(text) > 50: + self.send_message( + user_id, + "⚠️ Имя выглядит слишком коротким или длинным. Пожалуйста, введите корректное имя.", + 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 = 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 + + # Валидация регулярным выражением + if not PHONE_REGEX.match(phone_cleaned): + self.send_message( + user_id, + "⚠️ Некорректный формат телефона. Пожалуйста, отправьте номер в формате +79991234567.", + keyboard=get_cancel_keyboard() + ) + return + + # Сохраняем телефон в БД + 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() + ) + + 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": ["дота", "dota", "дотку", "дотка", "dota2"], + "2": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter"] + } + 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_REVIEW") + + updated_data = db.get_user_data(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" + 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 _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", "Не указано") + + review_text = ( + "🔍 **Пожалуйста, проверьте правильность заполненных данных:**\n\n" + f"👤 **Имя:** {name}\n" + f"📞 **Телефон:** {phone}\n" + f"🎮 **Выбранная услуга:** {question}\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_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 len(text) < 2 or len(text) > 50: + self.send_message( + user_id, + "⚠️ Имя выглядит слишком коротким или длинным. Пожалуйста, введите корректное имя.", + 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 = 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 + + if not PHONE_REGEX.match(phone_cleaned): + self.send_message( + user_id, + "⚠️ Некорректный формат телефона. Пожалуйста, отправьте номер в формате +79991234567.", + 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": ["дота", "dota", "дотку", "дотка", "dota2"], + "2": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter"] + } + 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 + + 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 _handle_form_consent_1(self, user_id: int, text: str, text_lower: str): + """Обработка объединенного шага согласий.""" + 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" + + # Отправляем подтверждение пользователю + self.send_message( + user_id, + "🎉 Заявка и все необходимые согласия успешно оформлены!\n\n" + f"Наш менеджер уже изучает детали и свяжется с вами {callback_time} по номеру " + f"{phone}. Спасибо за обращение!\n\n" + "Вы вернулись в главное меню бота.", + keyboard=get_main_keyboard() + ) + + # Уведомляем оператора о новой заявке со всеми согласиями + self._notify_operator_about_lead(user_id, updated_data) + 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): + """Резервный обработчик для совместимости с устаревшими состояниями в БД.""" + 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) + + 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", "Согласен") + + 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) + + 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) + + # ------------------------------------------ + # СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ + # ------------------------------------------ + + def _handoff_to_operator(self, user_id: int): + """Переводит пользователя в режим оператора и уведомляет команду поддержки.""" + db.update_user_state(user_id, "OPERATOR_CHAT") + + self.send_message( + user_id, + "🧑‍💼 Переключаю вас на оператора...\n\n" + "Я отправил уведомление специалисту поддержки. Он ответит вам прямо в этом диалоге в ближайшее время.\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]): + """Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий.""" + name = user_data["name"] + phone = user_data["phone"] + question = user_data["question"] + consent_1 = user_data.get("consent_1", "Согласен") + consent_2 = user_data.get("consent_2", "Согласен") + consent_3 = user_data.get("consent_3", "Согласен") + + operator_msg = ( + f"⚡ **Получена новая заявка!**\n\n" + f"👤 **Имя:** {name}\n" + f"📞 **Телефон:** {phone}\n" + f"🎯 **Выбранная услуга:** {question}\n\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) + + 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`" + ) + 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 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...") + 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"] + self.handle_message(user_id, text) + + +if __name__ == "__main__": + bot = VKBotApplication() + bot.run() diff --git a/bot_database.db b/bot_database.db new file mode 100644 index 0000000..bbfd328 Binary files /dev/null and b/bot_database.db differ