Админка, защита и 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user