Files

2586 lines
145 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
# Сколько секунд кэшировать список рантайм-админов из БД
ADMINS_CACHE_TTL = 30
from validators import (
RU_PHONE_REGEX, INTL_PHONE_REGEX, NAME_REGEX,
normalize_phone, format_phone_display, is_valid_name,
)
from keyboards import * # все get_*_keyboard
# Хэндл lock-файла держим в глобали на весь процесс — пока он открыт, блокировка жива.
_INSTANCE_LOCK_HANDLE = None
def _acquire_single_instance_lock() -> bool:
"""Не даёт запустить второй экземпляр бота (два параллельных longpoll ломают UX).
Берём неблокирующую эксклюзивную блокировку на bot.lock в каталоге скрипта.
Кросс-платформенно: msvcrt на Windows, fcntl на POSIX. Если уже заблокирован
другим процессом — возвращаем False (выживает уже запущенный инстанс).
"""
global _INSTANCE_LOCK_HANDLE
import os
lock_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bot.lock")
handle = None
try:
handle = open(lock_path, "w")
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
if handle is not None:
handle.close() # лок занят (или сбой) — не оставляем хэндл открытым
return False
_INSTANCE_LOCK_HANDLE = handle # держим открытым до конца процесса
return True
# ==========================================
# КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ
# ==========================================
class VKBotApplication:
def __init__(self):
self.vk_session = None
self.vk = None
self.longpoll = None
# Тексты рассылок, ожидающих подтверждения админом: {admin_id: text}
self._pending_broadcasts: Dict[int, str] = {}
# Контекст админских мастеров (что редактируем): {admin_id: {...}}
self._admin_ctx: Dict[int, dict] = {}
# Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}}
self._photo_cache: Dict[str, Dict[str, str]] = {}
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
self._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда
self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен
self._last_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора
self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда
# Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение
self._admins_cache: Optional[set] = None
self._admins_cache_ts: float = 0.0
# Инициализируем динамические услуги из базы данных
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 _runtime_admins(self) -> set:
"""Кэшированный набor админов, добавленных в рантайме (из БД).
Перечитываем из SQLite не чаще раза в ADMINS_CACHE_TTL — _is_admin вызывается
многократно на каждое сообщение, и прямой запрос каждый раз был бы расточителен.
Кэш инвалидируется при добавлении/удалении админа.
"""
now = time.time()
if self._admins_cache is None or now - self._admins_cache_ts > ADMINS_CACHE_TTL:
try:
self._admins_cache = set(db.get_admins())
except Exception:
self._admins_cache = set()
self._admins_cache_ts = now
return self._admins_cache
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
return user_id in self._runtime_admins()
def _operator_ids(self) -> list:
"""Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID."""
ids = getattr(config, "OPERATOR_PEER_IDS", None)
return ids if ids else [config.OPERATOR_PEER_ID]
def _notify_operators(self, message: str):
"""Отправляет сообщение всем операторам из списка."""
for op_id in self._operator_ids():
self.send_message(op_id, message)
def _vk_name(self, user_id: int) -> Optional[str]:
"""Реальное имя пользователя из VK (для сверки с введёнными данными)."""
if not self.vk or user_id <= 0:
return None
try:
info = self.vk.users.get(user_ids=user_id)
if info:
u = info[0]
full = f"{u.get('first_name', '')} {u.get('last_name', '')}".strip()
return full or None
except Exception as e:
logger.error(f"Не удалось получить имя VK для {user_id}: {e}")
return None
def _resolve_vk_user_id(self, raw: str) -> Optional[int]:
"""Из ссылки / короткого имени / ID получает числовой VK user_id (или None).
Принимает что угодно: https://vk.com/gruzdevas, vk.com/id123, @gruzdevas,
gruzdevas, 123456. Короткое имя резолвится через VK API.
"""
s = (raw or "").strip()
s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол
s = re.sub(r"^(m\.)?vk\.com/", "", s, flags=re.I) # убираем домен
s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент
if not s:
return None
m = re.match(r"^id(\d+)$", s, flags=re.I) # id123456
if m:
return int(m.group(1))
if s.isdigit(): # чистое число
return int(s)
if self.vk: # короткое имя → через API
try:
info = self.vk.users.get(user_ids=s)
if info:
return int(info[0]["id"])
except Exception as e:
logger.error(f"Не удалось определить VK id по '{s}': {e}")
return None
@staticmethod
def _progress_bar(step: int, total: int = 3) -> str:
"""Визуальный прогресс шага формы: ▰▰▱ Шаг 2 из 3."""
return "" * step + "" * (total - step) + f" Шаг {step} из {total}"
def _within_work_hours(self) -> bool:
"""True, если сейчас рабочее время (config.WORK_HOUR_START..WORK_HOUR_END)."""
from datetime import datetime
h = datetime.now().hour
return getattr(config, "WORK_HOUR_START", 9) <= h < getattr(config, "WORK_HOUR_END", 21)
def _send_admin_panel(self, user_id: int):
"""Главный экран админ-панели с кнопками разделов."""
is_super = self._is_super_admin(user_id)
extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else ""
self.send_message(
user_id,
"⚙️ Админ-панель ИКП\n\n"
"Выберите раздел на кнопках ниже:\n"
"• 📊 Статистика — заявки, отклики, диалоги\n"
"• 👥 Кандидаты — отклики и их статусы\n"
"• ⚙️ Управление услугами — добавить/изменить/удалить\n"
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
"• 📣 Рассылка — сообщение подписчикам"
+ extra,
keyboard=get_admin_keyboard(is_super=is_super)
)
def _send_admins_panel(self, user_id: int):
"""Раздел управления администраторами (только супер-админ)."""
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Раздел доступен только супер-админу.")
return
runtime = db.get_admins()
supers = getattr(config, "SUPER_ADMIN_IDS", [])
cfg = [a for a in getattr(config, "ADMIN_IDS", []) if a not in supers]
lines = "👑 Супер-админы (меняются только в коде):\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in supers) or "\n")
lines += "\n🛡 Админы из .env (ADMIN_IDS):\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in cfg) or "\n")
lines += "\n➕ Добавленные из бота:\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in runtime) or "\n")
kb = VkKeyboard(inline=True)
kb.add_button(" Добавить админа", color=VkKeyboardColor.PRIMARY)
kb.add_line()
kb.add_button("🗑 Удалить админа", color=VkKeyboardColor.NEGATIVE)
self.send_message(
user_id,
"👑 Управление администраторами\n\n"
f"{lines}\n"
"Управляйте кнопками ниже 👇\n\n"
"ℹ️ Сменить/добавить СУПЕР-АДМИНА можно только в коде: откройте config.py, "
"впишите VK ID в список SUPER_ADMIN_IDS и перезапустите бота.",
keyboard=kb.get_keyboard()
)
def _handle_admin_add_admin(self, user_id: int, text: str):
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Добавлять админов может только супер-админ.")
return
raw = text
if text.lower().startswith("админ доб"):
raw = text[len("админ доб"):].strip()
new_id = self._resolve_vk_user_id(raw)
if not new_id:
self.send_message(
user_id,
"⚠️ Не смог определить пользователя.\n"
"Пришлите ссылку на страницу VK (например, https://vk.com/gruzdevas), "
"короткое имя (gruzdevas) или числовой ID."
)
return
if db.add_admin(new_id):
self._admins_cache = None # инвалидируем кэш админов
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
raw = text
if text.lower().startswith("админ удал"):
raw = text[len("админ удал"):].strip()
rem_id = self._resolve_vk_user_id(raw)
if not rem_id:
self.send_message(user_id, "⚠️ Не смог определить пользователя. Пришлите ссылку/имя/ID.")
return
if db.remove_admin(rem_id):
self._admins_cache = None # инвалидируем кэш админов
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 _cleanup_antiflood(self):
"""Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно."""
now = time.time()
if now - self._last_antiflood_cleanup < BAN_SEC:
return
self._last_antiflood_cleanup = now
# Сообщения/страйки старше своих окон бесполезны — удаляем пустые ключи
for uid in [u for u, ts in self._msg_times.items()
if not ts or now - ts[-1] > FLOOD_WINDOW_SEC]:
del self._msg_times[uid]
for uid in [u for u, ts in self._flood_strikes.items()
if not ts or now - ts[-1] > BAN_WINDOW_SEC]:
del self._flood_strikes[uid]
for uid in [u for u, t in self._banned_until.items() if t <= now]:
del self._banned_until[uid]
for uid in [u for u, t in self._flood_warned.items() if now - t > FLOOD_WINDOW_SEC]:
del self._flood_warned[uid]
for uid in [u for u, t in self._last_notify.items() if now - t > NOTIFY_COOLDOWN_SEC]:
del self._last_notify[uid]
def _notify_allowed(self, user_id: int) -> bool:
"""Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе."""
now = time.time()
if now - self._last_notify.get(user_id, 0) < NOTIFY_COOLDOWN_SEC:
return False
self._last_notify[user_id] = now
return True
def handle_message(self, user_id: int, text: str, attachments: Optional[list] = None):
"""Маршрутизатор входящих сообщений на основе состояний пользователя."""
text_clean = text.strip()
text_lower = text_clean.lower()
attachments = attachments or []
# Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
self._cleanup_antiflood()
# Анти-флуд + временный бан (админы — исключение)
if not self._is_admin(user_id):
now = time.time()
# Уже забанен — молча игнорируем до конца бана
if self._banned_until.get(user_id, 0) > now:
return
if self._is_flooding(user_id):
# Фиксируем срабатывание флуда; при повторах — временный бан
strikes = [t for t in self._flood_strikes[user_id] if now - t < BAN_WINDOW_SEC]
strikes.append(now)
self._flood_strikes[user_id] = strikes
if len(strikes) >= BAN_STRIKES:
self._banned_until[user_id] = now + BAN_SEC
self._flood_strikes[user_id] = []
logger.warning(f"Временный бан пользователя {user_id} на {BAN_SEC} с (повторный флуд)")
self.send_message(
user_id,
f"🚫 Слишком много сообщений. Доступ к боту временно ограничен на {BAN_SEC // 60} минут."
)
return
# Иначе — мягкое предупреждение максимум раз за окно
if now - self._flood_warned.get(user_id, 0) > FLOOD_WINDOW_SEC:
self._flood_warned[user_id] = now
logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются")
self.send_message(
user_id,
"⏳ Слишком много сообщений подряд. Пожалуйста, подождите немного."
)
return
# Логируем входящее сообщение от пользователя
db.log_message(user_id, "user", text_clean)
# Получаем текущее состояние и заполненные данные
user_data = db.get_user_data(user_id)
state = user_data["state"]
# Глобальная обработка админ-команд и кнопок (только для ADMIN_IDS)
if self._is_admin(user_id):
# Мастера админки (ожидают ввод текста/выбор/фото) — ловим по состоянию
if state.startswith("ADMIN_"):
self._handle_admin_wizard(user_id, text_clean, text_lower, state, attachments)
return
# Инлайн-кнопки предпросмотра рассылки
if user_id in self._pending_broadcasts and text_lower in ["отправить ✅", "отправить", "отмена ❌", "отмена"]:
self._handle_admin_broadcast(user_id, "рассылка отмена" if "отмен" in text_lower else "рассылка подтвердить")
return
if text_lower in ["админка", "админка ⚙️", "/admin"]:
self._send_admin_panel(user_id)
return
# Кнопки-действия раздела «Услуги»
elif text_lower in [" добавить услугу", "добавить услугу"]:
db.update_user_state(user_id, "ADMIN_SVC_ADD")
self.send_message(user_id, "➕ Новая услуга. Отправьте: Название | Цена | Описание\n"
"Пример: Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["✏️ изменить услугу", "изменить услугу"]:
items = [(k, s["name"]) for k, s in db.get_services().items()]
db.update_user_state(user_id, "ADMIN_SVC_EDIT_PICK")
self.send_message(user_id, "✏️ Какую услугу изменить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🖼 сменить картинку", "сменить картинку"]:
items = [(k, s["name"]) for k, s in db.get_services().items()]
db.update_user_state(user_id, "ADMIN_SVC_IMG_PICK")
self.send_message(user_id, "🖼 Для какой услуги сменить картинку? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🗑 удалить услугу", "удалить услугу"]:
items = [(k, s["name"]) for k, s in db.get_services().items()]
db.update_user_state(user_id, "ADMIN_SVC_DEL_PICK")
self.send_message(user_id, "🗑 Какую услугу удалить? Выберите:", keyboard=get_pick_keyboard(items))
return
# Кнопки-действия раздела «Быстрые ответы»
elif text_lower in [" добавить ответ", "добавить ответ"]:
db.update_user_state(user_id, "ADMIN_FAQ_ADD")
self.send_message(user_id, "➕ Новый быстрый ответ. Отправьте: ключевое слово | текст ответа\n"
"Пример: расписание | Тренировки ежедневно с 10:00 до 22:00.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["✏️ изменить ответ", "изменить ответ"]:
items = [(i, kw) for i, kw, _ in db.get_custom_faq()]
db.update_user_state(user_id, "ADMIN_FAQ_EDIT_PICK")
self.send_message(user_id, "✏️ Какой ответ изменить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🗑 удалить ответ", "удалить ответ"]:
items = [(i, kw) for i, kw, _ in db.get_custom_faq()]
db.update_user_state(user_id, "ADMIN_FAQ_DEL_PICK")
self.send_message(user_id, "🗑 Какой ответ удалить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["в админку ↩️", "в админку"]:
self._send_admin_panel(user_id)
return
elif text_lower in ["статистика 📊", "стата", "статистика", "/stats"]:
self._handle_admin_stats(user_id)
return
elif text_lower in ["управление услугами ⚙️", "управление услугами", "услуги"]:
self._send_admin_menu(user_id)
return
elif text_lower in ["рассылка 📣", "рассылка"]:
# Кнопка без текста — показываем инструкцию
self._handle_admin_broadcast(user_id, "рассылка")
return
elif text_lower.startswith("рассылка"):
self._handle_admin_broadcast(user_id, text_clean)
return
elif text_lower in ["вакансии 💼", "вакансии"]:
self._send_vacancy_admin(user_id)
return
elif text_lower in [" вакансия игрок", "вакансия игрок"]:
db.update_user_state(user_id, "ADMIN_VAC_ADD_PLAYER")
self.send_message(user_id, "➕ Название нового направления для ИГРОКОВ (например, Valorant 🎯):",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["➕ вакансия сотрудник", "вакансия сотрудник"]:
db.update_user_state(user_id, "ADMIN_VAC_ADD_WORKER")
self.send_message(user_id, "➕ Название новой должности для СОТРУДНИКОВ (например, Аналитик):",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["🗑 удалить вакансию", "удалить вакансию"]:
items = [(i, f"🎮 {l}") for i, l in db.get_vacancy_options("player")]
items += [(i, f"💼 {l}") for i, l in db.get_vacancy_options("worker")]
if not items:
self.send_message(user_id, "Список вакансий пуст.")
self._send_vacancy_admin(user_id)
return
db.update_user_state(user_id, "ADMIN_VAC_DEL_PICK")
self.send_message(user_id, "🗑 Какую вакансию удалить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]:
self._send_faq_admin(user_id)
return
elif text_lower.startswith("фак доб "):
self._handle_faq_add(user_id, text_clean)
return
elif text_lower.startswith("фак удал "):
self._handle_faq_delete(user_id, text_clean)
return
elif text_lower in ["кандидаты 👥", "кандидаты"]:
self._send_candidates(user_id)
return
elif text_lower.startswith("статус "):
self._handle_candidate_status(user_id, text_clean)
return
elif text_lower in ["администраторы 👑", "администраторы", "админы"]:
self._send_admins_panel(user_id)
return
elif text_lower in [" добавить админа", "добавить админа"]:
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Только супер-админ.")
return
db.update_user_state(user_id, "ADMIN_ADMIN_ADD")
self.send_message(user_id, "➕ Пришлите ссылку на страницу нового администратора в VK\n"
"(например, https://vk.com/gruzdevas). Можно также короткое имя "
"или числовой ID — бот сам разберётся.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["🗑 удалить админа", "удалить админа"]:
if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Только супер-админ.")
return
runtime = db.get_admins()
if not runtime:
self.send_message(user_id, "Список добавленных из бота админов пуст.")
self._send_admins_panel(user_id)
return
db.update_user_state(user_id, "ADMIN_ADMIN_DEL_PICK")
self.send_message(user_id, "🗑 Кого удалить из админов? Выберите:",
keyboard=get_pick_keyboard([(a, f"vk.com/id{a}") for a in runtime]))
return
elif text_lower.startswith("админ доб"):
self._handle_admin_add_admin(user_id, text_clean)
return
elif text_lower.startswith("админ удал"):
self._handle_admin_remove_admin(user_id, text_clean)
return
elif text_lower.startswith("ред "):
self._handle_admin_edit(user_id, text_clean)
return
elif text_lower.startswith("доб "):
self._handle_admin_add(user_id, text_clean)
return
elif text_lower.startswith("удал "):
self._handle_admin_delete(user_id, text_clean)
return
elif text_lower in ["в главное меню ↩️"]:
self.send_message(
user_id, "🏠 Главное меню.",
keyboard=get_main_keyboard(is_admin=True)
)
return
# Глобальная отписка от рассылки (доступна в любом состоянии)
if text_lower in ["отписаться от рассылки 🔕", "отписаться от рассылки", "отписаться"]:
db.update_user_field(user_id, "consent_2", "Не согласен")
self.send_message(
user_id,
"🔕 Вы отписаны от рассылки. Рекламные сообщения больше присылать не будем. "
"Это не влияет на ваши заявки и поддержку."
)
return
logger.info(f"User {user_id} in state {state} sent: {text_clean}")
# Глобальная обработка отмены формы в любых состояниях заполнения опроса
if state.startswith("FORM_") and text_lower in ["отменить заполнение ❌", "отмена", "отменить"]:
db.reset_form(user_id)
self.send_message(
user_id,
"Заполнение заявки отменено. Мы вернулись в главное меню. Чем я могу помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
return
# Глобальная отмена отклика на вакансию
if state.startswith("VAC_") and text_lower in ["отменить ❌", "отмена", "отменить"]:
db.clear_vac_data(user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"Отклик отменён. Вы вернулись в главное меню.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
return
# Маршрутизация по состояниям (FSM)
if state == "FREE_CHAT":
self._handle_free_chat(user_id, text_clean, text_lower)
elif state == "FORM_RETURNING":
self._handle_form_returning(user_id, text_clean, text_lower, user_data)
elif state == "FORM_RET_NAME":
self._handle_form_ret_name(user_id, text_clean)
elif state == "FORM_RET_PHONE":
self._handle_form_ret_phone(user_id, text_clean)
elif state == "FORM_NAME":
self._handle_form_name(user_id, text_clean)
elif state == "FORM_PHONE":
self._handle_form_phone(user_id, text_clean)
elif state == "FORM_QUESTION":
self._handle_form_question(user_id, text_clean, user_data)
elif state == "FORM_CALLDATE":
self._handle_form_calldate(user_id, text_clean)
elif state == "FORM_CALLTIME":
self._handle_form_calltime(user_id, text_clean)
elif state == "FORM_CONFIRM_QUESTION":
self._handle_form_confirm_question(user_id, text_clean, user_data)
elif state == "FORM_REVIEW":
self._handle_form_review(user_id, text_clean, user_data)
elif state == "FORM_EDIT_MENU":
self._handle_form_edit_menu(user_id, text_clean, user_data)
elif state == "FORM_EDIT_NAME":
self._handle_form_edit_name(user_id, text_clean)
elif state == "FORM_EDIT_PHONE":
self._handle_form_edit_phone(user_id, text_clean)
elif state == "FORM_EDIT_QUESTION":
self._handle_form_edit_question(user_id, text_clean)
elif state == "FORM_CONSENT_1":
self._handle_form_consent_1(user_id, text_clean, text_lower)
elif state == "FORM_CONSENT_2":
self._handle_form_consent_2(user_id, text_clean, text_lower)
elif state == "FORM_CONSENT_3":
self._handle_form_consent_3(user_id, text_clean, text_lower, user_data)
elif state == "VAC_CHOOSE":
self._handle_vac_choose(user_id, text_clean, text_lower)
elif state == "VAC_P_DISCIPLINE":
self._handle_vac_p_discipline(user_id, text_clean, text_lower)
elif state == "VAC_P_RANK":
self._handle_vac_p_rank(user_id, text_clean)
elif state == "VAC_P_ROLE":
self._handle_vac_p_role(user_id, text_clean)
elif state == "VAC_P_EXP":
self._handle_vac_p_exp(user_id, text_clean)
elif state == "VAC_P_CONTACT":
self._handle_vac_p_contact(user_id, text_clean)
elif state == "VAC_W_POSITION":
self._handle_vac_w_position(user_id, text_clean, text_lower)
elif state == "VAC_W_POSITION_OTHER":
self._handle_vac_w_position_other(user_id, text_clean)
elif state == "VAC_W_EXP":
self._handle_vac_w_exp(user_id, text_clean)
elif state == "VAC_W_CONTACT":
self._handle_vac_w_contact(user_id, text_clean)
elif state == "OPERATOR_CHAT":
self._handle_operator_chat(user_id, text_clean, text_lower)
else:
# Предотвращение неопределенного состояния
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# ------------------------------------------
# ОБРАБОТЧИКИ СОСТОЯНИЙ (FSM)
# ------------------------------------------
def _handle_free_chat(self, user_id: int, text: str, text_lower: str):
"""Логика Свободного Чата (Главное меню, FAQ, переключение на форму)."""
# 1. Запуск формы опроса
if text_lower in ["заполнить заявку 📝", "заявка", "начать опрос", "опрос"]:
user_data = db.get_user_data(user_id)
# Если клиент уже оставлял данные — предлагаем подтвердить их, а не вводить заново
if user_data.get("name") and user_data.get("phone"):
db.update_user_state(user_id, "FORM_RETURNING")
self._send_returning_prompt(user_id, user_data)
else:
db.update_user_state(user_id, "FORM_NAME")
self.send_message(
user_id,
"📝 Начинаем заполнение заявки!\n\n"
f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.",
keyboard=get_cancel_keyboard()
)
return
# 1.5 Площадки ИКП — кнопки-ссылки
if text_lower in ["наши соцсети 🔗", "наши соцсети", "соцсети", "площадки", "ссылки"]:
self.send_message(
user_id,
"🔗 Мы на площадках ИКП — заходите, подписывайтесь и смотрите трансляции 👇",
keyboard=get_socials_keyboard()
)
return
# 2. Переход к списку частых вопросов (FAQ)
if text_lower in ["частые вопросы ❓", "faq", "вопросы", "помощь"]:
self.send_message(
user_id,
"❓ Выберите тему, которая вас интересует, на клавиатуре ниже:",
keyboard=get_faq_keyboard()
)
# Дополнительно — быстрые ответы из админки инлайн-кнопками
custom = db.get_custom_faq()
if custom:
kb = VkKeyboard(inline=True)
for n, (_id, kw, _ans) in enumerate(custom[:6]):
if n and n % 2 == 0:
kb.add_line()
kb.add_button(kw[:40], color=VkKeyboardColor.SECONDARY)
self.send_message(
user_id,
"💬 Популярные вопросы — нажмите, чтобы получить ответ:",
keyboard=kb.get_keyboard()
)
return
# 3. Возврат в главное меню
if text_lower in ["назад в меню ↩️", "меню", "назад", "в главное меню ↩️"]:
self.send_message(
user_id,
"🏠 Вы вернулись в главное меню. Выберите интересующее действие:",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
return
# 3.5 Раздел "Карьера в ИКП" (набор игроков и сотрудников)
if text_lower in ["карьера в икп 💼", "карьера", "вакансии", "вакансия", "работа", "набор", "хочу в команду"]:
db.clear_vac_data(user_id)
db.update_user_state(user_id, "VAC_CHOOSE")
self._send_career_choice(user_id)
return
# 4. Вызов оператора
if text_lower in ["позвать оператора 🧑‍💼", "позвать оператора", "оператор", "менеджер", "человек", "позови оператора", "связь с оператором"]:
self._handoff_to_operator(user_id)
return
# 5. Обработка конкретных ответов FAQ
for key, answer in config.FAQ_ANSWERS.items():
if key in text_lower:
self.send_message(user_id, answer, keyboard=get_faq_keyboard())
return
# 6. Приветственные сообщения
if text_lower in ["начать", "привет", "здравствуйте", "ку", "hi", "hello"]:
self.send_message(
user_id,
"👋 Здравствуйте! Это чат-бот киберспортивной организации ИКП — Игра. Киберспорт. Профессионалы.\n\n"
"Я помогу быстро оформить заявку на тренировки, рассказать об услугах или связать вас с командой. "
"Также у нас открыт набор игроков и сотрудников — загляните в раздел «Карьера в ИКП 💼».\n\n"
"Выберите действие на кнопках ниже 👇",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
attachment=self.get_logo_attachment(user_id)
)
return
# 6.5 Быстрые ответы по ключевым словам (редактируются админом)
quick = db.match_custom_faq(text_lower)
if quick:
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
return
# 6.7 Автору поесть (запрет на удаление)
if text_lower in ["/favi","/Favi", "/FAVI" ]:
self.send_message(
user_id,
"Пасихалка для знающих\n\n"
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
"Желаю хороших впечатлений! От этого бота\n\n"
"https://vk.com/favilonia",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
)
return
# 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру)
self.send_message(
user_id,
"🤔 Я пока только учусь и не смог полностью распознать ваш запрос.\n\n"
"Пожалуйста, воспользуйтесь кнопками меню ниже или напишите 'оператор', чтобы я пригласил специалиста.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]):
"""Приветствие для клиента, чьи имя и телефон уже сохранены."""
name = user_data.get("name")
phone = format_phone_display(user_data.get("phone", ""))
self.send_message(
user_id,
f"Здравствуйте! Мы вас помним, {name}. 👋\n\n"
f"Ваш номер телефона по-прежнему {phone}?\n\n"
"Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги.",
keyboard=get_returning_keyboard()
)
# Кнопки изменения данных — инлайн, под отдельным сообщением
self.send_message(
user_id,
"Нужно поправить данные? 👇",
keyboard=get_returning_changes_keyboard()
)
def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
"""Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново."""
if text_lower in ["да, всё верно ✅", "да, все верно", "да", "верно", "всё верно"]:
# Данные подтверждены — переходим сразу к выбору услуги
db.update_user_state(user_id, "FORM_QUESTION")
self._send_services_prompt(user_id, prefix="Отлично! Перейдём к выбору услуги.\n\n")
elif text_lower in ["сменить имя 👤", "сменить имя", "имя"]:
db.update_user_state(user_id, "FORM_RET_NAME")
self.send_message(user_id, "👤 Введите ваше новое имя:", keyboard=get_cancel_keyboard())
elif text_lower in ["сменить телефон 📞", "сменить телефон", "телефон"]:
db.update_user_state(user_id, "FORM_RET_PHONE")
self.send_message(
user_id,
"📞 Введите ваш новый номер телефона:",
keyboard=get_cancel_keyboard()
)
elif text_lower in ["заполнить заново 🔄", "заполнить заново", "заново"]:
db.reset_form_full(user_id)
db.update_user_state(user_id, "FORM_NAME")
self.send_message(
user_id,
"📝 Хорошо, заполним заявку с нуля!\n\n"
f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.",
keyboard=get_cancel_keyboard()
)
else:
self._send_returning_prompt(user_id, user_data)
def _handle_form_ret_name(self, user_id: int, text: str):
"""Смена имени у возвращающегося клиента → обратно к подтверждению."""
if not is_valid_name(text):
self.send_message(
user_id,
"⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. "
"Пожалуйста, введите корректное имя.",
keyboard=get_cancel_keyboard()
)
return
db.update_user_field(user_id, "name", text.strip())
db.update_user_state(user_id, "FORM_RETURNING")
self.send_message(user_id, "✅ Имя обновлено!")
self._send_returning_prompt(user_id, db.get_user_data(user_id))
def _handle_form_ret_phone(self, user_id: int, text: str):
"""Смена телефона у возвращающегося клиента → обратно к подтверждению."""
phone_cleaned = normalize_phone(text)
if not phone_cleaned:
self.send_message(
user_id,
"⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) "
"либо международный в формате +<код страны><номер>.",
keyboard=get_cancel_keyboard()
)
return
db.update_user_field(user_id, "phone", phone_cleaned)
db.update_user_state(user_id, "FORM_RETURNING")
self.send_message(user_id, "✅ Телефон обновлён!")
self._send_returning_prompt(user_id, db.get_user_data(user_id))
def _send_services_prompt(self, user_id: int, prefix: str = ""):
"""Отправляет услуги каруселью (с фолбэком на текст)."""
self._send_services(user_id, prefix)
def _handle_form_name(self, user_id: int, text: str):
"""Сохранение имени и переход к номеру телефона."""
# Валидация имени: только буквы/пробел/дефис, длина 2–50
if not is_valid_name(text):
self.send_message(
user_id,
"⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. "
"Пожалуйста, введите корректное имя.",
keyboard=get_cancel_keyboard()
)
return
# Сохраняем имя в БД
db.update_user_field(user_id, "name", text)
db.update_user_state(user_id, "FORM_PHONE")
self.send_message(
user_id,
f"👍 Приятно познакомиться, {text}!\n\n"
f"{self._progress_bar(2)}\nВведите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.",
keyboard=get_cancel_keyboard()
)
def _handle_form_phone(self, user_id: int, text: str):
"""Сохранение телефона и переход к выбору услуги."""
phone_cleaned = normalize_phone(text)
# Валидация (российский или международный формат)
if not phone_cleaned:
self.send_message(
user_id,
"⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) "
"либо международный в формате +<код страны><номер>.",
keyboard=get_cancel_keyboard()
)
return
# Сохраняем телефон в БД
db.update_user_field(user_id, "phone", phone_cleaned)
db.update_user_state(user_id, "FORM_QUESTION")
# Показываем услуги каруселью (с фолбэком на текст)
self._send_services(user_id, f"✅ Телефон принят!\n\n{self._progress_bar(3)}\nКакое направление вас интересует?\n\n")
@staticmethod
def _match_service(text: str) -> Optional[Dict[str, str]]:
"""Сопоставляет введённый текст с услугой: сначала по синонимам дисциплин,
затем по ключу/названию (с очисткой эмодзи). Возвращает svc или None."""
text_lower = text.lower().strip()
# 0. Точное совпадение по номеру/ключу услуги — высший приоритет.
# Иначе нечёткий поиск ниже ловит «2» внутри «counter-strike 2» и ломает выбор по номеру.
for key, svc in config.SERVICES.items():
if text_lower in (key, f"{key}.", f"{key}️⃣"):
return svc
# 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):
svc = config.SERVICES.get(key_opt)
if svc:
return svc
# 2. Нечёткий поиск по названию (если точный ключ и синонимы не сработали)
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 (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))):
return svc
return None
def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]):
"""Обработка выбора услуги с прямым переходом к проверке данных."""
selected_service = self._match_service(text)
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 = self._match_service(text)
if selected_service:
db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})")
db.update_user_state(user_id, "FORM_REVIEW")
updated_data = db.get_user_data(user_id)
self.send_message(user_id, "✅ Услуга успешно обновлена!")
self._send_review_message(user_id, updated_data)
else:
services_text = (
"⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n"
"Пожалуйста, выберите одну из доступных услуг на клавиатуре:\n\n"
)
for key, svc in config.SERVICES.items():
services_text += f"{key}️⃣ {svc['name']}\n"
services_text += f" 💰 Стоимость: {svc['price']}\n\n"
self.send_message(
user_id,
services_text,
keyboard=get_services_keyboard()
)
def get_offer_document_attachment(self, peer_id: int) -> Optional[str]:
"""Загружает документ оферты на сервер VK и возвращает attachment-строку."""
if hasattr(self, "_cached_doc_attachment") and self._cached_doc_attachment:
return self._cached_doc_attachment
import os
doc_path = os.path.join(os.path.dirname(__file__), "Dogovor_oferty_kibersport_versia_izmenennaya.docx")
if not os.path.exists(doc_path):
logger.error(f"Файл оферты не найден по пути: {doc_path}")
return None
if not self.vk:
# Режим эмуляции (консольный)
logger.info(f"[EMULATOR] Файл {os.path.basename(doc_path)} прикреплен к сообщению.")
return None
try:
import requests
logger.info("Загрузка документа оферты в VK API...")
upload_server = self.vk.docs.getMessagesUploadServer(peer_id=peer_id, type="doc")
upload_url = upload_server["upload_url"]
title = "Dogovor_oferty_kibersport.docx"
with open(doc_path, "rb") as f:
response = requests.post(upload_url, files={"file": (title, f)})
response_json = response.json()
if "file" not in response_json:
logger.error(f"Ошибка загрузки файла в VK: {response_json}")
return None
saved_doc = self.vk.docs.save(file=response_json["file"], title=title)
doc_info = saved_doc[0] if isinstance(saved_doc, list) else saved_doc
# docs.save может вернуть {'type': 'doc', 'doc': {...}} — берём вложенный объект
if isinstance(doc_info, dict) and "doc" in doc_info:
doc_info = doc_info["doc"]
attachment = f"doc{doc_info['owner_id']}_{doc_info['id']}"
self._cached_doc_attachment = attachment
logger.info(f"Документ загружен успешно: {attachment}")
return attachment
except Exception as e:
logger.error(f"Ошибка при загрузке документа в VK API: {e}")
return None
def _upload_photo(self, peer_id: int, image_path: str) -> Optional[Dict[str, str]]:
"""Загружает фото в VK для прикрепления к сообщению. Возвращает {'attachment','raw'} или None."""
if image_path in self._photo_cache:
return self._photo_cache[image_path]
import os
if not self.vk or not os.path.exists(image_path):
return None
try:
import requests
upload_server = self.vk.photos.getMessagesUploadServer(peer_id=peer_id)
upload_url = upload_server["upload_url"]
with open(image_path, "rb") as f:
response = requests.post(upload_url, files={"photo": (os.path.basename(image_path), f)})
r = response.json()
saved = self.vk.photos.saveMessagesPhoto(photo=r["photo"], server=r["server"], hash=r["hash"])
info = saved[0] if isinstance(saved, list) else saved
raw = f"{info['owner_id']}_{info['id']}"
result = {"attachment": f"photo{raw}", "raw": raw}
self._photo_cache[image_path] = result
logger.info(f"Фото загружено в VK: {result['attachment']}")
return result
except Exception as e:
logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}")
return None
@staticmethod
def _first_photo(attachments: list) -> Optional[dict]:
"""Возвращает первое фото-вложение из сообщения (или None)."""
for att in attachments or []:
if isinstance(att, dict) and att.get("type") == "photo":
return att.get("photo")
return None
def _save_service_image(self, service_id: str, photo: dict) -> bool:
"""Скачивает присланное фото, обрезает под 13:8 (650×400) и сохраняет как service_<id>.jpg."""
import os
try:
sizes = photo.get("sizes", [])
if not sizes:
return False
url = max(sizes, key=lambda s: s.get("width", 0)).get("url")
import requests
from PIL import Image
from io import BytesIO
data = requests.get(url, timeout=15).content
im = Image.open(BytesIO(data)).convert("RGB")
TARGET = 13 / 8
w, h = im.size
if w / h > TARGET:
nw = int(h * TARGET); x = (w - nw) // 2; box = (x, 0, x + nw, h)
else:
nh = int(w / TARGET); y = (h - nh) // 2; box = (0, y, w, y + nh)
im = im.crop(box).resize((650, 400), Image.LANCZOS)
path = os.path.join(os.path.dirname(__file__), "help_img", f"service_{service_id}.jpg")
im.save(path, "JPEG", quality=88)
self._photo_cache.pop(path, None) # сбросить кэш, чтобы перезалить в VK
logger.info(f"Картинка услуги {service_id} обновлена: {path}")
return True
except Exception as e:
logger.error(f"Ошибка сохранения картинки услуги {service_id}: {e}")
return False
def get_logo_attachment(self, peer_id: int) -> Optional[str]:
"""Attachment-строка с логотипом ИКП для прикрепления к приветствию."""
import os
logo_path = os.path.join(os.path.dirname(__file__), "help_img", "Logo.jpg")
info = self._upload_photo(peer_id, logo_path)
return info["attachment"] if info else None
def _prewarm_media(self):
"""Заранее (при старте) загружает логотип и картинки дисциплин в VK,
чтобы первое приветствие и первая карусель открывались мгновенно."""
import os, glob
try:
peer = config.OPERATOR_PEER_ID
base = os.path.join(os.path.dirname(__file__), "help_img")
# Грузим ВСЕ картинки из папки (кроме фона веб-формы), чтобы ничего
# не подгружалось лениво в момент сообщения.
count = 0
for path in sorted(glob.glob(os.path.join(base, "*.jpg"))):
if os.path.basename(path).lower() == "bg.jpg":
continue
if self._upload_photo(peer, path):
count += 1
logger.info(f"Медиа предзагружены в VK ({count} картинок) — открываются мгновенно.")
except Exception as e:
logger.error(f"Не удалось предзагрузить медиа: {e}")
@staticmethod
def _card_description(svc: Dict[str, str]) -> str:
"""Компактное описание карточки карусели: цена + краткая суть (лимит VK — 80 символов)."""
price = str(svc.get("price", "")).strip()
# Берём первую фразу описания (до точки) как короткую суть
blurb = str(svc.get("description", "")).strip().split(".")[0].strip()
text = f"{price} · {blurb}" if blurb else price
if len(text) > 80:
text = text[:79].rstrip(" ,;·") + ""
return text
def _build_services_carousel(self, peer_id: int) -> Optional[str]:
"""Собирает VK-карусель из доступных услуг. Возвращает JSON-строку шаблона или None."""
import os
base = os.path.join(os.path.dirname(__file__), "help_img")
# Картинки дисциплин по id услуги. ВАЖНО: VK требует пропорцию 13:8 (напр. 650×400).
# Логотип квадратный и не подходит — поэтому фото берём ТОЛЬКО если есть спец-файл дисциплины.
img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"}
elements = []
for key, svc in config.SERVICES.items():
# Сначала ищем загруженную админом картинку service_<id>.jpg, затем легаси-имя
uploaded = os.path.join(base, f"service_{key}.jpg")
legacy = os.path.join(base, img_by_id.get(key, ""))
disc_img = uploaded if os.path.exists(uploaded) else legacy
info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None
element = {
"title": svc["name"][:80],
"description": self._card_description(svc),
"buttons": [{
"action": {"type": "text", "label": f"Выбрать {svc['name']}"[:40]},
"color": "primary"
}]
}
if info:
element["photo_id"] = info["raw"]
elements.append(element)
if not elements:
return None
# VK требует фото либо у всех карточек, либо ни у одной
if any("photo_id" not in e for e in elements):
for e in elements:
e.pop("photo_id", None)
return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False)
def _send_services(self, user_id: int, intro: str):
"""Отправляет услуги каруселью (если возможно), иначе текстом-фолбэком."""
# Полный текстовый список — для фолбэка (и лога)
full_text = intro + "📋 Доступные направления и расценки:\n\n"
for key, svc in config.SERVICES.items():
full_text += f"{key}️⃣ {svc['name']}\n 💰 {svc['price']}\n 📝 {svc['description']}\n\n"
full_text += "Выберите направление кнопкой ниже или отправьте его номер/название."
if self.vk:
template = self._build_services_carousel(user_id)
if template:
try:
# 1) Короткий текст + кнопка отмены. Это сразу убирает «зависшие»
# кнопки прошлого шага и даёт нормальную кнопку «Отменить».
self.send_message(
user_id,
intro + "Листайте карточки ниже 👇 и нажмите «Выбрать» под нужным направлением.",
keyboard=get_cancel_keyboard()
)
# 2) Сама карусель (без клавиатуры — кнопка отмены сохраняется).
db.log_message(user_id, "bot", "[Карусель направлений]")
self.vk.messages.send(
peer_id=user_id, message="🎮 Выберите направление:",
template=template, random_id=get_random_id()
)
return
except Exception as e:
logger.error(f"Карусель не отправилась, фолбэк на текст: {e}")
# Фолбэк / режим эмуляции
self.send_message(user_id, full_text, keyboard=get_services_keyboard())
def _build_career_carousel(self, peer_id: int) -> Optional[str]:
"""Карусель выбора в разделе «Карьера»: карточки «Игроком» и «Сотрудником»."""
import os
base = os.path.join(os.path.dirname(__file__), "help_img")
cards = [
("career_player.jpg", "Игроком в состав 🎮",
"Покажи скилл и попади в ростер ИКП", "Игроком в состав 🎮"),
("career_worker.jpg", "Сотрудником 💼",
"Тренер, SMM, монтаж, менеджмент, разработка", "Сотрудником 💼"),
]
elements = []
for img_name, title, desc, label in cards:
img_path = os.path.join(base, img_name)
info = self._upload_photo(peer_id, img_path) if os.path.exists(img_path) else None
element = {
"title": title[:80],
"description": desc[:80],
"buttons": [{"action": {"type": "text", "label": label[:40]}, "color": "primary"}]
}
if info:
element["photo_id"] = info["raw"]
elements.append(element)
if any("photo_id" not in e for e in elements):
for e in elements:
e.pop("photo_id", None)
return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False)
def _send_career_choice(self, user_id: int):
"""Отправляет выбор в «Карьере» каруселью (с фолбэком на обычную клавиатуру)."""
intro = ("💼 Карьера в ИКП\n\n"
"Мы растём и ищем как игроков в состав, так и специалистов в команду.")
if self.vk:
template = self._build_career_carousel(user_id)
if template:
try:
self.send_message(
user_id,
intro + "\nВыберите на карточке ниже 👇, кем хотите присоединиться.",
keyboard=get_vacancy_back_keyboard()
)
db.log_message(user_id, "bot", "[Карусель карьеры]")
self.vk.messages.send(
peer_id=user_id, message="Кем хотите присоединиться?",
template=template, random_id=get_random_id()
)
return
except Exception as e:
logger.error(f"Карусель карьеры не отправилась, фолбэк: {e}")
# Фолбэк / эмуляция
self.send_message(
user_id,
intro + "\nКем вы хотите к нам присоединиться?",
keyboard=get_vacancy_choice_keyboard()
)
def _handle_form_consent_1(self, user_id: int, text: str, text_lower: str):
"""Шаг 1 — ОБЯЗАТЕЛЬНЫЕ согласия: оферта, обработка ПД и запись консультации."""
is_agree = text_lower in ["согласен(а) и подтверждаю ✅", "согласен и подтверждаю", "согласен(а) и подтверждаю", "согласен", "согласна", "да", "принимаю", "согласен(а) ✅"]
is_disagree = text_lower in ["не согласен(а) ❌", "не согласен", "не согласна", "нет", "не принимаю"]
if is_agree:
# Фиксируем обязательные согласия (оферта/ПД и запись консультации)
db.update_user_field(user_id, "consent_1", "Согласен")
db.update_user_field(user_id, "consent_3", "Согласен")
# Переходим к ОТДЕЛЬНОМУ необязательному согласию на рассылку (требование 152-ФЗ)
db.update_user_state(user_id, "FORM_CONSENT_2")
self.send_message(
user_id,
"✅ Спасибо! Обязательные согласия приняты.\n\n"
"📨 Последний шаг (по желанию): хотите получать новости, акции и спецпредложения ИКП?\n\n"
"Это необязательно и никак не влияет на обработку вашей заявки.",
keyboard=get_marketing_keyboard()
)
elif is_disagree:
self.send_message(
user_id,
"⚠️ Для оформления заявки необходимо принять условия оферты, согласие на обработку персональных данных и запись консультации.\n\n"
"Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.",
keyboard=get_binary_keyboard(show_skip=False)
)
else:
self.send_message(
user_id,
"❓ Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или выберите «Не согласен(а) ❌» / «Отменить заполнение ❌».",
keyboard=get_binary_keyboard(show_skip=False)
)
def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str):
"""Шаг 2 — НЕОБЯЗАТЕЛЬНОЕ согласие на маркетинговую рассылку. Любой выбор завершает заявку."""
is_agree = text_lower in ["согласен на рассылку ✅", "согласен на рассылку", "согласен", "согласна", "да", "хочу"]
is_decline = text_lower in ["без рассылки ➡️", "без рассылки", "не согласен", "не согласна", "нет", "не хочу", "пропустить ➡️", "пропустить"]
if is_agree:
db.update_user_field(user_id, "consent_2", "Согласен")
self._finalize_lead(user_id)
elif is_decline:
db.update_user_field(user_id, "consent_2", "Не согласен")
self._finalize_lead(user_id)
else:
self.send_message(
user_id,
"❓ Хотите получать рассылку ИКП? Нажмите «Согласен на рассылку ✅» или «Без рассылки ➡️».",
keyboard=get_marketing_keyboard()
)
def _handle_form_consent_3(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
"""Резервный обработчик для совместимости с устаревшими состояниями в БД."""
db.update_user_field(user_id, "consent_3", "Согласен")
self._finalize_lead(user_id)
@staticmethod
def _generate_promo() -> str:
"""Генерирует персональный промокод вида ИКП-AB12CD."""
code = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"ИКП-{code}"
def _finalize_lead(self, user_id: int):
"""Завершает оформление заявки: подтверждение клиенту + уведомление оператору и на почту."""
updated_data = db.get_user_data(user_id)
phone = updated_data["phone"]
call_time = updated_data.get("call_time") or "не указано"
marketing = (updated_data.get("consent_2") == "Согласен")
db.update_user_state(user_id, "FREE_CHAT")
# Персональный промокод на скидку (пока выключен флагом config.PROMO_ENABLED)
promo = self._generate_promo() if getattr(config, "PROMO_ENABLED", False) else None
# Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00)
from datetime import datetime
now = datetime.now()
if 9 <= now.hour < 21:
callback_time = "в течение 15 минут"
else:
callback_time = "сегодня утром, начиная с 9:00"
confirm_msg = (
"🎉 Заявка успешно оформлена!\n\n"
f"Наш менеджер свяжется с вами ({callback_time}) по номеру {format_phone_display(phone)}, "
f"удобное время: {call_time}.\n\n"
)
if promo:
confirm_msg += (
"🎁 Ваш персональный промокод на скидку 10% на первое занятие:\n"
f"{promo}\n"
"Назовите его менеджеру при записи. "
)
confirm_msg += "Спасибо за обращение!\n\nВы вернулись в главное меню бота."
self.send_message(user_id, confirm_msg, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
# Журналируем заявку для статистики и истории
try:
db.add_lead(
user_id=user_id, source="bot",
name=updated_data.get("name"), phone=phone,
service=updated_data.get("question"), call_time=updated_data.get("call_time"),
promo=promo, marketing=marketing
)
except Exception as e:
logger.error(f"Не удалось записать лид в журнал: {e}")
# Антиспам: не заваливаем оператора повторными уведомлениями от одного пользователя
if self._notify_allowed(user_id):
self._notify_operator_about_lead(user_id, updated_data, promo=promo)
else:
logger.warning(f"Подавлено повторное уведомление о заявке от {user_id} (антиспам)")
def _handle_operator_chat(self, user_id: int, text: str, text_lower: str):
"""Поведение бота в режиме активного диалога с живым человеком."""
# Пользователь хочет выйти из диалога с оператором
if text_lower in ["завершить диалог 🚪", "завершить", "/exit", "выход"]:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# Уведомляем операторов о закрытии диалога
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
return
# В остальное время бот МОЛЧИТ в диалог с пользователем.
logger.info(f"[OPERATOR MODE] Message from {user_id} bypasses bot: {text}")
self._forward_to_operator(user_id, text)
# ------------------------------------------
# СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ
# ------------------------------------------
# ------------------------------------------
# РАЗДЕЛ "КАРЬЕРА В ИКП" (НАЙМ ИГРОКОВ И СОТРУДНИКОВ)
# ------------------------------------------
@staticmethod
def _valid_free_text(text: str) -> bool:
"""Базовая проверка свободного ответа в отклике (не пусто, разумной длины)."""
return 1 <= len(text.strip()) <= 300
def _send_vac_options(self, user_id: int, track: str, question: str, include_other: bool = False):
"""Шлёт вопрос (с кнопкой отмены) и инлайн-кнопки доступных опций вакансии из БД."""
self.send_message(user_id, question, keyboard=get_vacancy_cancel_keyboard())
kb = VkKeyboard(inline=True)
opts = db.get_vacancy_options(track)
n = 0
for _id, label in opts[:5]:
if n and n % 2 == 0:
kb.add_line()
kb.add_button(label[:40], color=VkKeyboardColor.SECONDARY)
n += 1
if include_other:
kb.add_line()
kb.add_button("Другое ✍️", color=VkKeyboardColor.SECONDARY)
self.send_message(user_id, "Выберите вариант 👇", keyboard=kb.get_keyboard())
def _handle_vac_choose(self, user_id: int, text: str, text_lower: str):
"""Выбор ветки: игрок в состав или сотрудник."""
if text_lower in ["игроком в состав 🎮", "игроком", "игрок", "в состав", "состав"]:
db.update_user_state(user_id, "VAC_P_DISCIPLINE")
self._send_vac_options(user_id, "player",
"🎮 Набор в состав\n\nШаг 1 из 5: по какой дисциплине вы играете?")
elif text_lower in ["сотрудником 💼", "сотрудником", "сотрудник", "работником", "в команду"]:
db.update_user_state(user_id, "VAC_W_POSITION")
self._send_vac_options(user_id, "worker",
"💼 Набор в команду\n\nШаг 1 из 3: на какую должность вы претендуете?",
include_other=True)
elif text_lower in ["назад в меню ↩️", "назад", "меню", "в меню"]:
db.clear_vac_data(user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "🏠 Вы вернулись в главное меню.", keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
else:
self.send_message(
user_id,
"Пожалуйста, выберите на клавиатуре: «Игроком в состав 🎮» или «Сотрудником 💼».",
keyboard=get_vacancy_choice_keyboard()
)
# ----- Ветка игрока -----
def _handle_vac_p_discipline(self, user_id: int, text: str, text_lower: str):
discipline = None
# 1) Сопоставление с опциями из БД (нажата инлайн-кнопка или введено название)
for _id, label in db.get_vacancy_options("player"):
ll = label.lower()
if text.strip() == label or text_lower == ll or ll in text_lower or text_lower in ll:
discipline = label
break
# 2) Фолбэк-синонимы для свободного ввода
if not discipline:
syn = {
"Counter-Strike 2": ["кс", "cs", "cs2", "csgo", "ксго", "контра", "counter"],
"Dota 2": ["дота", "dota", "дотка", "доту"],
"World of Tanks": ["танк", "wot", "tanks", "ворлд оф танкс", "вот"],
}
for name, words in syn.items():
if any(w in text_lower for w in words):
discipline = name
break
if not discipline:
self._send_vac_options(user_id, "player", "Пожалуйста, выберите дисциплину на кнопках ниже:")
return
db.set_vac_field(user_id, "discipline", discipline)
db.update_user_state(user_id, "VAC_P_RANK")
self.send_message(
user_id,
f"Отлично, {discipline}!\n\n"
"Шаг 2 из 5: укажите ваш текущий ранг / MMR / ELO "
"(например, «Faceit lvl 8», «5000 MMR» или «Легенда»).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_rank(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, коротко укажите ваш ранг / MMR / ELO.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "rank", text.strip())
db.update_user_state(user_id, "VAC_P_ROLE")
self.send_message(
user_id,
"Шаг 3 из 5: ваша роль / позиция в игре? (например, AWPer, мид, саппорт, командир)",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_role(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, укажите вашу роль / позицию.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "role", text.strip())
db.update_user_state(user_id, "VAC_P_EXP")
self.send_message(
user_id,
"Шаг 4 из 5: расскажите коротко об опыте — в каких командах играли, турниры, достижения.",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_exp(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "experience", text.strip())
db.update_user_state(user_id, "VAC_P_CONTACT")
self.send_message(
user_id,
"Шаг 5 из 5: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_contact(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "contact", text.strip())
self._finalize_application(user_id, "player")
# ----- Ветка сотрудника -----
def _handle_vac_w_position(self, user_id: int, text: str, text_lower: str):
if text_lower in ["другое ✍️", "другое", "иное"]:
db.update_user_state(user_id, "VAC_W_POSITION_OTHER")
self.send_message(
user_id,
"✍️ Напишите, на какую должность вы претендуете.",
keyboard=get_vacancy_cancel_keyboard()
)
return
# Сопоставление с должностями из БД (инлайн-кнопка), иначе — произвольный текст
position = None
for _id, label in db.get_vacancy_options("worker"):
if text.strip() == label or text_lower == label.lower():
position = label
break
if not position:
if not self._valid_free_text(text):
self._send_vac_options(user_id, "worker",
"Выберите должность на кнопках ниже или нажмите «Другое ✍️»:",
include_other=True)
return
position = text.strip()
db.set_vac_field(user_id, "position", position)
db.update_user_state(user_id, "VAC_W_EXP")
self.send_message(
user_id,
f"Должность: {position}.\n\n"
"Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_w_position_other(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, напишите желаемую должность.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "position", text.strip())
db.update_user_state(user_id, "VAC_W_EXP")
self.send_message(
user_id,
"Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_w_exp(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "experience", text.strip())
db.update_user_state(user_id, "VAC_W_CONTACT")
self.send_message(
user_id,
"Шаг 3 из 3: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_w_contact(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "contact", text.strip())
self._finalize_application(user_id, "worker")
def _finalize_application(self, user_id: int, track: str):
"""Завершает отклик: подтверждение кандидату + уведомление HR/оператору и письмо."""
data = db.get_vac_data(user_id)
try:
db.add_application(user_id, track, data)
except Exception as e:
logger.error(f"Не удалось записать отклик в журнал: {e}")
db.clear_vac_data(user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"✅ Спасибо за отклик!\n\n"
"Мы изучим вашу анкету и свяжемся, если кандидатура подойдёт. Удачи! 🎮\n\n"
"Вы вернулись в главное меню.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# Антиспам: ограничиваем частоту уведомлений об откликах от одного пользователя
if self._notify_allowed(user_id):
self._notify_hr_about_application(user_id, track, data)
else:
logger.warning(f"Подавлено повторное уведомление об отклике от {user_id} (антиспам)")
def _notify_hr_about_application(self, user_id: int, track: str, data: Dict[str, Any]):
"""Уведомление об отклике оператору в VK и на HR-почту."""
if track == "player":
title = "Набор в состав (игрок)"
details = (
f"Дисциплина: {data.get('discipline', '')}\n"
f"Ранг/MMR/ELO: {data.get('rank', '')}\n"
f"Роль/позиция: {data.get('role', '')}\n"
f"Опыт: {data.get('experience', '')}\n"
f"Контакт: {data.get('contact', '')}"
)
else:
title = "Вакансия (сотрудник)"
details = (
f"Должность: {data.get('position', '')}\n"
f"Опыт/навыки: {data.get('experience', '')}\n"
f"Контакт: {data.get('contact', '')}"
)
operator_msg = (
f"🧩 Новый отклик — {title}!\n\n"
f"{details}\n\n"
f"🔗 Профиль кандидата: vk.com/id{user_id}"
)
self._notify_operators(operator_msg)
# Письмо на HR-адрес (пока совпадает с group-zakaz, см. config.HR_MAIL_TO)
try:
email_body = (
f"Новый отклик из чат-бота ВКонтакте — {title}.\n\n"
f"{details}\n\n"
f"Профиль кандидата: https://vk.com/id{user_id}\n"
)
mailer.send_email(f"Новый отклик — {title}", email_body, mail_to=getattr(config, "HR_MAIL_TO", None))
except Exception as mail_err:
logger.error(f"Не удалось отправить email об отклике: {mail_err}")
def _handoff_to_operator(self, user_id: int):
"""Переводит пользователя в режим оператора и уведомляет команду поддержки."""
db.update_user_state(user_id, "OPERATOR_CHAT")
# Сообщение клиенту зависит от рабочего времени
if self._within_work_hours():
client_text = ("🧑‍💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n"
"Чтобы вернуться к боту, нажмите кнопку ниже.")
else:
client_text = (f"🌙 Оператор сейчас оффлайн (работаем с {getattr(config,'WORK_HOUR_START',9)}:00 "
f"до {getattr(config,'WORK_HOUR_END',21)}:00).\n"
"Я передал ваш запрос — вам ответят утром, как начнётся рабочий день. "
"Можете оставить сообщение здесь. Чтобы вернуться к боту, нажмите кнопку ниже.")
self.send_message(user_id, client_text, keyboard=get_operator_keyboard())
# Получаем историю последних 10 сообщений для контекста оператора
history = db.get_dialogue_history(user_id, limit=10)
formatted_history = ""
for sender, msg, ts in history:
role = "Вы" if sender == "user" else "Бот"
formatted_history += f"[{ts}] {role}: {msg}\n"
# Отправляем алерт оператору с историей диалога
operator_msg = (
f"🚨 Запрос оператора!\n"
f"Пользователь: vk.com/id{user_id}\n"
f"Тип запроса: Прямой вызов из меню\n\n"
f"📋 Последние сообщения для контекста:\n{formatted_history}"
)
self._notify_operators(operator_msg)
def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any], promo: Optional[str] = None):
"""Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий."""
name = user_data["name"]
phone = user_data["phone"]
question = user_data["question"]
call_time = user_data.get("call_time") or "не указано"
consent_1 = user_data.get("consent_1", "Согласен")
consent_2 = user_data.get("consent_2", "Согласен")
consent_3 = user_data.get("consent_3", "Согласен")
promo_line = f"🎁 Промокод клиента: {promo}\n" if promo else ""
# Реальное имя из VK + флаг несовпадения (помогает отсеять «рандомные» данные)
vk_name = self._vk_name(user_id)
if vk_name:
mismatch = ""
if name and name.strip().lower() not in vk_name.lower() and vk_name.lower() not in name.strip().lower():
mismatch = " ⚠️ не совпадает с введённым!"
vk_name_line = f"🪪 Имя в VK: {vk_name}{mismatch}\n"
else:
vk_name_line = ""
operator_msg = (
f"⚡ Получена новая заявка!\n\n"
f"👤 Имя (введено): {name}\n"
f"{vk_name_line}"
f"📞 Телефон: {format_phone_display(phone)}\n"
f"🎯 Выбранная услуга: {question}\n"
f"🕒 Удобное время звонка: {call_time}\n"
f"{promo_line}\n"
f"⚖️ Согласия пользователя:\n"
f"1. Оферта и обработка ПД (Обяз.): {consent_1}\n"
f"2. Маркетинговые рассылки (Необяз.): {consent_2}\n"
f"3. Запись консультации (Обяз.): {consent_3}\n\n"
f"🔗 Профиль клиента: vk.com/id{user_id}\n"
f"Переписка сохранена в базе данных."
)
self._notify_operators(operator_msg)
# Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro)
try:
email_body = (
"Новая заявка из чат-бота ВКонтакте.\n\n"
f"Имя: {name}\n"
f"Телефон: {phone}\n"
f"Выбранная услуга: {question}\n"
f"Удобное время звонка: {call_time}\n"
+ (f"Промокод клиента: {promo}\n" if promo else "")
+ "\nСогласия:\n"
f" - Оферта и обработка ПД (обяз.): {consent_1}\n"
f" - Маркетинговые рассылки (необяз.): {consent_2}\n"
f" - Запись консультации (обяз.): {consent_3}\n\n"
f"Профиль клиента: https://vk.com/id{user_id}\n"
)
mailer.send_email(f"Новая заявка из бота — {name}", email_body)
except Exception as mail_err:
logger.error(f"Не удалось отправить email о заявке: {mail_err}")
def _forward_to_operator(self, user_id: int, text: str):
"""Пересылка сообщений от клиента всем операторам."""
self._notify_operators(f"💬 Сообщение от vk.com/id{user_id}:\n{text}")
# ------------------------------------------
# ЗАПУСК И ОБРАБОТКА LONGPOLL
# ------------------------------------------
def _send_vacancy_admin(self, user_id: int):
"""Раздел управления вакансиями (направления игроков / должности сотрудников)."""
players = db.get_vacancy_options("player")
workers = db.get_vacancy_options("worker")
lines = "🎮 Направления (игроки):\n"
lines += ("".join(f" • [{i}] {l}\n" for i, l in players) or "\n")
lines += "\n💼 Должности (сотрудники):\n"
lines += ("".join(f" • [{i}] {l}\n" for i, l in workers) or "\n")
kb = VkKeyboard(inline=True)
kb.add_button(" Вакансия игрок", color=VkKeyboardColor.PRIMARY)
kb.add_button("➕ Вакансия сотрудник", color=VkKeyboardColor.PRIMARY)
kb.add_line()
kb.add_button("🗑 Удалить вакансию", color=VkKeyboardColor.NEGATIVE)
self.send_message(
user_id,
"💼 Управление вакансиями\n\n" + lines + "\nУправляйте кнопками ниже 👇",
keyboard=kb.get_keyboard()
)
def _handle_admin_wizard(self, user_id: int, text: str, text_lower: str, state: str, attachments: list):
"""Мастера админки: добавление/изменение/удаление услуг и быстрых ответов, смена картинок."""
if text_lower in ["отмена ❌", "отмена", "в админку ↩️", "в админку", "назад"]:
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
def back_to_services(msg=None):
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
if msg:
self.send_message(user_id, msg)
self._send_admin_menu(user_id)
def back_to_faq(msg=None):
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
if msg:
self.send_message(user_id, msg)
self._send_faq_admin(user_id)
def pick_id(t):
t = t.strip()
return t.split(".")[0].split()[0] if t else ""
# ---------- Услуги ----------
if state == "ADMIN_SVC_ADD":
parts = [p.strip() for p in text.split("|")]
if len(parts) < 3 or not all(parts[:3]):
self.send_message(user_id, "⚠️ Формат: Название | Цена | Описание", keyboard=get_cancel_admin_keyboard())
return
new_id = db.add_service(parts[0], parts[1], parts[2])
config.SERVICES = db.get_services()
back_to_services(f"✅ Услуга добавлена (№{new_id}). Картинку можно задать через «🖼 Сменить картинку».")
return
if state == "ADMIN_SVC_EDIT_PICK":
sid = pick_id(text)
services = db.get_services()
if sid not in services:
self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.")
return
self._admin_ctx[user_id] = {"svc_id": sid}
db.update_user_state(user_id, "ADMIN_SVC_EDIT_VAL")
self.send_message(user_id,
f"✏️ Услуга «{services[sid]['name']}». Отправьте: поле | значение\n"
"Поля: имя, цена, описание\nПример: цена | 1 900 ₽/час",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_SVC_EDIT_VAL":
sid = self._admin_ctx.get(user_id, {}).get("svc_id")
if not sid or sid not in db.get_services():
back_to_services("⚠️ Услуга не найдена.")
return
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: поле | значение", keyboard=get_cancel_admin_keyboard())
return
field, value = [p.strip() for p in text.split("|", 1)]
mapping = {"имя": "name", "название": "name", "цена": "price", "стоимость": "price", "описание": "description"}
if field.lower() not in mapping or not value:
self.send_message(user_id, "⚠️ Поле должно быть: имя / цена / описание.", keyboard=get_cancel_admin_keyboard())
return
db.update_service(sid, mapping[field.lower()], value)
config.SERVICES = db.get_services()
back_to_services(f"✅ Услуга №{sid} обновлена.")
return
if state == "ADMIN_SVC_IMG_PICK":
sid = pick_id(text)
if sid not in db.get_services():
self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.")
return
self._admin_ctx[user_id] = {"svc_id": sid}
db.update_user_state(user_id, "ADMIN_SVC_IMG_WAIT")
self.send_message(user_id,
"🖼 Пришлите картинку (именно фото). Лучше широкую (~13:8) — я сам аккуратно обрежу.",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_SVC_IMG_WAIT":
photo = self._first_photo(attachments)
if not photo:
self.send_message(user_id, "⚠️ Прикрепите именно фото (картинкой), не файлом.", keyboard=get_cancel_admin_keyboard())
return
sid = self._admin_ctx.get(user_id, {}).get("svc_id")
ok = self._save_service_image(sid, photo) if sid else False
if ok:
import os
self._upload_photo(config.OPERATOR_PEER_ID,
os.path.join(os.path.dirname(__file__), "help_img", f"service_{sid}.jpg"))
back_to_services("✅ Картинка обновлена." if ok else "⚠️ Не удалось сохранить картинку.")
return
if state == "ADMIN_SVC_DEL_PICK":
sid = pick_id(text)
if sid not in db.get_services():
self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.")
return
db.delete_service(sid)
config.SERVICES = db.get_services()
back_to_services(f"✅ Услуга №{sid} удалена.")
return
# ---------- Быстрые ответы ----------
if state == "ADMIN_FAQ_ADD":
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard())
return
kw, ans = [p.strip() for p in text.split("|", 1)]
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard())
return
db.add_custom_faq(kw, ans)
back_to_faq("✅ Быстрый ответ добавлен.")
return
if state == "ADMIN_FAQ_EDIT_PICK":
fid = pick_id(text)
ids = [str(i) for i, _, _ in db.get_custom_faq()]
if fid not in ids:
self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.")
return
self._admin_ctx[user_id] = {"faq_id": int(fid)}
db.update_user_state(user_id, "ADMIN_FAQ_EDIT_VAL")
self.send_message(user_id, "✏️ Отправьте новый вариант: ключевое слово | текст ответа",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_FAQ_EDIT_VAL":
fid = self._admin_ctx.get(user_id, {}).get("faq_id")
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard())
return
kw, ans = [p.strip() for p in text.split("|", 1)]
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard())
return
if fid:
db.delete_custom_faq(fid)
db.add_custom_faq(kw, ans)
back_to_faq("✅ Быстрый ответ обновлён.")
return
if state == "ADMIN_FAQ_DEL_PICK":
fid = pick_id(text)
if not fid.isdigit() or not db.delete_custom_faq(int(fid)):
self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.")
return
back_to_faq(f"✅ Ответ №{fid} удалён.")
return
# ---------- Вакансии ----------
def back_to_vacancies(msg=None):
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
if msg:
self.send_message(user_id, msg)
self._send_vacancy_admin(user_id)
if state == "ADMIN_VAC_ADD_PLAYER":
if not self._valid_free_text(text):
self.send_message(user_id, "⚠️ Введите название направления.", keyboard=get_cancel_admin_keyboard())
return
db.add_vacancy_option("player", text.strip())
back_to_vacancies("✅ Направление для игроков добавлено.")
return
if state == "ADMIN_VAC_ADD_WORKER":
if not self._valid_free_text(text):
self.send_message(user_id, "⚠️ Введите название должности.", keyboard=get_cancel_admin_keyboard())
return
db.add_vacancy_option("worker", text.strip())
back_to_vacancies("✅ Должность для сотрудников добавлена.")
return
if state == "ADMIN_VAC_DEL_PICK":
vid = pick_id(text)
if not vid.isdigit() or not db.delete_vacancy_option(int(vid)):
self.send_message(user_id, "⚠️ Выберите вакансию кнопкой ниже.")
return
back_to_vacancies(f"✅ Вакансия №{vid} удалена.")
return
# ---------- Администраторы (супер-админ) ----------
if state == "ADMIN_ADMIN_ADD":
if not self._is_super_admin(user_id):
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
self._handle_admin_add_admin(user_id, text) # передаём сырой текст (ссылка/имя/ID)
db.update_user_state(user_id, "FREE_CHAT")
return
if state == "ADMIN_ADMIN_DEL_PICK":
if not self._is_super_admin(user_id):
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
self._handle_admin_remove_admin(user_id, "админ удал " + pick_id(text))
db.update_user_state(user_id, "FREE_CHAT")
return
# Неизвестное ADMIN_-состояние — сброс
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
def _send_admin_menu(self, user_id: int):
"""Отправляет администратору панель управления услугами с текущим списком."""
services = db.get_services()
services_list_text = ""
for key, svc in services.items():
services_list_text += f"🔹 [{key}] {svc['name']}\n"
services_list_text += f" 💰 Стоимость: {svc['price']}\n"
services_list_text += f" 📝 Описание: {svc['description']}\n\n"
admin_text = (
"⚙️ Управление услугами (направлениями)\n\n"
"📋 Текущие направления:\n\n"
f"{services_list_text}"
"Управляйте кнопками ниже:\n"
"• ➕ Добавить • ✏️ Изменить • 🖼 Сменить картинку • 🗑 Удалить"
)
self.send_message(user_id, admin_text, keyboard=get_svc_admin_keyboard())
def _handle_admin_edit(self, user_id: int, text: str):
"""Обрабатывает редактирование услуги администратором."""
parts = text.split(" ", 3)
if len(parts) < 4:
self.send_message(
user_id,
"⚠️ Некорректный формат команды редактирования.\n"
"Правильный формат: ред [номер] [поле] [новое значение]\n"
"Пример: ред 1 цена 1 800 ₽/час"
)
return
_, service_id, field_name, new_value = parts
field_name = field_name.lower().strip()
new_value = new_value.strip()
field_mapping = {
"имя": "name",
"название": "name",
"цена": "price",
"стоимость": "price",
"описание": "description"
}
if field_name not in field_mapping:
self.send_message(
user_id,
"⚠️ Неизвестное поле для редактирования. Допустимые поля: имя, цена, описание."
)
return
db_field = field_mapping[field_name]
services = db.get_services()
if service_id not in services:
self.send_message(
user_id,
f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг."
)
return
try:
db.update_service(service_id, db_field, new_value)
config.SERVICES = db.get_services()
self.send_message(
user_id,
f"✅ Услуга [{service_id}] успешно обновлена!\n"
f"Поле {field_name} изменено на: {new_value}."
)
self._send_admin_menu(user_id)
except Exception as e:
logger.error(f"Ошибка при редактировании услуги в БД: {e}")
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
def _handle_admin_add(self, user_id: int, text: str):
"""Обрабатывает добавление новой услуги администратором."""
content = text[4:].strip()
parts = [p.strip() for p in content.split("|")]
if len(parts) < 3:
self.send_message(
user_id,
"⚠️ Некорректный формат команды добавления.\n"
"Правильный формат: доб [Имя] | [Цена] | [Описание]\n"
"Пример: доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim."
)
return
name, price, description = parts
if not name or not price or not description:
self.send_message(user_id, "⚠️ Все три поля (имя, цена, описание) должны быть заполнены!")
return
try:
new_id = db.add_service(name, price, description)
config.SERVICES = db.get_services()
self.send_message(
user_id,
f"✅ Новая услуга успешно добавлена с номером [{new_id}]!"
)
self._send_admin_menu(user_id)
except Exception as e:
logger.error(f"Ошибка при добавлении услуги в БД: {e}")
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
def _handle_admin_delete(self, user_id: int, text: str):
"""Обрабатывает мягкое удаление услуги администратором."""
parts = text.split(" ")
if len(parts) < 2:
self.send_message(
user_id,
"⚠️ Укажите номер услуги для удаления.\n"
"Пример: удал 4"
)
return
service_id = parts[1].strip()
services = db.get_services()
if service_id not in services:
self.send_message(
user_id,
f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг."
)
return
try:
db.delete_service(service_id)
config.SERVICES = db.get_services()
self.send_message(
user_id,
f"✅ Услуга [{service_id}] успешно удалена из активного списка!"
)
self._send_admin_menu(user_id)
except Exception as e:
logger.error(f"Ошибка при деактивации услуги в БД: {e}")
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
def _handle_admin_stats(self, user_id: int):
"""Формирует и отправляет админу сводную статистику по заявкам и диалогам."""
ls = db.get_lead_stats()
ds = db.get_dialogue_stats()
total = ls["total"]
marketing_pct = round(ls["marketing_yes"] / total * 100) if total else 0
service_lines = ""
if ls["by_service"]:
for svc_name, cnt in ls["by_service"]:
service_lines += f"{svc_name}: {cnt}\n"
else:
service_lines = " • пока нет данных\n"
msg = (
"📊 Статистика ИКП\n\n"
"🗂 Заявки (всего): {total}\n"
" • сегодня: {today}\n"
" • за 7 дней: {last7}\n"
" • из бота: {from_bot}\n"
"с сайта: {from_web}\n\n"
"📨 Согласны на рассылку: {mk_yes} из {total} ({mk_pct}%)\n\n"
"🎮 По направлениям:\n{services}\n"
"💬 Диалоги:\n"
" • уникальных пользователей: {uniq}\n"
" • сообщений от клиентов: {umsg}"
).format(
total=total, today=ls["today"], last7=ls["last7"],
from_bot=ls["from_bot"], from_web=ls["from_web"],
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
)
# Блок откликов (карьера)
aps = db.get_application_stats()
msg += f"\n\n🧩 Отклики (карьера): {aps['total']}\n"
msg += f" • игроки: {aps['players']}\n"
if aps["by_discipline"]:
for disc, cnt in aps["by_discipline"]:
msg += f"{disc}: {cnt}\n"
msg += f" • сотрудники: {aps['workers']}\n"
if aps["by_position"]:
for pos, cnt in aps["by_position"]:
msg += f"{pos}: {cnt}\n"
self.send_message(user_id, msg)
def _send_faq_admin(self, user_id: int):
"""Раздел «Быстрые ответы»: список + инструкции."""
rows = db.get_custom_faq()
listing = ""
for _id, kw, ans in rows:
short = (ans[:50] + "") if len(ans) > 50 else ans
listing += f"🔹 [{_id}] «{kw}» → {short}\n"
if not listing:
listing = "Пока нет ни одного быстрого ответа.\n"
self.send_message(
user_id,
"💬 Быстрые ответы (по ключевым словам)\n\n"
"Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n"
f"📋 Текущие:\n{listing}\n"
"Управляйте кнопками ниже: ➕ добавить • ✏️ изменить • 🗑 удалить",
keyboard=get_faq_admin_keyboard()
)
def _handle_faq_add(self, user_id: int, text: str):
content = text[len("фак доб "):].strip()
if "|" not in content:
self.send_message(user_id, "⚠️ Формат: фак доб ключевое слово | текст ответа")
return
kw, ans = content.split("|", 1)
kw, ans = kw.strip(), ans.strip()
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.")
return
new_id = db.add_custom_faq(kw, ans)
self.send_message(user_id, f"✅ Быстрый ответ добавлен (№{new_id}): «{kw.lower()}».")
self._send_faq_admin(user_id)
def _handle_faq_delete(self, user_id: int, text: str):
arg = text[len("фак удал "):].strip()
if not arg.isdigit():
self.send_message(user_id, "⚠️ Укажите номер: фак удал 3")
return
if db.delete_custom_faq(int(arg)):
self.send_message(user_id, f"✅ Быстрый ответ №{arg} удалён.")
else:
self.send_message(user_id, f"⚠️ Ответ №{arg} не найден.")
self._send_faq_admin(user_id)
def _send_candidates(self, user_id: int):
"""Раздел «Кандидаты»: последние отклики со статусами + инструкция смены статуса."""
rows = db.get_recent_applications(limit=10)
if not rows:
self.send_message(user_id, "👥 Откликов пока нет.")
return
listing = ""
for r in rows:
who = r["discipline"] if r["track"] == "player" else r["position"]
tag = "🎮 игрок" if r["track"] == "player" else "💼 сотрудник"
listing += f"🔹 [{r['id']}] {tag}{who or ''}\n контакт: {r['contact'] or ''} | статус: {r['status'] or 'новый'}\n"
self.send_message(
user_id,
"👥 Последние отклики:\n\n"
f"{listing}\n"
"Сменить статус: статус [номер] [новый статус]\n"
"Пример: статус 3 на собесе\n"
"Рекомендуемые статусы: новый, на собесе, отказ, принят"
)
def _handle_candidate_status(self, user_id: int, text: str):
parts = text.split(" ", 2)
if len(parts) < 3 or not parts[1].isdigit():
self.send_message(user_id, "⚠️ Формат: статус [номер] [новый статус]\nПример: статус 3 принят")
return
app_id, new_status = int(parts[1]), parts[2].strip()
if db.set_application_status(app_id, new_status):
self.send_message(user_id, f"✅ Статус отклика №{app_id} → «{new_status}».")
else:
self.send_message(user_id, f"⚠️ Отклик №{app_id} не найден.")
def _handle_admin_broadcast(self, user_id: int, text: str):
"""Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка."""
# Отрезаем ключевое слово 'рассылка'
content = text[len("рассылка"):].strip()
content_lower = content.lower()
# Отмена рассылки
if content_lower in ["отмена", "отменить", "отмена ❌"]:
self._pending_broadcasts.pop(user_id, None)
self.send_message(user_id, "❌ Рассылка отменена.")
self._send_admin_panel(user_id)
return
# Шаг 2: подтверждение и запуск
if content_lower in ["подтвердить", "да", "отправить", "!"]:
pending = self._pending_broadcasts.get(user_id)
if not pending:
self.send_message(
user_id,
"❌ Нет рассылки, ожидающей подтверждения.\n"
"Сначала отправьте: рассылка <текст сообщения>."
)
return
recipients = db.get_marketing_subscribers()
if not recipients:
self.send_message(user_id, "⚠️ Нет подписчиков с согласием на рассылку — отправлять некому.")
self._pending_broadcasts.pop(user_id, None)
return
self.send_message(
user_id,
f"🚀 Запускаю рассылку для {len(recipients)} получателей. "
"Отправка идёт с паузами, чтобы не нарушать лимиты VK. Сообщу по завершении."
)
self._pending_broadcasts.pop(user_id, None)
# Запускаем в отдельном потоке, чтобы не блокировать обработку сообщений
threading.Thread(
target=self._run_broadcast, args=(user_id, pending, recipients), daemon=True
).start()
return
# Шаг 1: приём текста и предпросмотр
if not content:
self.send_message(
user_id,
"📣 Рассылка по подписчикам\n\n"
"Отправьте: рассылка <текст сообщения>\n"
"После этого я покажу число получателей и попрошу подтверждение.\n\n"
"Рассылка уходит только тем, кто дал согласие (consent_2)."
)
return
recipients = db.get_marketing_subscribers()
self._pending_broadcasts[user_id] = content
preview = (
"📋 Предпросмотр рассылки\n\n"
f"Получателей (с согласием): {len(recipients)}\n\n"
"Текст сообщения:\n"
"— — —\n"
f"{content}\n"
"— — —\n\n"
"Отправить или отменить?"
)
kb = VkKeyboard(inline=True)
kb.add_button("Отправить ✅", color=VkKeyboardColor.PRIMARY, payload={"cmd": "bc_send"})
kb.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE, payload={"cmd": "bc_cancel"})
self.send_message(user_id, preview, keyboard=kb.get_keyboard())
def _run_broadcast(self, admin_id: int, text: str, recipients: list):
"""Фоновая отправка рассылки с троттлингом и подсказкой об отписке."""
body = (
f"{text}\n\n"
"— — —\n"
"🔕 Чтобы отписаться от рассылки, отправьте: Отписаться от рассылки"
)
sent = 0
for uid in recipients:
try:
# Повторно проверяем согласие на случай отписки во время рассылки
data = db.get_user_data(uid)
if data.get("consent_2") != "Согласен":
continue
self.send_message(uid, body)
sent += 1
except Exception as e:
logger.error(f"Ошибка рассылки пользователю {uid}: {e}")
time.sleep(0.4) # троттлинг против лимитов VK
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
# Эмулятор консольного ввода для тестирования на Windows локально
test_user_id = 99999
# Эмулируем приветствие при старте
self.handle_message(test_user_id, "начать")
while True:
try:
user_input = input("Вы: ")
if user_input.lower() == "выход":
break
self.handle_message(test_user_id, user_input)
except (KeyboardInterrupt, EOFError):
break
return
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
# Старт-пинг администратору — лёгкий сигнal «бот жив»
try:
from datetime import datetime
ts = datetime.now().strftime("%d.%m.%Y %H:%M")
for admin_id in getattr(config, "ADMIN_IDS", []):
self.send_message(admin_id, f"✅ Бот ИКП запущен и на связи ({ts}).")
except Exception as e:
logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
while True:
try:
for event in self.longpoll.listen():
if event.type == VkBotEventType.MESSAGE_NEW:
message_obj = event.obj.message
user_id = message_obj["from_id"]
text = message_obj["text"]
attachments = message_obj.get("attachments", [])
try:
self.handle_message(user_id, text, attachments)
except Exception as e:
# Ошибка обработки одного сообщения не должна останавливать бота
logger.error(f"Ошибка обработки сообщения от {user_id}: {e}")
except KeyboardInterrupt:
logger.info("Остановка бота по запросу (Ctrl+C).")
break
except Exception as e:
# Сетевой обрыв/протухшая сессия longpoll — ждём и переподключаемся
logger.warning(f"Обрыв LongPoll ({e}). Переподключение через 3 с…")
time.sleep(3)
try:
self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID)
logger.info("LongPoll переподключён.")
except Exception as e2:
logger.error(f"Не удалось переподключить LongPoll: {e2}")
time.sleep(5)
if __name__ == "__main__":
import sys
if not _acquire_single_instance_lock():
logger.error("Уже запущен другой экземпляр бота — выходим.")
sys.exit(0)
bot = VKBotApplication()
bot.run()