From 3d709267ff200a6185d15cd19387957858d2023e Mon Sep 17 00:00:00 2001 From: Kenik Date: Tue, 23 Jun 2026 10:00:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=B8=20UX:=20=D0=B1?= =?UTF-8?q?=D1=8B=D1=81=D1=82=D1=80=D1=8B=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D1=8B,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D0=B2,?= =?UTF-8?q?=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8-=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D1=8B,=20=D0=B1=D0=B0=D0=BD,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81-=D0=B1=D0=B0?= =?UTF-8?q?=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бот: - Прогресс-бар (▰▰▱ Шаг 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 --- README.md | 93 ++++++++++++++++++++ bot.py | 222 ++++++++++++++++++++++++++++++++++++++++------- config.py | 14 +++ database.py | 81 +++++++++++++++++ requirements.txt | 4 + start_bot.bat | 13 +++ 6 files changed, 397 insertions(+), 30 deletions(-) create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 start_bot.bat diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2fae0d --- /dev/null +++ b/README.md @@ -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/` в репозиторий не попадают. diff --git a/bot.py b/bot.py index cb42f7b..5039212 100644 --- a/bot.py +++ b/bot.py @@ -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: diff --git a/config.py b/config.py index 410b342..167f29a 100644 --- a/config.py +++ b/config.py @@ -40,6 +40,20 @@ if admin_ids_raw: else: 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 веб-формы. '*' допустим только для локальной отладки. ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro") diff --git a/database.py b/database.py index 95c2b68..93ce0f6 100644 --- a/database.py +++ b/database.py @@ -113,6 +113,22 @@ class DatabaseManager: role TEXT, experience 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 ) """) @@ -410,6 +426,71 @@ class DatabaseManager: ) 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): """Сохранить заявку с веб-формы в базу данных SQLite.""" with self._get_connection() as conn: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0f96ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +vk_api>=11.9 +requests>=2.28 +openpyxl>=3.1 +Pillow>=10.0 diff --git a/start_bot.bat b/start_bot.bat new file mode 100644 index 0000000..cda5e7d --- /dev/null +++ b/start_bot.bat @@ -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