Админка, защита и 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
+192 -30
View File
@@ -36,6 +36,10 @@ FLOOD_MAX_MSGS = 12
FLOOD_WINDOW_SEC = 10
# Кулдаун между уведомлениями оператора о заявке/отклике от одного пользователя (сек)
NOTIFY_COOLDOWN_SEC = 120
# Временный бан при повторном флуде: после BAN_STRIKES срабатываний за BAN_WINDOW_SEC — бан на BAN_SEC
BAN_STRIKES = 3
BAN_WINDOW_SEC = 60
BAN_SEC = 600
# Регулярные выражения для валидации
# Российский номер для нормализации 8XXX / 7XXX -> +7XXX
@@ -94,9 +98,11 @@ def get_admin_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_line()
keyboard.add_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.PRIMARY)
@@ -310,6 +316,8 @@ class VKBotApplication:
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
self._msg_times: Dict[int, list] = defaultdict(list) # user_id -> метки времени сообщений
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
self._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда
self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен
self._last_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора
# Инициализируем динамические услуги из базы данных
@@ -364,6 +372,27 @@ class VKBotApplication:
"""Является ли пользователь администратором (по 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):
"""Главный экран админ-панели с кнопками разделов."""
self.send_message(
@@ -389,11 +418,27 @@ class VKBotApplication:
text_clean = text.strip()
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):
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:
self._flood_warned[user_id] = now
logger.warning(f"Флуд от пользователя {user_id} — сообщения игнорируются")
@@ -428,6 +473,21 @@ class VKBotApplication:
elif text_lower.startswith("рассылка"):
self._handle_admin_broadcast(user_id, text_clean)
return
elif text_lower in ["быстрые ответы 💬", "быстрые ответы", "фак"]:
self._send_faq_admin(user_id)
return
elif text_lower.startswith("фак доб "):
self._handle_faq_add(user_id, text_clean)
return
elif text_lower.startswith("фак удал "):
self._handle_faq_delete(user_id, text_clean)
return
elif text_lower in ["кандидаты 👥", "кандидаты"]:
self._send_candidates(user_id)
return
elif text_lower.startswith("статус "):
self._handle_candidate_status(user_id, text_clean)
return
elif text_lower.startswith("ред "):
self._handle_admin_edit(user_id, text_clean)
return
@@ -564,7 +624,7 @@ class VKBotApplication:
self.send_message(
user_id,
"📝 Начинаем заполнение заявки!\n\n"
"Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.",
f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.",
keyboard=get_cancel_keyboard()
)
return
@@ -627,6 +687,12 @@ class VKBotApplication:
)
return
# 6.5 Быстрые ответы по ключевым словам (редактируются админом)
quick = db.match_custom_faq(text_lower)
if quick:
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
return
# 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру)
self.send_message(
user_id,
@@ -670,7 +736,7 @@ class VKBotApplication:
self.send_message(
user_id,
"📝 Хорошо, заполним заявку с нуля!\n\n"
"Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.",
f"{self._progress_bar(1)}\nКак к вам обращаться? Пожалуйста, введите ваше имя.",
keyboard=get_cancel_keyboard()
)
else:
@@ -730,7 +796,7 @@ class VKBotApplication:
self.send_message(
user_id,
f"👍 Приятно познакомиться, {text}!\n\n"
"Шаг 2 из 3: Введите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.",
f"{self._progress_bar(2)}\nВведите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.",
keyboard=get_cancel_keyboard()
)
@@ -753,7 +819,7 @@ class VKBotApplication:
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]):
"""Обработка выбора услуги с прямым переходом к проверке данных."""
@@ -1391,13 +1457,10 @@ class VKBotApplication:
self.send_message(
user_id,
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
keyboard=get_main_keyboard()
)
# Уведомляем оператора о закрытии диалога
self.send_message(
config.OPERATOR_PEER_ID,
f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором."
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
)
# Уведомляем операторов о закрытии диалога
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
return
# В остальное время бот МОЛЧИТ в диалог с пользователем.
@@ -1632,7 +1695,7 @@ class VKBotApplication:
f"{details}\n\n"
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)
try:
@@ -1648,14 +1711,18 @@ class VKBotApplication:
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()
)
# Сообщение клиенту зависит от рабочего времени
if self._within_work_hours():
client_text = ("🧑‍💼 Передал ваш запрос оператору — он ответит здесь в ближайшее время.\n"
"Чтобы вернуться к боту, нажмите кнопку ниже.")
else:
client_text = (f"🌙 Оператор сейчас оффлайн (работаем с {getattr(config,'WORK_HOUR_START',9)}:00 "
f"до {getattr(config,'WORK_HOUR_END',21)}:00).\n"
"Я передал ваш запрос — вам ответят утром, как начнётся рабочий день. "
"Можете оставить сообщение здесь. Чтобы вернуться к боту, нажмите кнопку ниже.")
self.send_message(user_id, client_text, keyboard=get_operator_keyboard())
# Получаем историю последних 10 сообщений для контекста оператора
history = db.get_dialogue_history(user_id, limit=10)
formatted_history = ""
@@ -1670,7 +1737,7 @@ class VKBotApplication:
f"Тип запроса: Прямой вызов из меню\n\n"
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):
"""Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий."""
@@ -1697,7 +1764,7 @@ class VKBotApplication:
f"🔗 Профиль клиента: vk.com/id{user_id}\n"
f"Переписка сохранена в базе данных."
)
self.send_message(config.OPERATOR_PEER_ID, operator_msg)
self._notify_operators(operator_msg)
# Дублируем заявку на корпоративную почту (group-zakaz@infocyber.pro)
try:
@@ -1719,11 +1786,8 @@ class VKBotApplication:
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)
"""Пересылка сообщений от клиента всем операторам."""
self._notify_operators(f"💬 Сообщение от vk.com/id{user_id}:\n{text}")
# ------------------------------------------
# ЗАПУСК И ОБРАБОТКА LONGPOLL
@@ -1916,8 +1980,97 @@ class VKBotApplication:
mk_yes=ls["marketing_yes"], mk_pct=marketing_pct,
services=service_lines, uniq=ds["unique_users"], umsg=ds["user_messages"]
)
# Блок откликов (карьера)
aps = db.get_application_stats()
msg += f"\n\n🧩 Отклики (карьера): {aps['total']}\n"
msg += f" • игроки: {aps['players']}\n"
if aps["by_discipline"]:
for disc, cnt in aps["by_discipline"]:
msg += f"{disc}: {cnt}\n"
msg += f" • сотрудники: {aps['workers']}\n"
if aps["by_position"]:
for pos, cnt in aps["by_position"]:
msg += f"{pos}: {cnt}\n"
self.send_message(user_id, msg)
def _send_faq_admin(self, user_id: int):
"""Раздел «Быстрые ответы»: список + инструкции."""
rows = db.get_custom_faq()
listing = ""
for _id, kw, ans in rows:
short = (ans[:50] + "") if len(ans) > 50 else ans
listing += f"🔹 [{_id}] «{kw}» → {short}\n"
if not listing:
listing = "Пока нет ни одного быстрого ответа.\n"
self.send_message(
user_id,
"💬 Быстрые ответы (по ключевым словам)\n\n"
"Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n"
f"📋 Текущие:\n{listing}\n"
"Команды:\n"
"• Добавить: фак доб ключевое слово | текст ответа\n"
" Пример: фак доб расписание | Тренировки идут ежедневно с 10:00 до 22:00.\n"
"• Удалить: фак удал [номер]"
)
def _handle_faq_add(self, user_id: int, text: str):
content = text[len("фак доб "):].strip()
if "|" not in content:
self.send_message(user_id, "⚠️ Формат: фак доб ключевое слово | текст ответа")
return
kw, ans = content.split("|", 1)
kw, ans = kw.strip(), ans.strip()
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.")
return
new_id = db.add_custom_faq(kw, ans)
self.send_message(user_id, f"✅ Быстрый ответ добавлен (№{new_id}): «{kw.lower()}».")
self._send_faq_admin(user_id)
def _handle_faq_delete(self, user_id: int, text: str):
arg = text[len("фак удал "):].strip()
if not arg.isdigit():
self.send_message(user_id, "⚠️ Укажите номер: фак удал 3")
return
if db.delete_custom_faq(int(arg)):
self.send_message(user_id, f"✅ Быстрый ответ №{arg} удалён.")
else:
self.send_message(user_id, f"⚠️ Ответ №{arg} не найден.")
self._send_faq_admin(user_id)
def _send_candidates(self, user_id: int):
"""Раздел «Кандидаты»: последние отклики со статусами + инструкция смены статуса."""
rows = db.get_recent_applications(limit=10)
if not rows:
self.send_message(user_id, "👥 Откликов пока нет.")
return
listing = ""
for r in rows:
who = r["discipline"] if r["track"] == "player" else r["position"]
tag = "🎮 игрок" if r["track"] == "player" else "💼 сотрудник"
listing += f"🔹 [{r['id']}] {tag}{who or ''}\n контакт: {r['contact'] or ''} | статус: {r['status'] or 'новый'}\n"
self.send_message(
user_id,
"👥 Последние отклики:\n\n"
f"{listing}\n"
"Сменить статус: статус [номер] [новый статус]\n"
"Пример: статус 3 на собесе\n"
"Рекомендуемые статусы: новый, на собесе, отказ, принят"
)
def _handle_candidate_status(self, user_id: int, text: str):
parts = text.split(" ", 2)
if len(parts) < 3 or not parts[1].isdigit():
self.send_message(user_id, "⚠️ Формат: статус [номер] [новый статус]\nПример: статус 3 принят")
return
app_id, new_status = int(parts[1]), parts[2].strip()
if db.set_application_status(app_id, new_status):
self.send_message(user_id, f"✅ Статус отклика №{app_id} → «{new_status}».")
else:
self.send_message(user_id, f"⚠️ Отклик №{app_id} не найден.")
def _handle_admin_broadcast(self, user_id: int, text: str):
"""Рассылка по подписчикам: 'рассылка <текст>' → предпросмотр → 'рассылка подтвердить' → отправка."""
# Отрезаем ключевое слово 'рассылка'
@@ -2019,6 +2172,15 @@ class VKBotApplication:
return
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
# Старт-пинг администратору — лёгкий сигнal «бот жив»
try:
from datetime import datetime
ts = datetime.now().strftime("%d.%m.%Y %H:%M")
for admin_id in getattr(config, "ADMIN_IDS", []):
self.send_message(admin_id, f"✅ Бот ИКП запущен и на связи ({ts}).")
except Exception as e:
logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
while True:
try: