Files
bot_vk_ikp_prodagi/bot.py
T
Kenik 79cc1ba38d Доработка VK-бота ИКП: контент, новые функции, защита и визуал
Контент и брендинг:
- Реальные данные ИКP (infocyber.pro): услуги CS2/Dota 2/WoT, цены, FAQ, контакты, площадки
- Приветствие и тексты приведены к бренду ИКП

Новые функции:
- Раздельные согласия (152-ФЗ): маркетинговая рассылка — отдельный необязательный шаг
- Шаг «удобное время звонка» в форме заявки
- Раздел «Карьера в ИКП»: набор игроков и сотрудников (таблица applications)
- Админ-команды: статистика (стата) и рассылка по подписчикам с троттлингом и отпиской
- Журнал заявок leads, «бот помнит клиента», международные телефоны, валидация имени
- Email-уведомления о заявках и откликах (mailer.py); HR-адрес через HR_MAIL_TO

Защита:
- Анти-флуд по пользователю и кулдаун уведомлений оператору (анти-DDoS)
- Веб-форма: серверная валидация, honeypot, рейт-лимит, CORS по ALLOWED_ORIGIN, SQLite-first

Визуал:
- Убрана Markdown-разметка (VK её не рендерит)
- Логотип в приветствии, карусели услуг и «Карьеры» (фото 13:8), предзагрузка медиа
- Кнопки-ссылки на соцсети
- Веб-форма: анимации, конфетти на успехе

Промокоды добавлены, но пока ВЫКЛЮЧЕНЫ флагом PROMO_ENABLED (по умолчанию false).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:54:31 +03:00

1986 lines
108 KiB
Python
Raw 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
# Регулярные выражения для валидации
# Российский номер для нормализации 8XXX / 7XXX -> +7XXX
RU_PHONE_REGEX = re.compile(r"^\+?[78]\d{10}$")
# Международный формат (E.164): + и 10–15 цифр. Принимаем и иностранные номера.
INTL_PHONE_REGEX = re.compile(r"^\+?\d{10,15}$")
# Имя: буквы (кириллица/латиница), пробел, дефис, апостроф; длина 2–50
NAME_REGEX = re.compile(r"^[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё \-']{1,49}$")
def normalize_phone(text: str) -> Optional[str]:
"""Очищает и нормализует телефон. Возвращает нормализованный номер или None, если формат неверен."""
phone_cleaned = re.sub(r"[\s\-\(\)]", "", text)
# Российские номера приводим к виду +7XXXXXXXXXX
if phone_cleaned.startswith("8") and len(phone_cleaned) == 11:
phone_cleaned = "+7" + phone_cleaned[1:]
elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11:
phone_cleaned = "+" + phone_cleaned
# Принимаем как российский, так и любой международный номер в формате E.164
if RU_PHONE_REGEX.match(phone_cleaned) or INTL_PHONE_REGEX.match(phone_cleaned):
return phone_cleaned
return None
def is_valid_name(text: str) -> bool:
"""Имя должно состоять только из букв, пробела, дефиса или апострофа (без цифр и спецсимволов)."""
return bool(NAME_REGEX.match(text.strip()))
# ==========================================
# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте)
# ==========================================
def get_main_keyboard() -> str:
"""Главное меню бота."""
keyboard = VkKeyboard(one_time=False)
# Кнопка начать опрос (зеленая)
keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
# Карьера в ИКП (набор игроков и сотрудников)
keyboard.add_button("Карьера в ИКП 💼", color=VkKeyboardColor.POSITIVE)
keyboard.add_line()
# Кнопки FAQ и оператора
keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Позвать оператора 🧑‍💼", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_vacancy_choice_keyboard() -> str:
"""Выбор направления в разделе 'Карьера в ИКП'."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Игроком в состав 🎮", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("Сотрудником 💼", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard()
def get_vacancy_discipline_keyboard() -> str:
"""Выбор дисциплины для набора в состав."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Counter-Strike 2 🔫", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Dota 2 🎮", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("World of Tanks 🛡️", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_vacancy_position_keyboard() -> str:
"""Выбор должности для найма сотрудника."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("SMM / контент", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Монтажёр / дизайнер", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Менеджер", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Разработчик", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Другое ✍️", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_vacancy_cancel_keyboard() -> str:
"""Клавиатура отмены отклика на свободном текстовом шаге."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_vacancy_back_keyboard() -> str:
"""Клавиатура с одной кнопкой «Назад в меню» — под карусель выбора в «Карьере»."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard()
def get_faq_keyboard() -> str:
"""Клавиатура раздела Частых Вопросов (FAQ)."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Цены 💰", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Услуги ⚙️", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Контакты 📞", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("О компании 🏢", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Наши соцсети 🔗", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY)
return keyboard.get_keyboard()
def get_socials_keyboard() -> str:
"""Клавиатура с кнопками-ссылками на площадки ИКП (открываются в один тап)."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_openlink_button("VK Live 📺", "https://live.vkvideo.ru/ikp")
keyboard.add_line()
keyboard.add_openlink_button("Telegram ✈️", "https://t.me/ikp_team")
keyboard.add_openlink_button("Discord 🎧", "https://discord.gg/z92mAw7KEe")
keyboard.add_line()
keyboard.add_openlink_button("ВКонтакте 🌐", "https://vk.com/ikpro")
keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY)
return keyboard.get_keyboard()
def get_cancel_keyboard() -> str:
"""Клавиатура отмены заполнения формы."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_operator_keyboard() -> str:
"""Клавиатура при активном диалоге с оператором."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Завершить диалог 🚪", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_binary_keyboard(show_skip: bool = False) -> str:
"""Клавиатура с кнопками Согласен / Не согласен."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Согласен(а) и подтверждаю ✅", color=VkKeyboardColor.PRIMARY)
keyboard.add_button("Не согласен(а) ❌", color=VkKeyboardColor.NEGATIVE)
if show_skip:
keyboard.add_line()
keyboard.add_button("Пропустить ➡️", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_returning_keyboard() -> str:
"""Клавиатура для возвращающегося клиента, чьи данные уже известны."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Да, всё верно ✅", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("Сменить имя 👤", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Сменить телефон 📞", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Заполнить заново 🔄", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_calltime_keyboard() -> str:
"""Клавиатура выбора удобного времени для звонка."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Как можно скорее ⏰", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("Сегодня вечером 🌆", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Завтра ☀️", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("В выходные 🎮", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_marketing_keyboard() -> str:
"""Клавиатура для необязательного согласия на маркетинговую рассылку."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Согласен на рассылку ✅", color=VkKeyboardColor.PRIMARY)
keyboard.add_button("Без рассылки ➡️", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_services_keyboard() -> str:
"""Клавиатура со списком доступных услуг."""
keyboard = VkKeyboard(one_time=False)
first = True
for key, svc in config.SERVICES.items():
if not first:
keyboard.add_line()
keyboard.add_button(svc["name"], color=VkKeyboardColor.SECONDARY)
first = False
keyboard.add_line()
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_confirm_service_keyboard() -> str:
"""Клавиатура для подтверждения выбранной услуги."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Да, подтверждаю ✅", color=VkKeyboardColor.PRIMARY)
keyboard.add_button("Нет, выбрать другую 🔄", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line()
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_review_keyboard() -> str:
"""Клавиатура для финальной проверки данных пользователем."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Да, всё верно! ✅", color=VkKeyboardColor.PRIMARY)
keyboard.add_button("Изменить данные 🔄", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_edit_menu_keyboard() -> str:
"""Клавиатура меню изменения полей."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Изменить Имя 👤", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Изменить Телефон 📞", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Изменить Время 🕒", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.PRIMARY)
return keyboard.get_keyboard()
# ==========================================
# КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ
# ==========================================
class VKBotApplication:
def __init__(self):
self.vk_session = None
self.vk = None
self.longpoll = None
# Тексты рассылок, ожидающих подтверждения админом: {admin_id: text}
self._pending_broadcasts: Dict[int, str] = {}
# Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}}
self._photo_cache: Dict[str, Dict[str, str]] = {}
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
self._last_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора
# Инициализируем динамические услуги из базы данных
config.SERVICES = db.get_services()
if vk_api and config.VK_TOKEN != "your_vk_community_token_here":
try:
self.vk_session = vk_api.VkApi(token=config.VK_TOKEN)
self.vk = self.vk_session.get_api()
self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID)
logger.info("Успешное подключение к VK API!")
# Предзагрузка картинок в фоне, чтобы карусель не тормозила при первом показе
threading.Thread(target=self._prewarm_media, daemon=True).start()
except Exception as e:
logger.error(f"Ошибка авторизации в VK API: {e}")
def send_message(self, peer_id: int, message: str, keyboard: Optional[str] = None,
attachment: Optional[str] = None, template: Optional[str] = None):
"""Отправка сообщения пользователю с сохранением в лог."""
db.log_message(peer_id, "bot", message)
if self.vk:
try:
params = {
"peer_id": peer_id,
"message": message,
"random_id": get_random_id()
}
if keyboard:
params["keyboard"] = keyboard
if attachment:
params["attachment"] = attachment
if template:
params["template"] = template
self.vk.messages.send(**params)
except Exception as e:
logger.error(f"Не удалось отправить сообщение пользователю {peer_id}: {e}")
else:
attachment_str = f", Attachment: {attachment}" if attachment else ""
tmpl_str = ", Carousel: Да" if template else ""
logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str}{tmpl_str})")
def _is_flooding(self, user_id: int) -> bool:
"""True, если пользователь шлёт сообщения слишком часто (защита от флуда/DDoS)."""
now = time.time()
times = [t for t in self._msg_times[user_id] if now - t < FLOOD_WINDOW_SEC]
times.append(now)
self._msg_times[user_id] = times
return len(times) > FLOOD_MAX_MSGS
def _notify_allowed(self, user_id: int) -> bool:
"""Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе."""
now = time.time()
if now - self._last_notify.get(user_id, 0) < NOTIFY_COOLDOWN_SEC:
return False
self._last_notify[user_id] = now
return True
def handle_message(self, user_id: int, text: str):
"""Маршрутизатор входящих сообщений на основе состояний пользователя."""
text_clean = text.strip()
text_lower = text_clean.lower()
# Анти-флуд: шквал сообщений от одного пользователя не обрабатываем (админы — исключение)
if not (hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS):
if self._is_flooding(user_id):
now = time.time()
# Предупреждаем максимум раз за окно, чтобы само предупреждение не стало спамом
if now - self._flood_warned.get(user_id, 0) > FLOOD_WINDOW_SEC:
self._flood_warned[user_id] = now
logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются")
self.send_message(
user_id,
"⏳ Слишком много сообщений подряд. Пожалуйста, подождите немного."
)
return
# Логируем входящее сообщение от пользователя
db.log_message(user_id, "user", text_clean)
# Получаем текущее состояние и заполненные данные
user_data = db.get_user_data(user_id)
state = user_data["state"]
# Глобальная обработка админ-команд (доступна только пользователям из ADMIN_IDS)
if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS:
if text_lower in ["админка", "/admin", "услуги"]:
self._send_admin_menu(user_id)
return
elif text_lower.startswith("ред "):
self._handle_admin_edit(user_id, text_clean)
return
elif text_lower.startswith("доб "):
self._handle_admin_add(user_id, text_clean)
return
elif text_lower.startswith("удал "):
self._handle_admin_delete(user_id, text_clean)
return
elif text_lower in ["стата", "статистика", "/stats"]:
self._handle_admin_stats(user_id)
return
elif text_lower.startswith("рассылка"):
self._handle_admin_broadcast(user_id, text_clean)
return
# Глобальная отписка от рассылки (доступна в любом состоянии)
if text_lower in ["отписаться от рассылки 🔕", "отписаться от рассылки", "отписаться"]:
db.update_user_field(user_id, "consent_2", "Не согласен")
self.send_message(
user_id,
"🔕 Вы отписаны от рассылки. Рекламные сообщения больше присылать не будем. "
"Это не влияет на ваши заявки и поддержку."
)
return
logger.info(f"User {user_id} in state {state} sent: {text_clean}")
# Глобальная обработка отмены формы в любых состояниях заполнения опроса
if state.startswith("FORM_") and text_lower in ["отменить заполнение ❌", "отмена", "отменить"]:
db.reset_form(user_id)
self.send_message(
user_id,
"Заполнение заявки отменено. Мы вернулись в главное меню. Чем я могу помочь?",
keyboard=get_main_keyboard()
)
return
# Глобальная отмена отклика на вакансию
if state.startswith("VAC_") and text_lower in ["отменить ❌", "отмена", "отменить"]:
db.clear_vac_data(user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"Отклик отменён. Вы вернулись в главное меню.",
keyboard=get_main_keyboard()
)
return
# Маршрутизация по состояниям (FSM)
if state == "FREE_CHAT":
self._handle_free_chat(user_id, text_clean, text_lower)
elif state == "FORM_RETURNING":
self._handle_form_returning(user_id, text_clean, text_lower, user_data)
elif state == "FORM_RET_NAME":
self._handle_form_ret_name(user_id, text_clean)
elif state == "FORM_RET_PHONE":
self._handle_form_ret_phone(user_id, text_clean)
elif state == "FORM_NAME":
self._handle_form_name(user_id, text_clean)
elif state == "FORM_PHONE":
self._handle_form_phone(user_id, text_clean)
elif state == "FORM_QUESTION":
self._handle_form_question(user_id, text_clean, user_data)
elif state == "FORM_CALLTIME":
self._handle_form_calltime(user_id, text_clean)
elif state == "FORM_EDIT_CALLTIME":
self._handle_form_edit_calltime(user_id, text_clean)
elif state == "FORM_CONFIRM_QUESTION":
self._handle_form_confirm_question(user_id, text_clean, user_data)
elif state == "FORM_REVIEW":
self._handle_form_review(user_id, text_clean, user_data)
elif state == "FORM_EDIT_MENU":
self._handle_form_edit_menu(user_id, text_clean, user_data)
elif state == "FORM_EDIT_NAME":
self._handle_form_edit_name(user_id, text_clean)
elif state == "FORM_EDIT_PHONE":
self._handle_form_edit_phone(user_id, text_clean)
elif state == "FORM_EDIT_QUESTION":
self._handle_form_edit_question(user_id, text_clean)
elif state == "FORM_CONSENT_1":
self._handle_form_consent_1(user_id, text_clean, text_lower)
elif state == "FORM_CONSENT_2":
self._handle_form_consent_2(user_id, text_clean, text_lower)
elif state == "FORM_CONSENT_3":
self._handle_form_consent_3(user_id, text_clean, text_lower, user_data)
elif state == "VAC_CHOOSE":
self._handle_vac_choose(user_id, text_clean, text_lower)
elif state == "VAC_P_DISCIPLINE":
self._handle_vac_p_discipline(user_id, text_clean, text_lower)
elif state == "VAC_P_RANK":
self._handle_vac_p_rank(user_id, text_clean)
elif state == "VAC_P_ROLE":
self._handle_vac_p_role(user_id, text_clean)
elif state == "VAC_P_EXP":
self._handle_vac_p_exp(user_id, text_clean)
elif state == "VAC_P_CONTACT":
self._handle_vac_p_contact(user_id, text_clean)
elif state == "VAC_W_POSITION":
self._handle_vac_w_position(user_id, text_clean, text_lower)
elif state == "VAC_W_POSITION_OTHER":
self._handle_vac_w_position_other(user_id, text_clean)
elif state == "VAC_W_EXP":
self._handle_vac_w_exp(user_id, text_clean)
elif state == "VAC_W_CONTACT":
self._handle_vac_w_contact(user_id, text_clean)
elif state == "OPERATOR_CHAT":
self._handle_operator_chat(user_id, text_clean, text_lower)
else:
# Предотвращение неопределенного состояния
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
keyboard=get_main_keyboard()
)
# ------------------------------------------
# ОБРАБОТЧИКИ СОСТОЯНИЙ (FSM)
# ------------------------------------------
def _handle_free_chat(self, user_id: int, text: str, text_lower: str):
"""Логика Свободного Чата (Главное меню, FAQ, переключение на форму)."""
# 1. Запуск формы опроса
if text_lower in ["заполнить заявку 📝", "заявка", "начать опрос", "опрос"]:
user_data = db.get_user_data(user_id)
# Если клиент уже оставлял данные — предлагаем подтвердить их, а не вводить заново
if user_data.get("name") and user_data.get("phone"):
db.update_user_state(user_id, "FORM_RETURNING")
self._send_returning_prompt(user_id, user_data)
else:
db.update_user_state(user_id, "FORM_NAME")
self.send_message(
user_id,
"📝 Начинаем заполнение заявки!\n\n"
"Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.",
keyboard=get_cancel_keyboard()
)
return
# 1.5 Площадки ИКП — кнопки-ссылки
if text_lower in ["наши соцсети 🔗", "наши соцсети", "соцсети", "площадки", "ссылки"]:
self.send_message(
user_id,
"🔗 Мы на площадках ИКП — заходите, подписывайтесь и смотрите трансляции 👇",
keyboard=get_socials_keyboard()
)
return
# 2. Переход к списку частых вопросов (FAQ)
if text_lower in ["частые вопросы ❓", "faq", "вопросы", "помощь"]:
self.send_message(
user_id,
"❓ Выберите тему, которая вас интересует, на клавиатуре ниже:",
keyboard=get_faq_keyboard()
)
return
# 3. Возврат в главное меню
if text_lower in ["назад в меню ↩️", "меню", "назад"]:
self.send_message(
user_id,
"🏠 Вы вернулись в главное меню. Выберите интересующее действие:",
keyboard=get_main_keyboard()
)
return
# 3.5 Раздел "Карьера в ИКП" (набор игроков и сотрудников)
if text_lower in ["карьера в икп 💼", "карьера", "вакансии", "вакансия", "работа", "набор", "хочу в команду"]:
db.clear_vac_data(user_id)
db.update_user_state(user_id, "VAC_CHOOSE")
self._send_career_choice(user_id)
return
# 4. Вызов оператора
if text_lower in ["позвать оператора 🧑‍💼", "позвать оператора", "оператор", "менеджер", "человек", "позови оператора", "связь с оператором"]:
self._handoff_to_operator(user_id)
return
# 5. Обработка конкретных ответов FAQ
for key, answer in config.FAQ_ANSWERS.items():
if key in text_lower:
self.send_message(user_id, answer, keyboard=get_faq_keyboard())
return
# 6. Приветственные сообщения
if text_lower in ["начать", "привет", "здравствуйте", "ку", "hi", "hello"]:
self.send_message(
user_id,
"👋 Здравствуйте! Это чат-бот киберспортивной организации ИКП — Игра. Киберспорт. Профессионалы.\n\n"
"Я помогу быстро оформить заявку на тренировки, рассказать об услугах или связать вас с командой. "
"Также у нас открыт набор игроков и сотрудников — загляните в раздел «Карьера в ИКП 💼».\n\n"
"Выберите действие на кнопках ниже 👇",
keyboard=get_main_keyboard(),
attachment=self.get_logo_attachment(user_id)
)
return
# 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру)
self.send_message(
user_id,
"🤔 Я пока только учусь и не смог полностью распознать ваш запрос.\n\n"
"Пожалуйста, воспользуйтесь кнопками меню ниже или напишите 'оператор', чтобы я пригласил специалиста.",
keyboard=get_main_keyboard()
)
def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]):
"""Приветствие для клиента, чьи имя и телефон уже сохранены."""
name = user_data.get("name")
phone = user_data.get("phone")
self.send_message(
user_id,
f"Здравствуйте! Мы вас помним, {name}. 👋\n\n"
f"Ваш номер телефона по-прежнему {phone}?\n\n"
"Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги. "
"Либо измените данные или заполните заявку заново.",
keyboard=get_returning_keyboard()
)
def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
"""Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново."""
if text_lower in ["да, всё верно ✅", "да, все верно", "да", "верно", "всё верно"]:
# Данные подтверждены — переходим сразу к выбору услуги
db.update_user_state(user_id, "FORM_QUESTION")
self._send_services_prompt(user_id, prefix="Отлично! Перейдём к выбору услуги.\n\n")
elif text_lower in ["сменить имя 👤", "сменить имя", "имя"]:
db.update_user_state(user_id, "FORM_RET_NAME")
self.send_message(user_id, "👤 Введите ваше новое имя:", keyboard=get_cancel_keyboard())
elif text_lower in ["сменить телефон 📞", "сменить телефон", "телефон"]:
db.update_user_state(user_id, "FORM_RET_PHONE")
self.send_message(
user_id,
"📞 Введите ваш новый номер телефона:",
keyboard=get_cancel_keyboard()
)
elif text_lower in ["заполнить заново 🔄", "заполнить заново", "заново"]:
db.reset_form_full(user_id)
db.update_user_state(user_id, "FORM_NAME")
self.send_message(
user_id,
"📝 Хорошо, заполним заявку с нуля!\n\n"
"Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.",
keyboard=get_cancel_keyboard()
)
else:
self._send_returning_prompt(user_id, user_data)
def _handle_form_ret_name(self, user_id: int, text: str):
"""Смена имени у возвращающегося клиента → обратно к подтверждению."""
if not is_valid_name(text):
self.send_message(
user_id,
"⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. "
"Пожалуйста, введите корректное имя.",
keyboard=get_cancel_keyboard()
)
return
db.update_user_field(user_id, "name", text.strip())
db.update_user_state(user_id, "FORM_RETURNING")
self.send_message(user_id, "✅ Имя обновлено!")
self._send_returning_prompt(user_id, db.get_user_data(user_id))
def _handle_form_ret_phone(self, user_id: int, text: str):
"""Смена телефона у возвращающегося клиента → обратно к подтверждению."""
phone_cleaned = normalize_phone(text)
if not phone_cleaned:
self.send_message(
user_id,
"⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) "
"либо международный в формате +<код страны><номер>.",
keyboard=get_cancel_keyboard()
)
return
db.update_user_field(user_id, "phone", phone_cleaned)
db.update_user_state(user_id, "FORM_RETURNING")
self.send_message(user_id, "✅ Телефон обновлён!")
self._send_returning_prompt(user_id, db.get_user_data(user_id))
def _send_services_prompt(self, user_id: int, prefix: str = ""):
"""Отправляет услуги каруселью (с фолбэком на текст)."""
self._send_services(user_id, prefix)
def _handle_form_name(self, user_id: int, text: str):
"""Сохранение имени и переход к номеру телефона."""
# Валидация имени: только буквы/пробел/дефис, длина 2–50
if not is_valid_name(text):
self.send_message(
user_id,
"⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. "
"Пожалуйста, введите корректное имя.",
keyboard=get_cancel_keyboard()
)
return
# Сохраняем имя в БД
db.update_user_field(user_id, "name", text)
db.update_user_state(user_id, "FORM_PHONE")
self.send_message(
user_id,
f"👍 Приятно познакомиться, {text}!\n\n"
"Шаг 2 из 3: Введите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.",
keyboard=get_cancel_keyboard()
)
def _handle_form_phone(self, user_id: int, text: str):
"""Сохранение телефона и переход к выбору услуги."""
phone_cleaned = normalize_phone(text)
# Валидация (российский или международный формат)
if not phone_cleaned:
self.send_message(
user_id,
"⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) "
"либо международный в формате +<код страны><номер>.",
keyboard=get_cancel_keyboard()
)
return
# Сохраняем телефон в БД
db.update_user_field(user_id, "phone", phone_cleaned)
db.update_user_state(user_id, "FORM_QUESTION")
# Показываем услуги каруселью (с фолбэком на текст)
self._send_services(user_id, "✅ Телефон принят!\n\nШаг 3 из 3: какое направление вас интересует?\n\n")
def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]):
"""Обработка выбора услуги с прямым переходом к проверке данных."""
selected_service = None
text_lower = text.lower().strip()
# 1. Прямое сопоставление синонимов дисциплин для надежности
synonyms = {
"1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"],
"2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"],
"3": ["танки", "танк", "wot", "world of tanks", "ворлд оф танкс", "вот", "tanks"]
}
for key_opt, words in synonyms.items():
if any(w in text_lower for w in words):
selected_service = config.SERVICES.get(key_opt)
break
# 2. Поиск по названию или ключу (если синонимы не сработали)
if not selected_service:
for key, svc in config.SERVICES.items():
name_clean = svc["name"].lower().strip()
# Убираем эмодзи и спецсимволы для гибкого сравнения
name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip()
text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip()
if (text_lower == key or
text_lower == f"{key}." or
text_lower == f"{key}️⃣" or
name_clean in text_lower or
text_lower in name_clean or
(name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))):
selected_service = svc
break
if selected_service:
# Сохраняем временный выбор в поле question
db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})")
# Переходим к выбору удобного времени для звонка
db.update_user_state(user_id, "FORM_CALLTIME")
self.send_message(
user_id,
f"Отличный выбор — {selected_service['name']}!\n\n"
"🕒 Когда вам удобно, чтобы менеджер с вами связался?",
keyboard=get_calltime_keyboard()
)
else:
# Формируем список услуг с ценами для повторного показа
services_text = (
"⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n"
"Пожалуйста, выберите одну из доступных услуг на клавиатуре или отправьте её номер/название:\n\n"
)
for key, svc in config.SERVICES.items():
services_text += f"{key}️⃣ {svc['name']}\n"
services_text += f" 💰 Стоимость: {svc['price']}\n"
services_text += f" 📝 {svc['description']}\n\n"
self.send_message(
user_id,
services_text,
keyboard=get_services_keyboard()
)
def _handle_form_confirm_question(self, user_id: int, text: str, user_data: Dict[str, Any]):
"""Резервный обработчик для совместимости с устаревшим шагом подтверждения."""
db.update_user_state(user_id, "FORM_REVIEW")
self._send_review_message(user_id, user_data)
def _handle_form_calltime(self, user_id: int, text: str):
"""Сохранение удобного времени звонка и переход к финальной проверке."""
call_time = text.strip()
if len(call_time) < 2 or len(call_time) > 100:
self.send_message(
user_id,
"🕒 Пожалуйста, выберите время на клавиатуре или коротко напишите, когда вам удобно.",
keyboard=get_calltime_keyboard()
)
return
db.update_user_field(user_id, "call_time", call_time)
db.update_user_state(user_id, "FORM_REVIEW")
self._send_review_message(user_id, db.get_user_data(user_id))
def _handle_form_edit_calltime(self, user_id: int, text: str):
"""Сохранение нового времени звонка и возврат к проверке."""
call_time = text.strip()
if len(call_time) < 2 or len(call_time) > 100:
self.send_message(
user_id,
"🕒 Пожалуйста, выберите время на клавиатуре или коротко напишите, когда вам удобно.",
keyboard=get_calltime_keyboard()
)
return
db.update_user_field(user_id, "call_time", call_time)
db.update_user_state(user_id, "FORM_REVIEW")
self.send_message(user_id, "✅ Время звонка обновлено!")
self._send_review_message(user_id, db.get_user_data(user_id))
def _send_review_message(self, user_id: int, user_data: Dict[str, Any]):
"""Отправляет сводную карточку проверки данных перед финальным шагом."""
name = user_data.get("name", "Не указано")
phone = user_data.get("phone", "Не указано")
question = user_data.get("question", "Не указано")
call_time = user_data.get("call_time") or "Не указано"
review_text = (
"🔍 Пожалуйста, проверьте правильность заполненных данных:\n\n"
f"👤 Имя: {name}\n"
f"📞 Телефон: {phone}\n"
f"🎮 Выбранная услуга: {question}\n"
f"🕒 Удобное время звонка: {call_time}\n\n"
"Если всё верно, нажмите «Да, всё верно! ✅» для продолжения. Если хотите исправить — нажмите «Изменить данные 🔄»."
)
self.send_message(user_id, review_text, keyboard=get_review_keyboard())
def _handle_form_review(self, user_id: int, text: str, user_data: Dict[str, Any]):
"""Обработка решений на экране финальной проверки данных."""
text_lower = text.lower().strip()
if text_lower in ["да, всё верно! ✅", "да, всё верно", "все верно", "да", "верно"]:
# Переходим к шагу оферты и согласий
db.update_user_state(user_id, "FORM_CONSENT_1")
# Загружаем документ оферты для ознакомления
doc_attachment = self.get_offer_document_attachment(user_id)
# Отправляем значительно сокращенное и дружелюбное сообщение-согласие
self.send_message(
user_id,
"📄 Согласие с условиями и офертой\n\n"
"Для завершения отправки заявки подтвердите, пожалуйста, ваше согласие с условиями Публичной оферты, "
"Политикой обработки персональных данных и записью разговора.\n\n"
"«Я подтверждаю, что ознакомлен(а) с договором оферты (прикреплен ниже), принимаю его условия, "
"соглашаюсь на обработку персональных данных и запись консультации.»",
keyboard=get_binary_keyboard(show_skip=False),
attachment=doc_attachment
)
elif text_lower in ["изменить данные 🔄", "изменить данные", "изменить", "редактировать", "поменять"]:
# Переходим в меню изменения полей
db.update_user_state(user_id, "FORM_EDIT_MENU")
self.send_message(
user_id,
"🔄 Меню изменения данных\n\n"
"Пожалуйста, выберите на клавиатуре ниже, какое поле вы хотите изменить:",
keyboard=get_edit_menu_keyboard()
)
else:
self.send_message(
user_id,
"❓ Пожалуйста, подтвердите ваши данные кнопкой «Да, всё верно! ✅» или нажмите «Изменить данные 🔄» для редактирования.",
keyboard=get_review_keyboard()
)
def _handle_form_edit_menu(self, user_id: int, text: str, user_data: Dict[str, Any]):
"""Обработка меню выбора поля для редактирования."""
text_lower = text.lower().strip()
if text_lower in ["изменить имя 👤", "изменить имя", "имя", "1"]:
db.update_user_state(user_id, "FORM_EDIT_NAME")
self.send_message(
user_id,
"👤 Введите ваше новое имя:",
keyboard=get_cancel_keyboard()
)
elif text_lower in ["изменить телефон 📞", "изменить телефон", "телефон", "2"]:
db.update_user_state(user_id, "FORM_EDIT_PHONE")
self.send_message(
user_id,
"📞 Введите ваш новый номер телефона в формате +79991234567:",
keyboard=get_cancel_keyboard()
)
elif text_lower in ["изменить услугу 🎮", "изменить услугу", "услугу", "услуга", "3"]:
db.update_user_state(user_id, "FORM_EDIT_QUESTION")
services_text = (
"🎮 Выберите новую услугу из списка:\n\n"
)
for key, svc in config.SERVICES.items():
services_text += f"{key}️⃣ {svc['name']}\n"
services_text += f" 💰 Стоимость: {svc['price']}\n\n"
self.send_message(
user_id,
services_text,
keyboard=get_services_keyboard()
)
elif text_lower in ["изменить время 🕒", "изменить время", "время", "4"]:
db.update_user_state(user_id, "FORM_EDIT_CALLTIME")
self.send_message(
user_id,
"🕒 Когда вам удобно, чтобы менеджер с вами связался?",
keyboard=get_calltime_keyboard()
)
elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "5"]:
db.update_user_state(user_id, "FORM_REVIEW")
self._send_review_message(user_id, user_data)
else:
self.send_message(
user_id,
"❓ Выберите нужный пункт меню на клавиатуре или нажмите «Назад к проверке ↩️» для возврата к карточке данных.",
keyboard=get_edit_menu_keyboard()
)
def _handle_form_edit_name(self, user_id: int, text: str):
"""Сохранение нового имени и возврат к проверке."""
if not is_valid_name(text):
self.send_message(
user_id,
"⚠️ Имя должно содержать только буквы (без цифр и символов) и быть длиной от 2 до 50 знаков. "
"Пожалуйста, введите корректное имя.",
keyboard=get_cancel_keyboard()
)
return
db.update_user_field(user_id, "name", text)
db.update_user_state(user_id, "FORM_REVIEW")
updated_data = db.get_user_data(user_id)
self.send_message(user_id, "✅ Имя успешно обновлено!")
self._send_review_message(user_id, updated_data)
def _handle_form_edit_phone(self, user_id: int, text: str):
"""Сохранение нового телефона и возврат к проверке."""
phone_cleaned = normalize_phone(text)
if not phone_cleaned:
self.send_message(
user_id,
"⚠️ Некорректный формат телефона. Укажите российский номер (+79991234567 или 89991234567) "
"либо международный в формате +<код страны><номер>.",
keyboard=get_cancel_keyboard()
)
return
db.update_user_field(user_id, "phone", phone_cleaned)
db.update_user_state(user_id, "FORM_REVIEW")
updated_data = db.get_user_data(user_id)
self.send_message(user_id, "✅ Номер телефона успешно обновлен!")
self._send_review_message(user_id, updated_data)
def _handle_form_edit_question(self, user_id: int, text: str):
"""Сохранение новой услуги и возврат к проверке."""
selected_service = None
text_lower = text.lower().strip()
# 1. Прямое сопоставление синонимов дисциплин для надежности
synonyms = {
"1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"],
"2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"],
"3": ["танки", "танк", "wot", "world of tanks", "ворлд оф танкс", "вот", "tanks"]
}
for key_opt, words in synonyms.items():
if any(w in text_lower for w in words):
selected_service = config.SERVICES.get(key_opt)
break
# 2. Поиск по названию или ключу (если синонимы не сработали)
if not selected_service:
for key, svc in config.SERVICES.items():
name_clean = svc["name"].lower().strip()
name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip()
text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip()
if (text_lower == key or
text_lower == f"{key}." or
text_lower == f"{key}️⃣" or
name_clean in text_lower or
text_lower in name_clean or
(name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))):
selected_service = svc
break
if selected_service:
db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})")
db.update_user_state(user_id, "FORM_REVIEW")
updated_data = db.get_user_data(user_id)
self.send_message(user_id, "✅ Услуга успешно обновлена!")
self._send_review_message(user_id, updated_data)
else:
services_text = (
"⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n"
"Пожалуйста, выберите одну из доступных услуг на клавиатуре:\n\n"
)
for key, svc in config.SERVICES.items():
services_text += f"{key}️⃣ {svc['name']}\n"
services_text += f" 💰 Стоимость: {svc['price']}\n\n"
self.send_message(
user_id,
services_text,
keyboard=get_services_keyboard()
)
def get_offer_document_attachment(self, peer_id: int) -> Optional[str]:
"""Загружает документ оферты на сервер VK и возвращает attachment-строку."""
if hasattr(self, "_cached_doc_attachment") and self._cached_doc_attachment:
return self._cached_doc_attachment
import os
doc_path = os.path.join(os.path.dirname(__file__), "Dogovor_oferty_kibersport_versia_izmenennaya.docx")
if not os.path.exists(doc_path):
logger.error(f"Файл оферты не найден по пути: {doc_path}")
return None
if not self.vk:
# Режим эмуляции (консольный)
logger.info(f"[EMULATOR] Файл {os.path.basename(doc_path)} прикреплен к сообщению.")
return None
try:
import requests
logger.info("Загрузка документа оферты в VK API...")
upload_server = self.vk.docs.getMessagesUploadServer(peer_id=peer_id, type="doc")
upload_url = upload_server["upload_url"]
title = "Dogovor_oferty_kibersport.docx"
with open(doc_path, "rb") as f:
response = requests.post(upload_url, files={"file": (title, f)})
response_json = response.json()
if "file" not in response_json:
logger.error(f"Ошибка загрузки файла в VK: {response_json}")
return None
saved_doc = self.vk.docs.save(file=response_json["file"], title=title)
doc_info = saved_doc[0] if isinstance(saved_doc, list) else saved_doc
# docs.save может вернуть {'type': 'doc', 'doc': {...}} — берём вложенный объект
if isinstance(doc_info, dict) and "doc" in doc_info:
doc_info = doc_info["doc"]
attachment = f"doc{doc_info['owner_id']}_{doc_info['id']}"
self._cached_doc_attachment = attachment
logger.info(f"Документ загружен успешно: {attachment}")
return attachment
except Exception as e:
logger.error(f"Ошибка при загрузке документа в VK API: {e}")
return None
def _upload_photo(self, peer_id: int, image_path: str) -> Optional[Dict[str, str]]:
"""Загружает фото в VK для прикрепления к сообщению. Возвращает {'attachment','raw'} или None."""
if image_path in self._photo_cache:
return self._photo_cache[image_path]
import os
if not self.vk or not os.path.exists(image_path):
return None
try:
import requests
upload_server = self.vk.photos.getMessagesUploadServer(peer_id=peer_id)
upload_url = upload_server["upload_url"]
with open(image_path, "rb") as f:
response = requests.post(upload_url, files={"photo": (os.path.basename(image_path), f)})
r = response.json()
saved = self.vk.photos.saveMessagesPhoto(photo=r["photo"], server=r["server"], hash=r["hash"])
info = saved[0] if isinstance(saved, list) else saved
raw = f"{info['owner_id']}_{info['id']}"
result = {"attachment": f"photo{raw}", "raw": raw}
self._photo_cache[image_path] = result
logger.info(f"Фото загружено в VK: {result['attachment']}")
return result
except Exception as e:
logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}")
return None
def get_logo_attachment(self, peer_id: int) -> Optional[str]:
"""Attachment-строка с логотипом ИКП для прикрепления к приветствию."""
import os
logo_path = os.path.join(os.path.dirname(__file__), "help_img", "Logo.jpg")
info = self._upload_photo(peer_id, logo_path)
return info["attachment"] if info else None
def _prewarm_media(self):
"""Заранее (при старте) загружает логотип и картинки дисциплин в VK,
чтобы первое приветствие и первая карусель открывались мгновенно."""
import os
try:
peer = config.OPERATOR_PEER_ID
base = os.path.join(os.path.dirname(__file__), "help_img")
for fn in ("Logo.jpg", "cs2.jpg", "dota2.jpg", "wot.jpg"):
path = os.path.join(base, fn)
if os.path.exists(path):
self._upload_photo(peer, path)
logger.info("Медиа предзагружены в VK — карусель и логотип будут открываться быстро.")
except Exception as e:
logger.error(f"Не удалось предзагрузить медиа: {e}")
@staticmethod
def _card_description(svc: Dict[str, str]) -> str:
"""Компактное описание карточки карусели: цена + краткая суть (лимит VK — 80 символов)."""
price = str(svc.get("price", "")).strip()
# Берём первую фразу описания (до точки) как короткую суть
blurb = str(svc.get("description", "")).strip().split(".")[0].strip()
text = f"{price} · {blurb}" if blurb else price
if len(text) > 80:
text = text[:79].rstrip(" ,;·") + ""
return text
def _build_services_carousel(self, peer_id: int) -> Optional[str]:
"""Собирает VK-карусель из доступных услуг. Возвращает JSON-строку шаблона или None."""
import os
base = os.path.join(os.path.dirname(__file__), "help_img")
# Картинки дисциплин по id услуги. ВАЖНО: VK требует пропорцию 13:8 (напр. 650×400).
# Логотип квадратный и не подходит — поэтому фото берём ТОЛЬКО если есть спец-файл дисциплины.
img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"}
elements = []
for key, svc in config.SERVICES.items():
disc_img = os.path.join(base, img_by_id.get(key, ""))
info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None
element = {
"title": svc["name"][:80],
"description": self._card_description(svc),
"buttons": [{
"action": {"type": "text", "label": f"Выбрать {svc['name']}"[:40]},
"color": "primary"
}]
}
if info:
element["photo_id"] = info["raw"]
elements.append(element)
if not elements:
return None
# VK требует фото либо у всех карточек, либо ни у одной
if any("photo_id" not in e for e in elements):
for e in elements:
e.pop("photo_id", None)
return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False)
def _send_services(self, user_id: int, intro: str):
"""Отправляет услуги каруселью (если возможно), иначе текстом-фолбэком."""
# Полный текстовый список — для фолбэка (и лога)
full_text = intro + "📋 Доступные направления и расценки:\n\n"
for key, svc in config.SERVICES.items():
full_text += f"{key}️⃣ {svc['name']}\n 💰 {svc['price']}\n 📝 {svc['description']}\n\n"
full_text += "Выберите направление кнопкой ниже или отправьте его номер/название."
if self.vk:
template = self._build_services_carousel(user_id)
if template:
try:
# 1) Короткий текст + кнопка отмены. Это сразу убирает «зависшие»
# кнопки прошлого шага и даёт нормальную кнопку «Отменить».
self.send_message(
user_id,
intro + "Листайте карточки ниже 👇 и нажмите «Выбрать» под нужным направлением.",
keyboard=get_cancel_keyboard()
)
# 2) Сама карусель (без клавиатуры — кнопка отмены сохраняется).
db.log_message(user_id, "bot", "[Карусель направлений]")
self.vk.messages.send(
peer_id=user_id, message="🎮 Выберите направление:",
template=template, random_id=get_random_id()
)
return
except Exception as e:
logger.error(f"Карусель не отправилась, фолбэк на текст: {e}")
# Фолбэк / режим эмуляции
self.send_message(user_id, full_text, keyboard=get_services_keyboard())
def _build_career_carousel(self, peer_id: int) -> Optional[str]:
"""Карусель выбора в разделе «Карьера»: карточки «Игроком» и «Сотрудником»."""
import os
base = os.path.join(os.path.dirname(__file__), "help_img")
cards = [
("career_player.jpg", "Игроком в состав 🎮",
"Покажи скилл и попади в ростер ИКП", "Игроком в состав 🎮"),
("career_worker.jpg", "Сотрудником 💼",
"Тренер, SMM, монтаж, менеджмент, разработка", "Сотрудником 💼"),
]
elements = []
for img_name, title, desc, label in cards:
img_path = os.path.join(base, img_name)
info = self._upload_photo(peer_id, img_path) if os.path.exists(img_path) else None
element = {
"title": title[:80],
"description": desc[:80],
"buttons": [{"action": {"type": "text", "label": label[:40]}, "color": "primary"}]
}
if info:
element["photo_id"] = info["raw"]
elements.append(element)
if any("photo_id" not in e for e in elements):
for e in elements:
e.pop("photo_id", None)
return json.dumps({"type": "carousel", "elements": elements}, ensure_ascii=False)
def _send_career_choice(self, user_id: int):
"""Отправляет выбор в «Карьере» каруселью (с фолбэком на обычную клавиатуру)."""
intro = ("💼 Карьера в ИКП\n\n"
"Мы растём и ищем как игроков в состав, так и специалистов в команду.")
if self.vk:
template = self._build_career_carousel(user_id)
if template:
try:
self.send_message(
user_id,
intro + "\nВыберите на карточке ниже 👇, кем хотите присоединиться.",
keyboard=get_vacancy_back_keyboard()
)
db.log_message(user_id, "bot", "[Карусель карьеры]")
self.vk.messages.send(
peer_id=user_id, message="Кем хотите присоединиться?",
template=template, random_id=get_random_id()
)
return
except Exception as e:
logger.error(f"Карусель карьеры не отправилась, фолбэк: {e}")
# Фолбэк / эмуляция
self.send_message(
user_id,
intro + "\nКем вы хотите к нам присоединиться?",
keyboard=get_vacancy_choice_keyboard()
)
def _handle_form_consent_1(self, user_id: int, text: str, text_lower: str):
"""Шаг 1 — ОБЯЗАТЕЛЬНЫЕ согласия: оферта, обработка ПД и запись консультации."""
is_agree = text_lower in ["согласен(а) и подтверждаю ✅", "согласен и подтверждаю", "согласен(а) и подтверждаю", "согласен", "согласна", "да", "принимаю", "согласен(а) ✅"]
is_disagree = text_lower in ["не согласен(а) ❌", "не согласен", "не согласна", "нет", "не принимаю"]
if is_agree:
# Фиксируем обязательные согласия (оферта/ПД и запись консультации)
db.update_user_field(user_id, "consent_1", "Согласен")
db.update_user_field(user_id, "consent_3", "Согласен")
# Переходим к ОТДЕЛЬНОМУ необязательному согласию на рассылку (требование 152-ФЗ)
db.update_user_state(user_id, "FORM_CONSENT_2")
self.send_message(
user_id,
"✅ Спасибо! Обязательные согласия приняты.\n\n"
"📨 Последний шаг (по желанию): хотите получать новости, акции и спецпредложения ИКП?\n\n"
"Это необязательно и никак не влияет на обработку вашей заявки.",
keyboard=get_marketing_keyboard()
)
elif is_disagree:
self.send_message(
user_id,
"⚠️ Для оформления заявки необходимо принять условия оферты, согласие на обработку персональных данных и запись консультации.\n\n"
"Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.",
keyboard=get_binary_keyboard(show_skip=False)
)
else:
self.send_message(
user_id,
"❓ Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или выберите «Не согласен(а) ❌» / «Отменить заполнение ❌».",
keyboard=get_binary_keyboard(show_skip=False)
)
def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str):
"""Шаг 2 — НЕОБЯЗАТЕЛЬНОЕ согласие на маркетинговую рассылку. Любой выбор завершает заявку."""
is_agree = text_lower in ["согласен на рассылку ✅", "согласен на рассылку", "согласен", "согласна", "да", "хочу"]
is_decline = text_lower in ["без рассылки ➡️", "без рассылки", "не согласен", "не согласна", "нет", "не хочу", "пропустить ➡️", "пропустить"]
if is_agree:
db.update_user_field(user_id, "consent_2", "Согласен")
self._finalize_lead(user_id)
elif is_decline:
db.update_user_field(user_id, "consent_2", "Не согласен")
self._finalize_lead(user_id)
else:
self.send_message(
user_id,
"❓ Хотите получать рассылку ИКП? Нажмите «Согласен на рассылку ✅» или «Без рассылки ➡️».",
keyboard=get_marketing_keyboard()
)
def _handle_form_consent_3(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
"""Резервный обработчик для совместимости с устаревшими состояниями в БД."""
db.update_user_field(user_id, "consent_3", "Согласен")
self._finalize_lead(user_id)
@staticmethod
def _generate_promo() -> str:
"""Генерирует персональный промокод вида ИКП-AB12CD."""
code = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"ИКП-{code}"
def _finalize_lead(self, user_id: int):
"""Завершает оформление заявки: подтверждение клиенту + уведомление оператору и на почту."""
updated_data = db.get_user_data(user_id)
phone = updated_data["phone"]
call_time = updated_data.get("call_time") or "не указано"
marketing = (updated_data.get("consent_2") == "Согласен")
db.update_user_state(user_id, "FREE_CHAT")
# Персональный промокод на скидку (пока выключен флагом config.PROMO_ENABLED)
promo = self._generate_promo() if getattr(config, "PROMO_ENABLED", False) else None
# Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00)
from datetime import datetime
now = datetime.now()
if 9 <= now.hour < 21:
callback_time = "в течение 15 минут"
else:
callback_time = "сегодня утром, начиная с 9:00"
confirm_msg = (
"🎉 Заявка успешно оформлена!\n\n"
f"Наш менеджер свяжется с вами ({callback_time}) по номеру {phone}, "
f"удобное время: {call_time}.\n\n"
)
if promo:
confirm_msg += (
"🎁 Ваш персональный промокод на скидку 10% на первое занятие:\n"
f"{promo}\n"
"Назовите его менеджеру при записи. "
)
confirm_msg += "Спасибо за обращение!\n\nВы вернулись в главное меню бота."
self.send_message(user_id, confirm_msg, keyboard=get_main_keyboard())
# Журналируем заявку для статистики и истории
try:
db.add_lead(
user_id=user_id, source="bot",
name=updated_data.get("name"), phone=phone,
service=updated_data.get("question"), call_time=updated_data.get("call_time"),
promo=promo, marketing=marketing
)
except Exception as e:
logger.error(f"Не удалось записать лид в журнал: {e}")
# Антиспам: не заваливаем оператора повторными уведомлениями от одного пользователя
if self._notify_allowed(user_id):
self._notify_operator_about_lead(user_id, updated_data, promo=promo)
else:
logger.warning(f"Подавлено повторное уведомление о заявке от {user_id} (антиспам)")
def _handle_operator_chat(self, user_id: int, text: str, text_lower: str):
"""Поведение бота в режиме активного диалога с живым человеком."""
# Пользователь хочет выйти из диалога с оператором
if text_lower in ["завершить диалог 🚪", "завершить", "/exit", "выход"]:
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
keyboard=get_main_keyboard()
)
# Уведомляем оператора о закрытии диалога
self.send_message(
config.OPERATOR_PEER_ID,
f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором."
)
return
# В остальное время бот МОЛЧИТ в диалог с пользователем.
logger.info(f"[OPERATOR MODE] Message from {user_id} bypasses bot: {text}")
self._forward_to_operator(user_id, text)
# ------------------------------------------
# СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ
# ------------------------------------------
# ------------------------------------------
# РАЗДЕЛ "КАРЬЕРА В ИКП" (НАЙМ ИГРОКОВ И СОТРУДНИКОВ)
# ------------------------------------------
@staticmethod
def _valid_free_text(text: str) -> bool:
"""Базовая проверка свободного ответа в отклике (не пусто, разумной длины)."""
return 1 <= len(text.strip()) <= 300
def _handle_vac_choose(self, user_id: int, text: str, text_lower: str):
"""Выбор ветки: игрок в состав или сотрудник."""
if text_lower in ["игроком в состав 🎮", "игроком", "игрок", "в состав", "состав"]:
db.update_user_state(user_id, "VAC_P_DISCIPLINE")
self.send_message(
user_id,
"🎮 Набор в состав\n\nШаг 1 из 5: по какой дисциплине вы играете?",
keyboard=get_vacancy_discipline_keyboard()
)
elif text_lower in ["сотрудником 💼", "сотрудником", "сотрудник", "работником", "в команду"]:
db.update_user_state(user_id, "VAC_W_POSITION")
self.send_message(
user_id,
"💼 Набор в команду\n\nШаг 1 из 3: на какую должность вы претендуете?",
keyboard=get_vacancy_position_keyboard()
)
elif text_lower in ["назад в меню ↩️", "назад", "меню", "в меню"]:
db.clear_vac_data(user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "🏠 Вы вернулись в главное меню.", keyboard=get_main_keyboard())
else:
self.send_message(
user_id,
"Пожалуйста, выберите на клавиатуре: «Игроком в состав 🎮» или «Сотрудником 💼».",
keyboard=get_vacancy_choice_keyboard()
)
# ----- Ветка игрока -----
def _handle_vac_p_discipline(self, user_id: int, text: str, text_lower: str):
discipline = None
syn = {
"Counter-Strike 2": ["кс", "cs", "cs2", "csgo", "ксго", "контра", "counter"],
"Dota 2": ["дота", "dota", "дотка", "доту"],
"World of Tanks": ["танк", "wot", "tanks", "ворлд оф танкс", "вот"],
}
for name, words in syn.items():
if any(w in text_lower for w in words):
discipline = name
break
if not discipline:
self.send_message(
user_id,
"Пожалуйста, выберите дисциплину на клавиатуре ниже.",
keyboard=get_vacancy_discipline_keyboard()
)
return
db.set_vac_field(user_id, "discipline", discipline)
db.update_user_state(user_id, "VAC_P_RANK")
self.send_message(
user_id,
f"Отлично, {discipline}!\n\n"
"Шаг 2 из 5: укажите ваш текущий ранг / MMR / ELO "
"(например, «Faceit lvl 8», «5000 MMR» или «Легенда»).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_rank(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, коротко укажите ваш ранг / MMR / ELO.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "rank", text.strip())
db.update_user_state(user_id, "VAC_P_ROLE")
self.send_message(
user_id,
"Шаг 3 из 5: ваша роль / позиция в игре? (например, AWPer, мид, саппорт, командир)",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_role(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, укажите вашу роль / позицию.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "role", text.strip())
db.update_user_state(user_id, "VAC_P_EXP")
self.send_message(
user_id,
"Шаг 4 из 5: расскажите коротко об опыте — в каких командах играли, турниры, достижения.",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_exp(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "experience", text.strip())
db.update_user_state(user_id, "VAC_P_CONTACT")
self.send_message(
user_id,
"Шаг 5 из 5: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_p_contact(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "contact", text.strip())
self._finalize_application(user_id, "player")
# ----- Ветка сотрудника -----
def _handle_vac_w_position(self, user_id: int, text: str, text_lower: str):
known = {
"smm / контент": "SMM / контент",
"монтажёр / дизайнер": "Монтажёр / дизайнер",
"менеджер": "Менеджер",
"разработчик": "Разработчик",
}
if text_lower in ["другое ✍️", "другое", "иное"]:
db.update_user_state(user_id, "VAC_W_POSITION_OTHER")
self.send_message(
user_id,
"✍️ Напишите, на какую должность вы претендуете.",
keyboard=get_vacancy_cancel_keyboard()
)
return
# Известная кнопка или произвольный непустой текст принимаем как должность
position = known.get(text_lower)
if not position:
if not self._valid_free_text(text):
self.send_message(
user_id,
"Выберите должность на клавиатуре или нажмите «Другое ✍️».",
keyboard=get_vacancy_position_keyboard()
)
return
position = text.strip()
db.set_vac_field(user_id, "position", position)
db.update_user_state(user_id, "VAC_W_EXP")
self.send_message(
user_id,
f"Должность: {position}.\n\n"
"Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_w_position_other(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, напишите желаемую должность.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "position", text.strip())
db.update_user_state(user_id, "VAC_W_EXP")
self.send_message(
user_id,
"Шаг 2 из 3: расскажите коротко о вашем опыте и навыках (можно ссылку на портфолио).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_w_exp(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, опишите ваш опыт хотя бы кратко.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "experience", text.strip())
db.update_user_state(user_id, "VAC_W_CONTACT")
self.send_message(
user_id,
"Шаг 3 из 3: как с вами связаться? Укажите телефон или Telegram (например, +79991234567 или @nickname).",
keyboard=get_vacancy_cancel_keyboard()
)
def _handle_vac_w_contact(self, user_id: int, text: str):
if not self._valid_free_text(text):
self.send_message(user_id, "Пожалуйста, оставьте телефон или ник в Telegram для связи.", keyboard=get_vacancy_cancel_keyboard())
return
db.set_vac_field(user_id, "contact", text.strip())
self._finalize_application(user_id, "worker")
def _finalize_application(self, user_id: int, track: str):
"""Завершает отклик: подтверждение кандидату + уведомление HR/оператору и письмо."""
data = db.get_vac_data(user_id)
try:
db.add_application(user_id, track, data)
except Exception as e:
logger.error(f"Не удалось записать отклик в журнал: {e}")
db.clear_vac_data(user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
"✅ Спасибо за отклик!\n\n"
"Мы изучим вашу анкету и свяжемся, если кандидатура подойдёт. Удачи! 🎮\n\n"
"Вы вернулись в главное меню.",
keyboard=get_main_keyboard()
)
# Антиспам: ограничиваем частоту уведомлений об откликах от одного пользователя
if self._notify_allowed(user_id):
self._notify_hr_about_application(user_id, track, data)
else:
logger.warning(f"Подавлено повторное уведомление об отклике от {user_id} (антиспам)")
def _notify_hr_about_application(self, user_id: int, track: str, data: Dict[str, Any]):
"""Уведомление об отклике оператору в VK и на HR-почту."""
if track == "player":
title = "Набор в состав (игрок)"
details = (
f"Дисциплина: {data.get('discipline', '')}\n"
f"Ранг/MMR/ELO: {data.get('rank', '')}\n"
f"Роль/позиция: {data.get('role', '')}\n"
f"Опыт: {data.get('experience', '')}\n"
f"Контакт: {data.get('contact', '')}"
)
else:
title = "Вакансия (сотрудник)"
details = (
f"Должность: {data.get('position', '')}\n"
f"Опыт/навыки: {data.get('experience', '')}\n"
f"Контакт: {data.get('contact', '')}"
)
operator_msg = (
f"🧩 Новый отклик — {title}!\n\n"
f"{details}\n\n"
f"🔗 Профиль кандидата: vk.com/id{user_id}"
)
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
# Письмо на HR-адрес (пока совпадает с group-zakaz, см. config.HR_MAIL_TO)
try:
email_body = (
f"Новый отклик из чат-бота ВКонтакте — {title}.\n\n"
f"{details}\n\n"
f"Профиль кандидата: https://vk.com/id{user_id}\n"
)
mailer.send_email(f"Новый отклик — {title}", email_body, mail_to=getattr(config, "HR_MAIL_TO", None))
except Exception as mail_err:
logger.error(f"Не удалось отправить email об отклике: {mail_err}")
def _handoff_to_operator(self, user_id: int):
"""Переводит пользователя в режим оператора и уведомляет команду поддержки."""
db.update_user_state(user_id, "OPERATOR_CHAT")
self.send_message(
user_id,
"🧑‍💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n"
"Чтобы вернуться к боту, нажмите кнопку ниже.",
keyboard=get_operator_keyboard()
)
# Получаем историю последних 10 сообщений для контекста оператора
history = db.get_dialogue_history(user_id, limit=10)
formatted_history = ""
for sender, msg, ts in history:
role = "Вы" if sender == "user" else "Бот"
formatted_history += f"[{ts}] {role}: {msg}\n"
# Отправляем алерт оператору с историей диалога
operator_msg = (
f"🚨 Запрос оператора!\n"
f"Пользователь: vk.com/id{user_id}\n"
f"Тип запроса: Прямой вызов из меню\n\n"
f"📋 Последние сообщения для контекста:\n{formatted_history}"
)
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any], promo: Optional[str] = None):
"""Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий."""
name = user_data["name"]
phone = user_data["phone"]
question = user_data["question"]
call_time = user_data.get("call_time") or "не указано"
consent_1 = user_data.get("consent_1", "Согласен")
consent_2 = user_data.get("consent_2", "Согласен")
consent_3 = user_data.get("consent_3", "Согласен")
promo_line = f"🎁 Промокод клиента: {promo}\n" if promo else ""
operator_msg = (
f"⚡ Получена новая заявка!\n\n"
f"👤 Имя: {name}\n"
f"📞 Телефон: {phone}\n"
f"🎯 Выбранная услуга: {question}\n"
f"🕒 Удобное время звонка: {call_time}\n"
f"{promo_line}\n"
f"⚖️ Согласия пользователя:\n"
f"1. Оферта и обработка ПД (Обяз.): {consent_1}\n"
f"2. Маркетинговые рассылки (Необяз.): {consent_2}\n"
f"3. Запись консультации (Обяз.): {consent_3}\n\n"
f"🔗 Профиль клиента: vk.com/id{user_id}\n"
f"Переписка сохранена в базе данных."
)
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
# Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro)
try:
email_body = (
"Новая заявка из чат-бота ВКонтакте.\n\n"
f"Имя: {name}\n"
f"Телефон: {phone}\n"
f"Выбранная услуга: {question}\n"
f"Удобное время звонка: {call_time}\n"
+ (f"Промокод клиента: {promo}\n" if promo else "")
+ "\nСогласия:\n"
f" - Оферта и обработка ПД (обяз.): {consent_1}\n"
f" - Маркетинговые рассылки (необяз.): {consent_2}\n"
f" - Запись консультации (обяз.): {consent_3}\n\n"
f"Профиль клиента: https://vk.com/id{user_id}\n"
)
mailer.send_email(f"Новая заявка из бота — {name}", email_body)
except Exception as mail_err:
logger.error(f"Не удалось отправить email о заявке: {mail_err}")
def _forward_to_operator(self, user_id: int, text: str):
"""Пересылка сообщений от клиента в чат оператора."""
forward_msg = (
f"💬 Сообщение от vk.com/id{user_id}:\n{text}"
)
self.send_message(config.OPERATOR_PEER_ID, forward_msg)
# ------------------------------------------
# ЗАПУСК И ОБРАБОТКА LONGPOLL
# ------------------------------------------
def _send_admin_menu(self, user_id: int):
"""Отправляет администратору панель управления услугами с текущим списком."""
services = db.get_services()
services_list_text = ""
for key, svc in services.items():
services_list_text += f"🔹 [{key}] {svc['name']}\n"
services_list_text += f" 💰 Стоимость: {svc['price']}\n"
services_list_text += f" 📝 Описание: {svc['description']}\n\n"
admin_text = (
"⚙️ Панель управления услугами (Админ-меню)\n\n"
"Здесь вы можете редактировать существующие услуги или добавлять новые.\n\n"
"📋 Текущий список активных услуг:\n\n"
f"{services_list_text}"
"🔧 Доступные команды управления:\n\n"
"1. Редактирование услуги:\n"
" 👉 Отправьте: ред [номер] [поле] [новое значение]\n"
" Доступные поля: имя, цена, описание.\n"
" *Пример:* ред 1 цена 1 800 ₽/час\n\n"
"2. Добавление услуги:\n"
" 👉 Отправьте: доб [Имя] | [Цена] | [Описание]\n"
" *Пример:* доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.\n\n"
"3. Удаление услуги:\n"
" 👉 Отправьте: удал [номер]\n"
" *Пример:* удал 4\n\n"
"📊 Статистика: отправьте стата\n\n"
"📣 Рассылка по подписчикам:\n"
" 👉 рассылка [текст] → предпросмотр → рассылка подтвердить"
)
self.send_message(user_id, admin_text)
def _handle_admin_edit(self, user_id: int, text: str):
"""Обрабатывает редактирование услуги администратором."""
parts = text.split(" ", 3)
if len(parts) < 4:
self.send_message(
user_id,
"⚠️ Некорректный формат команды редактирования.\n"
"Правильный формат: ред [номер] [поле] [новое значение]\n"
"Пример: ред 1 цена 1 800 ₽/час"
)
return
_, service_id, field_name, new_value = parts
field_name = field_name.lower().strip()
new_value = new_value.strip()
field_mapping = {
"имя": "name",
"название": "name",
"цена": "price",
"стоимость": "price",
"описание": "description"
}
if field_name not in field_mapping:
self.send_message(
user_id,
"⚠️ Неизвестное поле для редактирования. Допустимые поля: имя, цена, описание."
)
return
db_field = field_mapping[field_name]
services = db.get_services()
if service_id not in services:
self.send_message(
user_id,
f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг."
)
return
try:
db.update_service(service_id, db_field, new_value)
config.SERVICES = db.get_services()
self.send_message(
user_id,
f"✅ Услуга [{service_id}] успешно обновлена!\n"
f"Поле {field_name} изменено на: {new_value}."
)
self._send_admin_menu(user_id)
except Exception as e:
logger.error(f"Ошибка при редактировании услуги в БД: {e}")
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
def _handle_admin_add(self, user_id: int, text: str):
"""Обрабатывает добавление новой услуги администратором."""
content = text[4:].strip()
parts = [p.strip() for p in content.split("|")]
if len(parts) < 3:
self.send_message(
user_id,
"⚠️ Некорректный формат команды добавления.\n"
"Правильный формат: доб [Имя] | [Цена] | [Описание]\n"
"Пример: доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim."
)
return
name, price, description = parts
if not name or not price or not description:
self.send_message(user_id, "⚠️ Все три поля (имя, цена, описание) должны быть заполнены!")
return
try:
new_id = db.add_service(name, price, description)
config.SERVICES = db.get_services()
self.send_message(
user_id,
f"✅ Новая услуга успешно добавлена с номером [{new_id}]!"
)
self._send_admin_menu(user_id)
except Exception as e:
logger.error(f"Ошибка при добавлении услуги в БД: {e}")
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
def _handle_admin_delete(self, user_id: int, text: str):
"""Обрабатывает мягкое удаление услуги администратором."""
parts = text.split(" ")
if len(parts) < 2:
self.send_message(
user_id,
"⚠️ Укажите номер услуги для удаления.\n"
"Пример: удал 4"
)
return
service_id = parts[1].strip()
services = db.get_services()
if service_id not in services:
self.send_message(
user_id,
f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг."
)
return
try:
db.delete_service(service_id)
config.SERVICES = db.get_services()
self.send_message(
user_id,
f"✅ Услуга [{service_id}] успешно удалена из активного списка!"
)
self._send_admin_menu(user_id)
except Exception as e:
logger.error(f"Ошибка при деактивации услуги в БД: {e}")
self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}")
def _handle_admin_stats(self, user_id: int):
"""Формирует и отправляет админу сводную статистику по заявкам и диалогам."""
ls = db.get_lead_stats()
ds = db.get_dialogue_stats()
total = ls["total"]
marketing_pct = round(ls["marketing_yes"] / total * 100) if total else 0
service_lines = ""
if ls["by_service"]:
for svc_name, cnt in ls["by_service"]:
service_lines += f"{svc_name}: {cnt}\n"
else:
service_lines = " • пока нет данных\n"
msg = (
"📊 Статистика ИКП\n\n"
"🗂 Заявки (всего): {total}\n"
" • сегодня: {today}\n"
" • за 7 дней: {last7}\n"
" • из бота: {from_bot}\n"
"с сайта: {from_web}\n\n"
"📨 Согласны на рассылку: {mk_yes} из {total} ({mk_pct}%)\n\n"
"🎮 По направлениям:\n{services}\n"
"💬 Диалоги:\n"
" • уникальных пользователей: {uniq}\n"
" • сообщений от клиентов: {umsg}"
).format(
total=total, today=ls["today"], last7=ls["last7"],
from_bot=ls["from_bot"], from_web=ls["from_web"],
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
)
self.send_message(user_id, msg)
def _handle_admin_broadcast(self, user_id: int, text: str):
"""Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка."""
# Отрезаем ключевое слово 'рассылка'
content = text[len("рассылка"):].strip()
content_lower = content.lower()
# Шаг 2: подтверждение и запуск
if content_lower in ["подтвердить", "да", "отправить", "!"]:
pending = self._pending_broadcasts.get(user_id)
if not pending:
self.send_message(
user_id,
"❌ Нет рассылки, ожидающей подтверждения.\n"
"Сначала отправьте: рассылка <текст сообщения>."
)
return
recipients = db.get_marketing_subscribers()
if not recipients:
self.send_message(user_id, "⚠️ Нет подписчиков с согласием на рассылку — отправлять некому.")
self._pending_broadcasts.pop(user_id, None)
return
self.send_message(
user_id,
f"🚀 Запускаю рассылку для {len(recipients)} получателей. "
"Отправка идёт с паузами, чтобы не нарушать лимиты VK. Сообщу по завершении."
)
self._pending_broadcasts.pop(user_id, None)
# Запускаем в отдельном потоке, чтобы не блокировать обработку сообщений
threading.Thread(
target=self._run_broadcast, args=(user_id, pending, recipients), daemon=True
).start()
return
# Шаг 1: приём текста и предпросмотр
if not content:
self.send_message(
user_id,
"📣 Рассылка по подписчикам\n\n"
"Отправьте: рассылка <текст сообщения>\n"
"После этого я покажу число получателей и попрошу подтверждение.\n\n"
"Рассылка уходит только тем, кто дал согласие (consent_2)."
)
return
recipients = db.get_marketing_subscribers()
self._pending_broadcasts[user_id] = content
preview = (
"📋 Предпросмотр рассылки\n\n"
f"Получателей (с согласием): {len(recipients)}\n\n"
"Текст сообщения:\n"
"— — —\n"
f"{content}\n"
"— — —\n\n"
"Чтобы отправить — напишите: рассылка подтвердить\n"
"Чтобы отменить — просто проигнорируйте."
)
self.send_message(user_id, preview)
def _run_broadcast(self, admin_id: int, text: str, recipients: list):
"""Фоновая отправка рассылки с троттлингом и подсказкой об отписке."""
body = (
f"{text}\n\n"
"— — —\n"
"🔕 Чтобы отписаться от рассылки, отправьте: Отписаться от рассылки"
)
sent = 0
for uid in recipients:
try:
# Повторно проверяем согласие на случай отписки во время рассылки
data = db.get_user_data(uid)
if data.get("consent_2") != "Согласен":
continue
self.send_message(uid, body)
sent += 1
except Exception as e:
logger.error(f"Ошибка рассылки пользователю {uid}: {e}")
time.sleep(0.4) # троттлинг против лимитов VK
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
# Эмулятор консольного ввода для тестирования на Windows локально
test_user_id = 99999
# Эмулируем приветствие при старте
self.handle_message(test_user_id, "начать")
while True:
try:
user_input = input("Вы: ")
if user_input.lower() == "выход":
break
self.handle_message(test_user_id, user_input)
except (KeyboardInterrupt, EOFError):
break
return
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
for event in self.longpoll.listen():
if event.type == VkBotEventType.MESSAGE_NEW:
message_obj = event.obj.message
user_id = message_obj["from_id"]
text = message_obj["text"]
self.handle_message(user_id, text)
if __name__ == "__main__":
bot = VKBotApplication()
bot.run()