31 Commits

Author SHA1 Message Date
Kenik 28499d9b55 Merge: защита от тихого зависания бота (watchdog + сетевой таймаут)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:48:37 +03:00
Kenik 6ee4e6cf0f Защита от тихого зависания бота (без таймаута сети + watchdog)
Причина 'бот в моменте перестаёт работать' без ошибок в логах:
vk_api ставит таймаут только на сам LongPoll-запрос (wait+10 с), а
обычные вызовы API (messages.send и т.п.) идут без таймаута вообще.
Если VK/сеть один раз не ответит на такой вызов — соединение виснет
навсегда, а так как обработка сообщений однопоточная (внутри цикла
LongPoll), весь бот намертво стопорится без единой строки в логе.
Docker restart: unless-stopped не спасает — процесс не падает, а просто
висит.

- socket.setdefaulttimeout(60) — подстраховка для всех сетевых вызовов
  без явного timeout (vk_api, requests в mailer.py и загрузке фото).
- Ручной вызов longpoll.check() вместо for event in listen() — даёт
  watchdog 'пульс' на каждом опросе сервера (~25-35 с), а не только при
  реальных сообщениях.
- Watchdog-поток: если цикл не подавал признаков жизни дольше 5 минут,
  принудительно завершает процесс (os._exit), чтобы Docker реально
  перезапустил контейнер.
2026-09-06 13:44:45 +03:00
Kenik 6ecbd7aa94 Напоминалки: кнопка смены часового пояса в разделе
Раньше сменить уже заданный пояс можно было только скрытой командой
'часовой пояс <±N>', нигде не упомянутой в интерфейсе. Добавлена кнопка
«🌐 Часовой пояс» — открывает тот же выбор (кнопки городов + ручной ввод)
заново, показывая текущее значение.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:22:57 +03:00
Kenik e41db35f13 Напоминалки: выбор часового пояса кнопками городов + ручной ввод
При гейте часового пояса теперь показывается клавиатура с частыми
российскими городами-поясами (Калининград..Камчатка). Клик по кнопке
шлёт 'Город, UTC+N' — _parse_tz_offset распознаёт и это, и обычный
ручной ввод вида '+3'/'-5', если своего города в списке нет.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:18:29 +03:00
Kenik 0085195941 Напоминалки: тест конвертации не должен зависеть от пояса сервера
По ревью Task 5: test_reminder_creation_converts_author_local_time_to_server
сравнивал результат с server_utc_offset_hours() самой машины, на которой
запускается тест. Так как сервер в этом окружении сам в UTC+3 — совпадающем
с поясом автора в тесте, — разница смещений была 0, и тест проходил бы даже
если бы конвертация вообще не выполнялась. Фикс: подменяем
server_utc_offset_hours на фиксированное значение (0), заведомо отличное от
пояса автора (3), чтобы тест реально ловил регрессию независимо от машины.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:54:55 +03:00
Kenik c814eca596 Напоминалки: тесты гейта часового пояса, конвертации и паузы
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:51:35 +03:00
Kenik b766d445b4 Напоминалки: гейт часового пояса и для текстовой команды удаления
По ревью Task 4: команда 'напоминалка удалить <id>' была единственной точкой
входа в раздел, не защищённой гейтом _require_reminder_tz — админ без заданного
пояса мог через неё попасть в _send_reminders_admin и увидеть список с
подписью (UTC+0) по фолбэку, так и не указав реальный часовой пояс.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:49:17 +03:00
Kenik 3ff411b554 Напоминалки: гейт часового пояса, конвертация времени, состояние паузы
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:44:35 +03:00
Kenik 5ce69fb66c Напоминалки: кнопка паузы в клавиатуре раздела
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:42:10 +03:00
Kenik 79ec619360 Напоминалки: часовой пояс админов и пауза в database.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:39:54 +03:00
Kenik 40cfcbd45f Напоминалки: конвертация между часовыми поясами (reminders.py)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:37:26 +03:00
Kenik 5efc0aca8d Напоминалки: план реализации паузы и часового пояса (docs)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:35:13 +03:00
Kenik 4429b99a55 Напоминалки: спека паузы и часового пояса
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:29:01 +03:00
Kenik 19bade27b6 Напоминалки: нижняя клавиатура раздела вместо инлайн (не остаются старые кнопки)
Инлайн-клавиатура в _send_reminders_admin не заменяла нижнюю (reply) клавиатуру
предыдущего шага мастера — после создания напоминания кнопки «Разово / Каждую
неделю / …» оставались висеть внизу. Теперь раздел использует обычную нижнюю
клавиатуру get_reminders_admin_keyboard(), как и раздел «Быстрые ответы».

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:11:15 +03:00
Kenik fc79da8f83 Напоминалки: кнопка удаления списком и ФИО получателей вместо голых id
- В разделе «Напоминалки» добавлена кнопка «🗑 Удалить напоминание» с выбором
  из списка (как в разделе «Быстрые ответы»), не только текстовая команда.
- При выборе получателей и в списке напоминаний теперь показывается ФИО
  админа из VK рядом со ссылкой (если удалось получить), чтобы не кликать
  по каждой ссылке для опознания.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:08:49 +03:00
Kenik 6d4dd16caf Напоминалки: план реализации (docs) 2026-09-02 21:56:18 +03:00
Kenik 77f314be3f Напоминалки: сквозные тесты мастера создания и удаления 2026-09-02 21:53:31 +03:00
Kenik 5d88b8899e Напоминалки: фоновый планировщик отправки в срок
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:49:28 +03:00
Kenik 86f8115de1 Напоминалки: мастер создания, список и удаление в bot.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:44:23 +03:00
Kenik 0e162ac9a1 Напоминалки: кнопка в админ-панели и клавиатура выбора периода
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:40:30 +03:00
Kenik bf958e1c12 Напоминалки: таблица reminders и CRUD в database.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:37:26 +03:00
Kenik f5ce327bbf Напоминалки: чистая логика расчёта дат (reminders.py)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:33:56 +03:00
Kenik 4bf7f98540 Спека: настраиваемые напоминалки для админов 2026-09-02 21:23:47 +03:00
Kenik 577d342bdd Логотип: не долбить VK/лог при отказе загрузки фото (кэшируем неудачу)
В логах на каждое приветствие сыпалось [15] Access denied (у токена нет прав на
фото). Теперь неудача загрузки кэшируется — приветствие уходит без логотипа, без
повторных ошибок. (Чтобы логотип показывался — токену нужно право «Фотографии».)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 21:50:53 +03:00
Kenik f9cdaec75a Фикс: бот слал «не понял запрос» на беседы и пустые сообщения
Причина случайного фолбэка «Пока не получилось определить запрос»:
- бот реагировал на сообщения из бесед/групповых чатов (peer_id != from_id) и слал
  отправителю в ЛС фолбэк — теперь такие сообщения игнорируются (работаем только в личке);
- сообщения без текста (стикер/фото/голосовое/гифка) давали text="" → фолбэк в
  главном меню — теперь пустые сообщения игнорируются.

Тест: test_empty_message_ignored. pytest 44 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 21:45:23 +03:00
Kenik e509c0cfd1 Docker: Dockerfile + docker-compose.yml для запуска бота в контейнере
- Dockerfile (python:3.11-slim): установка зависимостей, запуск bot.py.
- docker-compose.yml: авто-перезапуск (restart: unless-stopped), настройки из .env
  (env_file), база в именованном volume (данные не теряются при пересборке).
- .dockerignore: в образ не тащим .git/тесты/докумен/базу/.env.
- bot.py: путь к БД через DB_PATH (для volume в Docker), по умолчанию — как раньше.
- requirements.txt: убраны неиспользуемые Pillow и openpyxl (образ легче).

pytest passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:32:15 +03:00
Kenik 8f3c3b8b83 .env как готовый шаблон в репозитории (ID вписаны, токен-заглушка)
Чтобы при передаче человеку не заполнять всё вручную: .env лежит в репозитории
с уже прописанными ID админов/оператора и рабочими ссылками. Заполнить нужно
только VK_TOKEN и VK_GROUP_ID (ключ и ID своего сообщества). Реальный токен —
только локально, в репозиторий не пушится.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:35:57 +03:00
Kenik 9ded7a513d .env.example: понятная инструкция по активации + защита config от пустых значений
- .env.example переписан как пошаговый гид: разделы (подключение к сообществу,
  кому приходят заявки, почта, ссылки, /favi-stop), пометки «ОБЯЗАТЕЛЬНО»,
  правила заполнения (без инлайн-комментариев, числа не оставлять пустыми).
- config: безопасный разбор чисел (_int_env) — пустое/кривое VK_GROUP_ID/порт/часы
  больше не роняют бот при старте, откатываются на дефолт. '#' в значениях (URL
  #prices) не трогаем.

pytest 43 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:11:38 +03:00
Kenik d2ca750a2d Админка: показ/скрытие кнопок меню + экстренный стоп бота (/favi-stop)
1) Кнопки меню: раздел «Кнопки меню 🧩» в админке — показать/скрыть любую кнопку
   главного меню (Подобрать/Интенсив/Цены/Вопрос/Вебинар). Флаги в БД (bot_flags),
   дефолты в config.MENU_FEATURES (вебинар по умолчанию скрыт). Скрытый раздел не
   реагирует и на кодовые слова (интенсив выкл → «лето» игнорируется). Вебинар
   получил кнопку в меню (для теста ветки) + вход по callback из поста работает всегда.

2) Экстренное отключение (только супер-админ): /favi-stop → запрос пароля → бот
   уходит в аварийный режим и молчит для всех; /favi-start возвращает. Пароль хранится
   как PBKDF2-хэш в .env (FAVI_STOP_HASH), не в коде; сравнение константное, 3 попытки.

pytest 43 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:58:54 +03:00
Kenik 24daec1c09 Фикс: пустой ряд в клавиатуре ломал отправку («Задать вопрос» молчал) + vk.ru в резолвере
1) _kb при пустых рядах создавал ПУСТОЙ первый ряд (add_line до кнопок) — VK
   отклонял такую клавиатуру, из-за чего сообщения шага «Задать вопрос»,
   «другая игра», «нужен совет» не отправлялись (состояние при этом менялось).
   Теперь add_line ставится только между реальными кнопками — пустых рядов нет.
2) _resolve_vk_user_id теперь понимает домен vk.ru (и www./m.), а не только vk.com —
   ссылку https://vk.ru/screenname на добавление админа бот принимает.

Тесты: test_funnel_keyboards_no_empty_rows, test_resolve_vk_links. pytest 40 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:45:31 +03:00
Kenik 81c1162ca1 Админам: персональное отключение уведомлений от клиентов
В админ-панели кнопка «Уведомления: вкл 🔔 / выкл 🔕» — админ (в т.ч. из ADMIN_IDS/
супер-админ) может лично отключить приём уведомлений о заявках и сообщениях клиентов.
Флаг notify_muted хранится в профиле; _notify_operators пропускает замьюченных
получателей (у остальных операторов уведомления идут как обычно).

pytest 37 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:37:58 +03:00
19 changed files with 3925 additions and 87 deletions
+15
View File
@@ -0,0 +1,15 @@
# В образ не нужны:
.git
.gitignore
__pycache__/
*.pyc
*.log
bot.lock
bot_database.db
*.xlsx
data/
tests/
docs/
help_img/_orig/
# .env не копируем в образ — настройки прокидываются через env_file в docker-compose
.env
+97
View File
@@ -0,0 +1,97 @@
# ============================================================================
# НАСТРОЙКА БОТА «Кибершкола БАЗА» — файл .env
# ============================================================================
# Это ШАБЛОН. Что делать:
# 1) Скопируйте этот файл рядом и назовите ровно .env (без .example).
# Проще: запустите start_bot.bat — он сам создаст .env и откроет его в
# Блокноте. Заполните, сохраните, запустите start_bot.bat снова.
# 2) Заполните строки из раздела 1 и 2 (помечены «ОБЯЗАТЕЛЬНО»). Остальное —
# по желанию.
#
# ПРАВИЛА ЗАПОЛНЕНИЯ (важно!):
# • Формат строки: ПЕРЕМЕННАЯ=значение — без пробелов вокруг «=», без кавычек.
# • НЕ пишите комментарии в одной строке со значением. Комментарий — только
# на отдельной строке, начинается с #.
# • Числовые поля (ID, порт) не оставляйте пустыми — впишите число.
# ============================================================================
# ───────────────── 1. ПОДКЛЮЧЕНИЕ К СООБЩЕСТВУ (ОБЯЗАТЕЛЬНО) ──────────────
# VK_TOKEN — ключ доступа ВАШЕГО сообщества:
# Сообщество → Управление → Работа с API → Ключи доступа → Создать ключ
# (право «Сообщения сообщества») → скопировать сюда.
# Там же: Работа с API → Long Poll API → включить, версия — последняя.
VK_TOKEN=your_vk_community_token_here
# VK_GROUP_ID — числовой ID сообщества (только цифры). Замените 0 на свой ID.
# Узнать ID: Управление → Работа с API (виден там), либо через vk.com.
VK_GROUP_ID=0
# ───────────────── 2. КОМУ ПРИХОДЯТ ЗАЯВКИ (ОБЯЗАТЕЛЬНО) ──────────────────
# OPERATOR_PEER_ID — VK ID человека, которому падают заявки/запросы клиентов.
# Узнать свой ID можно, написав боту @idbot во ВКонтакте.
OPERATOR_PEER_ID=350793273
# ADMIN_IDS — у кого есть «Админка» в боте (через запятую, только цифры).
# Обычно тот же ID, что OPERATOR_PEER_ID. Пример: 350793273,12345678
ADMIN_IDS=350793273,25076348,9782434,255661162
# (Необязательно) Несколько операторов — заявки уйдут ВСЕМ. Через запятую.
# OPERATOR_PEER_IDS=350793273,12345678
# (Необязательно) Часы работы — вне их клиенту пишем «ответим утром».
WORK_HOUR_START=9
WORK_HOUR_END=21
# ───────────────── 3. ПОЧТА ДЛЯ ЗАЯВОК (по желанию) ──────────────────────
# Заполните — и каждая заявка продублируется на почту. Оставите пусто — бот
# работает как обычно, просто письма не шлёт.
# SMTP_HOST — сервер почты, напр. smtp.yandex.ru / smtp.mail.ru / smtp.gmail.com
SMTP_HOST=
# SMTP_PORT — 465 для SSL (обычно), 587 для STARTTLS
SMTP_PORT=465
# SMTP_USER — полный email-логин
SMTP_USER=
# SMTP_PASSWORD — ПАРОЛЬ ПРИЛОЖЕНИЯ (для Яндекс/Gmail), не основной пароль!
SMTP_PASSWORD=
# MAIL_FROM — от кого (пусто = как SMTP_USER)
MAIL_FROM=
# MAIL_TO — куда слать заявки (можно несколько через запятую)
MAIL_TO=group-zakaz@infocyber.pro
# SMTP_USE_SSL — true для порта 465, false для 587
SMTP_USE_SSL=true
# ───────────────── 4. ССЫЛКИ КНОПОК ЗАПИСИ/ОПЛАТЫ ────────────────────────
# Куда ведут кнопки в боте. Их также можно менять прямо из «Админки → Ссылки».
# UTM-метки бот добавляет сам.
LINK_TRIAL=https://infocyber.pro/form/index.html
LINK_TARIFFS=https://infocyber.pro/#prices
LINK_GROUP_4=https://infocyber.pro/form/index.html?service_id=web_2
LINK_GROUP_8=https://infocyber.pro/form/index.html?service_id=web_3
LINK_IND_1=https://infocyber.pro/form/index.html?service_id=web_1
LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4
LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5
# LINK_SUMMER — летний интенсив (замените, когда будет своя страница)
LINK_SUMMER=https://infocyber.pro/form/index.html
# ───────────────── 5. ЭКСТРЕННЫЙ СТОП /favi-stop (по желанию) ─────────────
# Команда /favi-stop у супер-админа отключает бота по паролю. Хранится ХЭШ
# пароля, а не сам пароль. Пусто — команда отключена.
# Сгенерировать хэш своего пароля (замените ВАШ_ПАРОЛЬ):
# py -3.11 -c "import hashlib,os,sys; s=os.urandom(16); print('pbkdf2_sha256$240000$'+s.hex()+'$'+hashlib.pbkdf2_hmac('sha256',sys.argv[1].encode(),s,240000).hex())" "ВАШ_ПАРОЛЬ"
FAVI_STOP_HASH=
# ───────────────── ПРОЧЕЕ (можно не трогать) ─────────────────────────────
ALLOWED_ORIGIN=https://infocyber.pro
PROMO_ENABLED=false
# ============================================================================
# СУПЕР-АДМИН (кто добавляет админов и делает /favi-stop) задаётся НЕ здесь,
# а в коде — файл config.py, список SUPER_ADMIN_IDS.
# Готово? Запускайте start_bot.bat.
# ============================================================================
+73 -24
View File
@@ -1,42 +1,72 @@
# VK API Configuration # ============================================================================
# Токен сообщества ВКонтакте (с правами на управление сообщениями) # НАСТРОЙКА БОТА «Кибершкола БАЗА» — файл .env
# ============================================================================
# Это ШАБЛОН. Что делать:
# 1) Скопируйте этот файл рядом и назовите ровно .env (без .example).
# Проще: запустите start_bot.bat — он сам создаст .env и откроет его в
# Блокноте. Заполните, сохраните, запустите start_bot.bat снова.
# 2) Заполните строки из раздела 1 и 2 (помечены «ОБЯЗАТЕЛЬНО»). Остальное —
# по желанию.
#
# ПРАВИЛА ЗАПОЛНЕНИЯ (важно!):
# • Формат строки: ПЕРЕМЕННАЯ=значение — без пробелов вокруг «=», без кавычек.
# • НЕ пишите комментарии в одной строке со значением. Комментарий — только
# на отдельной строке, начинается с #.
# • Числовые поля (ID, порт) не оставляйте пустыми — впишите число.
# ============================================================================
# ───────────────── 1. ПОДКЛЮЧЕНИЕ К СООБЩЕСТВУ (ОБЯЗАТЕЛЬНО) ──────────────
# VK_TOKEN — ключ доступа ВАШЕГО сообщества:
# Сообщество → Управление → Работа с API → Ключи доступа → Создать ключ
# (право «Сообщения сообщества») → скопировать сюда.
# Там же: Работа с API → Long Poll API → включить, версия — последняя.
VK_TOKEN=your_vk_community_token_here VK_TOKEN=your_vk_community_token_here
# Идентификатор (ID) сообщества ВКонтакте (только цифры) # VK_GROUP_ID — числовой ID сообщества (только цифры). Замените 0 на свой ID.
# Узнать ID: Управление → Работа с API (виден там), либо через vk.com.
VK_GROUP_ID=0 VK_GROUP_ID=0
# ID оператора / администратора ВКонтакте для отправки лидов и оповещений (только цифры)
OPERATOR_PEER_ID=12345678
# Список ID администраторов (через запятую) для управления услугами через чат # ───────────────── 2. КОМУ ПРИХОДЯТ ЗАЯВКИ (ОБЯЗАТЕЛЬНО) ──────────────────
ADMIN_IDS=12345678 # OPERATOR_PEER_ID — VK ID человека, которому падают заявки/запросы клиентов.
# Узнать свой ID можно, написав боту @idbot во ВКонтакте.
OPERATOR_PEER_ID=0
# Разрешённый источник (Origin) для CORS веб-формы (домен сайта). Для локальной отладки можно '*' # ADMIN_IDS — у кого есть «Админка» в боте (через запятую, только цифры).
ALLOWED_ORIGIN=https://infocyber.pro # Обычно тот же ID, что OPERATOR_PEER_ID. Пример: 350793273,12345678
ADMIN_IDS=0
# Промокоды на скидку. Пока выключены. Поставь true, когда захочешь их выдавать клиентам. # (Необязательно) Несколько операторов — заявки уйдут ВСЕМ. Через запятую.
PROMO_ENABLED=false # OPERATOR_PEER_IDS=350793273,12345678
# ===== Email (SMTP) для уведомлений о заявках ===== # (Необязательно) Часы работы — вне их клиенту пишем «ответим утром».
# Адрес SMTP-сервера почты (например, smtp.yandex.ru / smtp.gmail.com / smtp.mail.ru) WORK_HOUR_START=9
WORK_HOUR_END=21
# ───────────────── 3. ПОЧТА ДЛЯ ЗАЯВОК (по желанию) ──────────────────────
# Заполните — и каждая заявка продублируется на почту. Оставите пусто — бот
# работает как обычно, просто письма не шлёт.
# SMTP_HOST — сервер почты, напр. smtp.yandex.ru / smtp.mail.ru / smtp.gmail.com
SMTP_HOST= SMTP_HOST=
# Порт: 465 для SSL, 587 для STARTTLS # SMTP_PORT — 465 для SSL (обычно), 587 для STARTTLS
SMTP_PORT=465 SMTP_PORT=465
# Логин (полный email) и пароль (для Яндекс/Gmail — пароль приложения, не основной!) # SMTP_USER — полный email-логин
SMTP_USER= SMTP_USER=
# SMTP_PASSWORD — ПАРОЛЬ ПРИЛОЖЕНИЯ (для Яндекс/Gmail), не основной пароль!
SMTP_PASSWORD= SMTP_PASSWORD=
# От кого отправлять (по умолчанию = SMTP_USER) # MAIL_FROM — от кого (пусто = как SMTP_USER)
MAIL_FROM= MAIL_FROM=
# Куда отправлять заявки (можно несколько через запятую) # MAIL_TO — куда слать заявки (можно несколько через запятую)
MAIL_TO=group-zakaz@infocyber.pro MAIL_TO=group-zakaz@infocyber.pro
# Куда отправлять отклики на вакансии/набор в состав. Пусто = на тот же адрес, что MAIL_TO. # SMTP_USE_SSL — true для порта 465, false для 587
# Можно вынести на отдельный HR-адрес, напр. hr@infocyber.pro
HR_MAIL_TO=
# true — SSL (порт 465), false — STARTTLS (порт 587)
SMTP_USE_SSL=true SMTP_USE_SSL=true
# --- Ссылки Кибершколы БАЗА (кнопки записи/оплаты в боте) ---
# UTM добавляются кодом автоматически; здесь только базовые URL. Замените при необходимости. # ───────────────── 4. ССЫЛКИ КНОПОК ЗАПИСИ/ОПЛАТЫ ────────────────────────
# Куда ведут кнопки в боте. Их также можно менять прямо из «Админки → Ссылки».
# UTM-метки бот добавляет сам.
LINK_TRIAL=https://infocyber.pro/form/index.html LINK_TRIAL=https://infocyber.pro/form/index.html
LINK_TARIFFS=https://infocyber.pro/#prices LINK_TARIFFS=https://infocyber.pro/#prices
LINK_GROUP_4=https://infocyber.pro/form/index.html?service_id=web_2 LINK_GROUP_4=https://infocyber.pro/form/index.html?service_id=web_2
@@ -44,5 +74,24 @@ LINK_GROUP_8=https://infocyber.pro/form/index.html?service_id=web_3
LINK_IND_1=https://infocyber.pro/form/index.html?service_id=web_1 LINK_IND_1=https://infocyber.pro/form/index.html?service_id=web_1
LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4 LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4
LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5 LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5
# Летний интенсив: отдельной страницы на сайте нет — ведём на общую форму (замените, когда появится) # LINK_SUMMER — летний интенсив (замените, когда будет своя страница)
LINK_SUMMER=https://infocyber.pro/form/index.html LINK_SUMMER=https://infocyber.pro/form/index.html
# ───────────────── 5. ЭКСТРЕННЫЙ СТОП /favi-stop (по желанию) ─────────────
# Команда /favi-stop у супер-админа отключает бота по паролю. Хранится ХЭШ
# пароля, а не сам пароль. Пусто — команда отключена.
# Сгенерировать хэш своего пароля (замените ВАШ_ПАРОЛЬ):
# py -3.11 -c "import hashlib,os,sys; s=os.urandom(16); print('pbkdf2_sha256$240000$'+s.hex()+'$'+hashlib.pbkdf2_hmac('sha256',sys.argv[1].encode(),s,240000).hex())" "ВАШ_ПАРОЛЬ"
FAVI_STOP_HASH=
# ───────────────── ПРОЧЕЕ (можно не трогать) ─────────────────────────────
ALLOWED_ORIGIN=https://infocyber.pro
PROMO_ENABLED=false
# ============================================================================
# СУПЕР-АДМИН (кто добавляет админов и делает /favi-stop) задаётся НЕ здесь,
# а в коде — файл config.py, список SUPER_ADMIN_IDS.
# Готово? Запускайте start_bot.bat.
# ============================================================================
+3 -2
View File
@@ -1,5 +1,6 @@
# Configuration files containing credentials # .env НАМЕРЕННО добавлен в репозиторий как ГОТОВЫЙ шаблон (ID уже вписаны,
.env # токен — заглушка). ВАЖНО: реальный VK_TOKEN вписывается только локально на
# машине с ботом и в репозиторий пушить его НЕ нужно.
# Python byte-compiled files # Python byte-compiled files
__pycache__/ __pycache__/
+22
View File
@@ -0,0 +1,22 @@
# Образ бота «Кибершкола БАЗА» (VK). Python 3.11.
FROM python:3.11-slim
# Не писать .pyc, логи — сразу в вывод (docker logs)
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Сначала зависимости — так слой кэшируется и пересборка быстрее
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Код бота
COPY . .
# Каталог для базы (монтируется как volume, чтобы данные не терялись)
RUN mkdir -p /app/data
ENV DB_PATH=/app/data/bot_database.db
# Запуск бота (long poll, слушает VK)
CMD ["python", "-u", "bot.py"]
+567 -38
View File
@@ -7,11 +7,23 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json import json
import re import re
import time import time
import hmac
import hashlib
import logging import logging
import socket
import threading import threading
from collections import defaultdict from collections import defaultdict
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
# Глобальный таймаут для всех сетевых сокетов без явного timeout.
# Без этого библиотека vk_api ставит таймаут только на сам LongPoll-запрос
# (wait+10 сек), а обычные вызовы API (messages.send и т.п.) идут без
# таймаута вообще — если VK/сеть один раз не ответит, соединение висит
# бесконечно и весь однопоточный цикл обработки сообщений намертво стопорится
# без единой строки в логе. Docker restart:unless-stopped при этом не
# помогает — процесс не падает, а просто зависает.
socket.setdefaulttimeout(60)
# Нам понадобятся библиотеки vk_api. # Нам понадобятся библиотеки vk_api.
try: try:
import vk_api import vk_api
@@ -22,6 +34,9 @@ except ImportError:
vk_api = None vk_api = None
from database import DatabaseManager from database import DatabaseManager
from reminders import parse_reminder_datetime, compute_next_occurrence, \
server_utc_offset_hours, convert_time
from datetime import datetime
import config import config
import mailer import mailer
@@ -32,8 +47,9 @@ logging.basicConfig(
) )
logger = logging.getLogger("VK_Bot_IKP") logger = logging.getLogger("VK_Bot_IKP")
# Инициализация базы данных сессий # Инициализация базы данных сессий.
db = DatabaseManager() # Путь можно переопределить через DB_PATH (нужно для Docker: база в volume).
db = DatabaseManager(os.getenv("DB_PATH") or "bot_database.db")
# Анти-флуд: максимум сообщений от одного пользователя за окно времени # Анти-флуд: максимум сообщений от одного пользователя за окно времени
FLOOD_MAX_MSGS = 12 FLOOD_MAX_MSGS = 12
@@ -44,6 +60,20 @@ BAN_WINDOW_SEC = 60
BAN_SEC = 600 BAN_SEC = 600
# Сколько секунд кэшировать список рантайм-админов из БД # Сколько секунд кэшировать список рантайм-админов из БД
ADMINS_CACHE_TTL = 30 ADMINS_CACHE_TTL = 30
# Как часто фоновый поток проверяет, не пора ли отправить напоминание
REMINDER_CHECK_INTERVAL_SEC = 60
# Сколько секунд основной цикл LongPoll может не подавать признаков жизни,
# прежде чем watchdog сочтёт его зависшим и принудительно завершит процесс
# (штатный опрос происходит раз в ~25-35 с, так что 5 минут — большой запас)
WATCHDOG_TIMEOUT_SEC = 300
REMINDER_PERIOD_LABELS = {
"once": "разово",
"weekly": "каждую неделю",
"monthly": "каждый месяц",
"yearly": "каждый год",
}
REMINDER_PERIOD_BY_LABEL = {v: k for k, v in REMINDER_PERIOD_LABELS.items()}
from keyboards import * # все get_*_keyboard from keyboards import * # все get_*_keyboard
@@ -99,6 +129,7 @@ class VKBotApplication:
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._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда
self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен
self._favi_pending: Dict[int, dict] = {} # супер-админы, вводящие пароль /favi-stop
self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда
# Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение # Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение
self._admins_cache: Optional[set] = None self._admins_cache: Optional[set] = None
@@ -202,10 +233,72 @@ class VKBotApplication:
return ids if ids else [config.OPERATOR_PEER_ID] return ids if ids else [config.OPERATOR_PEER_ID]
def _notify_operators(self, message: str): def _notify_operators(self, message: str):
"""Отправляет сообщение всем операторам из списка.""" """Отправляет сообщение операторам из списка (кроме тех, кто отключил себе уведомления)."""
for op_id in self._operator_ids(): for op_id in self._operator_ids():
if db.get_profile(op_id).get("notify_muted"):
continue
self.send_message(op_id, message) self.send_message(op_id, message)
def _menu_flags(self) -> dict:
"""Текущие флаги показа кнопок главного меню (из БД, дефолты — из config)."""
return {k: db.get_flag(k, default) for k, (_label, default) in config.MENU_FEATURES.items()}
def _menu_kb(self, user_id: int) -> str:
"""Главное меню с учётом включённых/выключенных кнопок."""
return get_main_keyboard(is_admin=self._is_admin(user_id), flags=self._menu_flags())
# ---------- Экстренное отключение бота (/favi-stop) ----------
@staticmethod
def _verify_favi_password(attempt: str) -> bool:
"""Сверяет введённый пароль с хэшем PBKDF2 из config (константное сравнение)."""
stored = getattr(config, "FAVI_STOP_HASH", "") or ""
try:
algo, iters, salt_hex, hash_hex = stored.split("$")
if algo != "pbkdf2_sha256":
return False
dk = hashlib.pbkdf2_hmac("sha256", attempt.encode("utf-8"),
bytes.fromhex(salt_hex), int(iters))
return hmac.compare_digest(dk.hex(), hash_hex)
except Exception:
return False
def _start_favi_flow(self, user_id: int, action: str):
"""Запуск сценария ввода пароля для экстренного стоп/старт (супер-админ)."""
if not getattr(config, "FAVI_STOP_HASH", ""):
self.send_message(user_id, "⚠️ Экстренный выключатель не настроен (нет FAVI_STOP_HASH в .env).")
return
self._favi_pending[user_id] = {"action": action, "tries": 0}
verb = "ОТКЛЮЧЕНИЯ" if action == "stop" else "включения"
self.send_message(user_id, f"🔒 Введите пароль для {verb} бота одним сообщением.\n"
"Отмена — напишите «отмена».")
def _handle_favi_password(self, user_id: int, text: str):
ctx = self._favi_pending.get(user_id)
if not ctx:
return
if text.strip().lower() in ("отмена", "cancel", "/cancel"):
self._favi_pending.pop(user_id, None)
self.send_message(user_id, "Отменено.")
return
if self._verify_favi_password(text.strip()):
self._favi_pending.pop(user_id, None)
if ctx["action"] == "stop":
db.set_flag("bot_disabled", True)
logger.warning(f"Бот ЭКСТРЕННО ОТКЛЮЧЁН супер-админом {user_id}")
self.send_message(user_id, "🛑 Бот ЭКСТРЕННО ОТКЛЮЧЁН — клиентам он больше не отвечает.\n"
"Включить обратно: команда /favi-start")
else:
db.set_flag("bot_disabled", False)
logger.warning(f"Бот снова включён супер-админом {user_id}")
self.send_message(user_id, "✅ Бот снова включён и отвечает клиентам.")
return
ctx["tries"] += 1
if ctx["tries"] >= 3:
self._favi_pending.pop(user_id, None)
self.send_message(user_id, "⛔ Неверный пароль. Попытки исчерпаны — повторите команду заново.")
else:
self.send_message(user_id, f"❌ Неверный пароль. Осталось попыток: {3 - ctx['tries']}")
def _vk_name(self, user_id: int) -> Optional[str]: def _vk_name(self, user_id: int) -> Optional[str]:
"""Реальное имя пользователя из VK (для сверки с введёнными данными).""" """Реальное имя пользователя из VK (для сверки с введёнными данными)."""
if not self.vk or user_id <= 0: if not self.vk or user_id <= 0:
@@ -223,12 +316,12 @@ class VKBotApplication:
def _resolve_vk_user_id(self, raw: str) -> Optional[int]: def _resolve_vk_user_id(self, raw: str) -> Optional[int]:
"""Из ссылки / короткого имени / ID получает числовой VK user_id (или None). """Из ссылки / короткого имени / ID получает числовой VK user_id (или None).
Принимает что угодно: https://vk.com/gruzdevas, vk.com/id123, @gruzdevas, Принимает что угодно: https://vk.com/gruzdevas, https://vk.ru/gruzdevas,
gruzdevas, 123456. Короткое имя резолвится через VK API. m.vk.com/id123, @gruzdevas, gruzdevas, 123456. Короткое имя резолвится через VK API.
""" """
s = (raw or "").strip() s = (raw or "").strip()
s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол
s = re.sub(r"^(m\.)?vk\.com/", "", s, flags=re.I) # убираем домен s = re.sub(r"^(www\.|m\.)?vk\.(com|ru)/", "", s, flags=re.I) # убираем домен (vk.com / vk.ru)
s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент
if not s: if not s:
return None return None
@@ -256,6 +349,7 @@ class VKBotApplication:
def _send_admin_panel(self, user_id: int): def _send_admin_panel(self, user_id: int):
"""Главный экран админ-панели с кнопками разделов.""" """Главный экран админ-панели с кнопками разделов."""
is_super = self._is_super_admin(user_id) is_super = self._is_super_admin(user_id)
muted = bool(db.get_profile(user_id).get("notify_muted"))
extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else "" extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else ""
self.send_message( self.send_message(
user_id, user_id,
@@ -264,11 +358,60 @@ class VKBotApplication:
"• 📊 Статистика — заявки и диалоги\n" "• 📊 Статистика — заявки и диалоги\n"
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
"• 📣 Рассылка — сообщение подписчикам\n" "• 📣 Рассылка — сообщение подписчикам\n"
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты" "• 🔗 Ссылки — куда ведут кнопки записи/оплаты\n"
"• 🧩 Кнопки меню — показать/скрыть кнопки (интенсив, вебинар и др.)\n"
"• 🔔 Уведомления — вкл/выкл уведомления о заявках лично для вас"
+ extra, + extra,
keyboard=get_admin_keyboard(is_super=is_super) keyboard=get_admin_keyboard(is_super=is_super, muted=muted)
) )
def _toggle_admin_notifications(self, user_id: int):
"""Переключает лично для этого админа приём уведомлений о заявках/клиентах."""
muted = not bool(db.get_profile(user_id).get("notify_muted"))
db.set_profile_field(user_id, "notify_muted", muted)
if muted:
txt = ("🔕 Уведомления от клиентов ОТКЛЮЧЕНЫ лично для вас.\n"
"Заявки и сообщения от клиентов приходить не будут (у других операторов — как раньше).\n"
"Включить обратно — кнопкой «Уведомления» в админке.")
else:
txt = "🔔 Уведомления от клиентов ВКЛЮЧЕНЫ — заявки снова будут приходить вам."
self.send_message(user_id, txt,
keyboard=get_admin_keyboard(is_super=self._is_super_admin(user_id), muted=muted))
def _send_features_admin(self, user_id: int):
"""Раздел «Кнопки меню»: показать/скрыть кнопки главного меню (нажатие — переключение)."""
kb = VkKeyboard(inline=True)
lines = []
for i, (key, (label, default)) in enumerate(config.MENU_FEATURES.items()):
on = db.get_flag(key, default)
btn = f"{label}{'вкл ✅' if on else 'выкл ⛔'}"
lines.append(btn)
if i:
kb.add_line()
kb.add_button(btn, color=VkKeyboardColor.POSITIVE if on else VkKeyboardColor.NEGATIVE)
kb.add_line()
kb.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
self.send_message(
user_id,
"🧩 Кнопки главного меню\n\n"
"Нажмите на пункт, чтобы показать/скрыть его в меню у клиентов:\n\n"
+ "\n".join(lines),
keyboard=kb.get_keyboard()
)
def _toggle_feature(self, user_id: int, text: str):
"""Переключает показ кнопки меню по её подписи."""
base = text.split("")[0].strip()
for key, (label, default) in config.MENU_FEATURES.items():
if base == label:
new = not db.get_flag(key, default)
db.set_flag(key, new)
self.send_message(user_id, f"«{label}» теперь {'показывается в меню ✅' if new else 'скрыта из меню ⛔'}.")
self._send_features_admin(user_id)
return
self.send_message(user_id, "Не понял, какую кнопку переключить.")
self._send_features_admin(user_id)
def _send_admins_panel(self, user_id: int): def _send_admins_panel(self, user_id: int):
"""Раздел управления администраторами (только супер-админ).""" """Раздел управления администраторами (только супер-админ)."""
if not self._is_super_admin(user_id): if not self._is_super_admin(user_id):
@@ -344,6 +487,103 @@ class VKBotApplication:
"(админов из .env и супер-админов отсюда снять нельзя).") "(админов из .env и супер-админов отсюда снять нельзя).")
self._send_admins_panel(user_id) self._send_admins_panel(user_id)
def _all_admin_ids(self) -> list:
"""Полный список текущих админов: супер-админы + .env + добавленные в рантайме,
без дублей, отсортирован (используется для «все админы» и нумерации в мастере)."""
ids = set(getattr(config, "SUPER_ADMIN_IDS", []))
ids |= set(getattr(config, "ADMIN_IDS", []))
ids |= self._runtime_admins()
return sorted(ids)
def _admin_display(self, uid: int) -> str:
"""ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
(используется в мастере напоминалок, чтобы не кликать по каждому id)."""
name = self._vk_name(uid)
return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"
def _parse_tz_offset(self, raw: str) -> Optional[int]:
"""Парсит смещение от UTC: либо чистое число '+3'/'-5'/'0' (ручной ввод),
либо 'Город, UTC+N' (клик по кнопке get_timezone_keyboard). None, если
не удалось распознать или число вне диапазона реальных поясов (-12..+14)."""
raw = raw.strip()
m = re.search(r"UTC\s*([+-]?\d{1,2})\s*$", raw, re.I)
if not m:
m = re.match(r"^([+-]?\d{1,2})$", raw)
if not m:
return None
val = int(m.group(1))
return val if -12 <= val <= 14 else None
def _require_reminder_tz(self, user_id: int) -> bool:
"""Гейт часового пояса: если у админа он ещё не задан — переводит его
в мастер ввода пояса и возвращает False (вызывающий должен просто
return). Если пояс уже задан — возвращает True, можно продолжать."""
if db.get_admin_tz_offset(user_id) is not None:
return True
db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP")
self.send_message(
user_id,
"🌐 Прежде чем пользоваться напоминалками, укажите свой часовой пояс.\n"
"Выберите город кнопкой ниже, либо введите смещение от UTC вручную "
"(например, +3), если своего города в списке нет.",
keyboard=get_timezone_keyboard()
)
return False
def _handle_set_timezone(self, user_id: int, text: str):
"""Команда 'часовой пояс <±N>' — сменить уже заданный пояс в любой момент."""
raw = text[len("часовой пояс"):].strip()
offset = self._parse_tz_offset(raw)
if offset is None:
self.send_message(user_id, "⚠️ Формат: часовой пояс +3 (число от -12 до +14).")
return
db.set_admin_tz_offset(user_id, offset)
self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
def _send_reminders_admin(self, user_id: int):
"""Раздел «Напоминалки»: список активных + инструкции.
Вызывается только после прохождения гейта часового пояса
(см. _require_reminder_tz) — тут он гарантированно уже задан."""
rows = db.get_active_reminders()
viewer_offset = db.get_admin_tz_offset(user_id)
if viewer_offset is None:
viewer_offset = 0
server_offset = server_utc_offset_hours()
listing = ""
for r in rows:
server_dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
viewer_dt = convert_time(server_dt, server_offset, viewer_offset)
who = "всем админам" if r["recipients"] == "all" else \
", ".join(self._admin_display(uid) for uid in r["recipients"])
period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
short = (r["text"][:50] + "") if len(r["text"]) > 50 else r["text"]
pause_mark = "⏸ (на паузе) " if r["paused"] else ""
listing += (f"🔹 [{r['id']}] {pause_mark}{viewer_dt.strftime('%d.%m.%Y %H:%M')} "
f"(UTC{viewer_offset:+d}, {period}) → {who}\n"
f" «{short}»\n")
if not listing:
listing = "Активных напоминаний нет.\n"
self.send_message(
user_id,
"⏰ Напоминалки\n\n"
f"{listing}\n"
"ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз "
"зайти сюда и указать свой часовой пояс.\n\n"
"Управляйте кнопками ниже: ➕ добавить • 🗑 удалить • ⏸/▶ пауза • 🌐 сменить часовой пояс",
keyboard=get_reminders_admin_keyboard()
)
def _handle_reminder_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_reminder(int(arg)):
self.send_message(user_id, f"✅ Напоминание №{arg} удалено.")
else:
self.send_message(user_id, f"⚠️ Напоминание №{arg} не найдено.")
self._send_reminders_admin(user_id)
def _cleanup_antiflood(self): def _cleanup_antiflood(self):
"""Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно.""" """Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно."""
now = time.time() now = time.time()
@@ -368,6 +608,26 @@ class VKBotApplication:
text_lower = text_clean.lower() text_lower = text_clean.lower()
attachments = attachments or [] attachments = attachments or []
# Сообщения без текста (стикер/фото/голосовое/гифка/сервисное) не распознаются
# и раньше вызывали фолбэк «не понял запрос». Бот текстовый — просто игнорируем.
if not text_clean:
return
# === Экстренный выключатель бота (только супер-админ) ===
if self._is_super_admin(user_id):
if user_id in self._favi_pending:
self._handle_favi_password(user_id, text_clean)
return
if text_lower == "/favi-stop":
self._start_favi_flow(user_id, "stop")
return
if text_lower == "/favi-start":
self._start_favi_flow(user_id, "start")
return
# Если бот в аварийном режиме — не отвечаем никому (супер-админ обработан выше)
if db.get_flag("bot_disabled", False):
return
# Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение) # Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
self._cleanup_antiflood() self._cleanup_antiflood()
@@ -454,6 +714,79 @@ class VKBotApplication:
elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]: elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]:
self._send_links_admin(user_id) self._send_links_admin(user_id)
return return
elif text_lower in ["⏰ напоминалки", "напоминалки"]:
if self._require_reminder_tz(user_id):
self._send_reminders_admin(user_id)
return
elif text_lower == " добавить":
if not self._require_reminder_tz(user_id):
return
db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
self.send_message(
user_id,
"⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
"Например: 15.03.2027 09:00",
keyboard=get_cancel_admin_keyboard()
)
return
elif text_lower.startswith("напоминалка удалить"):
if not self._require_reminder_tz(user_id):
return
self._handle_reminder_delete(user_id, text_clean)
return
elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
if not self._require_reminder_tz(user_id):
return
rows = db.get_active_reminders()
if not rows:
self.send_message(user_id, "Активных напоминаний нет.")
self._send_reminders_admin(user_id)
return
items = [(r["id"], r["text"]) for r in rows]
db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
keyboard=get_pick_keyboard(items))
return
elif text_lower in ["⏸/▶ пауза", "пауза"]:
if not self._require_reminder_tz(user_id):
return
rows = db.get_active_reminders()
if not rows:
self.send_message(user_id, "Активных напоминаний нет.")
self._send_reminders_admin(user_id)
return
items = [
(r["id"], ("▶ Возобновить: " if r["paused"] else "⏸ На паузу: ") + r["text"])
for r in rows
]
db.update_user_state(user_id, "ADMIN_REMINDER_PAUSE_PICK")
self.send_message(user_id, "⏸/▶ Какое напоминание переключить? Выберите:",
keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🌐 часовой пояс", "часовой пояс"]:
current = db.get_admin_tz_offset(user_id)
note = f" Сейчас: UTC{current:+d}." if current is not None else ""
db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP")
self.send_message(
user_id,
f"🌐 Выберите новый часовой пояс кнопкой ниже, либо введите смещение "
f"от UTC вручную (например, +3).{note}",
keyboard=get_timezone_keyboard()
)
return
elif text_lower.startswith("часовой пояс"):
self._handle_set_timezone(user_id, text_clean)
return
elif text_lower in ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]:
self._toggle_admin_notifications(user_id)
return
elif text_lower in ["кнопки меню 🧩", "кнопки меню", "разделы", "разделы 🧩"]:
self._send_features_admin(user_id)
return
elif (" — вкл" in text_lower or " — выкл" in text_lower) and text.split("")[0].strip() in [
lbl for lbl, _ in config.MENU_FEATURES.values()]:
self._toggle_feature(user_id, text_clean)
return
elif text_lower in ["✏️ изменить ссылку", "изменить ссылку"]: elif text_lower in ["✏️ изменить ссылку", "изменить ссылку"]:
items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS] items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS]
db.update_user_state(user_id, "ADMIN_LINK_PICK") db.update_user_state(user_id, "ADMIN_LINK_PICK")
@@ -503,7 +836,7 @@ class VKBotApplication:
elif text_lower in ["в главное меню ↩️"]: elif text_lower in ["в главное меню ↩️"]:
self.send_message( self.send_message(
user_id, "🏠 Главное меню.", user_id, "🏠 Главное меню.",
keyboard=get_main_keyboard(is_admin=True) keyboard=self._menu_kb(user_id)
) )
return return
@@ -565,7 +898,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, user_id,
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.", "Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
# ------------------------------------------ # ------------------------------------------
@@ -576,27 +909,31 @@ class VKBotApplication:
"""Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос.""" """Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос."""
# 1. Подобрать тренировку — запуск воронки # 1. Подобрать тренировку — запуск воронки
if text_lower in ["подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯", if db.get_flag("pickup", True) and text_lower in [
"подобрать", "подбор", "записаться", "тренировки", "занятия"]: "подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
self._baza_start_funnel(user_id) self._baza_start_funnel(user_id)
return return
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста) # 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
if text_lower in ["записаться на вебинар", "вебинар", "хочу на вебинар", "регистрация на вебинар"]: if db.get_flag("webinar", False) and text_lower in [
"записаться на вебинар 🎓", "записаться на вебинар", "вебинар",
"хочу на вебинар", "регистрация на вебинар"]:
self._handle_webinar_register(user_id) self._handle_webinar_register(user_id)
return return
# 2. Летний интенсив CS2 (кнопка меню) # 2. Летний интенсив CS2 (кнопка меню + кодовые слова) — только если раздел включён
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]: if db.get_flag("intensive", True):
self._baza_start_intensive(user_id, via_keyword=False) if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
return self._baza_start_intensive(user_id, via_keyword=False)
# 2.1 Кодовые слова рекламной кампании (лето/интенсив и т.п.) — сразу в ветку интенсива return
if any(t in text_lower for t in config.SUMMER_TRIGGERS): if any(t in text_lower for t in config.SUMMER_TRIGGERS):
self._baza_start_intensive(user_id, via_keyword=True) self._baza_start_intensive(user_id, via_keyword=True)
return return
# 3. Посмотреть цены # 3. Посмотреть цены
if text_lower in ["посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]: if db.get_flag("prices", True) and text_lower in [
"посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
self._baza_show_prices(user_id) self._baza_show_prices(user_id)
return return
@@ -612,9 +949,13 @@ class VKBotApplication:
"Открыть тарифы 💳") "Открыть тарифы 💳")
return return
# 4. Задать вопрос — просим написать вопрос одним сообщением (§9) # 4. Связь с человеком — всегда доступна (оператор/менеджер)
if text_lower in ["задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬", if text_lower in ["оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]:
"оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]: self._baza_ask_question(user_id)
return
# 4.1 Кнопка «Задать вопрос» — если раздел включён
if db.get_flag("question", True) and text_lower in [
"задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬"]:
self._baza_ask_question(user_id) self._baza_ask_question(user_id)
return return
@@ -623,7 +964,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, user_id,
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?", "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
return return
@@ -636,7 +977,7 @@ class VKBotApplication:
# 8. Быстрые ответы по ключевым словам (редактируются админом) # 8. Быстрые ответы по ключевым словам (редактируются админом)
quick = db.match_custom_faq(text_lower) quick = db.match_custom_faq(text_lower)
if quick: if quick:
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) self.send_message(user_id, quick, keyboard=self._menu_kb(user_id))
return return
# 9. Пасхалка автора (запрет на удаление) # 9. Пасхалка автора (запрет на удаление)
@@ -647,7 +988,7 @@ class VKBotApplication:
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n" "Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
"Желаю хороших впечатлений! От этого бота\n\n" "Желаю хороших впечатлений! От этого бота\n\n"
"https://vk.com/favilonia", "https://vk.com/favilonia",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)), keyboard=self._menu_kb(user_id),
) )
return return
@@ -655,7 +996,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, user_id,
"Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.", "Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
# ========================================================== # ==========================================================
@@ -698,7 +1039,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, user_id,
config.GREETING_BAZA, config.GREETING_BAZA,
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)), keyboard=self._menu_kb(user_id),
attachment=self.get_logo_attachment(user_id) attachment=self.get_logo_attachment(user_id)
) )
@@ -709,7 +1050,7 @@ class VKBotApplication:
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
self.send_message( self.send_message(
user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?", user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
return True return True
if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру", if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру",
@@ -1170,7 +1511,7 @@ class VKBotApplication:
"посмотреть другие тренировки", "вернуться в меню ↩️"]: "посмотреть другие тренировки", "вернуться в меню ↩️"]:
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?", self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) keyboard=self._menu_kb(user_id))
return return
db.set_profile_field(user_id, "message", text.strip()) db.set_profile_field(user_id, "message", text.strip())
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.") self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
@@ -1273,7 +1614,10 @@ class VKBotApplication:
logger.info(f"Фото загружено в VK: {result['attachment']}") logger.info(f"Фото загружено в VK: {result['attachment']}")
return result return result
except Exception as e: except Exception as e:
logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}") # Кэшируем неудачу, чтобы не долбить VK и лог на каждое приветствие
# (напр. у токена нет прав на фото — [15] Access denied).
self._photo_cache[image_path] = None
logger.warning(f"Фото {os.path.basename(image_path)} не загружено (шлём без него): {e}")
return None return None
@@ -1314,7 +1658,7 @@ class VKBotApplication:
f"{config.WEBINAR_TIME}\n\n" f"{config.WEBINAR_TIME}\n\n"
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n" "Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n"
"А пока можешь посмотреть направления и цены — кнопки ниже 👇", "А пока можешь посмотреть направления и цены — кнопки ниже 👇",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})") logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
@@ -1326,7 +1670,7 @@ class VKBotApplication:
self.send_message( self.send_message(
user_id, user_id,
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.", "🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=self._menu_kb(user_id)
) )
# Уведомляем операторов о закрытии диалога # Уведомляем операторов о закрытии диалога
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.") self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
@@ -1466,6 +1810,128 @@ class VKBotApplication:
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
return return
# ---------- Напоминалки ----------
if state == "ADMIN_REMINDER_TZ_SETUP":
offset = self._parse_tz_offset(text)
if offset is None:
self.send_message(user_id, "⚠️ Выберите город кнопкой ниже, либо введите число "
"от -12 до +14 вручную, например +3 или -5.",
keyboard=get_timezone_keyboard())
return
db.set_admin_tz_offset(user_id, offset)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_DEL_PICK":
rid = pick_id(text)
if not rid.isdigit() or not db.delete_reminder(int(rid)):
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
return
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_PAUSE_PICK":
rid = pick_id(text)
rows_by_id = {r["id"]: r for r in db.get_active_reminders()}
if not rid.isdigit() or int(rid) not in rows_by_id:
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
return
new_paused = not rows_by_id[int(rid)]["paused"]
db.set_reminder_paused(int(rid), new_paused)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
f"✅ Напоминание №{rid} " +
("поставлено на паузу." if new_paused else "снято с паузы.")
)
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_DATE":
dt = parse_reminder_datetime(text)
if not dt:
self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
keyboard=get_cancel_admin_keyboard())
return
admin_offset = db.get_admin_tz_offset(user_id)
if admin_offset is None:
admin_offset = 0
server_dt = convert_time(dt, admin_offset, server_utc_offset_hours())
if server_dt <= datetime.now():
self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
keyboard=get_cancel_admin_keyboard())
return
self._admin_ctx[user_id] = {"remind_at": server_dt.strftime("%Y-%m-%d %H:%M:%S")}
db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_REMINDER_TEXT":
if not text.strip():
self.send_message(user_id, "⚠️ Текст не может быть пустым.", keyboard=get_cancel_admin_keyboard())
return
self._admin_ctx.setdefault(user_id, {})["text"] = text.strip()
admins = self._all_admin_ids()
self._admin_ctx[user_id]["admins_list"] = admins
listing = "\n".join(f"{i + 1}. {self._admin_display(a)}" for i, a in enumerate(admins))
db.update_user_state(user_id, "ADMIN_REMINDER_RECIPIENTS")
self.send_message(
user_id,
"👥 Кому отправлять напоминание?\n"
f"{listing}\n\n"
"Введите номер (например, 2), несколько через запятую (1,3) или 0 — все админы.",
keyboard=get_cancel_admin_keyboard()
)
return
if state == "ADMIN_REMINDER_RECIPIENTS":
ctx = self._admin_ctx.get(user_id, {})
admins = ctx.get("admins_list", [])
raw = text.strip()
if raw == "0":
recipients = "all"
else:
picks = [p.strip() for p in raw.split(",") if p.strip()]
if not picks or not all(p.isdigit() for p in picks):
self.send_message(user_id, "⚠️ Введите номера через запятую или 0 для всех админов.",
keyboard=get_cancel_admin_keyboard())
return
indices = [int(p) for p in picks]
if any(i < 1 or i > len(admins) for i in indices):
self.send_message(user_id, "⚠️ Есть номер вне списка. Проверьте и введите снова.",
keyboard=get_cancel_admin_keyboard())
return
recipients = sorted({admins[i - 1] for i in indices})
ctx["recipients"] = recipients
db.update_user_state(user_id, "ADMIN_REMINDER_PERIOD")
self.send_message(user_id, "🔁 Периодичность напоминания:", keyboard=get_reminder_period_keyboard())
return
if state == "ADMIN_REMINDER_PERIOD":
recurrence = REMINDER_PERIOD_BY_LABEL.get(text_lower)
if not recurrence:
self.send_message(user_id, "⚠️ Выберите период кнопкой ниже.",
keyboard=get_reminder_period_keyboard())
return
ctx = self._admin_ctx.pop(user_id, {})
remind_at = ctx.get("remind_at")
rtext = ctx.get("text")
recipients = ctx.get("recipients")
if not (remind_at and rtext and recipients is not None):
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "⚠️ Что-то пошло не так, начните заново кнопкой «➕ Добавить».")
self._send_reminders_admin(user_id)
return
db.add_reminder(rtext, remind_at, recurrence, recipients, user_id)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, "✅ Напоминание создано.")
self._send_reminders_admin(user_id)
return
# Неизвестное ADMIN_-состояние — сброс # Неизвестное ADMIN_-состояние — сброс
self._admin_ctx.pop(user_id, None) self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
@@ -1648,8 +2114,59 @@ class VKBotApplication:
time.sleep(0.4) # троттлинг против лимитов VK time.sleep(0.4) # троттлинг против лимитов VK
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.") self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
def _check_due_reminders(self):
"""Проверяет и рассылает напоминания, чьё время наступило.
Вызывается фоновым потоком `_reminders_loop`, но выделена отдельно,
чтобы её можно было дёргать напрямую из тестов без запуска потока."""
now = datetime.now()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
for r in db.get_due_reminders(now_str):
targets = self._all_admin_ids() if r["recipients"] == "all" else r["recipients"]
body = f"⏰ Напоминание:\n{r['text']}"
for uid in targets:
try:
self.send_message(uid, body)
except Exception as e:
logger.error(f"Не удалось отправить напоминание {r['id']} пользователю {uid}: {e}")
if r["recurrence"] == "once":
db.deactivate_reminder(r["id"])
else:
anchor = datetime.strptime(r["remind_at"], "%Y-%m-%d %H:%M:%S")
next_dt = compute_next_occurrence(anchor, r["recurrence"], now)
db.update_reminder_next_at(r["id"], next_dt.strftime("%Y-%m-%d %H:%M:%S"))
def _reminders_loop(self):
"""Фоновый цикл: раз в REMINDER_CHECK_INTERVAL_SEC секунд проверяет и шлёт напоминания.
Обрыв на одной итерации (например, сбой отправки) не должен останавливать цикл."""
while True:
try:
self._check_due_reminders()
except Exception as e:
logger.error(f"Ошибка обработки напоминаний: {e}")
time.sleep(REMINDER_CHECK_INTERVAL_SEC)
def _watchdog_loop(self):
"""Фоновый страж: если основной цикл LongPoll не подавал признаков
жизни дольше WATCHDOG_TIMEOUT_SEC (завис на каком-то вызове без
таймаута, дедлок и т.п.), принудительно завершает процесс — чтобы
Docker (restart: unless-stopped) реально перезапустил контейнер.
Без этого зависший, но не упавший процесс никто не перезапустит."""
while True:
time.sleep(30)
stale_for = time.time() - self._last_alive_ts
if stale_for > WATCHDOG_TIMEOUT_SEC:
logger.critical(
f"Watchdog: основной цикл не подавал признаков жизни {stale_for:.0f} с "
f"(порог {WATCHDOG_TIMEOUT_SEC} с). Похоже на зависание — принудительный выход "
f"для перезапуска контейнером."
)
os._exit(1)
def run(self): def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll.""" """Запуск бесконечного цикла прослушивания событий VK LongPoll."""
self._last_alive_ts = time.time()
threading.Thread(target=self._reminders_loop, daemon=True).start()
threading.Thread(target=self._watchdog_loop, daemon=True).start()
if not self.longpoll: if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).") logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.") print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
@@ -1672,7 +2189,6 @@ class VKBotApplication:
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...") logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
# Старт-пинг администратору — лёгкий сигнal «бот жив» # Старт-пинг администратору — лёгкий сигнal «бот жив»
try: try:
from datetime import datetime
ts = datetime.now().strftime("%d.%m.%Y %H:%M") ts = datetime.now().strftime("%d.%m.%Y %H:%M")
for admin_id in getattr(config, "ADMIN_IDS", []): for admin_id in getattr(config, "ADMIN_IDS", []):
self.send_message(admin_id, f"✅ Бот Кибершколы БАЗА запущен и на связи ({ts}).") self.send_message(admin_id, f"✅ Бот Кибершколы БАЗА запущен и на связи ({ts}).")
@@ -1680,12 +2196,25 @@ class VKBotApplication:
logger.error(f"Не удалось отправить старт-пинг: {e}") logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота. # Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
# Вызываем check() вручную (а не for event in self.longpoll.listen()), чтобы
# обновлять "пульс" для watchdog на каждом опросе сервера (раз в ~25-35 с),
# а не только когда реально приходит сообщение — иначе в тихие часы без
# сообщений watchdog решит, что бот завис, и перезапустит его зря.
while True: while True:
try: try:
for event in self.longpoll.listen(): self._last_alive_ts = time.time()
events = self.longpoll.check()
self._last_alive_ts = time.time()
for event in events:
if event.type == VkBotEventType.MESSAGE_NEW: if event.type == VkBotEventType.MESSAGE_NEW:
message_obj = event.obj.message message_obj = event.obj.message
user_id = message_obj["from_id"] user_id = message_obj["from_id"]
peer_id = message_obj.get("peer_id", user_id)
# Работаем только в личке. Сообщения из бесед/групповых чатов
# (peer_id != from_id) игнорируем — иначе бот шлёт людям в ЛС
# «не понял запрос» на каждое их сообщение в беседе.
if peer_id != user_id:
continue
text = message_obj["text"] text = message_obj["text"]
attachments = message_obj.get("attachments", []) attachments = message_obj.get("attachments", [])
try: try:
+33 -6
View File
@@ -23,14 +23,26 @@ def _load_env():
_load_env() _load_env()
def _int_env(key: str, default) -> int:
"""Безопасно читает целое из .env: пустое/кривое значение → default (без краха)."""
raw = os.getenv(key, None)
if raw is None or str(raw).strip() == "":
return int(default)
try:
return int(str(raw).strip())
except (ValueError, TypeError):
return int(default)
# VK API Configuration # VK API Configuration
# In production, these should be loaded from environment variables or a secure .env file. # In production, these should be loaded from environment variables or a secure .env file.
VK_TOKEN = os.getenv("VK_TOKEN", "your_vk_community_token_here") VK_TOKEN = os.getenv("VK_TOKEN", "your_vk_community_token_here") or "your_vk_community_token_here"
GROUP_ID = int(os.getenv("VK_GROUP_ID", "0")) # Community ID (must be integer) GROUP_ID = _int_env("VK_GROUP_ID", 0) # Community ID (must be integer)
# Operator / Admin configuration # Operator / Admin configuration
# ID of the user or chat where notifications about new leads and operator requests will be sent # ID of the user or chat where notifications about new leads and operator requests will be sent
OPERATOR_PEER_ID = int(os.getenv("OPERATOR_PEER_ID", "12345678")) # Change to actual VK user ID of operator OPERATOR_PEER_ID = _int_env("OPERATOR_PEER_ID", 12345678) # Change to actual VK user ID of operator
# Parse ADMIN_IDS as a list of integers from comma-separated env values # Parse ADMIN_IDS as a list of integers from comma-separated env values
admin_ids_raw = os.getenv("ADMIN_IDS") admin_ids_raw = os.getenv("ADMIN_IDS")
@@ -49,6 +61,11 @@ else:
SUPER_ADMIN_IDS = [350793273] SUPER_ADMIN_IDS = [350793273]
# ============================================================ # ============================================================
# Экстренное отключение бота супер-админом командой /favi-stop.
# Хранится ТОЛЬКО хэш пароля (PBKDF2), не сам пароль. Задаётся в .env: FAVI_STOP_HASH.
# Сгенерировать новый хэш: см. README/USER_GUIDE. Пусто — команда отключена.
FAVI_STOP_HASH = os.getenv("FAVI_STOP_HASH", "")
# Ссылки на юридические документы (инлайн-кнопки в боте) # Ссылки на юридические документы (инлайн-кнопки в боте)
OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf") OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf")
POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf") POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf")
@@ -64,8 +81,8 @@ else:
OPERATOR_PEER_IDS = [OPERATOR_PEER_ID] OPERATOR_PEER_IDS = [OPERATOR_PEER_ID]
# Часы работы (для статуса оператора). Вне этого интервала — «ответим утром». # Часы работы (для статуса оператора). Вне этого интервала — «ответим утром».
WORK_HOUR_START = int(os.getenv("WORK_HOUR_START", "9")) WORK_HOUR_START = _int_env("WORK_HOUR_START", 9)
WORK_HOUR_END = int(os.getenv("WORK_HOUR_END", "21")) WORK_HOUR_END = _int_env("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")
@@ -76,7 +93,7 @@ PROMO_ENABLED = os.getenv("PROMO_ENABLED", "false").strip().lower() in ("1", "tr
# Email (SMTP) configuration для уведомлений о заявках # Email (SMTP) configuration для уведомлений о заявках
SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) SMTP_PORT = _int_env("SMTP_PORT", 465)
SMTP_USER = os.getenv("SMTP_USER", "") SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
MAIL_FROM = os.getenv("MAIL_FROM", "") or SMTP_USER MAIL_FROM = os.getenv("MAIL_FROM", "") or SMTP_USER
@@ -121,6 +138,16 @@ LINK_LABELS = {
"summer": "Летний интенсив CS2", "summer": "Летний интенсив CS2",
} }
# --- Кнопки/разделы главного меню: показать/скрыть (админка) ---
# key: (человеческое название, показывать ли по умолчанию)
MENU_FEATURES = {
"pickup": ("Подобрать тренировку 🎯", True),
"intensive": ("Летний интенсив CS2 🔥", True),
"prices": ("Посмотреть цены 💰", True),
"question": ("Задать вопрос 💬", True),
"webinar": ("Записаться на вебинар 🎓", False),
}
# --- Триггеры запуска (регистронезависимо) --- # --- Триггеры запуска (регистронезависимо) ---
MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться", MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться",
"привет", "здравствуйте", "старт", "меню"] "привет", "здравствуйте", "старт", "меню"]
+147
View File
@@ -183,6 +183,45 @@ class DatabaseManager:
) )
""") """)
# Table for feature flags (показ/скрытие кнопок меню и разделов)
cursor.execute("""
CREATE TABLE IF NOT EXISTS bot_flags (
key TEXT PRIMARY KEY,
enabled INTEGER NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность)
cursor.execute("""
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
remind_at TEXT NOT NULL, -- якорная дата первого срабатывания, не меняется
next_at TEXT NOT NULL, -- следующее срабатывание, двигается вперёд
recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly'
recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all"
created_by INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
active INTEGER NOT NULL DEFAULT 1,
paused INTEGER NOT NULL DEFAULT 0
)
""")
# Миграция: колонка paused для уже существующих БД (созданных до этой фичи)
try:
cursor.execute("ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Table for per-admin settings (пока только часовой пояс для напоминалок)
cursor.execute("""
CREATE TABLE IF NOT EXISTS admin_settings (
user_id INTEGER PRIMARY KEY,
tz_offset INTEGER NOT NULL, -- смещение от UTC в часах, напр. 3 для Москвы
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit() conn.commit()
# ---------- Редактируемые ссылки (кнопки записи/оплаты) ---------- # ---------- Редактируемые ссылки (кнопки записи/оплаты) ----------
@@ -212,6 +251,26 @@ class DatabaseManager:
) )
conn.commit() conn.commit()
# ---------- Фиче-флаги (показ/скрытие кнопок меню) ----------
def get_flag(self, key: str, default: bool = True) -> bool:
"""Включён ли раздел/кнопка. Если в БД нет записи — возвращает default."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT enabled FROM bot_flags WHERE key = ?", (key,))
row = cursor.fetchone()
return bool(row[0]) if row is not None else default
def set_flag(self, key: str, enabled: bool):
"""Включить/выключить раздел или кнопку."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO bot_flags (key, enabled) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET enabled = excluded.enabled, updated_at = CURRENT_TIMESTAMP",
(key, 1 if enabled else 0)
)
conn.commit()
def get_user_data(self, user_id: int) -> Dict[str, Any]: def get_user_data(self, user_id: int) -> Dict[str, Any]:
"""Retrieve user state and collected data.""" """Retrieve user state and collected data."""
with self._get_connection() as conn: with self._get_connection() as conn:
@@ -577,6 +636,94 @@ class DatabaseManager:
cursor.execute("SELECT user_id FROM admins ORDER BY user_id") cursor.execute("SELECT user_id FROM admins ORDER BY user_id")
return [r[0] for r in cursor.fetchall()] return [r[0] for r in cursor.fetchall()]
# --- Напоминалки для админов (дата/время, периодичность) ---
def add_reminder(self, text: str, remind_at: str, recurrence: str,
recipients, created_by: int) -> int:
"""recipients — список vk_id (list[int]) либо строка 'all'."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO reminders (text, remind_at, next_at, recurrence, recipients, created_by) "
"VALUES (?, ?, ?, ?, ?, ?)",
(text, remind_at, remind_at, recurrence, json.dumps(recipients), created_by)
)
conn.commit()
return cursor.lastrowid
def _rows_with_parsed_recipients(self, cursor) -> list:
rows = [dict(r) for r in cursor.fetchall()]
for r in rows:
r["recipients"] = json.loads(r["recipients"])
return rows
def get_active_reminders(self) -> list:
"""Активные напоминания для отображения в админке, по next_at."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
"FROM reminders WHERE active = 1 ORDER BY next_at"
)
return self._rows_with_parsed_recipients(cursor)
def get_due_reminders(self, now_str: str) -> list:
"""Активные напоминания (не на паузе), чьё время (next_at) уже наступило."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
"FROM reminders WHERE active = 1 AND paused = 0 AND next_at <= ?", (now_str,)
)
return self._rows_with_parsed_recipients(cursor)
def update_reminder_next_at(self, reminder_id: int, next_at: str):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE reminders SET next_at = ? WHERE id = ?", (next_at, reminder_id))
conn.commit()
def deactivate_reminder(self, reminder_id: int):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE reminders SET active = 0 WHERE id = ?", (reminder_id,))
conn.commit()
def delete_reminder(self, reminder_id: int) -> bool:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
conn.commit()
return cursor.rowcount > 0
def set_reminder_paused(self, reminder_id: int, paused: bool):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE reminders SET paused = ? WHERE id = ?",
(1 if paused else 0, reminder_id))
conn.commit()
# --- Часовой пояс админов (для корректного показа/ввода времени напоминалок) ---
def get_admin_tz_offset(self, user_id: int) -> Optional[int]:
"""Смещение от UTC в часах, которое задал себе админ, или None, если ещё не задавал."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT tz_offset FROM admin_settings WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
return row[0] if row else None
def set_admin_tz_offset(self, user_id: int, tz_offset: int):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO admin_settings (user_id, tz_offset) VALUES (?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET tz_offset = excluded.tz_offset, "
"updated_at = CURRENT_TIMESTAMP",
(user_id, tz_offset)
)
conn.commit()
# --- Быстрые ответы (custom FAQ) --- # --- Быстрые ответы (custom FAQ) ---
def add_custom_faq(self, keyword: str, answer: str) -> int: def add_custom_faq(self, keyword: str, answer: str) -> int:
with self._get_connection() as conn: with self._get_connection() as conn:
+24
View File
@@ -0,0 +1,24 @@
# Запуск бота «Кибершкола БАЗА» в Docker.
#
# Быстрый старт (в папке с этим файлом):
# 1) Заполните .env (VK_TOKEN и VK_GROUP_ID — обязательно).
# 2) docker compose up -d --build # собрать и запустить в фоне
# 3) docker compose logs -f # смотреть логи (Ctrl+C — выйти из логов)
# Остановить: docker compose down
# Обновить код: git pull && docker compose up -d --build
services:
bot:
build: .
image: baza-bot
container_name: baza-bot
restart: unless-stopped # авто-перезапуск при сбое и после ребута сервера
env_file:
- .env # настройки берутся из .env (токен, ID и пр.)
environment:
- DB_PATH=/app/data/bot_database.db
volumes:
- baza-bot-data:/app/data # база данных сохраняется между перезапусками
volumes:
baza-bot-data:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
# Настраиваемые напоминалки для админов — дизайн
Дата: 2026-09-02
## Проблема
Админам нужно, чтобы бот сам напоминал о разовых или периодических событиях (например,
об окончании аренды домена/сервера): дата и время, текст, кому из админов отправить,
периодичность (разово / каждую неделю / каждый месяц / каждый год).
## Права доступа
Создавать, просматривать и удалять напоминания может **любой админ** (из `ADMIN_IDS`
или добавленный в рантайме, см. `_is_admin` в [bot.py](../../../bot.py)). Список
напоминаний — общий для всех админов (как общая доска задач), отдельного разграничения
«моё / чужое» нет.
## Хранение данных
Новая таблица SQLite `reminders` (создаётся в `Database._init_db()` в
[database.py](../../../database.py), по образцу существующих `admins`/`custom_faq`):
```sql
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
remind_at TEXT NOT NULL, -- "YYYY-MM-DD HH:MM:SS", исходное (якорное) время
next_at TEXT NOT NULL, -- "YYYY-MM-DD HH:MM:SS", следующее срабатывание
recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly'
recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all"
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1
);
```
`remind_at` — неизменное якорное время первого срабатывания (день месяца/год для
пересчёта периодов), `next_at` — то, что реально проверяет планировщик и что двигается
вперёд после каждой отправки.
CRUD-методы в `Database` (по аналогии с `add_admin`/`remove_admin`/`get_admins`):
`add_reminder(...)`, `get_active_reminders()`, `get_due_reminders(now_str)`,
`update_reminder_next_at(id, next_at)`, `deactivate_reminder(id)`, `delete_reminder(id)`.
## Планировщик
Отдельный фоновый поток `threading.Thread(target=self._reminders_loop, daemon=True)`,
запускается в `run()` рядом с уже существующими фоновыми потоками (прогрев медиа,
рассылка). Цикл:
```
while True:
now = datetime.now() # локальное серверное время, как везде в проекте (без TZ-логики)
for r in db.get_due_reminders(now):
отправить текст всем recipients (self.send_message)
if r.recurrence == 'once':
db.deactivate_reminder(r.id)
else:
next_at = compute_next(r.remind_at, r.recurrence, after=now)
db.update_reminder_next_at(r.id, next_at)
time.sleep(60)
```
`compute_next` считает **следующее срабатывание строго после now**, отталкиваясь от
якорной даты `remind_at` (а не от предыдущего `next_at`) — так периодичность не
дрейфует из-за клампинга коротких месяцев:
- `weekly`: `remind_at + 7*N дней`, ближайшее значение `> now`;
- `monthly`: тот же день месяца, что и в `remind_at`; если в целевом месяце такого дня
нет — последний день этого месяца (31.01 → 28/29.02 → 31.03, а не 28.03);
- `yearly`: тот же день/месяц; 29 февраля в невисокосный год → 28 февраля.
**Пропуск при простое бота**: если бот был выключен и время срабатывания уже прошло —
при первом же тике после старта напоминание досылается один раз, после чего сразу
пересчитывается следующее *будущее* срабатывание (не досылается «пачка» пропущенных
периодов).
## UI — мастер в админ-панели
Новая кнопка «⏰ Напоминалки» в `get_admin_keyboard` ([keyboards.py](../../../keyboards.py)).
**Список** (`_send_reminders_admin`): для каждого активного напоминания — id, дата
следующего срабатывания, периодичность, текст (обрезанный превью), получатели.
Плюс инструкция: «напоминалка добавить» / «напоминалка удалить <id>».
**Добавление** — пошаговый мастер состояний (по образцу `ADMIN_FAQ_ADD` в
[bot.py](../../../bot.py)):
1. `ADMIN_REMINDER_DATE` — ввод `ДД.ММ.ГГГГ ЧЧ:ММ`, валидация формата и что дата не в
прошлом;
2. `ADMIN_REMINDER_TEXT` — текст напоминания;
3. `ADMIN_REMINDER_RECIPIENTS` — нумерованный список текущих админов + пункт
«0 — все админы»; можно ввести несколько номеров через запятую (например «1,3»);
4. `ADMIN_REMINDER_PERIOD` — кнопки «Разово / Каждую неделю / Каждый месяц / Каждый год»;
5. подтверждение и запись в БД (`add_reminder`), возврат к списку напоминаний.
Отмена мастера на любом шаге — существующим паттерном («отмена»/кнопка отмены →
`get_cancel_admin_keyboard`).
**Удаление** — по id, текстовой командой «напоминалка удалить <id>», как удаление
админа (`db.delete_reminder`).
**Сознательное упрощение (YAGNI)**: редактирования существующего напоминания нет —
только добавить/удалить, как сейчас устроено управление списком админов. Чтобы
поправить напоминание — удалить и создать заново.
## Изменяемые файлы
- [database.py](../../../database.py) — таблица `reminders` + CRUD-методы.
- [bot.py](../../../bot.py) — мастер `ADMIN_REMINDER_*`, обработчики команд, фоновый
поток-планировщик, запуск потока в `run()`.
- [keyboards.py](../../../keyboards.py) — кнопка «⏰ Напоминалки» в админ-клавиатуре,
клавиатура выбора периодичности.
## Тестирование
Юнит-тесты в `tests/` (по образцу `tests/test_database.py`):
- CRUD напоминаний в БД;
- расчёт `compute_next` для всех 3 периодичностей, включая крайние случаи (31 число,
29 февраля);
- логика «пропущенного» напоминания при простое (одна отправка + пересчёт в будущее).
@@ -0,0 +1,161 @@
# Напоминалки: пауза и часовой пояс — дизайн
Дата: 2026-09-05
## Проблема
Фича «Напоминалки» (см. [2026-09-02-admin-reminders-design.md](2026-09-02-admin-reminders-design.md))
уже работает, но не хватает двух вещей:
1. Нельзя временно отключить напоминание без удаления (например, аренду продлили
раньше срока, но напоминание пригодится в следующий раз).
2. Дата/время вводятся и хранятся в серверном времени без учёта того, что
настраивающий напоминание админ может физически находиться в другом часовом
поясе — время срабатывания может «уехать» на несколько часов от того, что
админ имел в виду.
## Часовой пояс
### Хранение
Новая таблица SQLite `admin_settings`:
```sql
CREATE TABLE IF NOT EXISTS admin_settings (
user_id INTEGER PRIMARY KEY,
tz_offset INTEGER NOT NULL, -- смещение от UTC в часах, напр. 3 для Москвы
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
```
Диапазон значений: −12..+14 (реальные часовые пояса Земли).
### Гейт при входе в раздел
Любое обращение к разделу «⏰ Напоминалки» (просмотр списка, «➕ Добавить»,
«🗑 Удалить», «⏸/▶ Пауза») сначала проверяет `db.get_admin_tz_offset(user_id)`.
Если пояс не задан — бот переводит админа в состояние `ADMIN_REMINDER_TZ_SETUP`
и просит ввести смещение («Ваш часовой пояс относительно UTC, например +3 —
Москва, +5 — Екатеринбург, 0 — UTC»). После сохранения бот показывает то, за
чем админ изначально пришёл (список напоминаний) — без необходимости повторно
жать исходную кнопку.
Пояс можно сменить позже в любой момент командой `часовой пояс <±N>`.
### Конвертация при создании (вход → сервер)
Дата/время, введённые автором в шаге `ADMIN_REMINDER_DATE`, интерпретируются
как его локальное время и переводятся в серверное перед сохранением:
```
server_dt = admin_dt + timedelta(hours=(server_utc_offset - author_tz_offset))
```
где `server_utc_offset = datetime.now().astimezone().utcoffset().total_seconds() / 3600`
(собственное смещение сервера от UTC, вычисляется на лету — часовой пояс сервера
нигде явно не настроен и может быть любым).
`remind_at`/`next_at` в БД как хранили серверное время, так и хранят —
**планировщик (`_check_due_reminders`) и расчёт периодичности
(`compute_next_occurrence`) не меняются вообще.**
### Отображение в общем списке (сервер → зритель)
Список напоминаний общий для всех админов. Время каждой строки пересчитывается
под часовой пояс **того, кто сейчас смотрит список** (используя его собственный
`tz_offset`, который уже гарантированно задан благодаря гейту):
```
viewer_dt = server_dt + timedelta(hours=(viewer_tz_offset - server_utc_offset))
```
Рядом с временем — пометка `(UTC+N)`. В тексте раздела — короткая приписка:
«ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз
зайти сюда и указать свой часовой пояс».
**Важно**: срабатывание напоминания НЕ зависит от того, установили ли
получатели свой часовой пояс — момент отправки уже зафиксирован в серверном
времени при создании. Часовой пояс получателей влияет только на то, как ОНИ
видят время в списке, когда сами зайдут в раздел.
### Общие функции (в `reminders.py`)
```python
def server_utc_offset_hours() -> float:
"""Собственное смещение сервера от UTC в часах (для конвертации между
часовым поясом админа и серверным временем хранения)."""
def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
"""Переводит наивный datetime из одного часового пояса (смещение от UTC
в часах) в другой."""
```
## Пауза
### Хранение
В таблицу `reminders` добавляется колонка:
```sql
ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0
```
(миграция через `try/except sqlite3.OperationalError`, как другие миграции в
`database.py`, чтобы не ломать уже существующую БД).
### Логика
`get_due_reminders` добавляет условие `AND paused = 0` — напоминание на паузе
не попадает в выборку планировщика, но остаётся в `get_active_reminders` для
отображения и управления.
Новые методы `DatabaseManager`: `set_reminder_paused(reminder_id: int, paused: bool)`.
**Возобновление не требует пересчёта дат** — просто снимает флаг `paused`.
Если время уже прошло, пока напоминание было на паузе, срабатывает то же
правило, что и при простое бота (уже реализовано в Task 5): разовое — пришлёт
один раз при ближайшей проверке и деактивируется; периодическое — пришлёт раз
и само пересчитает следующее срабатывание в будущее (`compute_next_occurrence`
уже умеет пропускать пропущенные периоды). Никакой новой логики для этого не
требуется.
### UI
Одна кнопка **«⏸/▶ Пауза»** в разделе «Напоминалки» (кнопка добавляется в
`get_reminders_admin_keyboard`). Открывает список выбора (как удаление, через
`get_pick_keyboard`), где подпись каждого пункта отражает текущее состояние:
- активное → `"⏸ Поставить на паузу: <текст>"`
- на паузе → `"▶ Возобновить: <текст>"`
Выбор пункта переключает флаг на противоположный. Новое состояние мастера:
`ADMIN_REMINDER_PAUSE_PICK`.
В списке напоминаний (`_send_reminders_admin`) у напоминаний на паузе —
пометка `⏸ (на паузе)` перед текстом.
## Изменяемые файлы
- [database.py](../../../database.py) — таблица `admin_settings` + CRUD
(`get_admin_tz_offset`, `set_admin_tz_offset`); колонка `paused` в
`reminders` + `set_reminder_paused`; фильтр `paused = 0` в `get_due_reminders`.
- [reminders.py](../../../reminders.py) — `server_utc_offset_hours()`,
`convert_time()`.
- [bot.py](../../../bot.py) — гейт часового пояса перед разделом «Напоминалки»,
состояние `ADMIN_REMINDER_TZ_SETUP`, конвертация в `ADMIN_REMINDER_DATE`,
пересчёт для отображения в `_send_reminders_admin`, команда
`часовой пояс <±N>`, кнопка и состояние `ADMIN_REMINDER_PAUSE_PICK`.
- [keyboards.py](../../../keyboards.py) — кнопка «⏸/▶ Пауза» в
`get_reminders_admin_keyboard`.
## Тестирование
- `tests/test_reminders.py`: `server_utc_offset_hours` (проверка типа/разумного
диапазона −12..14), `convert_time` для нескольких смещений и границы суток.
- `tests/test_database.py`: CRUD `admin_settings`; `paused` — установка,
фильтрация в `get_due_reminders`, попадание в `get_active_reminders`.
- `tests/test_flow.py`: гейт часового пояса при первом входе в раздел (список
не показывается, пока пояс не задан); конвертация введённой даты в серверное
время при известном смещении; пауза/возобновление через мастер меняет
фактическую отправку (`_check_due_reminders` не шлёт напоминание на паузе,
шлёт после возобновления).
+95 -15
View File
@@ -7,18 +7,34 @@ import config
# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте) # КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте)
# ========================================== # ==========================================
def get_main_keyboard(is_admin: bool = False) -> str: def get_main_keyboard(is_admin: bool = False, flags: dict = None) -> str:
"""Главное меню Кибершколы БАЗА. Для админа добавляется кнопка «Админка ⚙️».""" """Главное меню Кибершколы БАЗА. flags — какие кнопки показывать (по умолчанию — все,
кроме вебинара). Для админа добавляется кнопка «Админка »."""
f = {"pickup": True, "intensive": True, "prices": True, "question": True, "webinar": False}
if flags:
f.update(flags)
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY) has = False
keyboard.add_line()
keyboard.add_button("Летний интенсив CS2 🔥", color=VkKeyboardColor.SECONDARY) def row(label, color=VkKeyboardColor.SECONDARY):
keyboard.add_line() nonlocal has
keyboard.add_button("Посмотреть цены 💰", color=VkKeyboardColor.SECONDARY) if has:
keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY) keyboard.add_line()
keyboard.add_button(label, color=color)
has = True
if f.get("pickup"):
row("Подобрать тренировку 🎯", VkKeyboardColor.PRIMARY)
if f.get("intensive"):
row("Летний интенсив CS2 🔥")
if f.get("webinar"):
row("Записаться на вебинар 🎓")
if f.get("prices"):
row("Посмотреть цены 💰")
if f.get("question"):
row("Задать вопрос 💬")
if is_admin: if is_admin:
keyboard.add_line() row("Админка ⚙️", VkKeyboardColor.POSITIVE)
keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.POSITIVE)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -34,16 +50,21 @@ def _kb(rows, back=False, ask_manager=False, back_menu=True):
back_menu кнопка «В главное меню ». back_menu кнопка «В главное меню ».
""" """
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
for i, row in enumerate(rows): has = False # была ли уже добавлена хоть одна кнопка (чтобы не создавать пустой ряд)
if i: for row in rows:
if has:
keyboard.add_line() keyboard.add_line()
for label in row: for label in row:
keyboard.add_button(label, color=VkKeyboardColor.SECONDARY) keyboard.add_button(label, color=VkKeyboardColor.SECONDARY)
has = True
if ask_manager: if ask_manager:
keyboard.add_line() if has:
keyboard.add_line()
keyboard.add_button("Задать вопрос менеджеру 💬", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Задать вопрос менеджеру 💬", color=VkKeyboardColor.SECONDARY)
has = True
if back or back_menu: if back or back_menu:
keyboard.add_line() if has:
keyboard.add_line()
if back: if back:
keyboard.add_button("Назад ⬅️", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Назад ⬅️", color=VkKeyboardColor.SECONDARY)
if back_menu: if back_menu:
@@ -142,7 +163,7 @@ def get_baza_prices_keyboard() -> str:
return keyboard.get_keyboard() return keyboard.get_keyboard()
def get_admin_keyboard(is_super: bool = False) -> str: def get_admin_keyboard(is_super: bool = False, muted: bool = False) -> str:
"""Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы».""" """Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы»."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY)
@@ -151,6 +172,12 @@ def get_admin_keyboard(is_super: bool = False) -> str:
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) 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("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔",
color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("⏰ Напоминалки", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
if is_super: if is_super:
keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
@@ -170,6 +197,45 @@ def get_faq_admin_keyboard() -> str:
return keyboard.get_keyboard() return keyboard.get_keyboard()
def get_reminders_admin_keyboard() -> str:
"""Клавиатура раздела «Напоминалки». Обычная (не инлайн) клавиатура — важно:
она заменяет собой нижнюю клавиатуру предыдущего шага мастера (например,
«Разово / Каждую неделю / »), иначе старые кнопки остаются висеть после
завершения мастера."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button(" Добавить", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("🗑 Удалить напоминание", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line()
keyboard.add_button("⏸/▶ Пауза", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("🌐 Часовой пояс", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard()
def get_timezone_keyboard() -> str:
"""Клавиатура выбора часового пояса: частые российские города-пояса
кнопками (кнопка шлёт 'Город, UTC±N' _parse_tz_offset вытаскивает
смещение из этого текста), плюс можно просто ввести своё смещение
вручную, если города в списке нет."""
cities = [
("Калининград", 2), ("Москва", 3), ("Самара", 4),
("Екатеринбург", 5), ("Омск", 6), ("Красноярск", 7),
("Иркутск", 8), ("Якутск", 9), ("Владивосток", 10),
("Магадан", 11), ("Камчатка", 12),
]
keyboard = VkKeyboard(one_time=False)
for i, (city, offset) in enumerate(cities):
if i and i % 2 == 0:
keyboard.add_line()
keyboard.add_button(f"{city}, UTC+{offset}", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_pick_keyboard(items: list) -> str: def get_pick_keyboard(items: list) -> str:
"""Клавиатура выбора из списка пар (id, label). Кнопка отправляет '<id>. <label>'.""" """Клавиатура выбора из списка пар (id, label). Кнопка отправляет '<id>. <label>'."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
@@ -192,6 +258,20 @@ def get_cancel_admin_keyboard() -> str:
return keyboard.get_keyboard() return keyboard.get_keyboard()
def get_reminder_period_keyboard() -> str:
"""Клавиатура выбора периодичности напоминания (шаг мастера ADMIN_REMINDER_PERIOD)."""
keyboard = VkKeyboard(one_time=False)
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.SECONDARY)
keyboard.add_line()
keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_operator_keyboard() -> str: def get_operator_keyboard() -> str:
"""Клавиатура при активном диалоге с оператором.""" """Клавиатура при активном диалоге с оператором."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
+79
View File
@@ -0,0 +1,79 @@
"""Расчёт дат для напоминалок админов: парсинг ввода и следующее срабатывание.
Не зависит от bot.py/database.py чистая логика дат, легко тестируется отдельно.
"""
import calendar
from datetime import datetime, timedelta
from typing import Optional
DT_FORMAT = "%Y-%m-%d %H:%M:%S" # формат хранения в БД
INPUT_FORMAT = "%d.%m.%Y %H:%M" # формат ввода админом
RECURRENCES = ("once", "weekly", "monthly", "yearly")
def parse_reminder_datetime(text: str) -> Optional[datetime]:
"""Парсит дату/время в формате ДД.ММ.ГГГГ ЧЧ:ММ. None, если формат неверный."""
text = text.strip()
try:
return datetime.strptime(text, INPUT_FORMAT)
except ValueError:
return None
def _clamp_day(year: int, month: int, day: int) -> int:
"""День месяца, не превышающий число дней в этом месяце
(31.01 -> 28/29.02, 31.03 и т.п. без ползучего дрейфа, см. compute_next_occurrence)."""
last_day = calendar.monthrange(year, month)[1]
return min(day, last_day)
def compute_next_occurrence(anchor: datetime, recurrence: str, after: datetime) -> datetime:
"""Следующее срабатывание строго после `after`, отталкиваясь от якорной даты `anchor`
(а не от предыдущего срабатывания) так периодичность не «дрейфует» из-за клампинга
коротких месяцев. Если пропущено несколько периодов (бот был выключен) промежуточные
пропускаются, возвращается ближайшее будущее относительно `after`.
"""
if recurrence not in RECURRENCES:
raise ValueError(f"Неизвестная периодичность: {recurrence}")
if recurrence == "weekly":
candidate = anchor
while candidate <= after:
candidate += timedelta(days=7)
return candidate
if recurrence == "monthly":
year, month = anchor.year, anchor.month
candidate = anchor
while candidate <= after:
month += 1
if month > 12:
month = 1
year += 1
day = _clamp_day(year, month, anchor.day)
candidate = candidate.replace(year=year, month=month, day=day)
return candidate
# yearly
year = anchor.year
candidate = anchor
while candidate <= after:
year += 1
day = _clamp_day(year, anchor.month, anchor.day)
candidate = candidate.replace(year=year, day=day)
return candidate
def server_utc_offset_hours() -> float:
"""Собственное смещение сервера от UTC в часах. Часовой пояс сервера нигде
явно не настроен (может быть любым в зависимости от хостинга), поэтому
вычисляется на лету через локальную таймзону ОС."""
offset = datetime.now().astimezone().utcoffset()
return offset.total_seconds() / 3600 if offset else 0.0
def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
"""Переводит наивный datetime из одного часового пояса (смещение от UTC
в часах) в другой просто сдвигает время на разницу смещений."""
return dt + timedelta(hours=(to_offset - from_offset))
-2
View File
@@ -1,4 +1,2 @@
vk_api>=11.9 vk_api>=11.9
requests>=2.28 requests>=2.28
openpyxl>=3.1
Pillow>=10.0
+50
View File
@@ -56,3 +56,53 @@ def test_marketing_subscribers(tmp_db):
tmp_db.update_user_field(11, "consent_2", "Не согласен") tmp_db.update_user_field(11, "consent_2", "Не согласен")
subs = tmp_db.get_marketing_subscribers() subs = tmp_db.get_marketing_subscribers()
assert 10 in subs and 11 not in subs assert 10 in subs and 11 not in subs
def test_reminders_crud(tmp_db):
rid = tmp_db.add_reminder("Продлить домен", "2027-03-15 09:00:00", "yearly", [1, 2], created_by=1)
active = tmp_db.get_active_reminders()
assert any(r["id"] == rid for r in active)
r = next(r for r in active if r["id"] == rid)
assert r["recipients"] == [1, 2] and r["recurrence"] == "yearly"
due = tmp_db.get_due_reminders("2027-03-15 09:00:00")
assert any(r["id"] == rid for r in due)
assert tmp_db.get_due_reminders("2027-03-14 09:00:00") == []
tmp_db.update_reminder_next_at(rid, "2028-03-15 09:00:00")
r2 = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
assert r2["next_at"] == "2028-03-15 09:00:00"
tmp_db.deactivate_reminder(rid)
assert not any(r["id"] == rid for r in tmp_db.get_active_reminders())
def test_reminders_all_recipients_and_delete(tmp_db):
rid = tmp_db.add_reminder("Оплатить сервер", "2027-01-01 09:00:00", "once", "all", created_by=1)
r = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
assert r["recipients"] == "all"
assert tmp_db.delete_reminder(rid)
assert not tmp_db.delete_reminder(rid) # уже удалено, повторное удаление — False
def test_admin_tz_offset(tmp_db):
assert tmp_db.get_admin_tz_offset(999) is None
tmp_db.set_admin_tz_offset(999, 3)
assert tmp_db.get_admin_tz_offset(999) == 3
tmp_db.set_admin_tz_offset(999, -5) # перезапись существующего значения
assert tmp_db.get_admin_tz_offset(999) == -5
def test_reminder_pause_excludes_from_due_but_stays_visible(tmp_db):
rid = tmp_db.add_reminder("Продлить домен", "2020-01-01 09:00:00", "once", "all", created_by=1)
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") # просрочено — должно найтись
tmp_db.set_reminder_paused(rid, True)
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") == [] # на паузе — не должно
row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 1 # но в общем списке всё ещё видно
tmp_db.set_reminder_paused(rid, False)
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") # сняли паузу — снова попадает в due
row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 0
+311
View File
@@ -1,4 +1,5 @@
import bot import bot
from datetime import datetime, timedelta
def _state(uid): def _state(uid):
@@ -74,6 +75,18 @@ def test_baza_records_lead_once(app):
assert any("Групповой" in name for name, _ in s["by_service"]) assert any("Групповой" in name for name, _ in s["by_service"])
def test_empty_message_ignored(app):
"""Пустые сообщения (стикер/фото/голосовое) не вызывают фолбэк — бот молчит."""
u = 70060
app._sent.clear()
app.handle_message(u, "")
app.handle_message(u, " ")
assert app._sent == []
# обычный нераспознанный текст фолбэк всё же даёт
app.handle_message(u, "абвгдейка")
assert any("Пока не получилось" in s["msg"] for s in app._sent)
def test_baza_question_ticket(app): def test_baza_question_ticket(app):
"""§9: клиенту и в карточку оператору уходит номер заявки (тикет).""" """§9: клиенту и в карточку оператору уходит номер заявки (тикет)."""
u = 70040 u = 70040
@@ -88,6 +101,7 @@ def test_baza_question_ticket(app):
def test_webinar_registration(app): def test_webinar_registration(app):
"""Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно).""" """Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно)."""
u = 70041 u = 70041
bot.db.set_flag("webinar", True) # вебинар по умолчанию скрыт — включаем
app.handle_message(u, "записаться на вебинар") app.handle_message(u, "записаться на вебинар")
assert "webinar_registered" in bot.db.get_profile(u).get("metki", []) assert "webinar_registered" in bot.db.get_profile(u).get("metki", [])
assert _state(u) == "FREE_CHAT" assert _state(u) == "FREE_CHAT"
@@ -213,6 +227,101 @@ def test_admin_edit_link(app):
assert app._link("group_4") == "https://infocyber.pro/pay/group4" assert app._link("group_4") == "https://infocyber.pro/pay/group4"
def test_resolve_vk_ru_and_com_links(app):
"""Резолвер принимает ссылки vk.ru и vk.com (id-форма работает без сети)."""
assert app._resolve_vk_user_id("https://vk.ru/id350793273") == 350793273
assert app._resolve_vk_user_id("https://vk.com/id777") == 777
assert app._resolve_vk_user_id("http://m.vk.ru/id42") == 42
assert app._resolve_vk_user_id("www.vk.com/id5") == 5
assert app._resolve_vk_user_id("id99") == 99
assert app._resolve_vk_user_id("12345") == 12345
def test_funnel_keyboards_no_empty_rows():
"""VK отклоняет клавиатуру с пустым рядом — таких быть не должно ни в одной."""
import json, keyboards
kbs = [
keyboards.get_baza_menu_only_keyboard(), keyboards.get_baza_forwho_keyboard(),
keyboards.get_baza_age_keyboard(), keyboards.get_baza_game_keyboard(),
keyboards.get_baza_level_keyboard(), keyboards.get_baza_format_keyboard(),
keyboards.get_baza_group_keyboard(), keyboards.get_baza_individual_keyboard(),
keyboards.get_baza_int_level_keyboard(),
]
for kb in kbs:
for row in json.loads(kb)["buttons"]:
assert len(row) > 0
def test_resolve_vk_links(app):
"""Ссылки vk.ru и vk.com (и голый id) корректно приводятся к числовому id."""
assert app._resolve_vk_user_id("https://vk.ru/id350793273") == 350793273
assert app._resolve_vk_user_id("https://vk.com/id777") == 777
assert app._resolve_vk_user_id("vk.ru/id42") == 42
assert app._resolve_vk_user_id("id100") == 100
assert app._resolve_vk_user_id("999") == 999
def test_menu_feature_toggle(app):
"""Скрытие кнопки меню: раздел «интенсив» выключается и не запускается по слову."""
import json
a = _admin()
# выключаем интенсив
app.handle_message(a, "Кнопки меню 🧩")
app.handle_message(a, "Летний интенсив CS2 🔥 — вкл ✅") # нажатие -> выкл
assert bot.db.get_flag("intensive", True) is False
# кнопки нет в меню
labels = [b["action"].get("label") for row in json.loads(app._menu_kb(a))["buttons"] for b in row]
assert not any("Летний интенсив" in x for x in labels)
# кодовое слово «лето» больше не запускает интенсив
u = 70050
app.handle_message(u, "лето")
assert _state(u) == "FREE_CHAT"
def test_favi_stop_start(app):
"""Экстренное отключение супер-админом по паролю и обратное включение."""
import config as cfg
# тестовый хэш пароля 'secret'
import hashlib, os
salt = os.urandom(16)
cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \
hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex()
a = bot.config.SUPER_ADMIN_IDS[0]
app.handle_message(a, "/favi-stop")
app.handle_message(a, "secret")
assert bot.db.get_flag("bot_disabled", False) is True
# обычный юзер не получает ответа
app._sent.clear(); app.handle_message(70051, "привет")
assert len(app._sent) == 0
# включаем обратно
app.handle_message(a, "/favi-start"); app.handle_message(a, "secret")
assert bot.db.get_flag("bot_disabled", False) is False
def test_favi_wrong_password(app):
import config as cfg, hashlib, os
salt = os.urandom(16)
cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \
hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex()
a = bot.config.SUPER_ADMIN_IDS[0]
app.handle_message(a, "/favi-stop")
app.handle_message(a, "wrong")
assert bot.db.get_flag("bot_disabled", False) is False # не отключился
def test_admin_mute_notifications(app):
"""Админ может отключить себе уведомления от клиентов — _notify_operators его пропускает."""
op = _admin()
bot.config.OPERATOR_PEER_IDS = [op]
app._sent.clear(); app._notify_operators("тест-уведомление")
assert any("тест-уведомление" in s["msg"] for s in app._sent) # по умолчанию доходит
app.handle_message(op, "Админка ⚙️")
app.handle_message(op, "Уведомления: вкл 🔔") # переключаем в «выкл»
assert bot.db.get_profile(op).get("notify_muted") is True
app._sent.clear(); app._notify_operators("второе уведомление")
assert not any("второе уведомление" in s["msg"] for s in app._sent) # больше не доходит
def test_admin_faq_crud(app): def test_admin_faq_crud(app):
a = _admin() a = _admin()
app.handle_message(a, "Быстрые ответы 💬") app.handle_message(a, "Быстрые ответы 💬")
@@ -243,3 +352,205 @@ def test_stats_runs(app):
app._sent.clear() app._sent.clear()
app.handle_message(a, "Статистика 📊") app.handle_message(a, "Статистика 📊")
assert any("Статистика" in s["msg"] for s in app._sent) assert any("Статистика" in s["msg"] for s in app._sent)
def test_admin_reminder_wizard_creates_reminder(app, monkeypatch):
admin = 80001
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не должен мешать
app.handle_message(admin, "Админка ⚙️")
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, " Добавить")
assert _state(admin) == "ADMIN_REMINDER_DATE"
app.handle_message(admin, "не дата") # невалидный формат — остаёмся на шаге
assert _state(admin) == "ADMIN_REMINDER_DATE"
app.handle_message(admin, "01.01.2099 09:00")
assert _state(admin) == "ADMIN_REMINDER_TEXT"
app.handle_message(admin, "Продлить домен")
assert _state(admin) == "ADMIN_REMINDER_RECIPIENTS"
app.handle_message(admin, "0") # все админы
assert _state(admin) == "ADMIN_REMINDER_PERIOD"
app.handle_message(admin, "Каждый год")
assert _state(admin) == "FREE_CHAT"
rows = bot.db.get_active_reminders()
assert len(rows) == 1
assert rows[0]["recipients"] == "all"
assert rows[0]["recurrence"] == "yearly"
assert rows[0]["text"] == "Продлить домен"
def test_admin_reminder_wizard_specific_recipients(app, monkeypatch):
admin, other = 80003, 80004
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other])
bot.db.set_admin_tz_offset(admin, 0)
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, " Добавить")
app.handle_message(admin, "01.01.2099 09:00")
app.handle_message(admin, "Оплатить сервер")
app.handle_message(admin, "9") # номер вне списка (только 2 админа)
assert _state(admin) == "ADMIN_REMINDER_RECIPIENTS"
app.handle_message(admin, "1") # первый по сортировке id
assert _state(admin) == "ADMIN_REMINDER_PERIOD"
app.handle_message(admin, "Разово")
rows = bot.db.get_active_reminders()
assert rows[0]["recipients"] == [min(admin, other)]
assert rows[0]["recurrence"] == "once"
def test_admin_reminder_delete(app, monkeypatch):
admin = 80002
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 0)
rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
app.handle_message(admin, f"напоминалка удалить {rid}")
assert not bot.db.get_active_reminders()
before = len(app._sent)
app.handle_message(admin, f"напоминалка удалить {rid}") # повторно — не найдено
# _handle_reminder_delete шлёт результат, а затем обновлённую панель «Напоминалки»,
# поэтому проверяем все сообщения этого шага, а не только последнее.
new_msgs = [s["msg"] for s in app._sent[before:]]
assert any("не найдено" in m for m in new_msgs)
def test_admin_reminder_delete_via_picker_button(app, monkeypatch):
"""Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную)."""
admin = 80005
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 0)
rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, "🗑 Удалить напоминание")
assert _state(admin) == "ADMIN_REMINDER_DEL_PICK"
app.handle_message(admin, f"{rid}. Продлить хостинг") # клик по кнопке пикера
assert _state(admin) == "FREE_CHAT"
assert not bot.db.get_active_reminders()
def test_reminder_tz_gate_before_list(app, monkeypatch):
"""Без заданного часового пояса раздел не открывается — сначала гейт."""
admin = 80006
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
app.handle_message(admin, "⏰ Напоминалки")
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
app.handle_message(admin, "не число") # невалидный формат — остаёмся на шаге
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
app.handle_message(admin, "+3")
assert _state(admin) == "FREE_CHAT"
assert bot.db.get_admin_tz_offset(admin) == 3
# повторный вход — пояс уже задан, гейт больше не срабатывает
app.handle_message(admin, "⏰ Напоминалки")
assert _state(admin) == "FREE_CHAT"
def test_reminder_tz_setup_via_city_button(app, monkeypatch):
"""Часовой пояс можно выбрать кнопкой города, а не только вводом числа."""
admin = 80010
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
app.handle_message(admin, "⏰ Напоминалки")
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
app.handle_message(admin, "Екатеринбург, UTC+5") # клик по кнопке клавиатуры
assert _state(admin) == "FREE_CHAT"
assert bot.db.get_admin_tz_offset(admin) == 5
def test_reminder_creation_converts_author_local_time_to_server(app, monkeypatch):
admin = 80007
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не мешает
# Фиксируем "серверный" пояс отдельно от пояса автора (0 != 3), чтобы тест
# не проходил случайно из-за совпадения с реальным поясом машины, где он
# запускается — иначе он ничего не проверял бы, если сервер сам в UTC+3.
monkeypatch.setattr(bot, "server_utc_offset_hours", lambda: 0)
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, " Добавить")
app.handle_message(admin, "01.06.2099 09:00") # 09:00 по времени автора (UTC+3)
app.handle_message(admin, "Тест конвертации")
app.handle_message(admin, "0")
app.handle_message(admin, "Разово")
rows = bot.db.get_active_reminders()
stored = datetime.strptime(rows[0]["remind_at"], "%Y-%m-%d %H:%M:%S")
server_offset = 0
expected = datetime(2099, 6, 1, 9, 0) + timedelta(hours=(server_offset - 3))
assert stored == expected
def test_reminder_pause_and_resume_via_picker(app, monkeypatch):
admin = 80008
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 0)
rid = bot.db.add_reminder("Продлить хостинг", "2020-01-01 09:00:00", "once", [admin], created_by=admin)
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, "⏸/▶ Пауза")
assert _state(admin) == "ADMIN_REMINDER_PAUSE_PICK"
app.handle_message(admin, f"{rid}. Продлить хостинг")
assert _state(admin) == "FREE_CHAT"
row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 1
# напоминание давно просрочено, но на паузе — планировщик не должен его отправить
app._sent.clear()
app._check_due_reminders()
assert not app._sent
assert bot.db.get_active_reminders() # всё ещё активно (не деактивировалось)
# возобновляем через тот же пикер
app.handle_message(admin, "⏸/▶ Пауза")
app.handle_message(admin, f"{rid}. Продлить хостинг")
row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 0
# теперь просроченное разовое напоминание должно уйти при следующей проверке
app._check_due_reminders()
assert app._sent
assert not bot.db.get_active_reminders() # once — деактивировалось после отправки
def test_timezone_change_command(app, monkeypatch):
admin = 80009
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3)
app.handle_message(admin, "часовой пояс -5")
assert bot.db.get_admin_tz_offset(admin) == -5
app.handle_message(admin, "часовой пояс много")
assert bot.db.get_admin_tz_offset(admin) == -5 # невалидный ввод не поменял значение
def test_timezone_change_via_button(app, monkeypatch):
"""Кнопка «🌐 Часовой пояс» открывает выбор заново, даже если пояс уже задан."""
admin = 80011
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3)
app.handle_message(admin, "⏰ Напоминалки")
assert _state(admin) == "FREE_CHAT" # пояс уже есть — гейт не срабатывает
app.handle_message(admin, "🌐 Часовой пояс")
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
app.handle_message(admin, "Владивосток, UTC+10")
assert _state(admin) == "FREE_CHAT"
assert bot.db.get_admin_tz_offset(admin) == 10
+72
View File
@@ -0,0 +1,72 @@
from datetime import datetime
from reminders import parse_reminder_datetime, compute_next_occurrence, \
server_utc_offset_hours, convert_time
def test_parse_reminder_datetime_valid():
dt = parse_reminder_datetime("15.03.2027 09:00")
assert dt == datetime(2027, 3, 15, 9, 0)
def test_parse_reminder_datetime_invalid():
assert parse_reminder_datetime("не дата") is None
assert parse_reminder_datetime("2027-03-15 09:00") is None
assert parse_reminder_datetime("32.13.2027 09:00") is None
def test_compute_next_weekly():
anchor = datetime(2026, 9, 2, 9, 0)
nxt = compute_next_occurrence(anchor, "weekly", after=anchor)
assert nxt == datetime(2026, 9, 9, 9, 0)
def test_compute_next_monthly_end_of_month_clamp_no_drift():
"""31.01 -> 28.02 (2026 не високосный), а дальше 31.03 — не 28.03 (без дрейфа)."""
anchor = datetime(2026, 1, 31, 9, 0)
first = compute_next_occurrence(anchor, "monthly", after=anchor)
assert first == datetime(2026, 2, 28, 9, 0)
second = compute_next_occurrence(anchor, "monthly", after=first)
assert second == datetime(2026, 3, 31, 9, 0)
def test_compute_next_yearly_leap_day():
anchor = datetime(2024, 2, 29, 9, 0)
nxt = compute_next_occurrence(anchor, "yearly", after=anchor)
assert nxt == datetime(2025, 2, 28, 9, 0)
def test_compute_next_skips_missed_periods_during_downtime():
"""Бот стоял два месяца — пропущенные периоды пропускаются, берём ближайшее будущее."""
anchor = datetime(2026, 1, 1, 9, 0)
after = datetime(2026, 3, 15, 0, 0)
nxt = compute_next_occurrence(anchor, "monthly", after=after)
assert nxt == datetime(2026, 4, 1, 9, 0)
def test_compute_next_unknown_recurrence_raises():
import pytest
with pytest.raises(ValueError):
compute_next_occurrence(datetime(2026, 1, 1, 9, 0), "daily", after=datetime(2026, 1, 1, 9, 0))
def test_server_utc_offset_hours_in_sane_range():
"""Собственное смещение сервера от UTC — разумное число часов (реальные пояса Земли)."""
offset = server_utc_offset_hours()
assert -12 <= offset <= 14
def test_convert_time_to_utc():
dt = datetime(2027, 3, 15, 9, 0) # 09:00 по времени автора (UTC+3, Москва)
result = convert_time(dt, from_offset=3, to_offset=0) # переводим в UTC
assert result == datetime(2027, 3, 15, 6, 0)
def test_convert_time_crosses_midnight_backwards():
dt = datetime(2027, 3, 15, 1, 0) # 01:00 по времени автора (UTC+5, Екатеринбург)
result = convert_time(dt, from_offset=5, to_offset=-2)
assert result == datetime(2027, 3, 14, 18, 0) # переходит на предыдущие сутки
def test_convert_time_same_offset_is_noop():
dt = datetime(2027, 1, 1, 12, 0)
assert convert_time(dt, from_offset=3, to_offset=3) == dt