Админка, защита и UX: быстрые ответы, статусы кандидатов, мульти-операторы, бан, прогресс-бар
Бот: - Прогресс-бар (▰▰▱ Шаг N из 3) в шагах заявки - Быстрые ответы по ключевым словам (таблица custom_faq) — отвечают до зова оператора; редактирование через админ-панель (раздел «Быстрые ответы»: добавить/список/удалить) - Отклики в статистике: счётчик игроков/сотрудников по дисциплинам и должностям - Статусы кандидатов (новый/на собесе/отказ/принят) — раздел «Кандидаты» в админке - Несколько операторов: уведомления уходят всем из OPERATOR_PEER_IDS - Часы работы (WORK_HOUR_START/END): вне часов хендофф сообщает «ответим утром» - Временный бан при повторном флуде (усиление анти-DDoS) - Старт-пинг администратору «бот запущен» (лёгкий healthcheck) Док и зависимости: - README.md (запуск, .env, автозапуск, админ-команды, структура) - requirements.txt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
# Чат-бот ИКП (ВКонтакте) + веб-форма заявок
|
||||||
|
|
||||||
|
Бот сообщества **ИКП — Игра. Киберспорт. Профессионалы** для приёма заявок на тренировки
|
||||||
|
(Counter-Strike 2, Dota 2, World of Tanks), набора игроков и сотрудников, FAQ и связи с оператором.
|
||||||
|
Плюс отдельная веб-форма заявки на бесплатный урок.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Заявка на тренировку** — пошаговая форма (имя → телефон → направление → удобное время), бот «помнит» клиента.
|
||||||
|
- **Карьера в ИКП** — набор игроков в состав и сотрудников (карусель с карточками).
|
||||||
|
- **FAQ** — цены, услуги, контакты, о компании, кнопки-ссылки на соцсети.
|
||||||
|
- **Оператор** — передача диалога живому человеку (учитываются часы работы, поддержка нескольких операторов).
|
||||||
|
- **Уведомления** — о заявках/откликах операторам в VK и на почту (SMTP).
|
||||||
|
- **Админ-панель** (в боте, по кнопке у админа): статистика, управление услугами, рассылка по подписчикам,
|
||||||
|
быстрые ответы по ключевым словам, кандидаты и их статусы.
|
||||||
|
- **Защита** — анти-флуд, временный бан, серверная валидация веб-формы, honeypot, rate-limit, CORS.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Зависимости: `vk_api`, `requests`, `openpyxl`, `Pillow`.
|
||||||
|
|
||||||
|
## Настройка `.env`
|
||||||
|
|
||||||
|
Скопируйте `.env.example` в `.env` и заполните. Файл `.env` в git **не попадает** (см. `.gitignore`).
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `VK_TOKEN` | Токен сообщества ВК (права на сообщения) |
|
||||||
|
| `VK_GROUP_ID` | ID сообщества (цифры) |
|
||||||
|
| `OPERATOR_PEER_ID` | ID оператора по умолчанию для уведомлений |
|
||||||
|
| `OPERATOR_PEER_IDS` | Несколько операторов через запятую (опц.) |
|
||||||
|
| `ADMIN_IDS` | ID администраторов через запятую |
|
||||||
|
| `WORK_HOUR_START` / `WORK_HOUR_END` | Часы работы (по умолч. 9 и 21) |
|
||||||
|
| `ALLOWED_ORIGIN` | Домен сайта для CORS веб-формы |
|
||||||
|
| `PROMO_ENABLED` | Промокоды (по умолч. `false`) |
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | SMTP для писем (для Яндекс/Gmail — пароль приложения) |
|
||||||
|
| `MAIL_FROM` / `MAIL_TO` | Отправитель и получатель уведомлений о заявках |
|
||||||
|
| `HR_MAIL_TO` | Куда слать отклики на вакансии (по умолч. = `MAIL_TO`) |
|
||||||
|
|
||||||
|
> Без SMTP письма просто не отправляются (тихо пропускаются) — на работу бота это не влияет.
|
||||||
|
|
||||||
|
## Запуск бота
|
||||||
|
|
||||||
|
**Просто:** двойной клик по `start_bot.bat` — он держит бота живым и перезапускает при сбое.
|
||||||
|
|
||||||
|
**Вручную:**
|
||||||
|
```bash
|
||||||
|
python bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Автозапуск при входе в Windows:** положите ярлык на `start_bot.bat` в папку `shell:startup`
|
||||||
|
(`Win+R` → `shell:startup`).
|
||||||
|
|
||||||
|
Без валидного `VK_TOKEN` бот стартует в режиме эмуляции (консоль) — удобно для локальных тестов.
|
||||||
|
|
||||||
|
## Веб-форма
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python form/server.py
|
||||||
|
```
|
||||||
|
Откройте http://localhost:8000/index.html . Заявки пишутся в SQLite (источник правды) и дублируются в Excel.
|
||||||
|
|
||||||
|
## Админ-панель (в боте)
|
||||||
|
|
||||||
|
У администратора в главном меню есть кнопка **«Админка ⚙️»**. Разделы: Статистика, Кандидаты,
|
||||||
|
Управление услугами, Быстрые ответы, Рассылка. Доступ — строго по `ADMIN_IDS`.
|
||||||
|
|
||||||
|
Текстовые команды (альтернатива кнопкам):
|
||||||
|
- `стата` — статистика
|
||||||
|
- `ред [N] [поле] [значение]`, `доб [Имя] | [Цена] | [Описание]`, `удал [N]` — услуги
|
||||||
|
- `фак доб [слово] | [ответ]`, `фак удал [N]` — быстрые ответы
|
||||||
|
- `статус [N] [новый статус]` — статус кандидата
|
||||||
|
- `рассылка [текст]` → `рассылка подтвердить` — рассылка подписчикам
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
bot.py — логика бота (FSM, клавиатуры, админка, рассылка, защита)
|
||||||
|
config.py — конфигурация из .env
|
||||||
|
database.py — SQLite (заявки, отклики, услуги, быстрые ответы, статистика)
|
||||||
|
mailer.py — отправка email
|
||||||
|
form/ — веб-форма (server.py, index.html, script.js, style.css)
|
||||||
|
help_img/ — картинки (логотип, дисциплины, карьера)
|
||||||
|
start_bot.bat — запуск с авто-перезапуском
|
||||||
|
```
|
||||||
|
|
||||||
|
## Данные
|
||||||
|
|
||||||
|
`bot_database.db`, `.env`, `*.xlsx`, `*.log` и бэкапы `help_img/_orig/` в репозиторий не попадают.
|
||||||
@@ -36,6 +36,10 @@ FLOOD_MAX_MSGS = 12
|
|||||||
FLOOD_WINDOW_SEC = 10
|
FLOOD_WINDOW_SEC = 10
|
||||||
# Кулдаун между уведомлениями оператора о заявке/отклике от одного пользователя (сек)
|
# Кулдаун между уведомлениями оператора о заявке/отклике от одного пользователя (сек)
|
||||||
NOTIFY_COOLDOWN_SEC = 120
|
NOTIFY_COOLDOWN_SEC = 120
|
||||||
|
# Временный бан при повторном флуде: после BAN_STRIKES срабатываний за BAN_WINDOW_SEC — бан на BAN_SEC
|
||||||
|
BAN_STRIKES = 3
|
||||||
|
BAN_WINDOW_SEC = 60
|
||||||
|
BAN_SEC = 600
|
||||||
|
|
||||||
# Регулярные выражения для валидации
|
# Регулярные выражения для валидации
|
||||||
# Российский номер для нормализации 8XXX / 7XXX -> +7XXX
|
# Российский номер для нормализации 8XXX / 7XXX -> +7XXX
|
||||||
@@ -94,9 +98,11 @@ def get_admin_keyboard() -> str:
|
|||||||
"""Клавиатура админ-панели с разделами."""
|
"""Клавиатура админ-панели с разделами."""
|
||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY)
|
||||||
|
keyboard.add_button("Кандидаты 👥", color=VkKeyboardColor.SECONDARY)
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
keyboard.add_button("Управление услугами ⚙️", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Управление услугами ⚙️", color=VkKeyboardColor.SECONDARY)
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY)
|
||||||
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.PRIMARY)
|
keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.PRIMARY)
|
||||||
@@ -310,6 +316,8 @@ class VKBotApplication:
|
|||||||
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
|
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
|
||||||
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
|
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
|
||||||
self._flood_warned: Dict[int, float] = {} # 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_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора
|
||||||
|
|
||||||
# Инициализируем динамические услуги из базы данных
|
# Инициализируем динамические услуги из базы данных
|
||||||
@@ -364,6 +372,27 @@ class VKBotApplication:
|
|||||||
"""Является ли пользователь администратором (по config.ADMIN_IDS)."""
|
"""Является ли пользователь администратором (по config.ADMIN_IDS)."""
|
||||||
return hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS
|
return hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS
|
||||||
|
|
||||||
|
def _operator_ids(self) -> list:
|
||||||
|
"""Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID."""
|
||||||
|
ids = getattr(config, "OPERATOR_PEER_IDS", None)
|
||||||
|
return ids if ids else [config.OPERATOR_PEER_ID]
|
||||||
|
|
||||||
|
def _notify_operators(self, message: str):
|
||||||
|
"""Отправляет сообщение всем операторам из списка."""
|
||||||
|
for op_id in self._operator_ids():
|
||||||
|
self.send_message(op_id, message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _progress_bar(step: int, total: int = 3) -> str:
|
||||||
|
"""Визуальный прогресс шага формы: ▰▰▱ Шаг 2 из 3."""
|
||||||
|
return "▰" * step + "▱" * (total - step) + f" Шаг {step} из {total}"
|
||||||
|
|
||||||
|
def _within_work_hours(self) -> bool:
|
||||||
|
"""True, если сейчас рабочее время (config.WORK_HOUR_START..WORK_HOUR_END)."""
|
||||||
|
from datetime import datetime
|
||||||
|
h = datetime.now().hour
|
||||||
|
return getattr(config, "WORK_HOUR_START", 9) <= h < getattr(config, "WORK_HOUR_END", 21)
|
||||||
|
|
||||||
def _send_admin_panel(self, user_id: int):
|
def _send_admin_panel(self, user_id: int):
|
||||||
"""Главный экран админ-панели с кнопками разделов."""
|
"""Главный экран админ-панели с кнопками разделов."""
|
||||||
self.send_message(
|
self.send_message(
|
||||||
@@ -389,11 +418,27 @@ class VKBotApplication:
|
|||||||
text_clean = text.strip()
|
text_clean = text.strip()
|
||||||
text_lower = text_clean.lower()
|
text_lower = text_clean.lower()
|
||||||
|
|
||||||
# Анти-флуд: шквал сообщений от одного пользователя не обрабатываем (админы — исключение)
|
# Анти-флуд + временный бан (админы — исключение)
|
||||||
if not (hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS):
|
if not self._is_admin(user_id):
|
||||||
if self._is_flooding(user_id):
|
|
||||||
now = time.time()
|
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:
|
if now - self._flood_warned.get(user_id, 0) > FLOOD_WINDOW_SEC:
|
||||||
self._flood_warned[user_id] = now
|
self._flood_warned[user_id] = now
|
||||||
logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются")
|
logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются")
|
||||||
@@ -428,6 +473,21 @@ class VKBotApplication:
|
|||||||
elif text_lower.startswith("рассылка"):
|
elif text_lower.startswith("рассылка"):
|
||||||
self._handle_admin_broadcast(user_id, text_clean)
|
self._handle_admin_broadcast(user_id, text_clean)
|
||||||
return
|
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.startswith("ред "):
|
elif text_lower.startswith("ред "):
|
||||||
self._handle_admin_edit(user_id, text_clean)
|
self._handle_admin_edit(user_id, text_clean)
|
||||||
return
|
return
|
||||||
@@ -564,7 +624,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"📝 Начинаем заполнение заявки!\n\n"
|
"📝 Начинаем заполнение заявки!\n\n"
|
||||||
"Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.",
|
f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.",
|
||||||
keyboard=get_cancel_keyboard()
|
keyboard=get_cancel_keyboard()
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -627,6 +687,12 @@ class VKBotApplication:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 6.5 Быстрые ответы по ключевым словам (редактируются админом)
|
||||||
|
quick = db.match_custom_faq(text_lower)
|
||||||
|
if quick:
|
||||||
|
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
|
||||||
|
return
|
||||||
|
|
||||||
# 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру)
|
# 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру)
|
||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -670,7 +736,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"📝 Хорошо, заполним заявку с нуля!\n\n"
|
"📝 Хорошо, заполним заявку с нуля!\n\n"
|
||||||
"Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.",
|
f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.",
|
||||||
keyboard=get_cancel_keyboard()
|
keyboard=get_cancel_keyboard()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -730,7 +796,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
f"👍 Приятно познакомиться, {text}!\n\n"
|
f"👍 Приятно познакомиться, {text}!\n\n"
|
||||||
"Шаг 2 из 3: Введите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.",
|
f"{self._progress_bar(2)}\nВведите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.",
|
||||||
keyboard=get_cancel_keyboard()
|
keyboard=get_cancel_keyboard()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -753,7 +819,7 @@ class VKBotApplication:
|
|||||||
db.update_user_state(user_id, "FORM_QUESTION")
|
db.update_user_state(user_id, "FORM_QUESTION")
|
||||||
|
|
||||||
# Показываем услуги каруселью (с фолбэком на текст)
|
# Показываем услуги каруселью (с фолбэком на текст)
|
||||||
self._send_services(user_id, "✅ Телефон принят!\n\nШаг 3 из 3: какое направление вас интересует?\n\n")
|
self._send_services(user_id, f"✅ Телефон принят!\n\n{self._progress_bar(3)}\nКакое направление вас интересует?\n\n")
|
||||||
|
|
||||||
def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]):
|
def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]):
|
||||||
"""Обработка выбора услуги с прямым переходом к проверке данных."""
|
"""Обработка выбора услуги с прямым переходом к проверке данных."""
|
||||||
@@ -1391,13 +1457,10 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
|
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
|
||||||
keyboard=get_main_keyboard()
|
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
||||||
)
|
|
||||||
# Уведомляем оператора о закрытии диалога
|
|
||||||
self.send_message(
|
|
||||||
config.OPERATOR_PEER_ID,
|
|
||||||
f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором."
|
|
||||||
)
|
)
|
||||||
|
# Уведомляем операторов о закрытии диалога
|
||||||
|
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# В остальное время бот МОЛЧИТ в диалог с пользователем.
|
# В остальное время бот МОЛЧИТ в диалог с пользователем.
|
||||||
@@ -1632,7 +1695,7 @@ class VKBotApplication:
|
|||||||
f"{details}\n\n"
|
f"{details}\n\n"
|
||||||
f"🔗 Профиль кандидата: vk.com/id{user_id}"
|
f"🔗 Профиль кандидата: vk.com/id{user_id}"
|
||||||
)
|
)
|
||||||
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
|
self._notify_operators(operator_msg)
|
||||||
|
|
||||||
# Письмо на HR-адрес (пока совпадает с group-zakaz, см. config.HR_MAIL_TO)
|
# Письмо на HR-адрес (пока совпадает с group-zakaz, см. config.HR_MAIL_TO)
|
||||||
try:
|
try:
|
||||||
@@ -1649,12 +1712,16 @@ class VKBotApplication:
|
|||||||
"""Переводит пользователя в режим оператора и уведомляет команду поддержки."""
|
"""Переводит пользователя в режим оператора и уведомляет команду поддержки."""
|
||||||
db.update_user_state(user_id, "OPERATOR_CHAT")
|
db.update_user_state(user_id, "OPERATOR_CHAT")
|
||||||
|
|
||||||
self.send_message(
|
# Сообщение клиенту зависит от рабочего времени
|
||||||
user_id,
|
if self._within_work_hours():
|
||||||
"🧑💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n"
|
client_text = ("🧑💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n"
|
||||||
"Чтобы вернуться к боту, нажмите кнопку ниже.",
|
"Чтобы вернуться к боту, нажмите кнопку ниже.")
|
||||||
keyboard=get_operator_keyboard()
|
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 сообщений для контекста оператора
|
# Получаем историю последних 10 сообщений для контекста оператора
|
||||||
history = db.get_dialogue_history(user_id, limit=10)
|
history = db.get_dialogue_history(user_id, limit=10)
|
||||||
@@ -1670,7 +1737,7 @@ class VKBotApplication:
|
|||||||
f"Тип запроса: Прямой вызов из меню\n\n"
|
f"Тип запроса: Прямой вызов из меню\n\n"
|
||||||
f"📋 Последние сообщения для контекста:\n{formatted_history}"
|
f"📋 Последние сообщения для контекста:\n{formatted_history}"
|
||||||
)
|
)
|
||||||
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
|
self._notify_operators(operator_msg)
|
||||||
|
|
||||||
def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any], promo: Optional[str] = None):
|
def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any], promo: Optional[str] = None):
|
||||||
"""Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий."""
|
"""Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий."""
|
||||||
@@ -1697,7 +1764,7 @@ class VKBotApplication:
|
|||||||
f"🔗 Профиль клиента: vk.com/id{user_id}\n"
|
f"🔗 Профиль клиента: vk.com/id{user_id}\n"
|
||||||
f"Переписка сохранена в базе данных."
|
f"Переписка сохранена в базе данных."
|
||||||
)
|
)
|
||||||
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
|
self._notify_operators(operator_msg)
|
||||||
|
|
||||||
# Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro)
|
# Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro)
|
||||||
try:
|
try:
|
||||||
@@ -1719,11 +1786,8 @@ class VKBotApplication:
|
|||||||
logger.error(f"Не удалось отправить email о заявке: {mail_err}")
|
logger.error(f"Не удалось отправить email о заявке: {mail_err}")
|
||||||
|
|
||||||
def _forward_to_operator(self, user_id: int, text: str):
|
def _forward_to_operator(self, user_id: int, text: str):
|
||||||
"""Пересылка сообщений от клиента в чат оператора."""
|
"""Пересылка сообщений от клиента всем операторам."""
|
||||||
forward_msg = (
|
self._notify_operators(f"💬 Сообщение от vk.com/id{user_id}:\n{text}")
|
||||||
f"💬 Сообщение от vk.com/id{user_id}:\n{text}"
|
|
||||||
)
|
|
||||||
self.send_message(config.OPERATOR_PEER_ID, forward_msg)
|
|
||||||
|
|
||||||
# ------------------------------------------
|
# ------------------------------------------
|
||||||
# ЗАПУСК И ОБРАБОТКА LONGPOLL
|
# ЗАПУСК И ОБРАБОТКА LONGPOLL
|
||||||
@@ -1916,8 +1980,97 @@ class VKBotApplication:
|
|||||||
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
|
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
|
||||||
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
|
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)
|
self.send_message(user_id, msg)
|
||||||
|
|
||||||
|
def _send_faq_admin(self, user_id: int):
|
||||||
|
"""Раздел «Быстрые ответы»: список + инструкции."""
|
||||||
|
rows = db.get_custom_faq()
|
||||||
|
listing = ""
|
||||||
|
for _id, kw, ans in rows:
|
||||||
|
short = (ans[:50] + "…") if len(ans) > 50 else ans
|
||||||
|
listing += f"🔹 [{_id}] «{kw}» → {short}\n"
|
||||||
|
if not listing:
|
||||||
|
listing = "Пока нет ни одного быстрого ответа.\n"
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"💬 Быстрые ответы (по ключевым словам)\n\n"
|
||||||
|
"Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n"
|
||||||
|
f"📋 Текущие:\n{listing}\n"
|
||||||
|
"Команды:\n"
|
||||||
|
"• Добавить: фак доб ключевое слово | текст ответа\n"
|
||||||
|
" Пример: фак доб расписание | Тренировки идут ежедневно с 10:00 до 22:00.\n"
|
||||||
|
"• Удалить: фак удал [номер]"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_faq_add(self, user_id: int, text: str):
|
||||||
|
content = text[len("фак доб "):].strip()
|
||||||
|
if "|" not in content:
|
||||||
|
self.send_message(user_id, "⚠️ Формат: фак доб ключевое слово | текст ответа")
|
||||||
|
return
|
||||||
|
kw, ans = content.split("|", 1)
|
||||||
|
kw, ans = kw.strip(), ans.strip()
|
||||||
|
if not kw or not ans:
|
||||||
|
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.")
|
||||||
|
return
|
||||||
|
new_id = db.add_custom_faq(kw, ans)
|
||||||
|
self.send_message(user_id, f"✅ Быстрый ответ добавлен (№{new_id}): «{kw.lower()}».")
|
||||||
|
self._send_faq_admin(user_id)
|
||||||
|
|
||||||
|
def _handle_faq_delete(self, user_id: int, text: str):
|
||||||
|
arg = text[len("фак удал "):].strip()
|
||||||
|
if not arg.isdigit():
|
||||||
|
self.send_message(user_id, "⚠️ Укажите номер: фак удал 3")
|
||||||
|
return
|
||||||
|
if db.delete_custom_faq(int(arg)):
|
||||||
|
self.send_message(user_id, f"✅ Быстрый ответ №{arg} удалён.")
|
||||||
|
else:
|
||||||
|
self.send_message(user_id, f"⚠️ Ответ №{arg} не найден.")
|
||||||
|
self._send_faq_admin(user_id)
|
||||||
|
|
||||||
|
def _send_candidates(self, user_id: int):
|
||||||
|
"""Раздел «Кандидаты»: последние отклики со статусами + инструкция смены статуса."""
|
||||||
|
rows = db.get_recent_applications(limit=10)
|
||||||
|
if not rows:
|
||||||
|
self.send_message(user_id, "👥 Откликов пока нет.")
|
||||||
|
return
|
||||||
|
listing = ""
|
||||||
|
for r in rows:
|
||||||
|
who = r["discipline"] if r["track"] == "player" else r["position"]
|
||||||
|
tag = "🎮 игрок" if r["track"] == "player" else "💼 сотрудник"
|
||||||
|
listing += f"🔹 [{r['id']}] {tag} — {who or '—'}\n контакт: {r['contact'] or '—'} | статус: {r['status'] or 'новый'}\n"
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"👥 Последние отклики:\n\n"
|
||||||
|
f"{listing}\n"
|
||||||
|
"Сменить статус: статус [номер] [новый статус]\n"
|
||||||
|
"Пример: статус 3 на собесе\n"
|
||||||
|
"Рекомендуемые статусы: новый, на собесе, отказ, принят"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_candidate_status(self, user_id: int, text: str):
|
||||||
|
parts = text.split(" ", 2)
|
||||||
|
if len(parts) < 3 or not parts[1].isdigit():
|
||||||
|
self.send_message(user_id, "⚠️ Формат: статус [номер] [новый статус]\nПример: статус 3 принят")
|
||||||
|
return
|
||||||
|
app_id, new_status = int(parts[1]), parts[2].strip()
|
||||||
|
if db.set_application_status(app_id, new_status):
|
||||||
|
self.send_message(user_id, f"✅ Статус отклика №{app_id} → «{new_status}».")
|
||||||
|
else:
|
||||||
|
self.send_message(user_id, f"⚠️ Отклик №{app_id} не найден.")
|
||||||
|
|
||||||
def _handle_admin_broadcast(self, user_id: int, text: str):
|
def _handle_admin_broadcast(self, user_id: int, text: str):
|
||||||
"""Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка."""
|
"""Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка."""
|
||||||
# Отрезаем ключевое слово 'рассылка'
|
# Отрезаем ключевое слово 'рассылка'
|
||||||
@@ -2019,6 +2172,15 @@ class VKBotApplication:
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
|
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-соединения (обычное дело) не должен ронять бота.
|
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ if admin_ids_raw:
|
|||||||
else:
|
else:
|
||||||
ADMIN_IDS = [OPERATOR_PEER_ID]
|
ADMIN_IDS = [OPERATOR_PEER_ID]
|
||||||
|
|
||||||
|
# Несколько операторов: уведомления о заявках/откликах/вызовах уходят ВСЕМ из списка.
|
||||||
|
# По умолчанию = один OPERATOR_PEER_ID. Задать через .env: OPERATOR_PEER_IDS=111,222,333
|
||||||
|
_op_ids_raw = os.getenv("OPERATOR_PEER_IDS")
|
||||||
|
if _op_ids_raw:
|
||||||
|
OPERATOR_PEER_IDS = [int(x.strip()) for x in _op_ids_raw.split(",") if x.strip().lstrip("-").isdigit()]
|
||||||
|
if not OPERATOR_PEER_IDS:
|
||||||
|
OPERATOR_PEER_IDS = [OPERATOR_PEER_ID]
|
||||||
|
else:
|
||||||
|
OPERATOR_PEER_IDS = [OPERATOR_PEER_ID]
|
||||||
|
|
||||||
|
# Часы работы (для статуса оператора). Вне этого интервала — «ответим утром».
|
||||||
|
WORK_HOUR_START = int(os.getenv("WORK_HOUR_START", "9"))
|
||||||
|
WORK_HOUR_END = int(os.getenv("WORK_HOUR_END", "21"))
|
||||||
|
|
||||||
# Разрешённый источник (Origin) для CORS веб-формы. '*' допустим только для локальной отладки.
|
# Разрешённый источник (Origin) для CORS веб-формы. '*' допустим только для локальной отладки.
|
||||||
ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro")
|
ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro")
|
||||||
|
|
||||||
|
|||||||
+81
@@ -113,6 +113,22 @@ class DatabaseManager:
|
|||||||
role TEXT,
|
role TEXT,
|
||||||
experience TEXT,
|
experience TEXT,
|
||||||
contact TEXT,
|
contact TEXT,
|
||||||
|
status TEXT DEFAULT 'новый',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Миграция: статус для существующих таблиц applications
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE applications ADD COLUMN status TEXT DEFAULT 'новый'")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Table for editable quick FAQ answers (быстрые ответы по ключевым словам)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_faq (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -410,6 +426,71 @@ class DatabaseManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# --- Быстрые ответы (custom FAQ) ---
|
||||||
|
def add_custom_faq(self, keyword: str, answer: str) -> int:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT INTO custom_faq (keyword, answer) VALUES (?, ?)",
|
||||||
|
(keyword.strip().lower(), answer.strip()))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def get_custom_faq(self) -> list:
|
||||||
|
"""Список (id, keyword, answer) всех быстрых ответов."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, keyword, answer FROM custom_faq ORDER BY id")
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
def delete_custom_faq(self, faq_id: int) -> bool:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM custom_faq WHERE id = ?", (faq_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def match_custom_faq(self, text_lower: str) -> Optional[str]:
|
||||||
|
"""Возвращает ответ, если в тексте встречается одно из ключевых слов."""
|
||||||
|
for _id, keyword, answer in self.get_custom_faq():
|
||||||
|
if keyword and keyword in text_lower:
|
||||||
|
return answer
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Отклики: статусы и статистика ---
|
||||||
|
def set_application_status(self, app_id: int, status: str) -> bool:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE applications SET status = ? WHERE id = ?", (status, app_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_recent_applications(self, limit: int = 10) -> list:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, track, discipline, position, contact, status, created_at "
|
||||||
|
"FROM applications ORDER BY id DESC LIMIT ?", (limit,)
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
def get_application_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Статистика откликов: всего, игроки/сотрудники, по дисциплинам и должностям."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
stats = {
|
||||||
|
"total": cursor.execute("SELECT COUNT(*) FROM applications").fetchone()[0],
|
||||||
|
"players": cursor.execute("SELECT COUNT(*) FROM applications WHERE track='player'").fetchone()[0],
|
||||||
|
"workers": cursor.execute("SELECT COUNT(*) FROM applications WHERE track='worker'").fetchone()[0],
|
||||||
|
}
|
||||||
|
cursor.execute("SELECT discipline, COUNT(*) FROM applications WHERE track='player' "
|
||||||
|
"AND discipline IS NOT NULL GROUP BY discipline ORDER BY COUNT(*) DESC")
|
||||||
|
stats["by_discipline"] = cursor.fetchall()
|
||||||
|
cursor.execute("SELECT position, COUNT(*) FROM applications WHERE track='worker' "
|
||||||
|
"AND position IS NOT NULL GROUP BY position ORDER BY COUNT(*) DESC")
|
||||||
|
stats["by_position"] = cursor.fetchall()
|
||||||
|
return stats
|
||||||
|
|
||||||
def add_web_registration(self, reg_id: str, data: dict):
|
def add_web_registration(self, reg_id: str, data: dict):
|
||||||
"""Сохранить заявку с веб-формы в базу данных SQLite."""
|
"""Сохранить заявку с веб-формы в базу данных SQLite."""
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
vk_api>=11.9
|
||||||
|
requests>=2.28
|
||||||
|
openpyxl>=3.1
|
||||||
|
Pillow>=10.0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title Бот ИКП (VK)
|
||||||
|
|
||||||
|
:loop
|
||||||
|
echo.
|
||||||
|
echo [%date% %time%] Запуск бота ИКП...
|
||||||
|
python bot.py
|
||||||
|
echo [%date% %time%] Бот остановился (код %errorlevel%). Перезапуск через 5 секунд...
|
||||||
|
echo Нажмите Ctrl+C, чтобы выйти полностью.
|
||||||
|
timeout /t 5 /nobreak >nul
|
||||||
|
goto loop
|
||||||
Reference in New Issue
Block a user