29097a18c3
- Супер-админ (config.SUPER_ADMIN_IDS, только через код): добавляет/удаляет обычных админов из бота (раздел «Администраторы 👑»), таблица admins. _is_admin теперь учитывает супер-админов, .env и добавленных из бота. - «Когда удобно созвониться» → выбор конкретного дня (кнопки на ближайшие даты) и времени (слоты), сохраняется как «День, ЧЧ:ММ». - Шаг согласия: инлайн-кнопки со ссылками на оферту и политику ПД (PDF), затем подтверждение. Старое вложение .docx убрано. - Маска отображения телефона: +7 (999) 123-45-67 в карточке и уведомлениях. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2355 lines
129 KiB
Python
2355 lines
129 KiB
Python
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
|
||
|
||
# Регулярные выражения для валидации
|
||
# Российский номер для нормализации 8XXX / 7XXX -> +7XXX
|
||
RU_PHONE_REGEX = re.compile(r"^\+?[78]\d{10}$")
|
||
# Международный формат (E.164): + и 10–15 цифр. Принимаем и иностранные номера.
|
||
INTL_PHONE_REGEX = re.compile(r"^\+?\d{10,15}$")
|
||
# Имя: буквы (кириллица/латиница), пробел, дефис, апостроф; длина 2–50
|
||
NAME_REGEX = re.compile(r"^[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё \-']{1,49}$")
|
||
|
||
|
||
def normalize_phone(text: str) -> Optional[str]:
|
||
"""Очищает и нормализует телефон. Возвращает нормализованный номер или None, если формат неверен."""
|
||
phone_cleaned = re.sub(r"[\s\-\(\)]", "", text)
|
||
|
||
# Российские номера приводим к виду +7XXXXXXXXXX
|
||
if phone_cleaned.startswith("8") and len(phone_cleaned) == 11:
|
||
phone_cleaned = "+7" + phone_cleaned[1:]
|
||
elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11:
|
||
phone_cleaned = "+" + phone_cleaned
|
||
|
||
# Принимаем как российский, так и любой международный номер в формате E.164
|
||
if RU_PHONE_REGEX.match(phone_cleaned) or INTL_PHONE_REGEX.match(phone_cleaned):
|
||
return phone_cleaned
|
||
return None
|
||
|
||
|
||
def format_phone_display(phone: str) -> str:
|
||
"""Красивое отображение номера: +7 (999) 123-45-67 для РФ, иначе как есть."""
|
||
if not phone:
|
||
return phone
|
||
digits = re.sub(r"\D", "", phone)
|
||
if len(digits) == 11 and digits[0] in ("7", "8"):
|
||
d = digits
|
||
return f"+7 ({d[1:4]}) {d[4:7]}-{d[7:9]}-{d[9:11]}"
|
||
return phone
|
||
|
||
|
||
def is_valid_name(text: str) -> bool:
|
||
"""Имя должно состоять только из букв, пробела, дефиса или апострофа (без цифр и спецсимволов)."""
|
||
return bool(NAME_REGEX.match(text.strip()))
|
||
|
||
|
||
# ==========================================
|
||
# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте)
|
||
# ==========================================
|
||
|
||
def get_main_keyboard(is_admin: bool = False) -> str:
|
||
"""Главное меню бота. Для админа добавляется кнопка «Админка ⚙️»."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
# Кнопка начать опрос (зеленая)
|
||
keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_line()
|
||
# Карьера в ИКП (набор игроков и сотрудников)
|
||
keyboard.add_button("Карьера в ИКП 💼", color=VkKeyboardColor.POSITIVE)
|
||
keyboard.add_line()
|
||
# Кнопки FAQ и оператора
|
||
keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Позвать оператора 🧑💼", color=VkKeyboardColor.NEGATIVE)
|
||
# Кнопка админ-панели — только для администраторов (отдельной строкой)
|
||
if is_admin:
|
||
keyboard.add_line()
|
||
keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.PRIMARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_admin_keyboard(is_super: bool = False) -> str:
|
||
"""Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы»."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Кандидаты 👥", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Управление услугами ⚙️", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
if is_super:
|
||
keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.NEGATIVE)
|
||
keyboard.add_line()
|
||
keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.PRIMARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_vacancy_choice_keyboard() -> str:
|
||
"""Выбор направления в разделе 'Карьера в ИКП'."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Игроком в состав 🎮", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Сотрудником 💼", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_vacancy_discipline_keyboard() -> str:
|
||
"""Выбор дисциплины для набора в состав."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Counter-Strike 2 🔫", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Dota 2 🎮", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("World of Tanks 🛡️", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_vacancy_position_keyboard() -> str:
|
||
"""Выбор должности для найма сотрудника."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("SMM / контент", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Монтажёр / дизайнер", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Менеджер", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Разработчик", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Другое ✍️", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_vacancy_cancel_keyboard() -> str:
|
||
"""Клавиатура отмены отклика на свободном текстовом шаге."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_vacancy_back_keyboard() -> str:
|
||
"""Клавиатура с одной кнопкой «Назад в меню» — под карусель выбора в «Карьере»."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_faq_keyboard() -> str:
|
||
"""Клавиатура раздела Частых Вопросов (FAQ)."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Цены 💰", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Услуги ⚙️", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Контакты 📞", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("О компании 🏢", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Наши соцсети 🔗", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_consent_docs_keyboard() -> str:
|
||
"""Инлайн-кнопки со ссылками на оферту и политику обработки ПД."""
|
||
keyboard = VkKeyboard(inline=True)
|
||
keyboard.add_openlink_button("📄 Оферта", getattr(config, "OFFER_URL", "https://infocyber.pro/"))
|
||
keyboard.add_line()
|
||
keyboard.add_openlink_button("🔐 Политика обработки ПД", getattr(config, "POLICY_URL", "https://infocyber.pro/"))
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_socials_keyboard() -> str:
|
||
"""Клавиатура с кнопками-ссылками на площадки ИКП (открываются в один тап)."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_openlink_button("VK Live 📺", "https://live.vkvideo.ru/ikp")
|
||
keyboard.add_line()
|
||
keyboard.add_openlink_button("Telegram ✈️", "https://t.me/ikp_team")
|
||
keyboard.add_openlink_button("Discord 🎧", "https://discord.gg/z92mAw7KEe")
|
||
keyboard.add_line()
|
||
keyboard.add_openlink_button("ВКонтакте 🌐", "https://vk.com/ikpro")
|
||
keyboard.add_line()
|
||
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_cancel_keyboard() -> str:
|
||
"""Клавиатура отмены заполнения формы."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_operator_keyboard() -> str:
|
||
"""Клавиатура при активном диалоге с оператором."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Завершить диалог 🚪", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_binary_keyboard(show_skip: bool = False) -> str:
|
||
"""Клавиатура с кнопками Согласен / Не согласен."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Согласен(а) и подтверждаю ✅", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_button("Не согласен(а) ❌", color=VkKeyboardColor.NEGATIVE)
|
||
if show_skip:
|
||
keyboard.add_line()
|
||
keyboard.add_button("Пропустить ➡️", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_returning_keyboard() -> str:
|
||
"""Клавиатура для возвращающегося клиента, чьи данные уже известны."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Да, всё верно ✅", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Сменить имя 👤", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Сменить телефон 📞", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Заполнить заново 🔄", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_calldate_keyboard() -> str:
|
||
"""Клавиатура выбора ДНЯ для звонка (сегодня/завтра + ближайшие даты)."""
|
||
from datetime import datetime, timedelta
|
||
wd = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||
today = datetime.now()
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Сегодня", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_button("Завтра", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_line()
|
||
for i in (2, 3, 4):
|
||
d = today + timedelta(days=i)
|
||
keyboard.add_button(f"{wd[d.weekday()]} {d.strftime('%d.%m')}", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_calltime_keyboard() -> str:
|
||
"""Клавиатура выбора ВРЕМЕНИ (слоты в рабочие часы)."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("10:00", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("12:00", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("14:00", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("16:00", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("18:00", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("20:00", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Любое время", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_marketing_keyboard() -> str:
|
||
"""Клавиатура для необязательного согласия на маркетинговую рассылку."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Согласен на рассылку ✅", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_button("Без рассылки ➡️", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_services_keyboard() -> str:
|
||
"""Клавиатура со списком доступных услуг."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
first = True
|
||
for key, svc in config.SERVICES.items():
|
||
if not first:
|
||
keyboard.add_line()
|
||
keyboard.add_button(svc["name"], color=VkKeyboardColor.SECONDARY)
|
||
first = False
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_confirm_service_keyboard() -> str:
|
||
"""Клавиатура для подтверждения выбранной услуги."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Да, подтверждаю ✅", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_button("Нет, выбрать другую 🔄", color=VkKeyboardColor.NEGATIVE)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_review_keyboard() -> str:
|
||
"""Клавиатура для финальной проверки данных пользователем."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Да, всё верно! ✅", color=VkKeyboardColor.PRIMARY)
|
||
keyboard.add_button("Изменить данные 🔄", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
def get_edit_menu_keyboard() -> str:
|
||
"""Клавиатура меню изменения полей."""
|
||
keyboard = VkKeyboard(one_time=False)
|
||
keyboard.add_button("Изменить Имя 👤", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Изменить Телефон 📞", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_button("Изменить Время 🕒", color=VkKeyboardColor.SECONDARY)
|
||
keyboard.add_line()
|
||
keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.PRIMARY)
|
||
return keyboard.get_keyboard()
|
||
|
||
|
||
|
||
# ==========================================
|
||
# КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ
|
||
# ==========================================
|
||
|
||
class VKBotApplication:
|
||
def __init__(self):
|
||
self.vk_session = None
|
||
self.vk = None
|
||
self.longpoll = None
|
||
# Тексты рассылок, ожидающих подтверждения админом: {admin_id: text}
|
||
self._pending_broadcasts: Dict[int, str] = {}
|
||
# Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}}
|
||
self._photo_cache: Dict[str, Dict[str, str]] = {}
|
||
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
|
||
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
|
||
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
|
||
self._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)
|
||
|
||
@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")
|
||
self.send_message(
|
||
user_id,
|
||
"👑 Управление администраторами\n\n"
|
||
f"{lines}\n"
|
||
"Команды:\n"
|
||
"• Добавить админа: админ доб [VK_ID]\n"
|
||
"• Удалить админа: админ удал [VK_ID]\n"
|
||
" (снять можно только добавленных из бота)\n\n"
|
||
"ℹ️ Сменить/добавить СУПЕР-АДМИНА можно только в коде: откройте config.py, "
|
||
"впишите VK ID в список SUPER_ADMIN_IDS и перезапустите бота.",
|
||
keyboard=get_admin_keyboard(is_super=True)
|
||
)
|
||
|
||
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):
|
||
"""Маршрутизатор входящих сообщений на основе состояний пользователя."""
|
||
text_clean = text.strip()
|
||
text_lower = text_clean.lower()
|
||
|
||
# Анти-флуд + временный бан (админы — исключение)
|
||
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 text_lower in ["админка", "админка ⚙️", "/admin"]:
|
||
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_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.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()
|
||
)
|
||
return
|
||
|
||
# Глобальная отмена отклика на вакансию
|
||
if state.startswith("VAC_") and text_lower in ["отменить ❌", "отмена", "отменить"]:
|
||
db.clear_vac_data(user_id)
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self.send_message(
|
||
user_id,
|
||
"Отклик отменён. Вы вернулись в главное меню.",
|
||
keyboard=get_main_keyboard()
|
||
)
|
||
return
|
||
|
||
# Маршрутизация по состояниям (FSM)
|
||
if state == "FREE_CHAT":
|
||
self._handle_free_chat(user_id, text_clean, text_lower)
|
||
elif state == "FORM_RETURNING":
|
||
self._handle_form_returning(user_id, text_clean, text_lower, user_data)
|
||
elif state == "FORM_RET_NAME":
|
||
self._handle_form_ret_name(user_id, text_clean)
|
||
elif state == "FORM_RET_PHONE":
|
||
self._handle_form_ret_phone(user_id, text_clean)
|
||
elif state == "FORM_NAME":
|
||
self._handle_form_name(user_id, text_clean)
|
||
elif state == "FORM_PHONE":
|
||
self._handle_form_phone(user_id, text_clean)
|
||
elif state == "FORM_QUESTION":
|
||
self._handle_form_question(user_id, text_clean, user_data)
|
||
elif state == "FORM_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()
|
||
)
|
||
|
||
# ------------------------------------------
|
||
# ОБРАБОТЧИКИ СОСТОЯНИЙ (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()
|
||
)
|
||
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 = user_data.get("phone")
|
||
self.send_message(
|
||
user_id,
|
||
f"Здравствуйте! Мы вас помним, {name}. 👋\n\n"
|
||
f"Ваш номер телефона по-прежнему {phone}?\n\n"
|
||
"Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги. "
|
||
"Либо измените данные или заполните заявку заново.",
|
||
keyboard=get_returning_keyboard()
|
||
)
|
||
|
||
def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
|
||
"""Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново."""
|
||
if text_lower in ["да, всё верно ✅", "да, все верно", "да", "верно", "всё верно"]:
|
||
# Данные подтверждены — переходим сразу к выбору услуги
|
||
db.update_user_state(user_id, "FORM_QUESTION")
|
||
self._send_services_prompt(user_id, prefix="Отлично! Перейдём к выбору услуги.\n\n")
|
||
elif text_lower in ["сменить имя 👤", "сменить имя", "имя"]:
|
||
db.update_user_state(user_id, "FORM_RET_NAME")
|
||
self.send_message(user_id, "👤 Введите ваше новое имя:", keyboard=get_cancel_keyboard())
|
||
elif text_lower in ["сменить телефон 📞", "сменить телефон", "телефон"]:
|
||
db.update_user_state(user_id, "FORM_RET_PHONE")
|
||
self.send_message(
|
||
user_id,
|
||
"📞 Введите ваш новый номер телефона:",
|
||
keyboard=get_cancel_keyboard()
|
||
)
|
||
elif text_lower in ["заполнить заново 🔄", "заполнить заново", "заново"]:
|
||
db.reset_form_full(user_id)
|
||
db.update_user_state(user_id, "FORM_NAME")
|
||
self.send_message(
|
||
user_id,
|
||
"📝 Хорошо, заполним заявку с нуля!\n\n"
|
||
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
|
||
|
||
def get_logo_attachment(self, peer_id: int) -> Optional[str]:
|
||
"""Attachment-строка с логотипом ИКП для прикрепления к приветствию."""
|
||
import os
|
||
logo_path = os.path.join(os.path.dirname(__file__), "help_img", "Logo.jpg")
|
||
info = self._upload_photo(peer_id, logo_path)
|
||
return info["attachment"] if info else None
|
||
|
||
def _prewarm_media(self):
|
||
"""Заранее (при старте) загружает логотип и картинки дисциплин в VK,
|
||
чтобы первое приветствие и первая карусель открывались мгновенно."""
|
||
import os
|
||
try:
|
||
peer = config.OPERATOR_PEER_ID
|
||
base = os.path.join(os.path.dirname(__file__), "help_img")
|
||
for fn in ("Logo.jpg", "cs2.jpg", "dota2.jpg", "wot.jpg",
|
||
"career_player.jpg", "career_worker.jpg"):
|
||
path = os.path.join(base, fn)
|
||
if os.path.exists(path):
|
||
self._upload_photo(peer, path)
|
||
logger.info("Медиа предзагружены в VK — карусель и логотип будут открываться быстро.")
|
||
except Exception as e:
|
||
logger.error(f"Не удалось предзагрузить медиа: {e}")
|
||
|
||
@staticmethod
|
||
def _card_description(svc: Dict[str, str]) -> str:
|
||
"""Компактное описание карточки карусели: цена + краткая суть (лимит VK — 80 символов)."""
|
||
price = str(svc.get("price", "")).strip()
|
||
# Берём первую фразу описания (до точки) как короткую суть
|
||
blurb = str(svc.get("description", "")).strip().split(".")[0].strip()
|
||
text = f"{price} · {blurb}" if blurb else price
|
||
if len(text) > 80:
|
||
text = text[:79].rstrip(" ,;·") + "…"
|
||
return text
|
||
|
||
def _build_services_carousel(self, peer_id: int) -> Optional[str]:
|
||
"""Собирает VK-карусель из доступных услуг. Возвращает JSON-строку шаблона или None."""
|
||
import os
|
||
base = os.path.join(os.path.dirname(__file__), "help_img")
|
||
# Картинки дисциплин по id услуги. ВАЖНО: VK требует пропорцию 13:8 (напр. 650×400).
|
||
# Логотип квадратный и не подходит — поэтому фото берём ТОЛЬКО если есть спец-файл дисциплины.
|
||
img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"}
|
||
elements = []
|
||
for key, svc in config.SERVICES.items():
|
||
disc_img = os.path.join(base, img_by_id.get(key, ""))
|
||
info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None
|
||
element = {
|
||
"title": svc["name"][:80],
|
||
"description": self._card_description(svc),
|
||
"buttons": [{
|
||
"action": {"type": "text", "label": f"Выбрать {svc['name']}"[:40]},
|
||
"color": "primary"
|
||
}]
|
||
}
|
||
if info:
|
||
element["photo_id"] = info["raw"]
|
||
elements.append(element)
|
||
if not elements:
|
||
return None
|
||
# VK требует фото либо у всех карточек, либо ни у одной
|
||
if any("photo_id" not in e for e in elements):
|
||
for e in elements:
|
||
e.pop("photo_id", None)
|
||
return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False)
|
||
|
||
def _send_services(self, user_id: int, intro: str):
|
||
"""Отправляет услуги каруселью (если возможно), иначе текстом-фолбэком."""
|
||
# Полный текстовый список — для фолбэка (и лога)
|
||
full_text = intro + "📋 Доступные направления и расценки:\n\n"
|
||
for key, svc in config.SERVICES.items():
|
||
full_text += f"{key}️⃣ {svc['name']}\n 💰 {svc['price']}\n 📝 {svc['description']}\n\n"
|
||
full_text += "Выберите направление кнопкой ниже или отправьте его номер/название."
|
||
|
||
if self.vk:
|
||
template = self._build_services_carousel(user_id)
|
||
if template:
|
||
try:
|
||
# 1) Короткий текст + кнопка отмены. Это сразу убирает «зависшие»
|
||
# кнопки прошлого шага и даёт нормальную кнопку «Отменить».
|
||
self.send_message(
|
||
user_id,
|
||
intro + "Листайте карточки ниже 👇 и нажмите «Выбрать» под нужным направлением.",
|
||
keyboard=get_cancel_keyboard()
|
||
)
|
||
# 2) Сама карусель (без клавиатуры — кнопка отмены сохраняется).
|
||
db.log_message(user_id, "bot", "[Карусель направлений]")
|
||
self.vk.messages.send(
|
||
peer_id=user_id, message="🎮 Выберите направление:",
|
||
template=template, random_id=get_random_id()
|
||
)
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"Карусель не отправилась, фолбэк на текст: {e}")
|
||
# Фолбэк / режим эмуляции
|
||
self.send_message(user_id, full_text, keyboard=get_services_keyboard())
|
||
|
||
def _build_career_carousel(self, peer_id: int) -> Optional[str]:
|
||
"""Карусель выбора в разделе «Карьера»: карточки «Игроком» и «Сотрудником»."""
|
||
import os
|
||
base = os.path.join(os.path.dirname(__file__), "help_img")
|
||
cards = [
|
||
("career_player.jpg", "Игроком в состав 🎮",
|
||
"Покажи скилл и попади в ростер ИКП", "Игроком в состав 🎮"),
|
||
("career_worker.jpg", "Сотрудником 💼",
|
||
"Тренер, SMM, монтаж, менеджмент, разработка", "Сотрудником 💼"),
|
||
]
|
||
elements = []
|
||
for img_name, title, desc, label in cards:
|
||
img_path = os.path.join(base, img_name)
|
||
info = self._upload_photo(peer_id, img_path) if os.path.exists(img_path) else None
|
||
element = {
|
||
"title": title[:80],
|
||
"description": desc[:80],
|
||
"buttons": [{"action": {"type": "text", "label": label[:40]}, "color": "primary"}]
|
||
}
|
||
if info:
|
||
element["photo_id"] = info["raw"]
|
||
elements.append(element)
|
||
if any("photo_id" not in e for e in elements):
|
||
for e in elements:
|
||
e.pop("photo_id", None)
|
||
return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False)
|
||
|
||
def _send_career_choice(self, user_id: int):
|
||
"""Отправляет выбор в «Карьере» каруселью (с фолбэком на обычную клавиатуру)."""
|
||
intro = ("💼 Карьера в ИКП\n\n"
|
||
"Мы растём и ищем как игроков в состав, так и специалистов в команду.")
|
||
if self.vk:
|
||
template = self._build_career_carousel(user_id)
|
||
if template:
|
||
try:
|
||
self.send_message(
|
||
user_id,
|
||
intro + "\nВыберите на карточке ниже 👇, кем хотите присоединиться.",
|
||
keyboard=get_vacancy_back_keyboard()
|
||
)
|
||
db.log_message(user_id, "bot", "[Карусель карьеры]")
|
||
self.vk.messages.send(
|
||
peer_id=user_id, message="Кем хотите присоединиться?",
|
||
template=template, random_id=get_random_id()
|
||
)
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"Карусель карьеры не отправилась, фолбэк: {e}")
|
||
# Фолбэк / эмуляция
|
||
self.send_message(
|
||
user_id,
|
||
intro + "\nКем вы хотите к нам присоединиться?",
|
||
keyboard=get_vacancy_choice_keyboard()
|
||
)
|
||
|
||
def _handle_form_consent_1(self, user_id: int, text: str, text_lower: str):
|
||
"""Шаг 1 — ОБЯЗАТЕЛЬНЫЕ согласия: оферта, обработка ПД и запись консультации."""
|
||
is_agree = text_lower in ["согласен(а) и подтверждаю ✅", "согласен и подтверждаю", "согласен(а) и подтверждаю", "согласен", "согласна", "да", "принимаю", "согласен(а) ✅"]
|
||
is_disagree = text_lower in ["не согласен(а) ❌", "не согласен", "не согласна", "нет", "не принимаю"]
|
||
|
||
if is_agree:
|
||
# Фиксируем обязательные согласия (оферта/ПД и запись консультации)
|
||
db.update_user_field(user_id, "consent_1", "Согласен")
|
||
db.update_user_field(user_id, "consent_3", "Согласен")
|
||
|
||
# Переходим к ОТДЕЛЬНОМУ необязательному согласию на рассылку (требование 152-ФЗ)
|
||
db.update_user_state(user_id, "FORM_CONSENT_2")
|
||
self.send_message(
|
||
user_id,
|
||
"✅ Спасибо! Обязательные согласия приняты.\n\n"
|
||
"📨 Последний шаг (по желанию): хотите получать новости, акции и спецпредложения ИКП?\n\n"
|
||
"Это необязательно и никак не влияет на обработку вашей заявки.",
|
||
keyboard=get_marketing_keyboard()
|
||
)
|
||
elif is_disagree:
|
||
self.send_message(
|
||
user_id,
|
||
"⚠️ Для оформления заявки необходимо принять условия оферты, согласие на обработку персональных данных и запись консультации.\n\n"
|
||
"Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.",
|
||
keyboard=get_binary_keyboard(show_skip=False)
|
||
)
|
||
else:
|
||
self.send_message(
|
||
user_id,
|
||
"❓ Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или выберите «Не согласен(а) ❌» / «Отменить заполнение ❌».",
|
||
keyboard=get_binary_keyboard(show_skip=False)
|
||
)
|
||
|
||
def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str):
|
||
"""Шаг 2 — НЕОБЯЗАТЕЛЬНОЕ согласие на маркетинговую рассылку. Любой выбор завершает заявку."""
|
||
is_agree = text_lower in ["согласен на рассылку ✅", "согласен на рассылку", "согласен", "согласна", "да", "хочу"]
|
||
is_decline = text_lower in ["без рассылки ➡️", "без рассылки", "не согласен", "не согласна", "нет", "не хочу", "пропустить ➡️", "пропустить"]
|
||
|
||
if is_agree:
|
||
db.update_user_field(user_id, "consent_2", "Согласен")
|
||
self._finalize_lead(user_id)
|
||
elif is_decline:
|
||
db.update_user_field(user_id, "consent_2", "Не согласен")
|
||
self._finalize_lead(user_id)
|
||
else:
|
||
self.send_message(
|
||
user_id,
|
||
"❓ Хотите получать рассылку ИКП? Нажмите «Согласен на рассылку ✅» или «Без рассылки ➡️».",
|
||
keyboard=get_marketing_keyboard()
|
||
)
|
||
|
||
def _handle_form_consent_3(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
|
||
"""Резервный обработчик для совместимости с устаревшими состояниями в БД."""
|
||
db.update_user_field(user_id, "consent_3", "Согласен")
|
||
self._finalize_lead(user_id)
|
||
|
||
@staticmethod
|
||
def _generate_promo() -> str:
|
||
"""Генерирует персональный промокод вида ИКП-AB12CD."""
|
||
code = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||
return f"ИКП-{code}"
|
||
|
||
def _finalize_lead(self, user_id: int):
|
||
"""Завершает оформление заявки: подтверждение клиенту + уведомление оператору и на почту."""
|
||
updated_data = db.get_user_data(user_id)
|
||
phone = updated_data["phone"]
|
||
call_time = updated_data.get("call_time") or "не указано"
|
||
marketing = (updated_data.get("consent_2") == "Согласен")
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
|
||
# Персональный промокод на скидку (пока выключен флагом config.PROMO_ENABLED)
|
||
promo = self._generate_promo() if getattr(config, "PROMO_ENABLED", False) else None
|
||
|
||
# Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00)
|
||
from datetime import datetime
|
||
now = datetime.now()
|
||
if 9 <= now.hour < 21:
|
||
callback_time = "в течение 15 минут"
|
||
else:
|
||
callback_time = "сегодня утром, начиная с 9:00"
|
||
|
||
confirm_msg = (
|
||
"🎉 Заявка успешно оформлена!\n\n"
|
||
f"Наш менеджер свяжется с вами ({callback_time}) по номеру {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())
|
||
|
||
# Журналируем заявку для статистики и истории
|
||
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 _handle_vac_choose(self, user_id: int, text: str, text_lower: str):
|
||
"""Выбор ветки: игрок в состав или сотрудник."""
|
||
if text_lower in ["игроком в состав 🎮", "игроком", "игрок", "в состав", "состав"]:
|
||
db.update_user_state(user_id, "VAC_P_DISCIPLINE")
|
||
self.send_message(
|
||
user_id,
|
||
"🎮 Набор в состав\n\nШаг 1 из 5: по какой дисциплине вы играете?",
|
||
keyboard=get_vacancy_discipline_keyboard()
|
||
)
|
||
elif text_lower in ["сотрудником 💼", "сотрудником", "сотрудник", "работником", "в команду"]:
|
||
db.update_user_state(user_id, "VAC_W_POSITION")
|
||
self.send_message(
|
||
user_id,
|
||
"💼 Набор в команду\n\nШаг 1 из 3: на какую должность вы претендуете?",
|
||
keyboard=get_vacancy_position_keyboard()
|
||
)
|
||
elif text_lower in ["назад в меню ↩️", "назад", "меню", "в меню"]:
|
||
db.clear_vac_data(user_id)
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
self.send_message(user_id, "🏠 Вы вернулись в главное меню.", keyboard=get_main_keyboard())
|
||
else:
|
||
self.send_message(
|
||
user_id,
|
||
"Пожалуйста, выберите на клавиатуре: «Игроком в состав 🎮» или «Сотрудником 💼».",
|
||
keyboard=get_vacancy_choice_keyboard()
|
||
)
|
||
|
||
# ----- Ветка игрока -----
|
||
|
||
def _handle_vac_p_discipline(self, user_id: int, text: str, text_lower: str):
|
||
discipline = None
|
||
syn = {
|
||
"Counter-Strike 2": ["кс", "cs", "cs2", "csgo", "ксго", "контра", "counter"],
|
||
"Dota 2": ["дота", "dota", "дотка", "доту"],
|
||
"World of Tanks": ["танк", "wot", "tanks", "ворлд оф танкс", "вот"],
|
||
}
|
||
for name, words in syn.items():
|
||
if any(w in text_lower for w in words):
|
||
discipline = name
|
||
break
|
||
if not discipline:
|
||
self.send_message(
|
||
user_id,
|
||
"Пожалуйста, выберите дисциплину на клавиатуре ниже.",
|
||
keyboard=get_vacancy_discipline_keyboard()
|
||
)
|
||
return
|
||
db.set_vac_field(user_id, "discipline", discipline)
|
||
db.update_user_state(user_id, "VAC_P_RANK")
|
||
self.send_message(
|
||
user_id,
|
||
f"Отлично, {discipline}!\n\n"
|
||
"Шаг 2 из 5: укажите ваш текущий ранг / MMR / ELO "
|
||
"(например, «Faceit lvl 8», «5000 MMR» или «Легенда»).",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_p_rank(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, коротко укажите ваш ранг / MMR / ELO.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "rank", text.strip())
|
||
db.update_user_state(user_id, "VAC_P_ROLE")
|
||
self.send_message(
|
||
user_id,
|
||
"Шаг 3 из 5: ваша роль / позиция в игре? (например, AWPer, мид, саппорт, командир)",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_p_role(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, укажите вашу роль / позицию.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "role", text.strip())
|
||
db.update_user_state(user_id, "VAC_P_EXP")
|
||
self.send_message(
|
||
user_id,
|
||
"Шаг 4 из 5: расскажите коротко об опыте — в каких командах играли, турниры, достижения.",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_p_exp(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "experience", text.strip())
|
||
db.update_user_state(user_id, "VAC_P_CONTACT")
|
||
self.send_message(
|
||
user_id,
|
||
"Шаг 5 из 5: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_p_contact(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "contact", text.strip())
|
||
self._finalize_application(user_id, "player")
|
||
|
||
# ----- Ветка сотрудника -----
|
||
|
||
def _handle_vac_w_position(self, user_id: int, text: str, text_lower: str):
|
||
known = {
|
||
"smm / контент": "SMM / контент",
|
||
"монтажёр / дизайнер": "Монтажёр / дизайнер",
|
||
"менеджер": "Менеджер",
|
||
"разработчик": "Разработчик",
|
||
}
|
||
if text_lower in ["другое ✍️", "другое", "иное"]:
|
||
db.update_user_state(user_id, "VAC_W_POSITION_OTHER")
|
||
self.send_message(
|
||
user_id,
|
||
"✍️ Напишите, на какую должность вы претендуете.",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
return
|
||
# Известная кнопка или произвольный непустой текст принимаем как должность
|
||
position = known.get(text_lower)
|
||
if not position:
|
||
if not self._valid_free_text(text):
|
||
self.send_message(
|
||
user_id,
|
||
"Выберите должность на клавиатуре или нажмите «Другое ✍️».",
|
||
keyboard=get_vacancy_position_keyboard()
|
||
)
|
||
return
|
||
position = text.strip()
|
||
db.set_vac_field(user_id, "position", position)
|
||
db.update_user_state(user_id, "VAC_W_EXP")
|
||
self.send_message(
|
||
user_id,
|
||
f"Должность: {position}.\n\n"
|
||
"Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_w_position_other(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, напишите желаемую должность.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "position", text.strip())
|
||
db.update_user_state(user_id, "VAC_W_EXP")
|
||
self.send_message(
|
||
user_id,
|
||
"Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_w_exp(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "experience", text.strip())
|
||
db.update_user_state(user_id, "VAC_W_CONTACT")
|
||
self.send_message(
|
||
user_id,
|
||
"Шаг 3 из 3: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).",
|
||
keyboard=get_vacancy_cancel_keyboard()
|
||
)
|
||
|
||
def _handle_vac_w_contact(self, user_id: int, text: str):
|
||
if not self._valid_free_text(text):
|
||
self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard())
|
||
return
|
||
db.set_vac_field(user_id, "contact", text.strip())
|
||
self._finalize_application(user_id, "worker")
|
||
|
||
def _finalize_application(self, user_id: int, track: str):
|
||
"""Завершает отклик: подтверждение кандидату + уведомление HR/оператору и письмо."""
|
||
data = db.get_vac_data(user_id)
|
||
try:
|
||
db.add_application(user_id, track, data)
|
||
except Exception as e:
|
||
logger.error(f"Не удалось записать отклик в журнал: {e}")
|
||
db.clear_vac_data(user_id)
|
||
db.update_user_state(user_id, "FREE_CHAT")
|
||
|
||
self.send_message(
|
||
user_id,
|
||
"✅ Спасибо за отклик!\n\n"
|
||
"Мы изучим вашу анкету и свяжемся, если кандидатура подойдёт. Удачи! 🎮\n\n"
|
||
"Вы вернулись в главное меню.",
|
||
keyboard=get_main_keyboard()
|
||
)
|
||
# Антиспам: ограничиваем частоту уведомлений об откликах от одного пользователя
|
||
if self._notify_allowed(user_id):
|
||
self._notify_hr_about_application(user_id, track, data)
|
||
else:
|
||
logger.warning(f"Подавлено повторное уведомление об отклике от {user_id} (антиспам)")
|
||
|
||
def _notify_hr_about_application(self, user_id: int, track: str, data: Dict[str, Any]):
|
||
"""Уведомление об отклике оператору в VK и на HR-почту."""
|
||
if track == "player":
|
||
title = "Набор в состав (игрок)"
|
||
details = (
|
||
f"Дисциплина: {data.get('discipline', '—')}\n"
|
||
f"Ранг/MMR/ELO: {data.get('rank', '—')}\n"
|
||
f"Роль/позиция: {data.get('role', '—')}\n"
|
||
f"Опыт: {data.get('experience', '—')}\n"
|
||
f"Контакт: {data.get('contact', '—')}"
|
||
)
|
||
else:
|
||
title = "Вакансия (сотрудник)"
|
||
details = (
|
||
f"Должность: {data.get('position', '—')}\n"
|
||
f"Опыт/навыки: {data.get('experience', '—')}\n"
|
||
f"Контакт: {data.get('contact', '—')}"
|
||
)
|
||
|
||
operator_msg = (
|
||
f"🧩 Новый отклик — {title}!\n\n"
|
||
f"{details}\n\n"
|
||
f"🔗 Профиль кандидата: vk.com/id{user_id}"
|
||
)
|
||
self._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 ""
|
||
|
||
operator_msg = (
|
||
f"⚡ Получена новая заявка!\n\n"
|
||
f"👤 Имя: {name}\n"
|
||
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_admin_menu(self, user_id: int):
|
||
"""Отправляет администратору панель управления услугами с текущим списком."""
|
||
services = db.get_services()
|
||
|
||
services_list_text = ""
|
||
for key, svc in services.items():
|
||
services_list_text += f"🔹 [{key}] {svc['name']}\n"
|
||
services_list_text += f" 💰 Стоимость: {svc['price']}\n"
|
||
services_list_text += f" 📝 Описание: {svc['description']}\n\n"
|
||
|
||
admin_text = (
|
||
"⚙️ Панель управления услугами (Админ-меню)\n\n"
|
||
"Здесь вы можете редактировать существующие услуги или добавлять новые.\n\n"
|
||
"📋 Текущий список активных услуг:\n\n"
|
||
f"{services_list_text}"
|
||
"🔧 Доступные команды управления:\n\n"
|
||
"1. Редактирование услуги:\n"
|
||
" 👉 Отправьте: ред [номер] [поле] [новое значение]\n"
|
||
" Доступные поля: имя, цена, описание.\n"
|
||
" *Пример:* ред 1 цена 1 800 ₽/час\n\n"
|
||
"2. Добавление услуги:\n"
|
||
" 👉 Отправьте: доб [Имя] | [Цена] | [Описание]\n"
|
||
" *Пример:* доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.\n\n"
|
||
"3. Удаление услуги:\n"
|
||
" 👉 Отправьте: удал [номер]\n"
|
||
" *Пример:* удал 4\n\n"
|
||
"📊 Статистика: отправьте стата\n\n"
|
||
"📣 Рассылка по подписчикам:\n"
|
||
" 👉 рассылка [текст] → предпросмотр → рассылка подтвердить"
|
||
)
|
||
self.send_message(user_id, admin_text)
|
||
|
||
def _handle_admin_edit(self, user_id: int, text: str):
|
||
"""Обрабатывает редактирование услуги администратором."""
|
||
parts = text.split(" ", 3)
|
||
if len(parts) < 4:
|
||
self.send_message(
|
||
user_id,
|
||
"⚠️ Некорректный формат команды редактирования.\n"
|
||
"Правильный формат: ред [номер] [поле] [новое значение]\n"
|
||
"Пример: ред 1 цена 1 800 ₽/час"
|
||
)
|
||
return
|
||
|
||
_, service_id, field_name, new_value = parts
|
||
field_name = field_name.lower().strip()
|
||
new_value = new_value.strip()
|
||
|
||
field_mapping = {
|
||
"имя": "name",
|
||
"название": "name",
|
||
"цена": "price",
|
||
"стоимость": "price",
|
||
"описание": "description"
|
||
}
|
||
|
||
if field_name not in field_mapping:
|
||
self.send_message(
|
||
user_id,
|
||
"⚠️ Неизвестное поле для редактирования. Допустимые поля: имя, цена, описание."
|
||
)
|
||
return
|
||
|
||
db_field = field_mapping[field_name]
|
||
services = db.get_services()
|
||
|
||
if service_id not in services:
|
||
self.send_message(
|
||
user_id,
|
||
f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг."
|
||
)
|
||
return
|
||
|
||
try:
|
||
db.update_service(service_id, db_field, new_value)
|
||
config.SERVICES = db.get_services()
|
||
|
||
self.send_message(
|
||
user_id,
|
||
f"✅ Услуга [{service_id}] успешно обновлена!\n"
|
||
f"Поле {field_name} изменено на: {new_value}."
|
||
)
|
||
self._send_admin_menu(user_id)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при редактировании услуги в БД: {e}")
|
||
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
|
||
|
||
def _handle_admin_add(self, user_id: int, text: str):
|
||
"""Обрабатывает добавление новой услуги администратором."""
|
||
content = text[4:].strip()
|
||
parts = [p.strip() for p in content.split("|")]
|
||
|
||
if len(parts) < 3:
|
||
self.send_message(
|
||
user_id,
|
||
"⚠️ Некорректный формат команды добавления.\n"
|
||
"Правильный формат: доб [Имя] | [Цена] | [Описание]\n"
|
||
"Пример: доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim."
|
||
)
|
||
return
|
||
|
||
name, price, description = parts
|
||
|
||
if not name or not price or not description:
|
||
self.send_message(user_id, "⚠️ Все три поля (имя, цена, описание) должны быть заполнены!")
|
||
return
|
||
|
||
try:
|
||
new_id = db.add_service(name, price, description)
|
||
config.SERVICES = db.get_services()
|
||
|
||
self.send_message(
|
||
user_id,
|
||
f"✅ Новая услуга успешно добавлена с номером [{new_id}]!"
|
||
)
|
||
self._send_admin_menu(user_id)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при добавлении услуги в БД: {e}")
|
||
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
|
||
|
||
def _handle_admin_delete(self, user_id: int, text: str):
|
||
"""Обрабатывает мягкое удаление услуги администратором."""
|
||
parts = text.split(" ")
|
||
if len(parts) < 2:
|
||
self.send_message(
|
||
user_id,
|
||
"⚠️ Укажите номер услуги для удаления.\n"
|
||
"Пример: удал 4"
|
||
)
|
||
return
|
||
|
||
service_id = parts[1].strip()
|
||
services = db.get_services()
|
||
|
||
if service_id not in services:
|
||
self.send_message(
|
||
user_id,
|
||
f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг."
|
||
)
|
||
return
|
||
|
||
try:
|
||
db.delete_service(service_id)
|
||
config.SERVICES = db.get_services()
|
||
|
||
self.send_message(
|
||
user_id,
|
||
f"✅ Услуга [{service_id}] успешно удалена из активного списка!"
|
||
)
|
||
self._send_admin_menu(user_id)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при деактивации услуги в БД: {e}")
|
||
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
|
||
|
||
def _handle_admin_stats(self, user_id: int):
|
||
"""Формирует и отправляет админу сводную статистику по заявкам и диалогам."""
|
||
ls = db.get_lead_stats()
|
||
ds = db.get_dialogue_stats()
|
||
|
||
total = ls["total"]
|
||
marketing_pct = round(ls["marketing_yes"] / total * 100) if total else 0
|
||
|
||
service_lines = ""
|
||
if ls["by_service"]:
|
||
for svc_name, cnt in ls["by_service"]:
|
||
service_lines += f" • {svc_name}: {cnt}\n"
|
||
else:
|
||
service_lines = " • пока нет данных\n"
|
||
|
||
msg = (
|
||
"📊 Статистика ИКП\n\n"
|
||
"🗂 Заявки (всего): {total}\n"
|
||
" • сегодня: {today}\n"
|
||
" • за 7 дней: {last7}\n"
|
||
" • из бота: {from_bot}\n"
|
||
" • с сайта: {from_web}\n\n"
|
||
"📨 Согласны на рассылку: {mk_yes} из {total} ({mk_pct}%)\n\n"
|
||
"🎮 По направлениям:\n{services}\n"
|
||
"💬 Диалоги:\n"
|
||
" • уникальных пользователей: {uniq}\n"
|
||
" • сообщений от клиентов: {umsg}"
|
||
).format(
|
||
total=total, today=ls["today"], last7=ls["last7"],
|
||
from_bot=ls["from_bot"], from_web=ls["from_web"],
|
||
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
|
||
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
|
||
)
|
||
|
||
# Блок откликов (карьера)
|
||
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"
|
||
"Команды:\n"
|
||
"• Добавить: фак доб ключевое слово | текст ответа\n"
|
||
" Пример: фак доб расписание | Тренировки идут ежедневно с 10:00 до 22:00.\n"
|
||
"• Удалить: фак удал [номер]"
|
||
)
|
||
|
||
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()
|
||
|
||
# Шаг 2: подтверждение и запуск
|
||
if content_lower in ["подтвердить", "да", "отправить", "!"]:
|
||
pending = self._pending_broadcasts.get(user_id)
|
||
if not pending:
|
||
self.send_message(
|
||
user_id,
|
||
"❌ Нет рассылки, ожидающей подтверждения.\n"
|
||
"Сначала отправьте: рассылка <текст сообщения>."
|
||
)
|
||
return
|
||
recipients = db.get_marketing_subscribers()
|
||
if not recipients:
|
||
self.send_message(user_id, "⚠️ Нет подписчиков с согласием на рассылку — отправлять некому.")
|
||
self._pending_broadcasts.pop(user_id, None)
|
||
return
|
||
self.send_message(
|
||
user_id,
|
||
f"🚀 Запускаю рассылку для {len(recipients)} получателей. "
|
||
"Отправка идёт с паузами, чтобы не нарушать лимиты VK. Сообщу по завершении."
|
||
)
|
||
self._pending_broadcasts.pop(user_id, None)
|
||
# Запускаем в отдельном потоке, чтобы не блокировать обработку сообщений
|
||
threading.Thread(
|
||
target=self._run_broadcast, args=(user_id, pending, recipients), daemon=True
|
||
).start()
|
||
return
|
||
|
||
# Шаг 1: приём текста и предпросмотр
|
||
if not content:
|
||
self.send_message(
|
||
user_id,
|
||
"📣 Рассылка по подписчикам\n\n"
|
||
"Отправьте: рассылка <текст сообщения>\n"
|
||
"После этого я покажу число получателей и попрошу подтверждение.\n\n"
|
||
"Рассылка уходит только тем, кто дал согласие (consent_2)."
|
||
)
|
||
return
|
||
|
||
recipients = db.get_marketing_subscribers()
|
||
self._pending_broadcasts[user_id] = content
|
||
preview = (
|
||
"📋 Предпросмотр рассылки\n\n"
|
||
f"Получателей (с согласием): {len(recipients)}\n\n"
|
||
"Текст сообщения:\n"
|
||
"— — —\n"
|
||
f"{content}\n"
|
||
"— — —\n\n"
|
||
"Чтобы отправить — напишите: рассылка подтвердить\n"
|
||
"Чтобы отменить — просто проигнорируйте."
|
||
)
|
||
self.send_message(user_id, preview)
|
||
|
||
def _run_broadcast(self, admin_id: int, text: str, recipients: list):
|
||
"""Фоновая отправка рассылки с троттлингом и подсказкой об отписке."""
|
||
body = (
|
||
f"{text}\n\n"
|
||
"— — —\n"
|
||
"🔕 Чтобы отписаться от рассылки, отправьте: Отписаться от рассылки"
|
||
)
|
||
sent = 0
|
||
for uid in recipients:
|
||
try:
|
||
# Повторно проверяем согласие на случай отписки во время рассылки
|
||
data = db.get_user_data(uid)
|
||
if data.get("consent_2") != "Согласен":
|
||
continue
|
||
self.send_message(uid, body)
|
||
sent += 1
|
||
except Exception as e:
|
||
logger.error(f"Ошибка рассылки пользователю {uid}: {e}")
|
||
time.sleep(0.4) # троттлинг против лимитов VK
|
||
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
|
||
|
||
def run(self):
|
||
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
|
||
if not self.longpoll:
|
||
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
|
||
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
|
||
|
||
# Эмулятор консольного ввода для тестирования на Windows локально
|
||
test_user_id = 99999
|
||
# Эмулируем приветствие при старте
|
||
self.handle_message(test_user_id, "начать")
|
||
|
||
while True:
|
||
try:
|
||
user_input = input("Вы: ")
|
||
if user_input.lower() == "выход":
|
||
break
|
||
self.handle_message(test_user_id, user_input)
|
||
except (KeyboardInterrupt, EOFError):
|
||
break
|
||
return
|
||
|
||
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
|
||
# Старт-пинг администратору — лёгкий сигн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"]
|
||
try:
|
||
self.handle_message(user_id, text)
|
||
except Exception as e:
|
||
# Ошибка обработки одного сообщения не должна останавливать бота
|
||
logger.error(f"Ошибка обработки сообщения от {user_id}: {e}")
|
||
except KeyboardInterrupt:
|
||
logger.info("Остановка бота по запросу (Ctrl+C).")
|
||
break
|
||
except Exception as e:
|
||
# Сетевой обрыв/протухшая сессия longpoll — ждём и переподключаемся
|
||
logger.warning(f"Обрыв LongPoll ({e}). Переподключение через 3 с…")
|
||
time.sleep(3)
|
||
try:
|
||
self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID)
|
||
logger.info("LongPoll переподключён.")
|
||
except Exception as e2:
|
||
logger.error(f"Не удалось переподключить LongPoll: {e2}")
|
||
time.sleep(5)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
bot = VKBotApplication()
|
||
bot.run()
|