Админка, защита и 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:
Kenik
2026-06-23 10:00:56 +03:00
parent 377188a015
commit 3d709267ff
6 changed files with 397 additions and 30 deletions
+93
View File
@@ -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/` в репозиторий не попадают.
+190 -28
View File
@@ -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):
now = time.time()
# Уже забанен — молча игнорируем до конца бана
if self._banned_until.get(user_id, 0) > now:
return
if self._is_flooding(user_id): if self._is_flooding(user_id):
now = time.time() # Фиксируем срабатывание флуда; при повторах — временный бан
# Предупреждаем максимум раз за окно, чтобы само предупреждение не стало спамом 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:
+14
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
vk_api>=11.9
requests>=2.28
openpyxl>=3.1
Pillow>=10.0
+13
View File
@@ -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