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 # Временный бан при повторном флуде: после BAN_STRIKES срабатываний за BAN_WINDOW_SEC — бан на BAN_SEC BAN_STRIKES = 3 BAN_WINDOW_SEC = 60 BAN_SEC = 600 from validators import ( RU_PHONE_REGEX, INTL_PHONE_REGEX, NAME_REGEX, normalize_phone, format_phone_display, is_valid_name, ) from keyboards import * # все 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] = {} # Контекст админских мастеров (что редактируем): {admin_id: {...}} self._admin_ctx: Dict[int, dict] = {} # Кэш загруженных в 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._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда self._banned_until: 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 _is_super_admin(self, user_id: int) -> bool: """Супер-админ (определён только в config.SUPER_ADMIN_IDS, меняется в коде).""" return user_id in getattr(config, "SUPER_ADMIN_IDS", []) def _is_admin(self, user_id: int) -> bool: """Админ: супер-админ, либо из config.ADMIN_IDS, либо добавленный в рантайме.""" if self._is_super_admin(user_id): return True if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS: return True try: return user_id in db.get_admins() except Exception: return False def _operator_ids(self) -> list: """Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID.""" ids = getattr(config, "OPERATOR_PEER_IDS", None) return ids if ids else [config.OPERATOR_PEER_ID] def _notify_operators(self, message: str): """Отправляет сообщение всем операторам из списка.""" for op_id in self._operator_ids(): self.send_message(op_id, message) def _vk_name(self, user_id: int) -> Optional[str]: """Реальное имя пользователя из VK (для сверки с введёнными данными).""" if not self.vk or user_id <= 0: return None try: info = self.vk.users.get(user_ids=user_id) if info: u = info[0] full = f"{u.get('first_name', '')} {u.get('last_name', '')}".strip() return full or None except Exception as e: logger.error(f"Не удалось получить имя VK для {user_id}: {e}") return None @staticmethod def _progress_bar(step: int, total: int = 3) -> str: """Визуальный прогресс шага формы: ▰▰▱ Шаг 2 из 3.""" return "▰" * step + "▱" * (total - step) + f" Шаг {step} из {total}" def _within_work_hours(self) -> bool: """True, если сейчас рабочее время (config.WORK_HOUR_START..WORK_HOUR_END).""" from datetime import datetime h = datetime.now().hour return getattr(config, "WORK_HOUR_START", 9) <= h < getattr(config, "WORK_HOUR_END", 21) def _send_admin_panel(self, user_id: int): """Главный экран админ-панели с кнопками разделов.""" is_super = self._is_super_admin(user_id) extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else "" self.send_message( user_id, "⚙️ Админ-панель ИКП\n\n" "Выберите раздел на кнопках ниже:\n" "• 📊 Статистика — заявки, отклики, диалоги\n" "• 👥 Кандидаты — отклики и их статусы\n" "• ⚙️ Управление услугами — добавить/изменить/удалить\n" "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" "• 📣 Рассылка — сообщение подписчикам" + extra, keyboard=get_admin_keyboard(is_super=is_super) ) def _send_admins_panel(self, user_id: int): """Раздел управления администраторами (только супер-админ).""" if not self._is_super_admin(user_id): self.send_message(user_id, "⛔ Раздел доступен только супер-админу.") return runtime = db.get_admins() supers = getattr(config, "SUPER_ADMIN_IDS", []) cfg = [a for a in getattr(config, "ADMIN_IDS", []) if a not in supers] lines = "👑 Супер-админы (меняются только в коде):\n" lines += ("".join(f" • vk.com/id{a}\n" for a in supers) or " —\n") lines += "\n🛡 Админы из .env (ADMIN_IDS):\n" lines += ("".join(f" • vk.com/id{a}\n" for a in cfg) or " —\n") lines += "\n➕ Добавленные из бота:\n" lines += ("".join(f" • vk.com/id{a}\n" for a in runtime) or " —\n") kb = VkKeyboard(inline=True) kb.add_button("➕ Добавить админа", color=VkKeyboardColor.PRIMARY) kb.add_line() kb.add_button("🗑 Удалить админа", color=VkKeyboardColor.NEGATIVE) self.send_message( user_id, "👑 Управление администраторами\n\n" f"{lines}\n" "Управляйте кнопками ниже 👇\n\n" "ℹ️ Сменить/добавить СУПЕР-АДМИНА можно только в коде: откройте config.py, " "впишите VK ID в список SUPER_ADMIN_IDS и перезапустите бота.", keyboard=kb.get_keyboard() ) def _handle_admin_add_admin(self, user_id: int, text: str): if not self._is_super_admin(user_id): self.send_message(user_id, "⛔ Добавлять админов может только супер-админ.") return arg = text.split(" ", 2)[-1].strip() if not arg.lstrip("-").isdigit(): self.send_message(user_id, "⚠️ Формат: админ доб [VK_ID числом]") return new_id = int(arg) if db.add_admin(new_id): self.send_message(user_id, f"✅ Пользователь vk.com/id{new_id} теперь администратор.") try: self.send_message(new_id, "🛡 Вам выданы права администратора бота ИКП. " "Откройте меню — появится кнопка «Админка ⚙️».") except Exception: pass else: self.send_message(user_id, f"ℹ️ Пользователь vk.com/id{new_id} уже администратор.") self._send_admins_panel(user_id) def _handle_admin_remove_admin(self, user_id: int, text: str): if not self._is_super_admin(user_id): self.send_message(user_id, "⛔ Удалять админов может только супер-админ.") return arg = text.split(" ", 2)[-1].strip() if not arg.lstrip("-").isdigit(): self.send_message(user_id, "⚠️ Формат: админ удал [VK_ID числом]") return rem_id = int(arg) if db.remove_admin(rem_id): self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.") else: self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота " "(админов из .env и супер-админов отсюда снять нельзя).") self._send_admins_panel(user_id) 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, attachments: Optional[list] = None): """Маршрутизатор входящих сообщений на основе состояний пользователя.""" text_clean = text.strip() text_lower = text_clean.lower() attachments = attachments or [] # Анти-флуд + временный бан (админы — исключение) if not self._is_admin(user_id): now = time.time() # Уже забанен — молча игнорируем до конца бана if self._banned_until.get(user_id, 0) > now: return if self._is_flooding(user_id): # Фиксируем срабатывание флуда; при повторах — временный бан strikes = [t for t in self._flood_strikes[user_id] if now - t < BAN_WINDOW_SEC] strikes.append(now) self._flood_strikes[user_id] = strikes if len(strikes) >= BAN_STRIKES: self._banned_until[user_id] = now + BAN_SEC self._flood_strikes[user_id] = [] logger.warning(f"Временный бан пользователя {user_id} на {BAN_SEC} с (повторный флуд)") self.send_message( user_id, f"🚫 Слишком много сообщений. Доступ к боту временно ограничен на {BAN_SEC // 60} минут." ) return # Иначе — мягкое предупреждение максимум раз за окно 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 self._is_admin(user_id): # Мастера админки (ожидают ввод текста/выбор/фото) — ловим по состоянию if state.startswith("ADMIN_"): self._handle_admin_wizard(user_id, text_clean, text_lower, state, attachments) return # Инлайн-кнопки предпросмотра рассылки if user_id in self._pending_broadcasts and text_lower in ["отправить ✅", "отправить", "отмена ❌", "отмена"]: self._handle_admin_broadcast(user_id, "рассылка отмена" if "отмен" in text_lower else "рассылка подтвердить") return if text_lower in ["админка", "админка ⚙️", "/admin"]: self._send_admin_panel(user_id) return # Кнопки-действия раздела «Услуги» elif text_lower in ["➕ добавить услугу", "добавить услугу"]: db.update_user_state(user_id, "ADMIN_SVC_ADD") self.send_message(user_id, "➕ Новая услуга. Отправьте: Название | Цена | Описание\n" "Пример: Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы.", keyboard=get_cancel_admin_keyboard()) return elif text_lower in ["✏️ изменить услугу", "изменить услугу"]: items = [(k, s["name"]) for k, s in db.get_services().items()] db.update_user_state(user_id, "ADMIN_SVC_EDIT_PICK") self.send_message(user_id, "✏️ Какую услугу изменить? Выберите:", keyboard=get_pick_keyboard(items)) return elif text_lower in ["🖼 сменить картинку", "сменить картинку"]: items = [(k, s["name"]) for k, s in db.get_services().items()] db.update_user_state(user_id, "ADMIN_SVC_IMG_PICK") self.send_message(user_id, "🖼 Для какой услуги сменить картинку? Выберите:", keyboard=get_pick_keyboard(items)) return elif text_lower in ["🗑 удалить услугу", "удалить услугу"]: items = [(k, s["name"]) for k, s in db.get_services().items()] db.update_user_state(user_id, "ADMIN_SVC_DEL_PICK") self.send_message(user_id, "🗑 Какую услугу удалить? Выберите:", keyboard=get_pick_keyboard(items)) return # Кнопки-действия раздела «Быстрые ответы» elif text_lower in ["➕ добавить ответ", "добавить ответ"]: db.update_user_state(user_id, "ADMIN_FAQ_ADD") self.send_message(user_id, "➕ Новый быстрый ответ. Отправьте: ключевое слово | текст ответа\n" "Пример: расписание | Тренировки ежедневно с 10:00 до 22:00.", keyboard=get_cancel_admin_keyboard()) return elif text_lower in ["✏️ изменить ответ", "изменить ответ"]: items = [(i, kw) for i, kw, _ in db.get_custom_faq()] db.update_user_state(user_id, "ADMIN_FAQ_EDIT_PICK") self.send_message(user_id, "✏️ Какой ответ изменить? Выберите:", keyboard=get_pick_keyboard(items)) return elif text_lower in ["🗑 удалить ответ", "удалить ответ"]: items = [(i, kw) for i, kw, _ in db.get_custom_faq()] db.update_user_state(user_id, "ADMIN_FAQ_DEL_PICK") self.send_message(user_id, "🗑 Какой ответ удалить? Выберите:", keyboard=get_pick_keyboard(items)) return elif text_lower in ["в админку ↩️", "в админку"]: self._send_admin_panel(user_id) return elif text_lower in ["статистика 📊", "стата", "статистика", "/stats"]: self._handle_admin_stats(user_id) return elif text_lower in ["управление услугами ⚙️", "управление услугами", "услуги"]: self._send_admin_menu(user_id) return elif text_lower in ["рассылка 📣", "рассылка"]: # Кнопка без текста — показываем инструкцию self._handle_admin_broadcast(user_id, "рассылка") return elif text_lower.startswith("рассылка"): self._handle_admin_broadcast(user_id, text_clean) return elif text_lower in ["вакансии 💼", "вакансии"]: self._send_vacancy_admin(user_id) return elif text_lower in ["➕ вакансия игрок", "вакансия игрок"]: db.update_user_state(user_id, "ADMIN_VAC_ADD_PLAYER") self.send_message(user_id, "➕ Название нового направления для ИГРОКОВ (например, Valorant 🎯):", keyboard=get_cancel_admin_keyboard()) return elif text_lower in ["➕ вакансия сотрудник", "вакансия сотрудник"]: db.update_user_state(user_id, "ADMIN_VAC_ADD_WORKER") self.send_message(user_id, "➕ Название новой должности для СОТРУДНИКОВ (например, Аналитик):", keyboard=get_cancel_admin_keyboard()) return elif text_lower in ["🗑 удалить вакансию", "удалить вакансию"]: items = [(i, f"🎮 {l}") for i, l in db.get_vacancy_options("player")] items += [(i, f"💼 {l}") for i, l in db.get_vacancy_options("worker")] if not items: self.send_message(user_id, "Список вакансий пуст.") self._send_vacancy_admin(user_id) return db.update_user_state(user_id, "ADMIN_VAC_DEL_PICK") self.send_message(user_id, "🗑 Какую вакансию удалить? Выберите:", keyboard=get_pick_keyboard(items)) return elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]: self._send_faq_admin(user_id) return elif text_lower.startswith("фак доб "): self._handle_faq_add(user_id, text_clean) return elif text_lower.startswith("фак удал "): self._handle_faq_delete(user_id, text_clean) return elif text_lower in ["кандидаты 👥", "кандидаты"]: self._send_candidates(user_id) return elif text_lower.startswith("статус "): self._handle_candidate_status(user_id, text_clean) return elif text_lower in ["администраторы 👑", "администраторы", "админы"]: self._send_admins_panel(user_id) return elif text_lower in ["➕ добавить админа", "добавить админа"]: if not self._is_super_admin(user_id): self.send_message(user_id, "⛔ Только супер-админ.") return db.update_user_state(user_id, "ADMIN_ADMIN_ADD") self.send_message(user_id, "➕ Пришлите VK ID нового администратора (числом).\n" "ID можно узнать на vk.com/ или через сервисы определения id.", keyboard=get_cancel_admin_keyboard()) return elif text_lower in ["🗑 удалить админа", "удалить админа"]: if not self._is_super_admin(user_id): self.send_message(user_id, "⛔ Только супер-админ.") return runtime = db.get_admins() if not runtime: self.send_message(user_id, "Список добавленных из бота админов пуст.") self._send_admins_panel(user_id) return db.update_user_state(user_id, "ADMIN_ADMIN_DEL_PICK") self.send_message(user_id, "🗑 Кого удалить из админов? Выберите:", keyboard=get_pick_keyboard([(a, f"vk.com/id{a}") for a in runtime])) return elif text_lower.startswith("админ доб"): self._handle_admin_add_admin(user_id, text_clean) return elif text_lower.startswith("админ удал"): self._handle_admin_remove_admin(user_id, text_clean) return elif text_lower.startswith("ред "): self._handle_admin_edit(user_id, text_clean) return elif text_lower.startswith("доб "): self._handle_admin_add(user_id, text_clean) return elif text_lower.startswith("удал "): self._handle_admin_delete(user_id, text_clean) return elif text_lower in ["в главное меню ↩️"]: self.send_message( user_id, "🏠 Главное меню.", keyboard=get_main_keyboard(is_admin=True) ) 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(is_admin=self._is_admin(user_id)) ) return # Глобальная отмена отклика на вакансию if state.startswith("VAC_") and text_lower in ["отменить ❌", "отмена", "отменить"]: db.clear_vac_data(user_id) db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "Отклик отменён. Вы вернулись в главное меню.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) return # Маршрутизация по состояниям (FSM) if state == "FREE_CHAT": self._handle_free_chat(user_id, text_clean, text_lower) elif state == "FORM_RETURNING": self._handle_form_returning(user_id, text_clean, text_lower, user_data) elif state == "FORM_RET_NAME": self._handle_form_ret_name(user_id, text_clean) elif state == "FORM_RET_PHONE": self._handle_form_ret_phone(user_id, text_clean) elif state == "FORM_NAME": self._handle_form_name(user_id, text_clean) elif state == "FORM_PHONE": self._handle_form_phone(user_id, text_clean) elif state == "FORM_QUESTION": self._handle_form_question(user_id, text_clean, user_data) elif state == "FORM_CALLDATE": self._handle_form_calldate(user_id, text_clean) elif state == "FORM_CALLTIME": self._handle_form_calltime(user_id, text_clean) elif state == "FORM_CONFIRM_QUESTION": self._handle_form_confirm_question(user_id, text_clean, user_data) elif state == "FORM_REVIEW": self._handle_form_review(user_id, text_clean, user_data) elif state == "FORM_EDIT_MENU": self._handle_form_edit_menu(user_id, text_clean, user_data) elif state == "FORM_EDIT_NAME": self._handle_form_edit_name(user_id, text_clean) elif state == "FORM_EDIT_PHONE": self._handle_form_edit_phone(user_id, text_clean) elif state == "FORM_EDIT_QUESTION": self._handle_form_edit_question(user_id, text_clean) elif state == "FORM_CONSENT_1": self._handle_form_consent_1(user_id, text_clean, text_lower) elif state == "FORM_CONSENT_2": self._handle_form_consent_2(user_id, text_clean, text_lower) elif state == "FORM_CONSENT_3": self._handle_form_consent_3(user_id, text_clean, text_lower, user_data) elif state == "VAC_CHOOSE": self._handle_vac_choose(user_id, text_clean, text_lower) elif state == "VAC_P_DISCIPLINE": self._handle_vac_p_discipline(user_id, text_clean, text_lower) elif state == "VAC_P_RANK": self._handle_vac_p_rank(user_id, text_clean) elif state == "VAC_P_ROLE": self._handle_vac_p_role(user_id, text_clean) elif state == "VAC_P_EXP": self._handle_vac_p_exp(user_id, text_clean) elif state == "VAC_P_CONTACT": self._handle_vac_p_contact(user_id, text_clean) elif state == "VAC_W_POSITION": self._handle_vac_w_position(user_id, text_clean, text_lower) elif state == "VAC_W_POSITION_OTHER": self._handle_vac_w_position_other(user_id, text_clean) elif state == "VAC_W_EXP": self._handle_vac_w_exp(user_id, text_clean) elif state == "VAC_W_CONTACT": self._handle_vac_w_contact(user_id, text_clean) elif state == "OPERATOR_CHAT": self._handle_operator_chat(user_id, text_clean, text_lower) else: # Предотвращение неопределенного состояния db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) # ------------------------------------------ # ОБРАБОТЧИКИ СОСТОЯНИЙ (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" f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.", 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() ) # Дополнительно — быстрые ответы из админки инлайн-кнопками custom = db.get_custom_faq() if custom: kb = VkKeyboard(inline=True) for n, (_id, kw, _ans) in enumerate(custom[:6]): if n and n % 2 == 0: kb.add_line() kb.add_button(kw[:40], color=VkKeyboardColor.SECONDARY) self.send_message( user_id, "💬 Популярные вопросы — нажмите, чтобы получить ответ:", keyboard=kb.get_keyboard() ) return # 3. Возврат в главное меню if text_lower in ["назад в меню ↩️", "меню", "назад", "в главное меню ↩️"]: self.send_message( user_id, "🏠 Вы вернулись в главное меню. Выберите интересующее действие:", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) 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(is_admin=self._is_admin(user_id)), attachment=self.get_logo_attachment(user_id) ) return # 6.5 Быстрые ответы по ключевым словам (редактируются админом) quick = db.match_custom_faq(text_lower) if quick: self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) return # 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру) self.send_message( user_id, "🤔 Я пока только учусь и не смог полностью распознать ваш запрос.\n\n" "Пожалуйста, воспользуйтесь кнопками меню ниже или напишите 'оператор', чтобы я пригласил специалиста.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]): """Приветствие для клиента, чьи имя и телефон уже сохранены.""" name = user_data.get("name") phone = format_phone_display(user_data.get("phone", "")) self.send_message( user_id, f"Здравствуйте! Мы вас помним, {name}. 👋\n\n" f"Ваш номер телефона по-прежнему {phone}?\n\n" "Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги.", keyboard=get_returning_keyboard() ) # Кнопки изменения данных — инлайн, под отдельным сообщением self.send_message( user_id, "Нужно поправить данные? 👇", keyboard=get_returning_changes_keyboard() ) def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]): """Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново.""" if text_lower in ["да, всё верно ✅", "да, все верно", "да", "верно", "всё верно"]: # Данные подтверждены — переходим сразу к выбору услуги db.update_user_state(user_id, "FORM_QUESTION") self._send_services_prompt(user_id, prefix="Отлично! Перейдём к выбору услуги.\n\n") elif text_lower in ["сменить имя 👤", "сменить имя", "имя"]: db.update_user_state(user_id, "FORM_RET_NAME") self.send_message(user_id, "👤 Введите ваше новое имя:", keyboard=get_cancel_keyboard()) elif text_lower in ["сменить телефон 📞", "сменить телефон", "телефон"]: db.update_user_state(user_id, "FORM_RET_PHONE") self.send_message( user_id, "📞 Введите ваш новый номер телефона:", keyboard=get_cancel_keyboard() ) elif text_lower in ["заполнить заново 🔄", "заполнить заново", "заново"]: db.reset_form_full(user_id) db.update_user_state(user_id, "FORM_NAME") self.send_message( user_id, "📝 Хорошо, заполним заявку с нуля!\n\n" f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.", keyboard=get_cancel_keyboard() ) else: self._send_returning_prompt(user_id, user_data) def _handle_form_ret_name(self, user_id: int, text: str): """Смена имени у возвращающегося клиента → обратно к подтверждению.""" if not is_valid_name(text): self.send_message( user_id, "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " "Пожалуйста, введите корректное имя.", keyboard=get_cancel_keyboard() ) return db.update_user_field(user_id, "name", text.strip()) db.update_user_state(user_id, "FORM_RETURNING") self.send_message(user_id, "✅ Имя обновлено!") self._send_returning_prompt(user_id, db.get_user_data(user_id)) def _handle_form_ret_phone(self, user_id: int, text: str): """Смена телефона у возвращающегося клиента → обратно к подтверждению.""" phone_cleaned = normalize_phone(text) if not phone_cleaned: self.send_message( user_id, "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " "либо международный в формате +<код страны><номер>.", keyboard=get_cancel_keyboard() ) return db.update_user_field(user_id, "phone", phone_cleaned) db.update_user_state(user_id, "FORM_RETURNING") self.send_message(user_id, "✅ Телефон обновлён!") self._send_returning_prompt(user_id, db.get_user_data(user_id)) def _send_services_prompt(self, user_id: int, prefix: str = ""): """Отправляет услуги каруселью (с фолбэком на текст).""" self._send_services(user_id, prefix) def _handle_form_name(self, user_id: int, text: str): """Сохранение имени и переход к номеру телефона.""" # Валидация имени: только буквы/пробел/дефис, длина 2–50 if not is_valid_name(text): self.send_message( user_id, "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " "Пожалуйста, введите корректное имя.", keyboard=get_cancel_keyboard() ) return # Сохраняем имя в БД db.update_user_field(user_id, "name", text) db.update_user_state(user_id, "FORM_PHONE") self.send_message( user_id, f"👍 Приятно познакомиться, {text}!\n\n" f"{self._progress_bar(2)}\nВведите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.", keyboard=get_cancel_keyboard() ) def _handle_form_phone(self, user_id: int, text: str): """Сохранение телефона и переход к выбору услуги.""" phone_cleaned = normalize_phone(text) # Валидация (российский или международный формат) if not phone_cleaned: self.send_message( user_id, "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " "либо международный в формате +<код страны><номер>.", keyboard=get_cancel_keyboard() ) return # Сохраняем телефон в БД db.update_user_field(user_id, "phone", phone_cleaned) db.update_user_state(user_id, "FORM_QUESTION") # Показываем услуги каруселью (с фолбэком на текст) self._send_services(user_id, f"✅ Телефон принят!\n\n{self._progress_bar(3)}\nКакое направление вас интересует?\n\n") 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_field(user_id, "call_time", None) db.update_user_state(user_id, "FORM_CALLDATE") self.send_message( user_id, f"Отличный выбор — {selected_service['name']}!\n\n" "📅 В какой день вам удобно, чтобы менеджер с вами связался?", keyboard=get_calldate_keyboard() ) else: # Формируем список услуг с ценами для повторного показа services_text = ( "⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n" "Пожалуйста, выберите одну из доступных услуг на клавиатуре или отправьте её номер/название:\n\n" ) for key, svc in config.SERVICES.items(): services_text += f"{key}️⃣ {svc['name']}\n" services_text += f" 💰 Стоимость: {svc['price']}\n" services_text += f" 📝 {svc['description']}\n\n" self.send_message( user_id, services_text, keyboard=get_services_keyboard() ) def _handle_form_confirm_question(self, user_id: int, text: str, user_data: Dict[str, Any]): """Резервный обработчик для совместимости с устаревшим шагом подтверждения.""" db.update_user_state(user_id, "FORM_REVIEW") self._send_review_message(user_id, user_data) def _handle_form_calldate(self, user_id: int, text: str): """Сохранение выбранного ДНЯ и переход к выбору времени.""" day = text.strip() if len(day) < 2 or len(day) > 40: self.send_message( user_id, "📅 Выберите день на клавиатуре ниже или коротко напишите дату.", keyboard=get_calldate_keyboard() ) return # Временно храним выбранный день в call_time (время добавится следующим шагом) db.update_user_field(user_id, "call_time", day) db.update_user_state(user_id, "FORM_CALLTIME") self.send_message( user_id, f"📅 День: {day}.\n\n🕒 В какое время удобно? Выберите слот или напишите своё время:", keyboard=get_calltime_keyboard() ) def _handle_form_calltime(self, user_id: int, text: str): """Сохранение времени (день+время) и переход к финальной проверке.""" time_part = text.strip() if len(time_part) < 2 or len(time_part) > 40: self.send_message( user_id, "🕒 Выберите время на клавиатуре или коротко напишите его (например, 15:30).", keyboard=get_calltime_keyboard() ) return day = (db.get_user_data(user_id).get("call_time") or "").strip() combined = f"{day}, {time_part}" if day else time_part db.update_user_field(user_id, "call_time", combined) db.update_user_state(user_id, "FORM_REVIEW") self._send_review_message(user_id, db.get_user_data(user_id)) def _send_review_message(self, user_id: int, user_data: Dict[str, Any]): """Отправляет сводную карточку проверки данных перед финальным шагом.""" name = user_data.get("name", "Не указано") phone = format_phone_display(user_data.get("phone", "Не указано")) question = user_data.get("question", "Не указано") call_time = user_data.get("call_time") or "Не указано" review_text = ( "🔍 Пожалуйста, проверьте правильность заполненных данных:\n\n" f"👤 Имя: {name}\n" f"📞 Телефон: {phone}\n" f"🎮 Выбранная услуга: {question}\n" f"🕒 Удобное время звонка: {call_time}\n\n" "Если всё верно, нажмите «Да, всё верно! ✅» для продолжения. Если хотите исправить — нажмите «Изменить данные 🔄»." ) self.send_message(user_id, review_text, keyboard=get_review_keyboard()) def _handle_form_review(self, user_id: int, text: str, user_data: Dict[str, Any]): """Обработка решений на экране финальной проверки данных.""" text_lower = text.lower().strip() if text_lower in ["да, всё верно! ✅", "да, всё верно", "все верно", "да", "верно"]: # Переходим к шагу оферты и согласий db.update_user_state(user_id, "FORM_CONSENT_1") # Сообщение 1 — документы инлайн-кнопками (открываются по ссылке) self.send_message( user_id, "📄 Перед отправкой ознакомьтесь, пожалуйста, с документами (кнопки ниже):", keyboard=get_consent_docs_keyboard() ) # Сообщение 2 — подтверждение согласия (кнопки снизу) self.send_message( user_id, "Подтвердите согласие с условиями Публичной оферты, Политикой обработки персональных данных " "и записью консультации:\n\n" "«Я ознакомлен(а) с документами, принимаю условия оферты, соглашаюсь на обработку " "персональных данных и запись консультации.»", keyboard=get_binary_keyboard(show_skip=False) ) elif text_lower in ["изменить данные 🔄", "изменить данные", "изменить", "редактировать", "поменять"]: # Переходим в меню изменения полей db.update_user_state(user_id, "FORM_EDIT_MENU") self.send_message( user_id, "🔄 Меню изменения данных\n\n" "Пожалуйста, выберите на клавиатуре ниже, какое поле вы хотите изменить:", keyboard=get_edit_menu_keyboard() ) else: self.send_message( user_id, "❓ Пожалуйста, подтвердите ваши данные кнопкой «Да, всё верно! ✅» или нажмите «Изменить данные 🔄» для редактирования.", keyboard=get_review_keyboard() ) def _handle_form_edit_menu(self, user_id: int, text: str, user_data: Dict[str, Any]): """Обработка меню выбора поля для редактирования.""" text_lower = text.lower().strip() if text_lower in ["изменить имя 👤", "изменить имя", "имя", "1"]: db.update_user_state(user_id, "FORM_EDIT_NAME") self.send_message( user_id, "👤 Введите ваше новое имя:", keyboard=get_cancel_keyboard() ) elif text_lower in ["изменить телефон 📞", "изменить телефон", "телефон", "2"]: db.update_user_state(user_id, "FORM_EDIT_PHONE") self.send_message( user_id, "📞 Введите ваш новый номер телефона в формате +79991234567:", keyboard=get_cancel_keyboard() ) elif text_lower in ["изменить услугу 🎮", "изменить услугу", "услугу", "услуга", "3"]: db.update_user_state(user_id, "FORM_EDIT_QUESTION") services_text = ( "🎮 Выберите новую услугу из списка:\n\n" ) for key, svc in config.SERVICES.items(): services_text += f"{key}️⃣ {svc['name']}\n" services_text += f" 💰 Стоимость: {svc['price']}\n\n" self.send_message( user_id, services_text, keyboard=get_services_keyboard() ) elif text_lower in ["изменить время 🕒", "изменить время", "время", "4"]: db.update_user_field(user_id, "call_time", None) db.update_user_state(user_id, "FORM_CALLDATE") self.send_message( user_id, "📅 В какой день вам удобно, чтобы менеджер с вами связался?", keyboard=get_calldate_keyboard() ) elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "5"]: db.update_user_state(user_id, "FORM_REVIEW") self._send_review_message(user_id, user_data) else: self.send_message( user_id, "❓ Выберите нужный пункт меню на клавиатуре или нажмите «Назад к проверке ↩️» для возврата к карточке данных.", keyboard=get_edit_menu_keyboard() ) def _handle_form_edit_name(self, user_id: int, text: str): """Сохранение нового имени и возврат к проверке.""" if not is_valid_name(text): self.send_message( user_id, "⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. " "Пожалуйста, введите корректное имя.", keyboard=get_cancel_keyboard() ) return db.update_user_field(user_id, "name", text) db.update_user_state(user_id, "FORM_REVIEW") updated_data = db.get_user_data(user_id) self.send_message(user_id, "✅ Имя успешно обновлено!") self._send_review_message(user_id, updated_data) def _handle_form_edit_phone(self, user_id: int, text: str): """Сохранение нового телефона и возврат к проверке.""" phone_cleaned = normalize_phone(text) if not phone_cleaned: self.send_message( user_id, "⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) " "либо международный в формате +<код страны><номер>.", keyboard=get_cancel_keyboard() ) return db.update_user_field(user_id, "phone", phone_cleaned) db.update_user_state(user_id, "FORM_REVIEW") updated_data = db.get_user_data(user_id) self.send_message(user_id, "✅ Номер телефона успешно обновлен!") self._send_review_message(user_id, updated_data) def _handle_form_edit_question(self, user_id: int, text: str): """Сохранение новой услуги и возврат к проверке.""" selected_service = 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 @staticmethod def _first_photo(attachments: list) -> Optional[dict]: """Возвращает первое фото-вложение из сообщения (или None).""" for att in attachments or []: if isinstance(att, dict) and att.get("type") == "photo": return att.get("photo") return None def _save_service_image(self, service_id: str, photo: dict) -> bool: """Скачивает присланное фото, обрезает под 13:8 (650×400) и сохраняет как service_.jpg.""" import os try: sizes = photo.get("sizes", []) if not sizes: return False url = max(sizes, key=lambda s: s.get("width", 0)).get("url") import requests from PIL import Image from io import BytesIO data = requests.get(url, timeout=15).content im = Image.open(BytesIO(data)).convert("RGB") TARGET = 13 / 8 w, h = im.size if w / h > TARGET: nw = int(h * TARGET); x = (w - nw) // 2; box = (x, 0, x + nw, h) else: nh = int(w / TARGET); y = (h - nh) // 2; box = (0, y, w, y + nh) im = im.crop(box).resize((650, 400), Image.LANCZOS) path = os.path.join(os.path.dirname(__file__), "help_img", f"service_{service_id}.jpg") im.save(path, "JPEG", quality=88) self._photo_cache.pop(path, None) # сбросить кэш, чтобы перезалить в VK logger.info(f"Картинка услуги {service_id} обновлена: {path}") return True except Exception as e: logger.error(f"Ошибка сохранения картинки услуги {service_id}: {e}") return False def get_logo_attachment(self, peer_id: int) -> Optional[str]: """Attachment-строка с логотипом ИКП для прикрепления к приветствию.""" 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, glob try: peer = config.OPERATOR_PEER_ID base = os.path.join(os.path.dirname(__file__), "help_img") # Грузим ВСЕ картинки из папки (кроме фона веб-формы), чтобы ничего # не подгружалось лениво в момент сообщения. count = 0 for path in sorted(glob.glob(os.path.join(base, "*.jpg"))): if os.path.basename(path).lower() == "bg.jpg": continue if self._upload_photo(peer, path): count += 1 logger.info(f"Медиа предзагружены в VK ({count} картинок) — открываются мгновенно.") except Exception as e: logger.error(f"Не удалось предзагрузить медиа: {e}") @staticmethod def _card_description(svc: Dict[str, str]) -> str: """Компактное описание карточки карусели: цена + краткая суть (лимит VK — 80 символов).""" price = str(svc.get("price", "")).strip() # Берём первую фразу описания (до точки) как короткую суть blurb = str(svc.get("description", "")).strip().split(".")[0].strip() text = f"{price} · {blurb}" if blurb else price if len(text) > 80: text = text[:79].rstrip(" ,;·") + "…" return text def _build_services_carousel(self, peer_id: int) -> Optional[str]: """Собирает VK-карусель из доступных услуг. Возвращает JSON-строку шаблона или None.""" import os base = os.path.join(os.path.dirname(__file__), "help_img") # Картинки дисциплин по id услуги. ВАЖНО: VK требует пропорцию 13:8 (напр. 650×400). # Логотип квадратный и не подходит — поэтому фото берём ТОЛЬКО если есть спец-файл дисциплины. img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"} elements = [] for key, svc in config.SERVICES.items(): # Сначала ищем загруженную админом картинку service_.jpg, затем легаси-имя uploaded = os.path.join(base, f"service_{key}.jpg") legacy = os.path.join(base, img_by_id.get(key, "")) disc_img = uploaded if os.path.exists(uploaded) else legacy info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None element = { "title": svc["name"][:80], "description": self._card_description(svc), "buttons": [{ "action": {"type": "text", "label": f"Выбрать {svc['name']}"[:40]}, "color": "primary" }] } if info: element["photo_id"] = info["raw"] elements.append(element) if not elements: return None # VK требует фото либо у всех карточек, либо ни у одной if any("photo_id" not in e for e in elements): for e in elements: e.pop("photo_id", None) return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False) def _send_services(self, user_id: int, intro: str): """Отправляет услуги каруселью (если возможно), иначе текстом-фолбэком.""" # Полный текстовый список — для фолбэка (и лога) full_text = intro + "📋 Доступные направления и расценки:\n\n" for key, svc in config.SERVICES.items(): full_text += f"{key}️⃣ {svc['name']}\n 💰 {svc['price']}\n 📝 {svc['description']}\n\n" full_text += "Выберите направление кнопкой ниже или отправьте его номер/название." if self.vk: template = self._build_services_carousel(user_id) if template: try: # 1) Короткий текст + кнопка отмены. Это сразу убирает «зависшие» # кнопки прошлого шага и даёт нормальную кнопку «Отменить». self.send_message( user_id, intro + "Листайте карточки ниже 👇 и нажмите «Выбрать» под нужным направлением.", keyboard=get_cancel_keyboard() ) # 2) Сама карусель (без клавиатуры — кнопка отмены сохраняется). db.log_message(user_id, "bot", "[Карусель направлений]") self.vk.messages.send( peer_id=user_id, message="🎮 Выберите направление:", template=template, random_id=get_random_id() ) return except Exception as e: logger.error(f"Карусель не отправилась, фолбэк на текст: {e}") # Фолбэк / режим эмуляции self.send_message(user_id, full_text, keyboard=get_services_keyboard()) def _build_career_carousel(self, peer_id: int) -> Optional[str]: """Карусель выбора в разделе «Карьера»: карточки «Игроком» и «Сотрудником».""" import os base = os.path.join(os.path.dirname(__file__), "help_img") cards = [ ("career_player.jpg", "Игроком в состав 🎮", "Покажи скилл и попади в ростер ИКП", "Игроком в состав 🎮"), ("career_worker.jpg", "Сотрудником 💼", "Тренер, SMM, монтаж, менеджмент, разработка", "Сотрудником 💼"), ] elements = [] for img_name, title, desc, label in cards: img_path = os.path.join(base, img_name) info = self._upload_photo(peer_id, img_path) if os.path.exists(img_path) else None element = { "title": title[:80], "description": desc[:80], "buttons": [{"action": {"type": "text", "label": label[:40]}, "color": "primary"}] } if info: element["photo_id"] = info["raw"] elements.append(element) if any("photo_id" not in e for e in elements): for e in elements: e.pop("photo_id", None) return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False) def _send_career_choice(self, user_id: int): """Отправляет выбор в «Карьере» каруселью (с фолбэком на обычную клавиатуру).""" intro = ("💼 Карьера в ИКП\n\n" "Мы растём и ищем как игроков в состав, так и специалистов в команду.") if self.vk: template = self._build_career_carousel(user_id) if template: try: self.send_message( user_id, intro + "\nВыберите на карточке ниже 👇, кем хотите присоединиться.", keyboard=get_vacancy_back_keyboard() ) db.log_message(user_id, "bot", "[Карусель карьеры]") self.vk.messages.send( peer_id=user_id, message="Кем хотите присоединиться?", template=template, random_id=get_random_id() ) return except Exception as e: logger.error(f"Карусель карьеры не отправилась, фолбэк: {e}") # Фолбэк / эмуляция self.send_message( user_id, intro + "\nКем вы хотите к нам присоединиться?", keyboard=get_vacancy_choice_keyboard() ) def _handle_form_consent_1(self, user_id: int, text: str, text_lower: str): """Шаг 1 — ОБЯЗАТЕЛЬНЫЕ согласия: оферта, обработка ПД и запись консультации.""" is_agree = text_lower in ["согласен(а) и подтверждаю ✅", "согласен и подтверждаю", "согласен(а) и подтверждаю", "согласен", "согласна", "да", "принимаю", "согласен(а) ✅"] is_disagree = text_lower in ["не согласен(а) ❌", "не согласен", "не согласна", "нет", "не принимаю"] if is_agree: # Фиксируем обязательные согласия (оферта/ПД и запись консультации) db.update_user_field(user_id, "consent_1", "Согласен") db.update_user_field(user_id, "consent_3", "Согласен") # Переходим к ОТДЕЛЬНОМУ необязательному согласию на рассылку (требование 152-ФЗ) db.update_user_state(user_id, "FORM_CONSENT_2") self.send_message( user_id, "✅ Спасибо! Обязательные согласия приняты.\n\n" "📨 Последний шаг (по желанию): хотите получать новости, акции и спецпредложения ИКП?\n\n" "Это необязательно и никак не влияет на обработку вашей заявки.", keyboard=get_marketing_keyboard() ) elif is_disagree: self.send_message( user_id, "⚠️ Для оформления заявки необходимо принять условия оферты, согласие на обработку персональных данных и запись консультации.\n\n" "Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.", keyboard=get_binary_keyboard(show_skip=False) ) else: self.send_message( user_id, "❓ Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или выберите «Не согласен(а) ❌» / «Отменить заполнение ❌».", keyboard=get_binary_keyboard(show_skip=False) ) def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str): """Шаг 2 — НЕОБЯЗАТЕЛЬНОЕ согласие на маркетинговую рассылку. Любой выбор завершает заявку.""" is_agree = text_lower in ["согласен на рассылку ✅", "согласен на рассылку", "согласен", "согласна", "да", "хочу"] is_decline = text_lower in ["без рассылки ➡️", "без рассылки", "не согласен", "не согласна", "нет", "не хочу", "пропустить ➡️", "пропустить"] if is_agree: db.update_user_field(user_id, "consent_2", "Согласен") self._finalize_lead(user_id) elif is_decline: db.update_user_field(user_id, "consent_2", "Не согласен") self._finalize_lead(user_id) else: self.send_message( user_id, "❓ Хотите получать рассылку ИКП? Нажмите «Согласен на рассылку ✅» или «Без рассылки ➡️».", keyboard=get_marketing_keyboard() ) def _handle_form_consent_3(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]): """Резервный обработчик для совместимости с устаревшими состояниями в БД.""" db.update_user_field(user_id, "consent_3", "Согласен") self._finalize_lead(user_id) @staticmethod def _generate_promo() -> str: """Генерирует персональный промокод вида ИКП-AB12CD.""" code = "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) return f"ИКП-{code}" def _finalize_lead(self, user_id: int): """Завершает оформление заявки: подтверждение клиенту + уведомление оператору и на почту.""" updated_data = db.get_user_data(user_id) phone = updated_data["phone"] call_time = updated_data.get("call_time") or "не указано" marketing = (updated_data.get("consent_2") == "Согласен") db.update_user_state(user_id, "FREE_CHAT") # Персональный промокод на скидку (пока выключен флагом config.PROMO_ENABLED) promo = self._generate_promo() if getattr(config, "PROMO_ENABLED", False) else None # Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00) from datetime import datetime now = datetime.now() if 9 <= now.hour < 21: callback_time = "в течение 15 минут" else: callback_time = "сегодня утром, начиная с 9:00" confirm_msg = ( "🎉 Заявка успешно оформлена!\n\n" f"Наш менеджер свяжется с вами ({callback_time}) по номеру {format_phone_display(phone)}, " f"удобное время: {call_time}.\n\n" ) if promo: confirm_msg += ( "🎁 Ваш персональный промокод на скидку 10% на первое занятие:\n" f"{promo}\n" "Назовите его менеджеру при записи. " ) confirm_msg += "Спасибо за обращение!\n\nВы вернулись в главное меню бота." self.send_message(user_id, confirm_msg, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) # Журналируем заявку для статистики и истории try: db.add_lead( user_id=user_id, source="bot", name=updated_data.get("name"), phone=phone, service=updated_data.get("question"), call_time=updated_data.get("call_time"), promo=promo, marketing=marketing ) except Exception as e: logger.error(f"Не удалось записать лид в журнал: {e}") # Антиспам: не заваливаем оператора повторными уведомлениями от одного пользователя if self._notify_allowed(user_id): self._notify_operator_about_lead(user_id, updated_data, promo=promo) else: logger.warning(f"Подавлено повторное уведомление о заявке от {user_id} (антиспам)") def _handle_operator_chat(self, user_id: int, text: str, text_lower: str): """Поведение бота в режиме активного диалога с живым человеком.""" # Пользователь хочет выйти из диалога с оператором if text_lower in ["завершить диалог 🚪", "завершить", "/exit", "выход"]: db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) # Уведомляем операторов о закрытии диалога self._notify_operators(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 _send_vac_options(self, user_id: int, track: str, question: str, include_other: bool = False): """Шлёт вопрос (с кнопкой отмены) и инлайн-кнопки доступных опций вакансии из БД.""" self.send_message(user_id, question, keyboard=get_vacancy_cancel_keyboard()) kb = VkKeyboard(inline=True) opts = db.get_vacancy_options(track) n = 0 for _id, label in opts[:5]: if n and n % 2 == 0: kb.add_line() kb.add_button(label[:40], color=VkKeyboardColor.SECONDARY) n += 1 if include_other: kb.add_line() kb.add_button("Другое ✍️", color=VkKeyboardColor.SECONDARY) self.send_message(user_id, "Выберите вариант 👇", keyboard=kb.get_keyboard()) def _handle_vac_choose(self, user_id: int, text: str, text_lower: str): """Выбор ветки: игрок в состав или сотрудник.""" if text_lower in ["игроком в состав 🎮", "игроком", "игрок", "в состав", "состав"]: db.update_user_state(user_id, "VAC_P_DISCIPLINE") self._send_vac_options(user_id, "player", "🎮 Набор в состав\n\nШаг 1 из 5: по какой дисциплине вы играете?") elif text_lower in ["сотрудником 💼", "сотрудником", "сотрудник", "работником", "в команду"]: db.update_user_state(user_id, "VAC_W_POSITION") self._send_vac_options(user_id, "worker", "💼 Набор в команду\n\nШаг 1 из 3: на какую должность вы претендуете?", include_other=True) elif text_lower in ["назад в меню ↩️", "назад", "меню", "в меню"]: db.clear_vac_data(user_id) db.update_user_state(user_id, "FREE_CHAT") self.send_message(user_id, "🏠 Вы вернулись в главное меню.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) else: self.send_message( user_id, "Пожалуйста, выберите на клавиатуре: «Игроком в состав 🎮» или «Сотрудником 💼».", keyboard=get_vacancy_choice_keyboard() ) # ----- Ветка игрока ----- def _handle_vac_p_discipline(self, user_id: int, text: str, text_lower: str): discipline = None # 1) Сопоставление с опциями из БД (нажата инлайн-кнопка или введено название) for _id, label in db.get_vacancy_options("player"): ll = label.lower() if text.strip() == label or text_lower == ll or ll in text_lower or text_lower in ll: discipline = label break # 2) Фолбэк-синонимы для свободного ввода if not discipline: syn = { "Counter-Strike 2": ["кс", "cs", "cs2", "csgo", "ксго", "контра", "counter"], "Dota 2": ["дота", "dota", "дотка", "доту"], "World of Tanks": ["танк", "wot", "tanks", "ворлд оф танкс", "вот"], } for name, words in syn.items(): if any(w in text_lower for w in words): discipline = name break if not discipline: self._send_vac_options(user_id, "player", "Пожалуйста, выберите дисциплину на кнопках ниже:") return db.set_vac_field(user_id, "discipline", discipline) db.update_user_state(user_id, "VAC_P_RANK") self.send_message( user_id, f"Отлично, {discipline}!\n\n" "Шаг 2 из 5: укажите ваш текущий ранг / MMR / ELO " "(например, «Faceit lvl 8», «5000 MMR» или «Легенда»).", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_p_rank(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, коротко укажите ваш ранг / MMR / ELO.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "rank", text.strip()) db.update_user_state(user_id, "VAC_P_ROLE") self.send_message( user_id, "Шаг 3 из 5: ваша роль / позиция в игре? (например, AWPer, мид, саппорт, командир)", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_p_role(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, укажите вашу роль / позицию.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "role", text.strip()) db.update_user_state(user_id, "VAC_P_EXP") self.send_message( user_id, "Шаг 4 из 5: расскажите коротко об опыте — в каких командах играли, турниры, достижения.", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_p_exp(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "experience", text.strip()) db.update_user_state(user_id, "VAC_P_CONTACT") self.send_message( user_id, "Шаг 5 из 5: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_p_contact(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "contact", text.strip()) self._finalize_application(user_id, "player") # ----- Ветка сотрудника ----- def _handle_vac_w_position(self, user_id: int, text: str, text_lower: str): if text_lower in ["другое ✍️", "другое", "иное"]: db.update_user_state(user_id, "VAC_W_POSITION_OTHER") self.send_message( user_id, "✍️ Напишите, на какую должность вы претендуете.", keyboard=get_vacancy_cancel_keyboard() ) return # Сопоставление с должностями из БД (инлайн-кнопка), иначе — произвольный текст position = None for _id, label in db.get_vacancy_options("worker"): if text.strip() == label or text_lower == label.lower(): position = label break if not position: if not self._valid_free_text(text): self._send_vac_options(user_id, "worker", "Выберите должность на кнопках ниже или нажмите «Другое ✍️»:", include_other=True) return position = text.strip() db.set_vac_field(user_id, "position", position) db.update_user_state(user_id, "VAC_W_EXP") self.send_message( user_id, f"Должность: {position}.\n\n" "Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_w_position_other(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, напишите желаемую должность.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "position", text.strip()) db.update_user_state(user_id, "VAC_W_EXP") self.send_message( user_id, "Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_w_exp(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "experience", text.strip()) db.update_user_state(user_id, "VAC_W_CONTACT") self.send_message( user_id, "Шаг 3 из 3: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).", keyboard=get_vacancy_cancel_keyboard() ) def _handle_vac_w_contact(self, user_id: int, text: str): if not self._valid_free_text(text): self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard()) return db.set_vac_field(user_id, "contact", text.strip()) self._finalize_application(user_id, "worker") def _finalize_application(self, user_id: int, track: str): """Завершает отклик: подтверждение кандидату + уведомление HR/оператору и письмо.""" data = db.get_vac_data(user_id) try: db.add_application(user_id, track, data) except Exception as e: logger.error(f"Не удалось записать отклик в журнал: {e}") db.clear_vac_data(user_id) db.update_user_state(user_id, "FREE_CHAT") self.send_message( user_id, "✅ Спасибо за отклик!\n\n" "Мы изучим вашу анкету и свяжемся, если кандидатура подойдёт. Удачи! 🎮\n\n" "Вы вернулись в главное меню.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) ) # Антиспам: ограничиваем частоту уведомлений об откликах от одного пользователя if self._notify_allowed(user_id): self._notify_hr_about_application(user_id, track, data) else: logger.warning(f"Подавлено повторное уведомление об отклике от {user_id} (антиспам)") def _notify_hr_about_application(self, user_id: int, track: str, data: Dict[str, Any]): """Уведомление об отклике оператору в VK и на HR-почту.""" if track == "player": title = "Набор в состав (игрок)" details = ( f"Дисциплина: {data.get('discipline', '—')}\n" f"Ранг/MMR/ELO: {data.get('rank', '—')}\n" f"Роль/позиция: {data.get('role', '—')}\n" f"Опыт: {data.get('experience', '—')}\n" f"Контакт: {data.get('contact', '—')}" ) else: title = "Вакансия (сотрудник)" details = ( f"Должность: {data.get('position', '—')}\n" f"Опыт/навыки: {data.get('experience', '—')}\n" f"Контакт: {data.get('contact', '—')}" ) operator_msg = ( f"🧩 Новый отклик — {title}!\n\n" f"{details}\n\n" f"🔗 Профиль кандидата: vk.com/id{user_id}" ) self._notify_operators(operator_msg) # Письмо на HR-адрес (пока совпадает с group-zakaz, см. config.HR_MAIL_TO) try: email_body = ( f"Новый отклик из чат-бота ВКонтакте — {title}.\n\n" f"{details}\n\n" f"Профиль кандидата: https://vk.com/id{user_id}\n" ) mailer.send_email(f"Новый отклик — {title}", email_body, mail_to=getattr(config, "HR_MAIL_TO", None)) except Exception as mail_err: logger.error(f"Не удалось отправить email об отклике: {mail_err}") def _handoff_to_operator(self, user_id: int): """Переводит пользователя в режим оператора и уведомляет команду поддержки.""" db.update_user_state(user_id, "OPERATOR_CHAT") # Сообщение клиенту зависит от рабочего времени if self._within_work_hours(): client_text = ("🧑‍💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n" "Чтобы вернуться к боту, нажмите кнопку ниже.") else: client_text = (f"🌙 Оператор сейчас оффлайн (работаем с {getattr(config,'WORK_HOUR_START',9)}:00 " f"до {getattr(config,'WORK_HOUR_END',21)}:00).\n" "Я передал ваш запрос — вам ответят утром, как начнётся рабочий день. " "Можете оставить сообщение здесь. Чтобы вернуться к боту, нажмите кнопку ниже.") self.send_message(user_id, client_text, keyboard=get_operator_keyboard()) # Получаем историю последних 10 сообщений для контекста оператора history = db.get_dialogue_history(user_id, limit=10) formatted_history = "" for sender, msg, ts in history: role = "Вы" if sender == "user" else "Бот" formatted_history += f"[{ts}] {role}: {msg}\n" # Отправляем алерт оператору с историей диалога operator_msg = ( f"🚨 Запрос оператора!\n" f"Пользователь: vk.com/id{user_id}\n" f"Тип запроса: Прямой вызов из меню\n\n" f"📋 Последние сообщения для контекста:\n{formatted_history}" ) self._notify_operators(operator_msg) def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any], promo: Optional[str] = None): """Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий.""" name = user_data["name"] phone = user_data["phone"] question = user_data["question"] call_time = user_data.get("call_time") or "не указано" consent_1 = user_data.get("consent_1", "Согласен") consent_2 = user_data.get("consent_2", "Согласен") consent_3 = user_data.get("consent_3", "Согласен") promo_line = f"🎁 Промокод клиента: {promo}\n" if promo else "" # Реальное имя из VK + флаг несовпадения (помогает отсеять «рандомные» данные) vk_name = self._vk_name(user_id) if vk_name: mismatch = "" if name and name.strip().lower() not in vk_name.lower() and vk_name.lower() not in name.strip().lower(): mismatch = " ⚠️ не совпадает с введённым!" vk_name_line = f"🪪 Имя в VK: {vk_name}{mismatch}\n" else: vk_name_line = "" operator_msg = ( f"⚡ Получена новая заявка!\n\n" f"👤 Имя (введено): {name}\n" f"{vk_name_line}" f"📞 Телефон: {format_phone_display(phone)}\n" f"🎯 Выбранная услуга: {question}\n" f"🕒 Удобное время звонка: {call_time}\n" f"{promo_line}\n" f"⚖️ Согласия пользователя:\n" f"1. Оферта и обработка ПД (Обяз.): {consent_1}\n" f"2. Маркетинговые рассылки (Необяз.): {consent_2}\n" f"3. Запись консультации (Обяз.): {consent_3}\n\n" f"🔗 Профиль клиента: vk.com/id{user_id}\n" f"Переписка сохранена в базе данных." ) self._notify_operators(operator_msg) # Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro) try: email_body = ( "Новая заявка из чат-бота ВКонтакте.\n\n" f"Имя: {name}\n" f"Телефон: {phone}\n" f"Выбранная услуга: {question}\n" f"Удобное время звонка: {call_time}\n" + (f"Промокод клиента: {promo}\n" if promo else "") + "\nСогласия:\n" f" - Оферта и обработка ПД (обяз.): {consent_1}\n" f" - Маркетинговые рассылки (необяз.): {consent_2}\n" f" - Запись консультации (обяз.): {consent_3}\n\n" f"Профиль клиента: https://vk.com/id{user_id}\n" ) mailer.send_email(f"Новая заявка из бота — {name}", email_body) except Exception as mail_err: logger.error(f"Не удалось отправить email о заявке: {mail_err}") def _forward_to_operator(self, user_id: int, text: str): """Пересылка сообщений от клиента всем операторам.""" self._notify_operators(f"💬 Сообщение от vk.com/id{user_id}:\n{text}") # ------------------------------------------ # ЗАПУСК И ОБРАБОТКА LONGPOLL # ------------------------------------------ def _send_vacancy_admin(self, user_id: int): """Раздел управления вакансиями (направления игроков / должности сотрудников).""" players = db.get_vacancy_options("player") workers = db.get_vacancy_options("worker") lines = "🎮 Направления (игроки):\n" lines += ("".join(f" • [{i}] {l}\n" for i, l in players) or " —\n") lines += "\n💼 Должности (сотрудники):\n" lines += ("".join(f" • [{i}] {l}\n" for i, l in workers) or " —\n") kb = VkKeyboard(inline=True) kb.add_button("➕ Вакансия игрок", color=VkKeyboardColor.PRIMARY) kb.add_button("➕ Вакансия сотрудник", color=VkKeyboardColor.PRIMARY) kb.add_line() kb.add_button("🗑 Удалить вакансию", color=VkKeyboardColor.NEGATIVE) self.send_message( user_id, "💼 Управление вакансиями\n\n" + lines + "\nУправляйте кнопками ниже 👇", keyboard=kb.get_keyboard() ) def _handle_admin_wizard(self, user_id: int, text: str, text_lower: str, state: str, attachments: list): """Мастера админки: добавление/изменение/удаление услуг и быстрых ответов, смена картинок.""" if text_lower in ["отмена ❌", "отмена", "в админку ↩️", "в админку", "назад"]: self._admin_ctx.pop(user_id, None) db.update_user_state(user_id, "FREE_CHAT") self._send_admin_panel(user_id) return def back_to_services(msg=None): self._admin_ctx.pop(user_id, None) db.update_user_state(user_id, "FREE_CHAT") if msg: self.send_message(user_id, msg) self._send_admin_menu(user_id) def back_to_faq(msg=None): self._admin_ctx.pop(user_id, None) db.update_user_state(user_id, "FREE_CHAT") if msg: self.send_message(user_id, msg) self._send_faq_admin(user_id) def pick_id(t): t = t.strip() return t.split(".")[0].split()[0] if t else "" # ---------- Услуги ---------- if state == "ADMIN_SVC_ADD": parts = [p.strip() for p in text.split("|")] if len(parts) < 3 or not all(parts[:3]): self.send_message(user_id, "⚠️ Формат: Название | Цена | Описание", keyboard=get_cancel_admin_keyboard()) return new_id = db.add_service(parts[0], parts[1], parts[2]) config.SERVICES = db.get_services() back_to_services(f"✅ Услуга добавлена (№{new_id}). Картинку можно задать через «🖼 Сменить картинку».") return if state == "ADMIN_SVC_EDIT_PICK": sid = pick_id(text) services = db.get_services() if sid not in services: self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.") return self._admin_ctx[user_id] = {"svc_id": sid} db.update_user_state(user_id, "ADMIN_SVC_EDIT_VAL") self.send_message(user_id, f"✏️ Услуга «{services[sid]['name']}». Отправьте: поле | значение\n" "Поля: имя, цена, описание\nПример: цена | 1 900 ₽/час", keyboard=get_cancel_admin_keyboard()) return if state == "ADMIN_SVC_EDIT_VAL": sid = self._admin_ctx.get(user_id, {}).get("svc_id") if not sid or sid not in db.get_services(): back_to_services("⚠️ Услуга не найдена.") return if "|" not in text: self.send_message(user_id, "⚠️ Формат: поле | значение", keyboard=get_cancel_admin_keyboard()) return field, value = [p.strip() for p in text.split("|", 1)] mapping = {"имя": "name", "название": "name", "цена": "price", "стоимость": "price", "описание": "description"} if field.lower() not in mapping or not value: self.send_message(user_id, "⚠️ Поле должно быть: имя / цена / описание.", keyboard=get_cancel_admin_keyboard()) return db.update_service(sid, mapping[field.lower()], value) config.SERVICES = db.get_services() back_to_services(f"✅ Услуга №{sid} обновлена.") return if state == "ADMIN_SVC_IMG_PICK": sid = pick_id(text) if sid not in db.get_services(): self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.") return self._admin_ctx[user_id] = {"svc_id": sid} db.update_user_state(user_id, "ADMIN_SVC_IMG_WAIT") self.send_message(user_id, "🖼 Пришлите картинку (именно фото). Лучше широкую (~13:8) — я сам аккуратно обрежу.", keyboard=get_cancel_admin_keyboard()) return if state == "ADMIN_SVC_IMG_WAIT": photo = self._first_photo(attachments) if not photo: self.send_message(user_id, "⚠️ Прикрепите именно фото (картинкой), не файлом.", keyboard=get_cancel_admin_keyboard()) return sid = self._admin_ctx.get(user_id, {}).get("svc_id") ok = self._save_service_image(sid, photo) if sid else False if ok: import os self._upload_photo(config.OPERATOR_PEER_ID, os.path.join(os.path.dirname(__file__), "help_img", f"service_{sid}.jpg")) back_to_services("✅ Картинка обновлена." if ok else "⚠️ Не удалось сохранить картинку.") return if state == "ADMIN_SVC_DEL_PICK": sid = pick_id(text) if sid not in db.get_services(): self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.") return db.delete_service(sid) config.SERVICES = db.get_services() back_to_services(f"✅ Услуга №{sid} удалена.") return # ---------- Быстрые ответы ---------- if state == "ADMIN_FAQ_ADD": if "|" not in text: self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard()) return kw, ans = [p.strip() for p in text.split("|", 1)] if not kw or not ans: self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard()) return db.add_custom_faq(kw, ans) back_to_faq("✅ Быстрый ответ добавлен.") return if state == "ADMIN_FAQ_EDIT_PICK": fid = pick_id(text) ids = [str(i) for i, _, _ in db.get_custom_faq()] if fid not in ids: self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.") return self._admin_ctx[user_id] = {"faq_id": int(fid)} db.update_user_state(user_id, "ADMIN_FAQ_EDIT_VAL") self.send_message(user_id, "✏️ Отправьте новый вариант: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard()) return if state == "ADMIN_FAQ_EDIT_VAL": fid = self._admin_ctx.get(user_id, {}).get("faq_id") if "|" not in text: self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard()) return kw, ans = [p.strip() for p in text.split("|", 1)] if not kw or not ans: self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard()) return if fid: db.delete_custom_faq(fid) db.add_custom_faq(kw, ans) back_to_faq("✅ Быстрый ответ обновлён.") return if state == "ADMIN_FAQ_DEL_PICK": fid = pick_id(text) if not fid.isdigit() or not db.delete_custom_faq(int(fid)): self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.") return back_to_faq(f"✅ Ответ №{fid} удалён.") return # ---------- Вакансии ---------- def back_to_vacancies(msg=None): self._admin_ctx.pop(user_id, None) db.update_user_state(user_id, "FREE_CHAT") if msg: self.send_message(user_id, msg) self._send_vacancy_admin(user_id) if state == "ADMIN_VAC_ADD_PLAYER": if not self._valid_free_text(text): self.send_message(user_id, "⚠️ Введите название направления.", keyboard=get_cancel_admin_keyboard()) return db.add_vacancy_option("player", text.strip()) back_to_vacancies("✅ Направление для игроков добавлено.") return if state == "ADMIN_VAC_ADD_WORKER": if not self._valid_free_text(text): self.send_message(user_id, "⚠️ Введите название должности.", keyboard=get_cancel_admin_keyboard()) return db.add_vacancy_option("worker", text.strip()) back_to_vacancies("✅ Должность для сотрудников добавлена.") return if state == "ADMIN_VAC_DEL_PICK": vid = pick_id(text) if not vid.isdigit() or not db.delete_vacancy_option(int(vid)): self.send_message(user_id, "⚠️ Выберите вакансию кнопкой ниже.") return back_to_vacancies(f"✅ Вакансия №{vid} удалена.") return # ---------- Администраторы (супер-админ) ---------- if state == "ADMIN_ADMIN_ADD": if not self._is_super_admin(user_id): db.update_user_state(user_id, "FREE_CHAT") self._send_admin_panel(user_id) return self._handle_admin_add_admin(user_id, "админ доб " + pick_id(text)) db.update_user_state(user_id, "FREE_CHAT") return if state == "ADMIN_ADMIN_DEL_PICK": if not self._is_super_admin(user_id): db.update_user_state(user_id, "FREE_CHAT") self._send_admin_panel(user_id) return self._handle_admin_remove_admin(user_id, "админ удал " + pick_id(text)) db.update_user_state(user_id, "FREE_CHAT") return # Неизвестное ADMIN_-состояние — сброс self._admin_ctx.pop(user_id, None) db.update_user_state(user_id, "FREE_CHAT") self._send_admin_panel(user_id) def _send_admin_menu(self, user_id: int): """Отправляет администратору панель управления услугами с текущим списком.""" services = db.get_services() services_list_text = "" for key, svc in services.items(): services_list_text += f"🔹 [{key}] {svc['name']}\n" services_list_text += f" 💰 Стоимость: {svc['price']}\n" services_list_text += f" 📝 Описание: {svc['description']}\n\n" admin_text = ( "⚙️ Управление услугами (направлениями)\n\n" "📋 Текущие направления:\n\n" f"{services_list_text}" "Управляйте кнопками ниже:\n" "• ➕ Добавить • ✏️ Изменить • 🖼 Сменить картинку • 🗑 Удалить" ) self.send_message(user_id, admin_text, keyboard=get_svc_admin_keyboard()) def _handle_admin_edit(self, user_id: int, text: str): """Обрабатывает редактирование услуги администратором.""" parts = text.split(" ", 3) if len(parts) < 4: self.send_message( user_id, "⚠️ Некорректный формат команды редактирования.\n" "Правильный формат: ред [номер] [поле] [новое значение]\n" "Пример: ред 1 цена 1 800 ₽/час" ) return _, service_id, field_name, new_value = parts field_name = field_name.lower().strip() new_value = new_value.strip() field_mapping = { "имя": "name", "название": "name", "цена": "price", "стоимость": "price", "описание": "description" } if field_name not in field_mapping: self.send_message( user_id, "⚠️ Неизвестное поле для редактирования. Допустимые поля: имя, цена, описание." ) return db_field = field_mapping[field_name] services = db.get_services() if service_id not in services: self.send_message( user_id, f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг." ) return try: db.update_service(service_id, db_field, new_value) config.SERVICES = db.get_services() self.send_message( user_id, f"✅ Услуга [{service_id}] успешно обновлена!\n" f"Поле {field_name} изменено на: {new_value}." ) self._send_admin_menu(user_id) except Exception as e: logger.error(f"Ошибка при редактировании услуги в БД: {e}") self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") def _handle_admin_add(self, user_id: int, text: str): """Обрабатывает добавление новой услуги администратором.""" content = text[4:].strip() parts = [p.strip() for p in content.split("|")] if len(parts) < 3: self.send_message( user_id, "⚠️ Некорректный формат команды добавления.\n" "Правильный формат: доб [Имя] | [Цена] | [Описание]\n" "Пример: доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim." ) return name, price, description = parts if not name or not price or not description: self.send_message(user_id, "⚠️ Все три поля (имя, цена, описание) должны быть заполнены!") return try: new_id = db.add_service(name, price, description) config.SERVICES = db.get_services() self.send_message( user_id, f"✅ Новая услуга успешно добавлена с номером [{new_id}]!" ) self._send_admin_menu(user_id) except Exception as e: logger.error(f"Ошибка при добавлении услуги в БД: {e}") self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") def _handle_admin_delete(self, user_id: int, text: str): """Обрабатывает мягкое удаление услуги администратором.""" parts = text.split(" ") if len(parts) < 2: self.send_message( user_id, "⚠️ Укажите номер услуги для удаления.\n" "Пример: удал 4" ) return service_id = parts[1].strip() services = db.get_services() if service_id not in services: self.send_message( user_id, f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг." ) return try: db.delete_service(service_id) config.SERVICES = db.get_services() self.send_message( user_id, f"✅ Услуга [{service_id}] успешно удалена из активного списка!" ) self._send_admin_menu(user_id) except Exception as e: logger.error(f"Ошибка при деактивации услуги в БД: {e}") self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") def _handle_admin_stats(self, user_id: int): """Формирует и отправляет админу сводную статистику по заявкам и диалогам.""" 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"] ) # Блок откликов (карьера) aps = db.get_application_stats() msg += f"\n\n🧩 Отклики (карьера): {aps['total']}\n" msg += f" • игроки: {aps['players']}\n" if aps["by_discipline"]: for disc, cnt in aps["by_discipline"]: msg += f" ◦ {disc}: {cnt}\n" msg += f" • сотрудники: {aps['workers']}\n" if aps["by_position"]: for pos, cnt in aps["by_position"]: msg += f" ◦ {pos}: {cnt}\n" self.send_message(user_id, msg) def _send_faq_admin(self, user_id: int): """Раздел «Быстрые ответы»: список + инструкции.""" rows = db.get_custom_faq() listing = "" for _id, kw, ans in rows: short = (ans[:50] + "…") if len(ans) > 50 else ans listing += f"🔹 [{_id}] «{kw}» → {short}\n" if not listing: listing = "Пока нет ни одного быстрого ответа.\n" self.send_message( user_id, "💬 Быстрые ответы (по ключевым словам)\n\n" "Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n" f"📋 Текущие:\n{listing}\n" "Управляйте кнопками ниже: ➕ добавить • ✏️ изменить • 🗑 удалить", keyboard=get_faq_admin_keyboard() ) def _handle_faq_add(self, user_id: int, text: str): content = text[len("фак доб "):].strip() if "|" not in content: self.send_message(user_id, "⚠️ Формат: фак доб ключевое слово | текст ответа") return kw, ans = content.split("|", 1) kw, ans = kw.strip(), ans.strip() if not kw or not ans: self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.") return new_id = db.add_custom_faq(kw, ans) self.send_message(user_id, f"✅ Быстрый ответ добавлен (№{new_id}): «{kw.lower()}».") self._send_faq_admin(user_id) def _handle_faq_delete(self, user_id: int, text: str): arg = text[len("фак удал "):].strip() if not arg.isdigit(): self.send_message(user_id, "⚠️ Укажите номер: фак удал 3") return if db.delete_custom_faq(int(arg)): self.send_message(user_id, f"✅ Быстрый ответ №{arg} удалён.") else: self.send_message(user_id, f"⚠️ Ответ №{arg} не найден.") self._send_faq_admin(user_id) def _send_candidates(self, user_id: int): """Раздел «Кандидаты»: последние отклики со статусами + инструкция смены статуса.""" rows = db.get_recent_applications(limit=10) if not rows: self.send_message(user_id, "👥 Откликов пока нет.") return listing = "" for r in rows: who = r["discipline"] if r["track"] == "player" else r["position"] tag = "🎮 игрок" if r["track"] == "player" else "💼 сотрудник" listing += f"🔹 [{r['id']}] {tag} — {who or '—'}\n контакт: {r['contact'] or '—'} | статус: {r['status'] or 'новый'}\n" self.send_message( user_id, "👥 Последние отклики:\n\n" f"{listing}\n" "Сменить статус: статус [номер] [новый статус]\n" "Пример: статус 3 на собесе\n" "Рекомендуемые статусы: новый, на собесе, отказ, принят" ) def _handle_candidate_status(self, user_id: int, text: str): parts = text.split(" ", 2) if len(parts) < 3 or not parts[1].isdigit(): self.send_message(user_id, "⚠️ Формат: статус [номер] [новый статус]\nПример: статус 3 принят") return app_id, new_status = int(parts[1]), parts[2].strip() if db.set_application_status(app_id, new_status): self.send_message(user_id, f"✅ Статус отклика №{app_id} → «{new_status}».") else: self.send_message(user_id, f"⚠️ Отклик №{app_id} не найден.") def _handle_admin_broadcast(self, user_id: int, text: str): """Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка.""" # Отрезаем ключевое слово 'рассылка' content = text[len("рассылка"):].strip() content_lower = content.lower() # Отмена рассылки if content_lower in ["отмена", "отменить", "отмена ❌"]: self._pending_broadcasts.pop(user_id, None) self.send_message(user_id, "❌ Рассылка отменена.") self._send_admin_panel(user_id) return # Шаг 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" "Отправить или отменить?" ) kb = VkKeyboard(inline=True) kb.add_button("Отправить ✅", color=VkKeyboardColor.PRIMARY, payload={"cmd": "bc_send"}) kb.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE, payload={"cmd": "bc_cancel"}) self.send_message(user_id, preview, keyboard=kb.get_keyboard()) 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...") # Старт-пинг администратору — лёгкий сигнal «бот жив» try: from datetime import datetime ts = datetime.now().strftime("%d.%m.%Y %H:%M") for admin_id in getattr(config, "ADMIN_IDS", []): self.send_message(admin_id, f"✅ Бот ИКП запущен и на связи ({ts}).") except Exception as e: logger.error(f"Не удалось отправить старт-пинг: {e}") # Устойчивый цикл: обрыв 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"] attachments = message_obj.get("attachments", []) try: self.handle_message(user_id, text, attachments) 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()