Compare commits

...

48 Commits
v0.2 ... master

Author SHA1 Message Date
MpakobecEx 448ab837fa Аналитика снова пишет события в Postgres
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 20s Details
При проверке на сервере выяснилось, что psycopg-соединение не умеет executemany: батч событий уходил в fallback и терялся. Запись переведена на курсор, добавлен тест на этот путь; версия 0.7.2.
2026-11-03 02:55:00 +05:00
MpakobecEx cf14dfa80d Построчный журнал сообщений больше не нужен
CI / lint-test (push) Successful in 20s Details
CI / build-push (push) Successful in 1m6s Details
Счётчики сообщений теперь живут только в analytics.messages_daily: /инфо, /рейтинг и админка суммируют дневные значения, а bot.add_user больше не пишет строку на каждое сообщение. Старые строки bot.messages один раз перенесены в дневную таблицу и сама таблица удалена; в карточке пользователя «последнее сообщение» стало датой.
2026-11-03 02:43:05 +05:00
MpakobecEx d7bbacb67c Аналитика переехала в Postgres, ClickHouse больше не нужен
Логи сообщений больше не дублируются отдельным событием: вместо этого в схеме analytics растёт дневной счётчик по каждому игроку. События и счётчики хранятся в Postgres, партиции по дням создаются автоматически и не удаляются, а в админке появилась отдельная вкладка «Сообщения по дням». Сервис ClickHouse, скрипты его бэкапа и зависимость убраны; сбой аналитики по-прежнему не мешает боту.
2026-11-03 02:32:31 +05:00
MpakobecEx 4403a4bb21 Перезапуск бота больше не повторяет рассылку
CI / lint-test (push) Successful in 30s Details
CI / build-push (push) Successful in 23s Details
Расписание common.check в 00:35 совпало с выкаткой: старый процесс успел отправить цепочку, новый после старта отправил её ещё раз. Теперь каждая отработанная минута атомарно отмечается в bot.schedule_runs; если отметка уже есть, рассылка пропускается. Старые отметки чистятся через 7 дней.
2026-10-26 02:58:31 +05:00
MpakobecEx 1ab434d8b0 Шаблон сохраняется целиком одной кнопкой
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 20s Details
В редакторе шаблона теперь одна форма и одна кнопка «Сохранить всё»: название, описание и все сообщения. Добавление, удаление и перестановка сообщений происходят на странице без запросов к серверу и применяются при сохранении, поэтому несохранённые правки не теряются. Сервер сохраняет весь набор одной транзакцией, включая порядок, кнопки и картинки.
2026-10-26 02:48:51 +05:00
MpakobecEx afeed5b67e Открытая группа меню закрывает соседнюю
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 18s Details
При наведении или клике на «Сообщения»/«Пользователи» остальные группы теперь закрываются сразу, поэтому два списка не висят одновременно. Клик вне меню по-прежнему закрывает всё.
2026-10-26 02:32:01 +05:00
MpakobecEx 92d2d1efcc Пункты выпадающего меню больше не наезжают друг на друга
CI / lint-test (push) Successful in 29s Details
CI / build-push (push) Successful in 22s Details
Контейнер выпадашки имел flex-col, но при открытии оставался display: block — ссылки с вертикальными отступами накладывались. Теперь при открытии добавляется flex, при закрытии убирается.
2026-10-26 02:15:53 +05:00
MpakobecEx 0206ab5212 Выпадающее меню больше не закрывается от любого движения мыши
CI / lint-test (push) Successful in 18s Details
CI / build-push (push) Successful in 21s Details
Между кнопкой и списком был зазор, из-за которого hover срабатывал как выход из группы. Убрал зазор и добавил задержку закрытия 150 мс с отменой при возврате курсора — перейти в пункты меню теперь можно спокойно.
2026-10-26 02:05:13 +05:00
MpakobecEx 73e83e2413 Меню в шапке собрано в группы
CI / lint-test (push) Successful in 18s Details
CI / build-push (push) Successful in 18s Details
Вместо длинного ряда ссылок теперь «Сообщения» (шаблоны, расписания, отправка) и «Пользователи» (список, админы, звания); на десктопе группы раскрываются при наведении и по клику, на телефоне — по нажатию. Служебный текст «админ» и версия из шапки убраны, версия осталась в подвале.
2026-10-26 01:54:34 +05:00
MpakobecEx d85bc5b0f4 Отправка без кнопок больше не падает на reply_markup
CI / lint-test (push) Successful in 18s Details
CI / build-push (push) Successful in 30s Details
При пустом списке кнопок в Bot API уходило поле reply_markup: null, а Telegram ждёт объект или отсутствие поля. Теперь поле не передаётся, если кнопок нет — и для текста, и для фото.
2026-10-26 00:31:27 +05:00
MpakobecEx 5c6c49685a Раздел «Последовательности» стал «Шаблонами сообщений», а счётчик сообщений починен
CI / lint-test (push) Successful in 19s Details
CI / build-push (push) Successful in 21s Details
В списке колонка «Сообщений» показывала метод словаря вместо числа: SQL-алиас items переименован в item_count, и шаблон больше не натыкается на dict.items. Заодно в интерфейсе и README раздел называется «Шаблоны сообщений», а одинаковое название и описание больше не дублируются в списке.
2026-10-25 22:32:37 +05:00
MpakobecEx 3347dacbb5 Справочник званий редактируется из админки
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 18s Details
Появился раздел «Звания»: название, цена и видимость; изменения сразу видит бот, потому что читает bot.titles напрямую. Удалить можно только звание, которое ни у кого не стоит, иначе предлагается скрыть — данные игроков не ломаются.
2026-10-25 22:03:07 +05:00
MpakobecEx ae6faeee1e Все сообщения бота переехали в последовательности
Миграция V24 создаёт последовательность из каждого старого шаблона, а команды, магазин и рассылки теперь отправляются через общий sender: один и тот же формат с картинками и кнопками. Старые шаблоны остаются фолбэком в коде, поэтому сбой БД не оставит бота без текстов. Раздел «Сообщения» убран — редактирование только в «Последовательностях», предпросмотр вынесен в общий /preview.
2026-10-25 22:01:24 +05:00
MpakobecEx 84fc104717 В админку можно войти через установленный Telegram
К виджету добавился deep-link: сайт создаёт одноразовый токен на 5 минут и открывает t.me/<bot>?start=login_<token>, бот проверяет администратора и подтверждает вход, а сайт забирает токен и открывает сессию. В БД хранится только SHA-256 токена; ссылка одноразовая. Если deep-link не настроен, остаётся обычный виджет.
2026-10-25 21:54:50 +05:00
MpakobecEx 08c57f9b39 Из админки можно отправить сообщение в чат от имени бота
Появился раздел «Отправить»: текст с тем же редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Отправка идёт в основной чат через Bot API; успешные и неуспешные попытки пишутся в bot.admin_messages (кто, когда, что и какая ошибка).
2026-10-25 21:52:00 +05:00
MpakobecEx cfa0e29bbf Админка стала удобной с телефона
Навигация сворачивается в бургер-меню, второстепенные колонки таблиц скрываются на узких экранах, формы и тулбар редактора нормально складываются в одну колонку. Основные действия и ссылки остаются доступны на ширине 360–390 px.
2026-10-25 21:47:06 +05:00
MpakobecEx 1c85590f6f В меню Telegram появились команды с русскими подписями
CI / lint-test (push) Successful in 18s Details
CI / build-push (push) Successful in 20s Details
Telegram не принимает кириллицу в именах команд, поэтому в меню теперь латинские алиасы (/help, /info, /nick, /list, /who, /purify, /eggs, /shop, /top, /sub, /unsub) с русскими описаниями, а русские команды продолжают работать при вводе вручную. При каждом старте бот сверяет набор с ожидаемым и обновляет его, если меню меняли вручную или оно пустое.
2026-10-25 20:20:17 +05:00
MpakobecEx 0ae337711e Бот перестал реагировать на чужие группы и форвардить их сообщения
CI / lint-test (push) Successful in 16s Details
CI / build-push (push) Successful in 19s Details
Сообщение в чате уведомлений оказалось форвардом из группы «Никитка играется с ботом», где тестовый бот был администратором: он видел сообщение прод-бота, считал отправителя посторонним и присылал access_denied с форвардом. Теперь чужие группы и каналы игнорируются полностью — и сообщения, и выходы, и callback-кнопки; личные чаты и основной чат работают как раньше.
2026-10-25 02:13:04 +05:00
MpakobecEx e125d4bb0e Приветствие стало последовательностью сообщений с картинками и кнопками
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 19s Details
Вместо трёх жёстко зашитых шаблонов теперь есть последовательности: сообщения по порядку, у каждого свой текст, картинка из общего volume media и несколько кнопок. Приветствие миграцией перенесено в последовательность welcome (короткая подпись к фото + остальной текст), а если последовательность недоступна — бот откатывается на старые шаблоны из кода. В админке появился раздел «Последовательности» и редактор текста с тулбаром: жирный, курсив, подчёркивание, зачёркивание, код, цитата, спойлер и ссылка, со счётчиком символов и предпросмотром.
2026-10-25 02:06:59 +05:00
MpakobecEx 34b6574e43 Приветствие снова читается одним блоком: фото с короткой подписью и одно сообщение
Первая строка welcome.text уходит подписью к фото, остальное вместе с text2 и text3 — одним сообщением с кнопкой. Так текст не разбит на три части и не упирается в лимит подписи к фото (1024 символа). В редакторе welcome.text появилась подсказка, что первая строка — подпись. Если понадобится больше гибкости, следующим шагом можно сделать последовательность сообщений с картинками.
2026-10-25 01:28:56 +05:00
MpakobecEx 8ae4e8bb6c Админка научилась управлять админами, шаблонами и исключать из чата
CI / lint-test (push) Successful in 16s Details
CI / build-push (push) Successful in 56s Details
Появился раздел «Админы»: добавить человека по Telegram-ID, сменить роль, выключить или удалить (с защитой от отключения себя и последнего администратора). В карточке пользователя теперь можно выгнать из чата: бот банит и сразу разбанивает, данные удаляются, кто кого выгнал — уходит в чат логов. Заодно исправлен старый баг: при исключении уведомление и удаление применялись к тому, кто выгонял, а не к выгнанному. Шаблоны можно создавать из «Сообщений», а смена ника через /ник теперь видна в истории — с пометкой, что это сделал сам пользователь, а не админ.
2026-10-25 01:05:02 +05:00
MpakobecEx 3a5d786aa2 ClickHouse больше не пишет гигабайты trace_log на тестовом контуре
CI / lint-test (push) Successful in 16s Details
CI / build-push (push) Successful in 16s Details
Профилировщик ClickHouse на test-контуре генерировал ~1.3 ГБ в сутки (~6.6 ГБ за 5 дней), хотя для событий бота он не нужен. Файл в clickhouse/config.d отключает trace_log и монтируется в оба compose; чтобы вернуть — удалить файл и пересоздать контейнер.
2026-10-25 00:37:09 +05:00
MpakobecEx 1bedaba5e1 Логи в админке заработали: в её образе не хватало клиента ClickHouse
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 5m43s Details
Раздел «Логи» писал ошибку вместо событий: clickhouse-connect стоял только в зависимостях бота, а админка собирается из своего списка. Добавил клиент в requirements-admin, чтобы чтение событий работало в её контейнере.
2026-10-25 00:23:39 +05:00
MpakobecEx ebdd0b3726 Тест яичек больше не зависит от часового пояса раннера
CI / lint-test (push) Successful in 14s Details
CI / build-push (push) Successful in 53s Details
В CI дата бралась из локального времени раннера (UTC), а событие проверяется по Europe/Moscow — вблизи полуночи тест падал на ровном месте. Теперь окно для проверки берётся с запасом в сутки по московскому времени.
2026-10-19 04:14:03 +05:00
MpakobecEx ca0a6ba343 CI снова видит тесты админки
CI / lint-test (push) Failing after 21s Details
CI / build-push (push) Has been skipped Details
В requirements-dev не хватало зависимостей админки: fastapi, jinja2 и itsdangerous ставились только в её образ, поэтому прогон тестов на чистом раннере упал бы на импорте. Теперь dev-набор подключает requirements-admin, и сборка проверяет бота и админку одним прогоном.
2026-10-19 04:07:49 +05:00
MpakobecEx 6b15d8ae04 Яички снова стали пасхальным событием, а не круглогодичной валютой
CI / lint-test (push) Failing after 11s Details
CI / build-push (push) Has been skipped Details
Окно теперь считается само: с 1 апреля по православную Пасху (в 2026 году — 12 апреля) вместо зашитых дат 2025 года, из-за которых команда не работала вовсе. Вне окна /яички отвечает «рано» или «событие завершилось», а строка про яички не показывается ни в /инфо, ни в карточке админки. Если понадобится другое окно, даты фиксируются через EGG_EVENT_START/EGG_EVENT_END.
2026-10-19 04:03:22 +05:00
MpakobecEx 107a79d05e Логи стали видны из админки, а не только через SQL
Раздел «Логи» читает analytics.events из ClickHouse: фильтры по типу события, пользователю (ID или username) и датам, по 50 строк на страницу, время в часовом поясе админки. Payload раскрывается как JSON, ID ведёт в карточку игрока. Чтение идёт отдельным ленивым клиентом: если ClickHouse лежит, страница сообщает об этом и не роняет админку. Боту для этого ничего не меняли.
2026-10-19 04:00:30 +05:00
MpakobecEx 92791f1ba9 Админ может править игроков, не заходя в базу
В карточке появились ник, звание, подписка на рассылки и корректировка баланса: баланс меняется обычной записью в журнал money с действием «Корректировка админом», поэтому /инфо сразу показывает новую сумму. Все четыре действия пишутся в bot.user_changes — кто, когда, было → стало и причина; история видна в той же карточке. Бот читает эти таблицы напрямую, так что изменения действуют без перезапуска.
2026-10-19 03:58:25 +05:00
MpakobecEx 82fa21da3d В админке появился список игроков с поиском и карточкой
Искать можно по нику, username, имени и Telegram-ID; в карточке видно уровень, опыт с серией, баланс (SUM(money.amount)), яички, сообщения, очищения, подписку и последние операции по балансу. Страница пока только читает PostgreSQL: правку ника, звания и подписки включим следующим шагом, когда посмотрим на реальные данные.
2026-10-19 03:56:08 +05:00
MpakobecEx f345844dfe Админка закрылась входом через Telegram
Подпись Telegram-логина проверяется токеном бота, а доступ есть только у записей в bot.admin_users: роль перечитывается из БД на каждом запросе, поэтому выключение пользователя сразу закрывает доступ. Сессия — подписанная HttpOnly-cookie на 12 часов, все формы получили CSRF-токен, первые администраторы засеваются из ADMIN_IDS при старте. Домены в BotFather уже заданы, но NPM и публикацию не включаем — снаружи админка по-прежнему только на loopback.
2026-10-19 03:53:23 +05:00
MpakobecEx d695f1991f У админки теперь постоянные порты, которые не спорят с Portainer
Тестовый контур слушает 8100, прод — 8101 на хосте VPS (8000 занят Portainer). Адрес привязки вынесен в ADMIN_BIND_IP: по умолчанию только loopback, а для nginx-proxy-manager ставится 172.17.0.1, чтобы контейнер NPM мог достучаться до админки, но порт не оказался в интернете. Примеры окружений и DEPLOY обновлены.
2026-10-19 03:46:24 +05:00
MpakobecEx 8fb7fe5dd9 Расписания рассылок переехали из кода в админку
В админке появился раздел «Расписания»: время, дни недели, таймзона, шаблон и вкл/выкл, с историей изменений и удалением. Бот раз в минуту читает активные строки из БД, поэтому правки применяются без перезапуска; при сбое базы он переживает его на последнем прочитанном наборе или на расписании из кода. Стартовые рассылки перенесены миграцией V16, так что поведение не меняется. Заодно в зависимости добавлен tzdata: в slim-образах без него падает ZoneInfo на Europe/Moscow.
2026-10-19 03:41:07 +05:00
MpakobecEx 1e4e905f24 Бот берёт тексты из админки, не дожидаясь перезапуска
Шаблоны из message_templates читаются через кеш на 45 секунд (TEMPLATE_CACHE_TTL), поэтому правка в админке доезжает до следующего сообщения или рассылки сама. Если база недоступна или таблицы ещё нет, бот работает на текстах из кода, а неудачный запрос не повторяется на каждом сообщении — админка не может уронить бота. Расписания рассылок теперь ссылаются на ключи шаблонов, а не на строки, так что тексты рассылок тоже редактируются.
2026-10-19 03:37:58 +05:00
MpakobecEx 6e3209a339 Админка научилась показывать и править тексты сообщений
Появились таблицы шаблонов, истории правок и расписаний: тексты по умолчанию переехали в миграцию-сид, а раздел «Сообщения» показывает список, даёт отредактировать текст с проверкой тегов и предпросмотром через тот же рендерер, что у бота. Каждое сохранение пишет старую и новую версию в историю — это аудит и заготовка для отката. Бот пока читает тексты из кода: переключение на базу — следующий шаг (4.5).
2026-10-19 03:34:41 +05:00
MpakobecEx b11676764e У админки появился собственный сервис, а не только место в плане
Каркас FastAPI живёт в admin/ и собирается отдельным образом: главная страница со списком будущих разделов и /healthz для healthcheck, пока без БД и логина. Сервис добавлен в оба compose (порт только на loopback до появления домена), CI теперь собирает и публикует два образа — бота и админку. Настройки админки наследуют общие, но не требуют BOT_TOKEN: сервис запускается, даже пока токен не заполнен.
2026-10-19 03:34:10 +05:00
MpakobecEx 16bd8c06b5 Бэкап событий работает на живом контуре, а не только в режиме пропуска
CI / lint-test (push) Successful in 13s Details
CI / build-push (push) Successful in 11s Details
Проверка на VPS вскрыла две поломки, которые не проявлялись, пока контур не запущен: ssh склеивал аргументы SQL-запроса в одну строку, и запрос падал с синтаксической ошибкой, а импорт на домашнем сервере требовал clickhouse-client, которого там нет. Теперь аргументы экранируются, клиент можно подменить обёрткой через docker compose (CLICKHOUSE_CLIENT), а скрипты в репозитории получили бит выполнения — иначе make check и make backup не запускались из свежего клона.
2026-10-19 03:17:22 +05:00
MpakobecEx c524d20259 Бэкап пропускает прогон, если контур на VPS не запущен
CI / lint-test (push) Successful in 11s Details
CI / build-push (push) Successful in 10s Details
- добавлена проверка запущенных сервисов контура на VPS перед выгрузкой
- если clickhouse не запущен или каталога проекта нет, скрипт пишет причину в лог и выходит с кодом 0
- ошибки ssh по-прежнему считаются ошибкой и возвращают ненулевой код
- версия 0.3.8
2026-10-18 04:48:54 +05:00
MpakobecEx a28bd622c7 Бэкап событий переведён на pull с домашнего сервера
CI / lint-test (push) Successful in 16s Details
CI / build-push (push) Successful in 13s Details
- скрипт бэкапа запускается на домашнем сервере и сам забирает вчерашнюю партицию с VPS по ssh
- входящий ssh на домашний сервер больше не нужен, rsync не требуется
- выгрузка идёт через docker compose на VPS, пароль ClickHouse читается из окружения контейнера
- архив пишется сначала во временный файл, импорт идемпотентен, удаление партиций только после сверки
- env/backup.env.example переписан под новые параметры, cron теперь на домашнем сервере
- версия 0.3.7
2026-10-16 15:57:22 +05:00
MpakobecEx a0fa223eaa Надёжный healthcheck ClickHouse и пример запроса в DEPLOY
CI / lint-test (push) Successful in 16s Details
CI / build-push (push) Successful in 30s Details
- healthcheck ClickHouse переведён на wget по http://127.0.0.1:8123/ping без зависимости от переменных окружения
- wget подтверждён в образе clickhouse-server (используется его entrypoint)
- пример запроса к событиям в DEPLOY выполняется от пользователя контейнера, а не от хостовой оболочки
- версия 0.3.6
2026-10-16 15:31:21 +05:00
MpakobecEx cd9004c10f Документация Блока 3 и версия 0.3.5
- docs/events.md дополнен схемой ClickHouse, агрегатами, регламентом хранения и списком уведомлений в чат логов
- README описывает аналитику, уведомления, бэкап событий и структуру проекта
- DEPLOY.md описывает сервис ClickHouse, проверку, настройку бэкапа и cron
- примеры сообщений коммитов приведены к стилю без префиксов
- версия 0.3.5
2026-10-16 15:30:00 +05:00
MpakobecEx dbf78aa1d5 Ежедневный бэкап событий ClickHouse на домашний сервер
- scripts/clickhouse_backup.sh выгружает вчерашнюю партицию в Parquet.gz, передаёт её по rsync, импортирует и сверяет количество строк
- scripts/clickhouse_import.sh импортирует архив в ClickHouse домашнего сервера и повторный импорт дня не дублирует строки
- на VPS удаляются только партиции старше грейса, и только после успешной сверки
- env/backup.env.example с параметрами переноса, цель make backup
- версия 0.3.4
2026-10-16 15:28:25 +05:00
MpakobecEx 2031133538 Уведомления в чат логов и ежечасный пинг
- единая отправка уведомлений через bot/services/alerts.py, ошибки не ломают обработку
- вход и выход участников и успешные очищения уходят в ALERT_CHAT_ID
- ежечасный пинг «бот живой» перенесён в heartbeat, дублирующий пинг из рассылок убран
- событие bot_alive в ClickHouse для графиков доступности
- служебные теги chat_title, version и uptime в реестре тегов
- версия 0.3.3
2026-10-16 15:26:56 +05:00
MpakobecEx 6ec8de88b4 Логирование действий бота в ClickHouse
- middleware пишет события message и command по основному чату, текст сообщений не сохраняется
- сервисы экономики, магазина, подписок и участников пишут события своих операций
- bot.purification возвращает уровень до и после, чтобы отличать повышение уровня
- добавлены события bot_start, bot_stop, access_denied и error
- аналитика инициализируется и закрывается вместе с ботом, её сбой не влияет на работу
- версия 0.3.2
2026-10-16 15:24:24 +05:00
MpakobecEx f85f876a7f Сервис ClickHouse, схема событий и агрегаты
- docker-compose (prod и dev) — контейнер clickhouse 25.3 с volume, ulimits и healthcheck
- порт только на loopback хоста, номер задаётся CLICKHOUSE_HTTP_PORT (test 8124, prod 8123)
- clickhouse/init/001_events.sql — таблица analytics.events с дневными партициями плюс агрегаты events_daily и money_daily
- бот ходит в ClickHouse по имени сервиса и стартует независимо от его готовности
- env-примеры контуров, цель make ch и проверка ClickHouse в check_contour.sh
- версия 0.3.1
2026-10-16 15:21:21 +05:00
MpakobecEx 84f75e7c7d Клиент ClickHouse, каталог событий и батчинг записи
- common/analytics — AnalyticsClient с буфером, flush по размеру и таймеру, graceful-flush при остановке
- при недоступности ClickHouse события уходят в stdout, бот не падает
- каталог EventType и docs/events.md проверяются тестом на синхронность
- настройки CLICKHOUSE_* и ANALYTICS_* с дефолтами
- версия 0.3.0
2026-10-16 15:19:58 +05:00
MpakobecEx aafb9c9e3b Доработки контуров, тесты конфига и CI
CI / lint-test (push) Successful in 7s Details
CI / build-push (push) Successful in 12s Details
2026-10-16 15:00:08 +05:00
MpakobecEx 05169b9b39 fix(config): не декодировать ADMIN_IDS как JSON
CI / lint-test (push) Successful in 7s Details
CI / build-push (push) Successful in 11s Details
2026-10-16 02:14:39 +05:00
MpakobecEx 9f33831971 Единый CHAT_ID и доступ только участникам чата
CI / lint-test (push) Successful in 8s Details
CI / build-push (push) Successful in 11s Details
2026-10-16 02:05:37 +05:00
149 changed files with 11583 additions and 309 deletions

View File

@ -2,6 +2,10 @@
.gitignore
.gitattributes
# секреты локального окружения (в образ не попадают)
.env
.env.*
# venv / кеши
.venv
.venv11
@ -26,6 +30,8 @@ deploy
.gitea
tests
docs
scripts
Makefile
*.md
Dockerfile
docker-compose*.yml

View File

@ -5,6 +5,12 @@ APP_ENV=dev
# Токен бота (получается у @BotFather)
BOT_TOKEN=
# Username бота без @ (для кнопки Telegram-входа в админку)
BOT_USERNAME=
# Публичный HTTPS-адрес админки (для Telegram Login Widget)
ADMIN_PUBLIC_URL=http://localhost:8000
# Секрет подписи cookie-сессии: openssl rand -hex 32 (в проде обязателен)
ADMIN_SESSION_SECRET=
# Postgres
DB_HOST=localhost
@ -18,14 +24,34 @@ DB_POOL_MAX=10
DB_STATEMENT_TIMEOUT_MS=15000
# Чаты
# Основной чат клана (prod) и тестовый чат
PROD_CHAT_ID=
TEST_CHAT_ID=
# Личный чат для алертов об ошибках
ALERT_CHAT_ID=
# Основной чат клана (доступ в бота — только его участникам)
CHAT_ID=
# Чат для служебных уведомлений/логов (опционально)
ALERT_CHAT_ID=0
# Telegram ID администраторов (через запятую)
ADMIN_IDS=
# Часовой пояс для расписаний
TIMEZONE=Europe/Moscow
# Аналитика (Postgres, схема analytics). Логируются действия бота (см. docs/events.md).
# ANALYTICS_ENABLED=false — полностью выключить аналитику.
ANALYTICS_ENABLED=true
# Батчинг записи событий: размер батча, интервал сброса (сек), предел буфера.
ANALYTICS_BATCH_SIZE=200
ANALYTICS_FLUSH_INTERVAL=5.0
ANALYTICS_BUFFER_MAX=5000
# Шаблоны сообщений: сколько секунд бот держит тексты из БД в памяти.
# Правка в админке применяется без перезапуска в течение этого времени.
TEMPLATE_CACHE_TTL=45
# Пасхальное событие «Яички». Пусто — автоматически 1 апреля → Православная Пасха.
# Можно задать вручную (ISO): EGG_EVENT_START=2027-04-01, EGG_EVENT_END=2027-05-02.
EGG_EVENT_START=
EGG_EVENT_END=
# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
ADMIN_HTTP_PORT=8000
ADMIN_BIND_IP=127.0.0.1

View File

@ -6,9 +6,15 @@ on:
branches: [master]
workflow_dispatch:
# Отменяем предыдущий незавершённый прогон при новом пуше.
concurrency:
group: ci
cancel-in-progress: true
env:
REGISTRY: git.mpakosrv.ru
IMAGE_NAME: mpakobecex/dispelevilbot-bot
IMAGE_NAME_BOT: mpakobecex/dispelevilbot-bot
IMAGE_NAME_ADMIN: mpakobecex/dispelevilbot-admin
jobs:
lint-test:
@ -20,12 +26,10 @@ jobs:
python-version: "3.12"
- name: Установка зависимостей
run: pip install -r requirements-dev.txt
# В Forgejo 1.21 `continue-on-error` игнорируется, поэтому неблокирующий
# режим для black сделан через shell. ruff обязателен.
- name: Ruff
run: ruff check .
- name: Black
run: black --check . || echo "::warning::black нашёл отличия в форматировании (пока не блокирует)"
run: black --check .
- name: Pytest
run: pytest -q
@ -37,14 +41,22 @@ jobs:
- uses: actions/checkout@v4
- name: Логин в Gitea Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Сборка и публикация образа
- name: Сборка и публикация образов
run: |
set -e
IMAGE="$REGISTRY/$IMAGE_NAME"
SHA="${GITHUB_SHA:-unknown}"
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
ARGS=""
for t in $TAGS; do ARGS="$ARGS -t $t"; done
docker build $ARGS .
for t in $TAGS; do docker push "$t"; done
SHORT_SHA="$(echo "${GITHUB_SHA:-unknown}" | cut -c1-12)"
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
build_push() {
image="$1"
dockerfile="$2"
tags="$image:$SHORT_SHA $image:latest"
if [ -n "$VERSION" ]; then tags="$tags $image:$VERSION"; fi
args=""
for t in $tags; do args="$args -t $t"; done
docker build $args -f "$dockerfile" .
for t in $tags; do docker push "$t"; done
}
build_push "$REGISTRY/$IMAGE_NAME_BOT" Dockerfile
build_push "$REGISTRY/$IMAGE_NAME_ADMIN" admin/Dockerfile

View File

@ -15,9 +15,16 @@ cd DispelEvilBot
```bash
cp env/test.env.example env/test.env
# заполнить: BOT_TOKEN, DB_PASSWORD, тестовые чаты (TEST_CHAT_ID/PROD_CHAT_ID), ADMIN_IDS
# заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.),
# ADMIN_IDS (опц.)
# плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен),
# ADMIN_SESSION_SECRET (openssl rand -hex 32)
```
Порт админки (`ADMIN_HTTP_PORT`) у контуров должен различаться: `prod` — `8101`,
`test` — `8100` (8000 на VPS занят Portainer). `ADMIN_BIND_IP` по умолчанию `127.0.0.1`;
чтобы nginx-proxy-manager (контейнер) видел админку, ставьте `172.17.0.1`.
## 3. Логин в registry (если пакет приватный)
```bash
@ -46,7 +53,76 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs -
1. `postgres` поднимается и проходит healthcheck;
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
3. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`).
3. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же;
4. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста).
Проверка контура (сервисы, миграции, Postgres, схема аналитики):
```bash
make check E=test
```
## Аналитика (Postgres)
События и дневные счётчики сообщений пишутся в схему `analytics` той же БД Postgres
(контракт — [`docs/events.md`](docs/events.md)). Отдельного сервиса нет.
```bash
make psql E=test
# пример: события за сегодня по типам
SELECT event_type, count(*) FROM analytics.events
WHERE event_time >= current_date GROUP BY event_type;
# сообщения по дням
SELECT day, sum(count) FROM analytics.messages_daily
WHERE day >= current_date - 7 GROUP BY day ORDER BY day;
```
Если Postgres недоступен, бот продолжает работать: батч аналитики уходит в stdout
(WARNING) и отбрасывается. Полностью выключить можно флагом `ANALYTICS_ENABLED=false`.
Партиции создаются приложением при старте и раз в 6 часов; старые **не удаляются**.
Схема создаётся миграцией `V26__analytics_schema.sql`.
## Админка (FastAPI)
Сервис `admin` — отдельный образ `mpakobecex/dispelevilbot-admin`, общий пакет `common`.
Наружу пока **не публикуется**: по умолчанию только loopback хоста
(`127.0.0.1:${ADMIN_HTTP_PORT}`). Для nginx-proxy-manager (он в контейнере) задайте
`ADMIN_BIND_IP=172.17.0.1` и указывайте в NPM цель `172.17.0.1:8100` (test) /
`172.17.0.1:8101` (prod). Домен и TLS — Блок 4.2.
```bash
# проверка живости
curl -s http://127.0.0.1:8100/healthz # test
curl -s http://127.0.0.1:8101/healthz # prod
```
Локально без Docker: `pip install -r requirements-admin.txt && python -m admin`
(по умолчанию `0.0.0.0:8000`, переопределяется `ADMIN_HOST`/`ADMIN_PORT`).
### Вход через Telegram
Админка закрыта Telegram Login Widget. Что нужно на контуре:
- `BOT_TOKEN` — токен бота этого контура (им же проверяется подпись `hash`);
- `BOT_USERNAME` — username бота без `@` (виджет рисует кнопку);
- `ADMIN_PUBLIC_URL` — публичный HTTPS-адрес (`https://debot-test.mpakosrv.ru`);
- `ADMIN_SESSION_SECRET` — `openssl rand -hex 32`;
- `ADMIN_IDS` — Telegram-ID администраторов: ими засевается `bot.admin_users` при старте
(`ON CONFLICT DO NOTHING`, существующих не трогает).
Домен должен быть привязан к боту в `@BotFather` (`/setdomain`), а TLS — настроен на
reverse proxy. Сессия живёт 12 часов (`ADMIN_SESSION_MAX_AGE`), cookie подписана и
`HttpOnly`; в проде ставится флаг `Secure`. Все POST-формы проверяют CSRF-токен.
Роль администратора перечитывается из БД на каждом запросе — отключение пользователя
(`enabled = false`) или удаление записи сразу закрывает доступ.
## Бэкап
Аналитика теперь лежит в Postgres, поэтому отдельного бэкапа событий больше нет.
Бэкап БД — стандартными средствами Postgres (`pg_dump`/`pg_basebackup`); при росте
истории следите за размером `analytics` и диском.
## Обновление и откат
@ -67,7 +143,13 @@ docker compose -p <контур> --env-file env/<контур>.env -f docker-com
## Переменные окружения
См. `env/prod.env.example` / `env/test.env.example`.
См. `env/prod.env.example` / `env/test.env.example` (контуры).
## Дашборд (позже)
Superset поднимается отдельно и читает Postgres (схема `analytics`) с полной историей
событий. Отдельный read-only пользователь Postgres и сам дашборд — финальный этап,
в этот контур не входят.
## Перенос данных на прод (позже, короткое окно простоя)

View File

@ -18,6 +18,8 @@ COPY common ./common
COPY bot ./bot
COPY images ./images
RUN mkdir -p /app/media && chown app:app /app/media
USER app
# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py).

51
Makefile Normal file
View File

@ -0,0 +1,51 @@
# Управление контурами test/prod.
# Пример: make up E=test
#
# Используем .RECIPEPREFIX, чтобы не зависеть от табов (рецепты начинаются с ">").
E ?= test
FILE := env/$(E).env
COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml
.DEFAULT_GOAL := help
.RECIPEPREFIX := >
.PHONY: help up pull down restart logs ps images check psql
help:
> @echo "Контур задаётся через E=test|prod (по умолчанию test):"
> @echo " make up E=test поднять контур"
> @echo " make pull E=test подтянуть образ из registry"
> @echo " make down E=test остановить (данные сохраняются)"
> @echo " make restart E=test пересоздать контейнеры"
> @echo " make logs E=test стримить логи"
> @echo " make ps E=test статус сервисов"
> @echo " make images E=test образы контура"
> @echo " make check E=test проверка контура (скрипт)"
> @echo " make psql E=test psql в БД контура"
up:
> $(COMPOSE) up -d
pull:
> $(COMPOSE) pull
down:
> $(COMPOSE) down
restart:
> $(COMPOSE) up -d --force-recreate
logs:
> $(COMPOSE) logs -f
ps:
> $(COMPOSE) ps
images:
> $(COMPOSE) images
check:
> ./scripts/check_contour.sh $(E)
psql:
> $(COMPOSE) exec postgres sh -lc 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'

141
README.md
View File

@ -6,9 +6,12 @@ Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепо
## Структура
```
common/ # общий пакет: config, logging, db, repositories, models, texts
bot/ # сервис бота: handlers, services, keyboards, notifications
sql/ # миграции Flyway (V__*.sql, R__*.sql)
common/ # общий пакет: config, logging, db, repositories, models, texts, analytics
bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications
admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile
sql/ # миграции Flyway (V__*.sql, R__*.sql), включая схему analytics
docs/ # документация (docs/events.md — события аналитики)
scripts/ # эксплуатационные скрипты (проверка контура)
images/ # статичные картинки (приветствие)
deploy/ # материалы деплоя (Caddyfile, инструкции)
tests/ # тесты
@ -20,7 +23,7 @@ docker-compose.yml / docker-compose.prod.yml
## Требования
- Python 3.12
- PostgreSQL 14+
- PostgreSQL 14+ (аналитика — в схеме `analytics`)
## Настройка
@ -39,20 +42,109 @@ APP_ENV=prod python -m bot # prod
или через `./start.sh` (режим берётся из `APP_ENV`).
Админка (FastAPI) запускается отдельным процессом: `python -m admin` (зависимости —
`requirements-admin.txt`), по умолчанию слушает `0.0.0.0:8000`, проверка живучести — `GET /healthz`.
## База данных
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
## Админка (Блок 4)
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
Все тексты бота — **шаблоны сообщений** (`bot.message_sequences`): миграция `V24`
переносит каждый старый шаблон в последовательность из одного сообщения, а бот
отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
Первые администраторы засеваются из `ADMIN_IDS` при старте. Сессия — подписанная
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
Дополнительно — **вход через установленный Telegram**: кнопка «Открыть Telegram»
создаёт одноразовый токен (5 минут) и открывает `t.me/<bot>?start=login_<token>`;
бот подтверждает вход, сайт забирает токен и открывает сессию. Токен в БД хранится
как SHA-256 и используется один раз.
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`). Каждая
отправленная минута фиксируется в `bot.schedule_runs`, поэтому перезапуск бота в ту же
минуту не повторяет рассылку.
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
подписка на рассылки, дата вступления и последние операции по балансу. В карточке можно
сменить ник и звание, включить/выключить подписку и скорректировать баланс (запись в
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
`bot.user_changes`: кто, когда, было → стало и причина.
Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу
видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого
не стоит; используемое — скрывается (`visible = false`).
Раздел «Логи» — события из Postgres и отдельная вкладка «Сообщения по дням»: фильтры
по типу (для событий), пользователю (ID или username) и датам, пагинация по 50.
Время показывается в `TIMEZONE`, payload раскрывается как JSON, ID пользователя ведёт
в его карточку. Если Postgres недоступен, страница показывает ошибку, а не падает.
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
пользователь через `/ник` или админ.
Раздел «Шаблоны сообщений» — набор сообщений по порядку (приветствие и любые другие):
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
Все правки — текст, кнопки, картинки, добавление/удаление и порядок сообщений —
хранятся на странице и применяются одной кнопкой «Сохранить всё».
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
(1024 для подписи к фото, 4096 для обычного сообщения).
Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц
скрываются на узких экранах, формы складываются в одну колонку.
Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же
редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка
(успешная или нет) пишется в `bot.admin_messages`.
## Аналитика (Postgres)
Все значимые действия бота пишутся в Postgres, схема `analytics`:
`analytics.events` (события, партиции по дням) и `analytics.messages_daily`
(количество сообщений пользователя по дням). Контракт событий —
[`docs/events.md`](docs/events.md) (типы, payload, примеры запросов).
- схема — `sql/V26__analytics_schema.sql`;
- клиент и батчинг — `common/analytics/` (запись неблокирующая, сбой Postgres не влияет на бота);
- сообщения не пишутся поштучно: на каждое сообщение инкрементится дневной счётчик;
- построчная `bot.messages` удалена (V27), старые данные перенесены в `analytics.messages_daily`;
- партиции создаются приложением и **не удаляются** — история хранится бессрочно;
- выключается флагом `ANALYTICS_ENABLED=false`.
## Уведомления в чат логов
В `ALERT_CHAT_ID` (если задан) уходят: вход и выход участников, успешные очищения и
ежечасный пинг «бот живой». Отправка не критична: ошибки только пишутся в лог.
## Docker
Локальный запуск (postgres + migrate + bot):
Локальный запуск (postgres + migrate + bot + admin):
```bash
cp .env.example .env # заполнить
docker compose up --build
```
CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose
по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 /
prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет
вместе с доменом и TLS (Блок 4.2). Картинки шаблонов лежат в общем
Docker-volume `media` (`/app/media`), который монтируется и боту, и админке.
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
```bash
@ -71,11 +163,17 @@ black --check .
pytest
```
## Бэкап
Аналитика теперь в Postgres, отдельного бэкапа событий нет. Бэкап БД — стандартными
средствами Postgres; история `analytics` не чистится, поэтому следите за размером БД
и диском. Подробности — [`DEPLOY.md`](DEPLOY.md).
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.7.2`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Docker-образы тегируются версией (Блок 2), события аналитики получают `bot_version`.
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
## Процесс работы с git
@ -90,23 +188,44 @@ pytest
- Переход на «ветки + PR» и защиту ветки — когда появится второй разработчик.
```bash
# обычная работа
# обычная работа: сообщение без префиксов, по существу изменения
git add -A
git commit -m "feat: ..."
git commit -m "Краткое описание изменения"
git push origin master
# выпуск релиза
# 1) поднять __version__ в common/version.py
git commit -am "chore: bump version to X.Y"
git commit -am "Версия X.Y"
git tag -a vX.Y -m "Release vX.Y"
git push origin master --tags
```
## Приветствие новых участников
Бот отправляет фото с короткой персональной подписью (первая строка шаблона `welcome.text`),
а затем одно сообщение со всем остальным текстом (`welcome.text` без первой строки +
`welcome.text2` + `welcome.text3`) и кнопкой на канал. Так приветствие остаётся одним
смысловым блоком, но не упирается в лимит подписи к фото (1024 символа).
## Пасхальное событие «Яички»
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**
(в 2026 году — 12 апреля). Вне окна бот отвечает, что ещё рано, а строка про яички
не показывается ни в `/инфо`, ни в карточке пользователя в админке.
Даты можно зафиксировать переменными `EGG_EVENT_START`/`EGG_EVENT_END` (ISO,
например `2027-04-01` и `2027-05-02`).
## Команды бота
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
`/магазин`, `/рейтинг`, `/отписка`, `/подписка`.
В меню Telegram показываются латинские алиасы (`/help`, `/info`, `/nick`, `/list`,
`/who`, `/purify`, `/eggs`, `/shop`, `/top`, `/sub`, `/unsub`) с русскими описаниями:
Telegram не принимает кириллицу в именах команд. Русские команды работают при вводе
вручную. Набор сверяется с ожидаемым при каждом старте бота (`ensure_commands`).
## Переменные окружения
См. `.env.example`.
- общие — `.env.example` (включая `ANALYTICS_*`);
- контуры — `env/prod.env.example`, `env/test.env.example`.

32
admin/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
# Образ сервиса админки (FastAPI + Jinja2/HTMX).
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Непривилегированный пользователь
RUN useradd --create-home --uid 1000 app
COPY requirements-admin.txt ./
RUN pip install --no-cache-dir -r requirements-admin.txt
COPY common ./common
COPY admin ./admin
COPY images ./images
RUN mkdir -p /app/media && chown app:app /app/media
USER app
EXPOSE 8000
# Живость процесса: HTTP-проверка /healthz (порт берётся из ADMIN_PORT).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('ADMIN_PORT','8000')+'/healthz', timeout=3)"
CMD ["python", "-m", "admin"]

4
admin/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""Сервис админки DispelEvilBot (FastAPI + HTMX).
Отдельный процесс/образ; общий код — пакет ``common``. Запуск: ``python -m admin``.
"""

31
admin/__main__.py Normal file
View File

@ -0,0 +1,31 @@
"""Точка входа админки: python -m admin"""
from __future__ import annotations
import logging
import uvicorn
from common.config import get_admin_settings
from common.logging_config import setup_logging
logger = logging.getLogger(__name__)
def main() -> None:
"""Запустить uvicorn с приложением из ``admin.app``."""
setup_logging(logging.INFO)
settings = get_admin_settings()
logger.info("Запуск админки (APP_ENV=%s) на %s:%s", settings.app_env, settings.admin_host, settings.admin_port)
# log_config=None — чтобы uvicorn не перетирал единый формат логов.
uvicorn.run(
"admin.app:create_app",
factory=True,
host=settings.admin_host,
port=settings.admin_port,
log_config=None,
)
if __name__ == "__main__":
main()

103
admin/app.py Normal file
View File

@ -0,0 +1,103 @@
"""Создание FastAPI-приложения админки."""
from __future__ import annotations
import logging
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from admin.media import ensure_media
from admin.middleware import AdminAuthMiddleware
from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, titles, users
from common import __version__
from common.analytics.partitions import ensure_partitions
from common.config import AdminSettings, get_admin_settings
from common.db.pool import close_pool, init_pool
from common.logging_config import setup_logging
from common.repositories import admins_repo
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
logger = logging.getLogger(__name__)
def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -> FastAPI:
"""Собрать приложение: настройки, шаблоны, роутеры.
Настройки можно передать явно (тесты); по умолчанию читаются из окружения.
``init_db=False`` — не открывать пул Postgres (юнит-тесты без БД).
"""
setup_logging()
settings = settings or get_admin_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
if init_db:
await init_pool(app.state.settings)
ensure_media(app.state.settings)
try:
await ensure_partitions(app.state.settings)
except Exception:
logger.warning("Не удалось создать партиции аналитики", exc_info=True)
try:
inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids)
if inserted:
logger.info("Добавлены администраторы из ADMIN_IDS: %s", inserted)
except Exception:
logger.warning(
"Не удалось засеять администраторов (применены ли миграции?)",
exc_info=True,
)
try:
yield
finally:
if init_db:
await close_pool()
app = FastAPI(
title="DispelEvilBot Admin",
version=__version__,
# Swagger/OpenAPI наружу пока не нужны: админка закрыта логином (Блок 4.2).
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
app.state.settings = settings
app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Порядок важен: SessionMiddleware добавляется последним и оборачивает auth,
# поэтому request.session доступен в проверке администратора.
app.add_middleware(AdminAuthMiddleware)
session_secret = settings.admin_session_secret
if not session_secret:
session_secret = secrets.token_urlsafe(32)
logger.warning("ADMIN_SESSION_SECRET не задан — сессии слетят при перезапуске")
app.add_middleware(
SessionMiddleware,
secret_key=session_secret,
session_cookie="debot_admin",
max_age=settings.admin_session_max_age,
same_site="lax",
https_only=settings.is_prod,
)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(pages.router)
app.include_router(preview.router)
app.include_router(sequences.router)
app.include_router(titles.router)
app.include_router(schedules.router)
app.include_router(users.router)
app.include_router(admins.router)
app.include_router(logs.router)
app.include_router(send.router)
return app

33
admin/buttons.py Normal file
View File

@ -0,0 +1,33 @@
"""Разбор URL-кнопок из textarea: по одной в строке ``Текст | https://…``."""
from __future__ import annotations
MAX_BUTTONS = 8
def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]:
buttons: list[tuple[str, str]] = []
errors: list[str] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if "|" not in line:
errors.append(f"Кнопка без разделителя «|»: {line}")
continue
text, url = (part.strip() for part in line.split("|", 1))
if not text:
errors.append("У кнопки пустой текст")
continue
if not url.startswith(("https://", "http://")):
errors.append(f"Ссылка кнопки должна начинаться с http(s): {url}")
continue
if len(text) > 64:
errors.append("Текст кнопки длиннее 64 символов")
continue
buttons.append((text, url))
if len(buttons) > MAX_BUTTONS:
errors.append(f"Не больше {MAX_BUTTONS} кнопок на сообщение")
return buttons, errors

98
admin/forms.py Normal file
View File

@ -0,0 +1,98 @@
"""Проверка и разбор форм админки (чистые функции — без HTTP и БД)."""
from __future__ import annotations
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
WEEKDAY_NAMES: dict[int, str] = {
1: "Пн",
2: "Вт",
3: "Ср",
4: "Чт",
5: "Пт",
6: "Сб",
7: "Вс",
}
MAX_DESCRIPTION_LENGTH = 200
def format_weekdays(days: list[int] | tuple[int, ...] | None) -> str:
"""Человеческое описание дней ISO: ``1,3,5`` -> ``Пн, Ср, Пт``."""
if not days:
return "каждый день"
return ", ".join(WEEKDAY_NAMES[day] for day in sorted(set(days)) if day in WEEKDAY_NAMES)
def parse_schedule_fields(
*,
template_key: str,
hour: str,
minute: str,
weekdays: list[str],
timezone: str,
description: str,
enabled: bool,
) -> tuple[dict[str, Any] | None, list[str]]:
"""Собрать данные расписания из полей формы.
Возвращает ``(data, errors)``: при ошибках ``data is None`` и заполнен ``errors``.
"""
errors: list[str] = []
try:
hour_value = int(hour)
except (TypeError, ValueError):
hour_value = -1
errors.append("Час должен быть числом")
try:
minute_value = int(minute)
except (TypeError, ValueError):
minute_value = -1
errors.append("Минуты должны быть числом")
if not 0 <= hour_value <= 23 and "Час должен быть числом" not in errors:
errors.append("Час должен быть от 0 до 23")
if not 0 <= minute_value <= 59 and "Минуты должны быть числом" not in errors:
errors.append("Минуты должны быть от 0 до 59")
days: list[int] = []
for raw in weekdays:
try:
day = int(raw)
except (TypeError, ValueError):
errors.append(f"Некорректный день недели: {raw}")
continue
if 1 <= day <= 7:
days.append(day)
else:
errors.append("День недели должен быть от 1 (Пн) до 7 (Вс)")
tz_name = (timezone or "").strip() or "Europe/Moscow"
try:
ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
errors.append(f"Неизвестная таймзона: {tz_name}")
if not template_key.strip():
errors.append("Выберите шаблон сообщения")
if len(description) > MAX_DESCRIPTION_LENGTH:
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
if errors:
return None, errors
return (
{
"template_key": template_key,
"hour": hour_value,
"minute": minute_value,
"weekdays": sorted(set(days)),
"timezone": tz_name,
"enabled": enabled,
"description": description.strip(),
},
[],
)

56
admin/media.py Normal file
View File

@ -0,0 +1,56 @@
"""Общий каталог картинок для бота и админки (Docker volume)."""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from starlette.datastructures import UploadFile
from common.config import AdminSettings
logger = logging.getLogger(__name__)
#: Встроенная картинка приветствия (лежит в образе бота и админки).
BUILTIN_WELCOME = Path("images/welcome_image.jpg")
#: Имя файла в volume, которое ждёт сид последовательности `welcome`.
WELCOME_IMAGE_NAME = "welcome_image.jpg"
#: Допустимые типы и максимальный размер загружаемых картинок.
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_IMAGE_BYTES = 5 * 1024 * 1024
async def read_image(upload: UploadFile) -> tuple[bytes, str, str]:
"""Прочитать картинку из формы. Возвращает (данные, mime, расширение)."""
content_type = (upload.content_type or "").lower()
extension = ALLOWED_IMAGE_TYPES.get(content_type)
if extension is None:
raise ValueError("Картинка должна быть JPEG, PNG или WebP")
data = await upload.read()
if len(data) > MAX_IMAGE_BYTES:
raise ValueError("Картинка больше 5 МБ")
return data, content_type, extension
def ensure_media(settings: AdminSettings) -> Path:
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
directory = Path(settings.media_dir)
directory.mkdir(parents=True, exist_ok=True)
target = directory / WELCOME_IMAGE_NAME
if not target.exists() and BUILTIN_WELCOME.is_file():
try:
shutil.copyfile(BUILTIN_WELCOME, target)
logger.info("Приветственная картинка скопирована в %s", target)
except OSError:
logger.warning("Не удалось скопировать приветственную картинку в %s", target, exc_info=True)
return directory
def media_path(settings: AdminSettings, name: str) -> Path:
"""Безопасный путь к файлу в media (без выхода из каталога)."""
return Path(settings.media_dir) / Path(name).name

55
admin/middleware.py Normal file
View File

@ -0,0 +1,55 @@
"""Middleware админки: пускает дальше только вошедших администраторов."""
from __future__ import annotations
import logging
import secrets
from urllib.parse import quote
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from common.repositories import admins_repo
logger = logging.getLogger(__name__)
#: Эндпоинты без авторизации.
PUBLIC_PATHS = frozenset(
{
"/healthz",
"/login",
"/auth/telegram",
"/auth/deeplink/start",
"/auth/deeplink/status",
}
)
class AdminAuthMiddleware(BaseHTTPMiddleware):
"""Сессия + роль из БД на каждом запросе (кроме служебных и логина)."""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in PUBLIC_PATHS:
return await call_next(request)
admin_id = request.session.get("admin_id") if "session" in request.scope else None
if not admin_id:
target = quote(request.url.path)
return RedirectResponse(url=f"/login?next={target}", status_code=303)
try:
admin = await admins_repo.get_admin(int(admin_id))
except Exception:
logger.exception("Не удалось проверить администратора")
return RedirectResponse(url="/login", status_code=303)
if admin is None or not admin["enabled"]:
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
request.state.admin = admin
# CSRF-токен живёт в сессии; если её создали раньше — добираем.
if not request.session.get("csrf"):
request.session["csrf"] = secrets.token_urlsafe(32)
return await call_next(request)

39
admin/preview.py Normal file
View File

@ -0,0 +1,39 @@
"""Предпросмотр шаблонов: тестовые значения для всех тегов.
Контекст общий для админки и тестов: он должен покрывать ``TAG_REGISTRY``,
иначе в предпросмотре часть тегов останется пустой.
"""
from __future__ import annotations
from typing import Any
from common import __version__
SAMPLE_CONTEXT: dict[str, Any] = {
"username": "@test_user",
"actor": "@admin_user",
"nick": "ТестовыйНик",
"title": "Ветеран",
"lvl": 3,
"exp": 120,
"exp_max": 300,
"money": 1500,
"dt": "01.01.2026",
"msg_count": 42,
"eggs": 2,
"eggs_line": "<b>🥚 Количество яичек:</b> 2",
"h": "12",
"m": "05",
"s": "09",
"exp_add": 10,
"money_add": 50,
"usernames": "@test_user @second_user",
"users_list": "1: @test_user - ТестовыйНик\n2: @second_user - Второй",
"users_list_count": 2,
"tops": "1) ТестовыйНик - 42\n2) Второй - 17",
"promo": "PROMO-2026",
"chat_title": "Dispel Evil",
"version": __version__,
"uptime": "2ч 15м",
}

1
admin/routes/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Роутеры админки."""

145
admin/routes/admins.py Normal file
View File

@ -0,0 +1,145 @@
"""Раздел «Админы»: назначение и управление доступом к веб-панели."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.security import check_csrf
from common import __version__
from common.repositories import admins_repo
router = APIRouter(prefix="/admins", tags=["admins"])
#: Допустимые роли (обе сейчас дают полный доступ, moderator — задел).
ROLES = ("admin", "moderator")
async def _list_response(
request: Request,
*,
errors: list[str] | None = None,
form: dict[str, str] | None = None,
saved: str | None = None,
status_code: int = 200,
) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="admins/list.html",
context={
"version": __version__,
"admins": await admins_repo.list_admins(),
"roles": ROLES,
"errors": errors or [],
"form": form or {"telegram_id": "", "username": "", "role": "admin"},
"saved": saved,
"current_id": int(request.state.admin["telegram_id"]),
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_admins(request: Request, saved: str | None = None) -> HTMLResponse:
"""Список администраторов и форма добавления."""
return await _list_response(request, saved=saved)
@router.post("")
async def add_admin(request: Request) -> Response:
"""Добавить администратора по Telegram-ID."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
raw_id = str(form.get("telegram_id", "")).strip()
username = str(form.get("username", "")).strip().lstrip("@")
role = str(form.get("role", "admin"))
raw_form = {"telegram_id": raw_id, "username": username, "role": role}
errors: list[str] = []
try:
telegram_id = int(raw_id)
except (TypeError, ValueError):
telegram_id = 0
errors.append("Telegram-ID должен быть числом")
if telegram_id <= 0 and "Telegram-ID должен быть числом" not in errors:
errors.append("Telegram-ID должен быть положительным")
if role not in ROLES:
errors.append("Неизвестная роль")
if len(username) > 64:
errors.append("Username длиннее 64 символов")
if errors:
return await _list_response(request, errors=errors, form=raw_form, status_code=400)
await admins_repo.upsert_admin(telegram_id, username or None, role)
return RedirectResponse(url="/admins?saved=added", status_code=303)
@router.post("/{telegram_id}/role")
async def change_role(request: Request, telegram_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
role = str(form.get("role", ""))
if role not in ROLES:
return await _list_response(request, errors=["Неизвестная роль"], status_code=400)
if not await admins_repo.set_admin_role(telegram_id, role):
raise HTTPException(status_code=404, detail="Администратор не найден")
return RedirectResponse(url="/admins?saved=role", status_code=303)
@router.post("/{telegram_id}/toggle")
async def toggle_admin(request: Request, telegram_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if telegram_id == int(request.state.admin["telegram_id"]):
return await _list_response(
request,
errors=["Нельзя выключить самого себя"],
status_code=400,
)
admins = await admins_repo.list_admins()
target = next((item for item in admins if int(item["telegram_id"]) == telegram_id), None)
if target is None:
raise HTTPException(status_code=404, detail="Администратор не найден")
enabled_count = sum(1 for item in admins if item["enabled"])
if target["enabled"] and enabled_count <= 1:
return await _list_response(
request,
errors=["Нельзя выключить последнего администратора"],
status_code=400,
)
await admins_repo.set_admin_enabled(telegram_id, not target["enabled"])
return RedirectResponse(url="/admins?saved=toggle", status_code=303)
@router.post("/{telegram_id}/delete")
async def delete_admin(request: Request, telegram_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if telegram_id == int(request.state.admin["telegram_id"]):
return await _list_response(
request,
errors=["Нельзя удалить самого себя"],
status_code=400,
)
admins = await admins_repo.list_admins()
target = next((item for item in admins if int(item["telegram_id"]) == telegram_id), None)
if target is None:
raise HTTPException(status_code=404, detail="Администратор не найден")
if target["enabled"] and sum(1 for item in admins if item["enabled"]) <= 1:
return await _list_response(
request,
errors=["Нельзя удалить последнего администратора"],
status_code=400,
)
await admins_repo.delete_admin(telegram_id)
return RedirectResponse(url="/admins?saved=deleted", status_code=303)

168
admin/routes/auth.py Normal file
View File

@ -0,0 +1,168 @@
"""Вход в админку через Telegram Login Widget."""
from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
from common import __version__
from common.repositories import admin_login_tokens_repo, admins_repo
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"])
#: Поля, которые Telegram включает в подпись (остальные в query игнорируем).
TELEGRAM_FIELDS = frozenset({"id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"})
def _deny(request: Request, message: str, status_code: int = 403) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="auth/denied.html",
context={"version": __version__, "message": message},
status_code=status_code,
)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/") -> Response:
"""Страница входа с кнопкой Telegram."""
target = safe_redirect_target(next)
if request.session.get("admin_id"):
return RedirectResponse(url=target, status_code=303)
settings = request.app.state.settings
return request.app.state.templates.TemplateResponse(
request=request,
name="auth/login.html",
context={
"version": __version__,
"bot_username": settings.bot_username,
"public_url": settings.admin_public_url.rstrip("/"),
"configured": bool(settings.bot_token and settings.bot_username),
"deeplink": bool(settings.bot_username and settings.admin_public_url),
"deeplink_pending": bool(request.cookies.get("debot_login_token")),
"deeplink_error": request.query_params.get("error") == "deeplink",
"next": target,
},
)
@router.get("/auth/deeplink/start")
async def deeplink_start(request: Request) -> Response:
"""Создать токен и открыть установленный Telegram с deep-link."""
settings = request.app.state.settings
username = settings.bot_username.lstrip("@")
if not username:
return RedirectResponse(url="/login?error=deeplink", status_code=303)
token = await admin_login_tokens_repo.create_token()
response = RedirectResponse(
url=f"https://t.me/{username}?start=login_{token}",
status_code=303,
)
response.set_cookie(
"debot_login_token",
token,
max_age=300,
httponly=True,
samesite="lax",
secure=settings.is_prod,
)
return response
@router.get("/auth/deeplink/status")
async def deeplink_status(request: Request) -> JSONResponse:
"""Проверить, подтвердил ли пользователь вход в боте, и открыть сессию."""
token = request.cookies.get("debot_login_token")
if not token:
return JSONResponse({"status": "none"})
try:
telegram_id = await admin_login_tokens_repo.consume_token(token)
except Exception:
logger.exception("Не удалось проверить deep-link токен")
return JSONResponse({"status": "pending"})
if telegram_id is None:
return JSONResponse({"status": "pending"})
try:
admin = await admins_repo.get_admin(telegram_id)
except Exception:
logger.exception("Ошибка проверки администратора при deep-link входе")
return JSONResponse({"status": "pending"})
if admin is None or not admin["enabled"]:
response = JSONResponse({"status": "denied"})
response.delete_cookie("debot_login_token")
return response
request.session.clear()
request.session["admin_id"] = telegram_id
request.session["admin_username"] = admin["username"] or ""
request.session["csrf"] = secrets.token_urlsafe(32)
try:
await admins_repo.touch_login(telegram_id)
except Exception:
logger.warning("Не удалось обновить last_login_at", exc_info=True)
logger.info("Вход в админку по deep-link: %s", telegram_id)
response = JSONResponse({"status": "ok"})
response.delete_cookie("debot_login_token")
return response
@router.get("/auth/telegram")
async def telegram_callback(request: Request) -> Response:
"""Проверить подпись Telegram, роль в БД и открыть сессию."""
settings = request.app.state.settings
data = {key: value for key, value in request.query_params.items() if key in TELEGRAM_FIELDS}
if not verify_telegram_auth(data, settings.bot_token):
logger.warning("Отклонён Telegram-логин: неверная подпись или устаревший auth_date")
return _deny(request, "Не удалось проверить вход через Telegram. Попробуйте ещё раз.")
try:
telegram_id = int(str(data.get("id")))
except (TypeError, ValueError):
return _deny(request, "Telegram не передал идентификатор пользователя.", status_code=400)
try:
admin = await admins_repo.get_admin(telegram_id)
except Exception:
logger.exception("Ошибка проверки администратора при входе")
return _deny(request, "Не удалось проверить доступ. Попробуйте позже.", status_code=503)
if admin is None or not admin["enabled"]:
logger.info("Отказ во входе: %s нет среди администраторов", telegram_id)
return _deny(request, "У вас нет доступа к админке. Обратитесь к владельцу.")
request.session.clear()
request.session["admin_id"] = telegram_id
request.session["admin_username"] = admin["username"] or data.get("username") or data.get("first_name") or ""
request.session["csrf"] = secrets.token_urlsafe(32)
try:
await admins_repo.touch_login(telegram_id)
except Exception:
logger.warning("Не удалось обновить last_login_at", exc_info=True)
logger.info("Вход в админку: %s", telegram_id)
return RedirectResponse(url=safe_redirect_target(request.query_params.get("next")), status_code=303)
@router.post("/logout")
async def logout(request: Request) -> Response:
"""Выйти: очистить сессию."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
request.session.clear()
return RedirectResponse(url="/login", status_code=303)

16
admin/routes/health.py Normal file
View File

@ -0,0 +1,16 @@
"""Служебные эндпоинты: healthcheck для Docker и мониторинга."""
from __future__ import annotations
from fastapi import APIRouter, Request
from common import __version__
router = APIRouter(tags=["service"])
@router.get("/healthz")
async def healthz(request: Request) -> dict[str, str]:
"""Вернуть состояние сервиса (используется healthcheck'ом контейнера)."""
settings = request.app.state.settings
return {"status": "ok", "version": __version__, "app_env": settings.app_env}

139
admin/routes/logs.py Normal file
View File

@ -0,0 +1,139 @@
"""Раздел «Логи»: события и дневные счётчики сообщений из Postgres."""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from common import __version__
from common.analytics import ALL_EVENT_TYPES
from common.analytics import read as analytics_read
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/logs", tags=["logs"])
#: Сколько строк показывать на странице.
PAGE_SIZE = 50
def _pretty_payload(payload: Any) -> str:
"""Отформатировать JSON-payload для просмотра; битый JSON — как есть."""
if isinstance(payload, dict):
return json.dumps(payload, ensure_ascii=False, indent=2)
try:
return json.dumps(json.loads(str(payload or "")), ensure_ascii=False, indent=2)
except (TypeError, ValueError):
return str(payload or "")
def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]:
"""Добавить локальное время и читаемый payload."""
moment = row.get("event_time")
if isinstance(moment, datetime):
if moment.tzinfo is None:
moment = moment.replace(tzinfo=UTC)
time_local = moment.astimezone(settings.tzinfo).strftime("%d.%m.%Y %H:%M:%S")
else:
time_local = "—"
return {
**row,
"time_local": time_local,
"payload_pretty": _pretty_payload(row.get("payload")),
}
def _decorate_message(row: dict[str, Any]) -> dict[str, Any]:
day = row.get("day")
return {
**row,
"day_local": day.strftime("%d.%m.%Y") if hasattr(day, "strftime") else str(day or "—"),
}
@router.get("", response_class=HTMLResponse)
async def logs_page(
request: Request,
view: str = "events",
event_type: str = "",
user: str = "",
date_from: str = "",
date_to: str = "",
page: int = 1,
) -> HTMLResponse:
"""Показать события или дневные счётчики сообщений с фильтрами."""
settings = request.app.state.settings
view = view if view in {"events", "messages"} else "events"
today = datetime.now(settings.tzinfo).date()
date_from = date_from or (today - timedelta(days=6)).isoformat()
date_to = date_to or today.isoformat()
page = max(page, 1)
events: list[dict[str, Any]] = []
messages: list[dict[str, Any]] = []
total = 0
error: str | None = None
try:
if view == "messages":
messages, total = await analytics_read.fetch_messages(
settings,
user=user,
date_from=date_from,
date_to=date_to,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
)
else:
events, total = await analytics_read.fetch_events(
settings,
event_type=event_type,
user=user,
date_from=date_from,
date_to=date_to,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
)
except ValueError:
error = "Даты должны быть в формате ГГГГ-ММ-ДД."
except Exception:
logger.exception("Не удалось прочитать аналитику из Postgres")
error = "Postgres недоступен — логи временно не читаются."
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
query_params = {
"view": view if view != "events" else "",
"event_type": event_type if view == "events" else "",
"user": user,
"date_from": date_from,
"date_to": date_to,
}
query_base = urlencode({key: value for key, value in query_params.items() if value})
return request.app.state.templates.TemplateResponse(
request=request,
name="logs/list.html",
context={
"version": __version__,
"view": view,
"events": [_decorate(row, settings) for row in events],
"messages": [_decorate_message(row) for row in messages],
"event_types": sorted(ALL_EVENT_TYPES),
"event_type": event_type,
"user": user,
"date_from": date_from,
"date_to": date_to,
"page": page,
"pages": pages,
"total": total,
"error": error,
"query_base": query_base,
},
)

65
admin/routes/pages.py Normal file
View File

@ -0,0 +1,65 @@
"""HTML-страницы админки (серверный рендер Jinja2 + HTMX)."""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from common import __version__
router = APIRouter(tags=["pages"])
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
SECTIONS: tuple[dict[str, str | None], ...] = (
{
"title": "Шаблоны сообщений",
"description": "Тексты бота: сообщения, картинки и кнопки",
"url": "/sequences",
},
{
"title": "Расписания",
"description": "Время рассылок, дни недели и шаблон",
"url": "/schedules",
},
{
"title": "Пользователи",
"description": "Ники, звания, баланс и подписка",
"url": "/users",
},
{
"title": "Звания",
"description": "Справочник магазина званий",
"url": "/titles",
},
{
"title": "Админы",
"description": "Доступ к веб-панели",
"url": "/admins",
},
{
"title": "Логи",
"description": "События и сообщения из Postgres с фильтрами",
"url": "/logs",
},
{
"title": "Отправить",
"description": "Сообщение в чат от имени бота",
"url": "/send",
},
)
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""Главная страница: версия, окружение и список будущих разделов."""
templates = request.app.state.templates
settings = request.app.state.settings
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"app_env": settings.app_env,
"sections": SECTIONS,
},
)

44
admin/routes/preview.py Normal file
View File

@ -0,0 +1,44 @@
"""Предпросмотр шаблонов и сообщений (HTMX)."""
from __future__ import annotations
import html
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from admin.preview import SAMPLE_CONTEXT
from admin.security import check_csrf
from common.texts.render import MAX_MESSAGE_LENGTH, TemplateError, render, validate
router = APIRouter(tags=["preview"])
@router.post("/preview", response_class=HTMLResponse)
async def preview_message(request: Request) -> HTMLResponse:
"""Отрендерить текст с тестовыми значениями; результат — в sandbox-iframe."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
text = str(form.get("text", ""))
unknown = validate(text)
if unknown:
return HTMLResponse(
'<p class="text-sm text-red-600">Неизвестные теги: ' + html.escape(", ".join(unknown)) + "</p>"
)
try:
rendered = render(text, SAMPLE_CONTEXT)
except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
return HTMLResponse(f'<p class="text-sm text-red-600">{html.escape(str(exc))}</p>')
if len(rendered) > MAX_MESSAGE_LENGTH:
return HTMLResponse(
f'<p class="text-sm text-red-600">Текст длиннее лимита Telegram: '
f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.</p>"
)
return HTMLResponse(
'<iframe sandbox class="h-64 w-full rounded border border-slate-200 bg-white" '
f'srcdoc="{html.escape(rendered, quote=True)}"></iframe>'
)

204
admin/routes/schedules.py Normal file
View File

@ -0,0 +1,204 @@
"""Раздел «Расписания»: CRUD рассылок, которые исполняет бот."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.forms import format_weekdays, parse_schedule_fields
from admin.security import check_csrf
from common import __version__
from common.repositories import schedules_repo, templates_repo
router = APIRouter(prefix="/schedules", tags=["schedules"])
def _editor_id(request: Request) -> int:
"""Telegram-ID текущего администратора (ставит middleware)."""
return int(request.state.admin["telegram_id"])
def _raw_from_form(form: Any) -> dict[str, Any]:
return {
"template_key": str(form.get("template_key", "")),
"hour": str(form.get("hour", "")),
"minute": str(form.get("minute", "")),
"weekdays": [str(day) for day in form.getlist("weekdays")],
"timezone": str(form.get("timezone", "")),
"description": str(form.get("description", "")),
"enabled": form.get("enabled") is not None,
}
def _view(values: Mapping[str, Any], *, is_new: bool) -> dict[str, Any]:
"""Привести значения (из БД или формы) к виду, удобному шаблону."""
days: list[int] = []
for day in values.get("weekdays") or ():
try:
days.append(int(day))
except (TypeError, ValueError):
continue
return {
"template_key": values.get("template_key") or "",
"hour": values.get("hour", 16),
"minute": values.get("minute", 0),
"weekdays": sorted(set(days)),
"timezone": values.get("timezone") or "Europe/Moscow",
"description": values.get("description") or "",
"enabled": bool(values.get("enabled", True)),
"id": values.get("id"),
"is_new": is_new,
"action": "/schedules" if is_new else f"/schedules/{values.get('id')}",
}
def _form_response(
request: Request,
schedule: dict[str, Any],
message_templates: list[dict[str, Any]],
*,
errors: list[str] | None = None,
history: list[dict[str, Any]] | None = None,
status_code: int = 200,
) -> Response:
return request.app.state.templates.TemplateResponse(
request=request,
name="schedules/form.html",
context={
"version": __version__,
"schedule": schedule,
"message_templates": message_templates,
"errors": errors or [],
"history": history or [],
"weekday_names": {1: "Пн", 2: "Вт", 3: "Ср", 4: "Чт", 5: "Пт", 6: "Сб", 7: "Вс"},
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_schedules(request: Request) -> HTMLResponse:
"""Список расписаний: время, дни, шаблон, таймзона и статус."""
return request.app.state.templates.TemplateResponse(
request=request,
name="schedules/list.html",
context={
"version": __version__,
"schedules": await schedules_repo.list_schedules(),
"format_weekdays": format_weekdays,
},
)
@router.get("/new", response_class=HTMLResponse)
async def new_schedule(request: Request) -> HTMLResponse:
"""Форма создания расписания."""
message_templates = await templates_repo.list_templates()
defaults = {
"template_key": message_templates[0]["key"] if message_templates else "",
"hour": 16,
"minute": 0,
"weekdays": [],
"timezone": "Europe/Moscow",
"description": "",
"enabled": True,
}
return _form_response(request, _view(defaults, is_new=True), message_templates)
@router.post("")
async def create_schedule(request: Request) -> Response:
"""Создать расписание (с проверкой полей и существования шаблона)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
raw = _raw_from_form(form)
data, errors = parse_schedule_fields(**raw)
message_templates = await templates_repo.list_templates()
if data is not None and await templates_repo.get_template(data["template_key"]) is None:
errors.append("Шаблон не найден")
if errors:
return _form_response(
request,
_view(raw, is_new=True),
message_templates,
errors=errors,
status_code=400,
)
await schedules_repo.create_schedule(data, editor_id=_editor_id(request))
return RedirectResponse(url="/schedules", status_code=303)
@router.get("/{schedule_id}", response_class=HTMLResponse)
async def edit_schedule(request: Request, schedule_id: int) -> HTMLResponse:
"""Форма редактирования и история изменений."""
schedule = await schedules_repo.get_schedule(schedule_id)
if schedule is None:
raise HTTPException(status_code=404, detail="Расписание не найдено")
return _form_response(
request,
_view(schedule, is_new=False),
await templates_repo.list_templates(),
history=await schedules_repo.schedule_history(schedule_id),
)
@router.post("/{schedule_id}")
async def update_schedule(request: Request, schedule_id: int) -> Response:
"""Сохранить изменения расписания."""
if await schedules_repo.get_schedule(schedule_id) is None:
raise HTTPException(status_code=404, detail="Расписание не найдено")
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
raw = _raw_from_form(form)
data, errors = parse_schedule_fields(**raw)
message_templates = await templates_repo.list_templates()
if data is not None and await templates_repo.get_template(data["template_key"]) is None:
errors.append("Шаблон не найден")
if errors:
view = _view({**raw, "id": schedule_id}, is_new=False)
return _form_response(
request,
view,
message_templates,
errors=errors,
history=await schedules_repo.schedule_history(schedule_id),
status_code=400,
)
await schedules_repo.update_schedule(schedule_id, data, editor_id=_editor_id(request))
return RedirectResponse(url="/schedules", status_code=303)
@router.post("/{schedule_id}/toggle")
async def toggle_schedule(request: Request, schedule_id: int) -> Response:
"""Включить или выключить расписание."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
schedule = await schedules_repo.get_schedule(schedule_id)
if schedule is None:
raise HTTPException(status_code=404, detail="Расписание не найдено")
await schedules_repo.set_enabled(schedule_id, not schedule["enabled"], editor_id=_editor_id(request))
return RedirectResponse(url="/schedules", status_code=303)
@router.post("/{schedule_id}/delete")
async def delete_schedule(request: Request, schedule_id: int) -> Response:
"""Удалить расписание (история при этом сохраняется)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if not await schedules_repo.delete_schedule(schedule_id, editor_id=_editor_id(request)):
raise HTTPException(status_code=404, detail="Расписание не найдено")
return RedirectResponse(url="/schedules", status_code=303)

150
admin/routes/send.py Normal file
View File

@ -0,0 +1,150 @@
"""Ручная отправка сообщения в основной чат от имени бота."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.datastructures import UploadFile
from admin import telegram
from admin.buttons import MAX_BUTTONS, parse_buttons
from admin.media import read_image
from admin.security import check_csrf
from common import __version__
from common.repositories import admin_messages_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/send", tags=["send"])
#: Лимит подписи к фото.
MAX_CAPTION = 1024
async def _recent() -> list[dict[str, Any]]:
try:
return await admin_messages_repo.recent(limit=5)
except Exception:
logger.warning("Не удалось прочитать журнал отправок", exc_info=True)
return []
async def _render(
request: Request,
*,
text: str = "",
buttons_text: str = "",
errors: list[str] | None = None,
saved: bool = False,
status_code: int = 200,
) -> HTMLResponse:
settings = request.app.state.settings
return request.app.state.templates.TemplateResponse(
request=request,
name="send/form.html",
context={
"version": __version__,
"text": text,
"buttons_text": buttons_text,
"errors": errors or [],
"saved": saved,
"chat_id": settings.chat_id,
"max_message": MAX_MESSAGE_LENGTH,
"max_caption": MAX_CAPTION,
"max_buttons": MAX_BUTTONS,
"tags": TAG_REGISTRY,
"recent": await _recent(),
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def send_form(request: Request, saved: bool = False) -> HTMLResponse:
return await _render(request, saved=saved)
@router.post("")
async def send_message(request: Request) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
text = str(form.get("text", ""))
raw_buttons = str(form.get("buttons", ""))
buttons, errors = parse_buttons(raw_buttons)
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
settings = request.app.state.settings
photo: tuple[bytes, str, str] | None = None
upload = form.get("image")
if isinstance(upload, UploadFile) and upload.filename:
try:
data, mime, extension = await read_image(upload)
photo = (data, mime, f"upload{extension}")
except ValueError as exc:
errors.append(str(exc))
limit = MAX_CAPTION if photo else MAX_MESSAGE_LENGTH
if len(text) > limit:
errors.append(f"С фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}")
if not text.strip() and photo is None:
errors.append("Нужен текст или картинка")
if not settings.chat_id:
errors.append("CHAT_ID не задан")
if errors:
return await _render(
request,
text=text,
buttons_text=raw_buttons,
errors=errors,
status_code=400,
)
success = True
error_text: str | None = None
try:
await telegram.send_to_chat(
settings,
text=text,
buttons=buttons,
photo=photo,
)
except telegram.TelegramError as exc:
success = False
error_text = str(exc)
except Exception:
success = False
error_text = "Не удалось отправить сообщение"
logger.exception("Ошибка отправки сообщения из админки")
try:
await admin_messages_repo.log_message(
editor_id=int(request.state.admin["telegram_id"]),
chat_id=settings.chat_id,
text=text,
buttons=buttons,
has_image=photo is not None,
success=success,
error=error_text,
)
except Exception:
logger.warning("Не удалось записать журнал отправки", exc_info=True)
if not success:
return await _render(
request,
text=text,
buttons_text=raw_buttons,
errors=[f"Telegram: {error_text}"],
status_code=400,
)
return RedirectResponse(url="/send?saved=true", status_code=303)

441
admin/routes/sequences.py Normal file
View File

@ -0,0 +1,441 @@
"""Раздел «Последовательности»: сообщения по порядку, картинки и кнопки."""
from __future__ import annotations
import logging
import re
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from starlette.datastructures import UploadFile
from admin.buttons import MAX_BUTTONS, parse_buttons
from admin.media import media_path, read_image
from admin.security import check_csrf
from common import __version__
from common.repositories import sequences_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sequences", tags=["sequences"])
#: Формат ключа последовательности.
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
#: Лимит подписи к фото.
MAX_CAPTION = 1024
def _editor_id(request: Request) -> int:
return int(request.state.admin["telegram_id"])
def _prepare_items(items: list[dict[str, Any]]) -> None:
"""Добавить к каждому сообщению строку кнопок и токен для формы."""
for item in items:
item.setdefault("token", str(item["id"]))
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"])
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
data, _, extension = await read_image(upload)
name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
path = media_path(settings, name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return name
def _delete_image(settings: Any, name: str | None) -> None:
if not name:
return
try:
media_path(settings, name).unlink(missing_ok=True)
except OSError:
logger.warning("Не удалось удалить картинку %s", name, exc_info=True)
async def _list_response(
request: Request,
*,
errors: list[str] | None = None,
form: dict[str, str] | None = None,
saved: str | None = None,
status_code: int = 200,
) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="sequences/list.html",
context={
"version": __version__,
"sequences": await sequences_repo.list_sequences(),
"errors": errors or [],
"form": form or {"key": "", "title": "", "description": ""},
"saved": saved,
},
status_code=status_code,
)
async def _editor_response(
request: Request,
key: str,
*,
errors: list[str] | None = None,
saved: str | None = None,
overrides: dict[int, dict[str, Any]] | None = None,
items_override: list[dict[str, Any]] | None = None,
status_code: int = 200,
) -> HTMLResponse:
sequence = await sequences_repo.get_sequence(key)
if sequence is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if items_override is not None:
items = items_override
else:
items = await sequences_repo.get_items(key)
for item in items:
override = (overrides or {}).get(int(item["id"]))
if override:
item.update(override)
_prepare_items(items)
item_order = ",".join(str(item["token"]) for item in items)
return request.app.state.templates.TemplateResponse(
request=request,
name="sequences/edit.html",
context={
"version": __version__,
"sequence": sequence,
"items": items,
"item_order": item_order,
"history": await sequences_repo.history(key, limit=5),
"errors": errors or [],
"saved": saved,
"max_caption": MAX_CAPTION,
"max_message": MAX_MESSAGE_LENGTH,
"max_buttons": MAX_BUTTONS,
"tags": TAG_REGISTRY,
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_sequences(request: Request, saved: str | None = None) -> HTMLResponse:
return await _list_response(request, saved=saved)
@router.post("")
async def create_sequence(request: Request) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
key = str(form.get("key", "")).strip()
title = str(form.get("title", "")).strip()
description = str(form.get("description", "")).strip()
raw_form = {"key": key, "title": title, "description": description}
errors: list[str] = []
if not KEY_RE.match(key):
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–32)")
if not title:
errors.append("Название не может быть пустым")
if errors:
return await _list_response(request, errors=errors, form=raw_form, status_code=400)
if not await sequences_repo.create_sequence(
key,
title,
description,
editor_id=_editor_id(request),
):
return await _list_response(
request,
errors=["Шаблон с таким ключом уже существует"],
form=raw_form,
status_code=400,
)
return RedirectResponse(url=f"/sequences/{key}", status_code=303)
@router.get("/media/{filename}")
async def serve_media(request: Request, filename: str) -> FileResponse:
"""Отдать картинку из media-volume (доступ только после входа)."""
path = media_path(request.app.state.settings, filename)
if not path.is_file():
raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(path)
@router.get("/{key}", response_class=HTMLResponse)
async def edit_sequence(request: Request, key: str, saved: str | None = None) -> HTMLResponse:
return await _editor_response(request, key, saved=saved)
@router.post("/{key}/save")
async def save_template(request: Request, key: str) -> Response:
"""Сохранить шаблон целиком: параметры и все сообщения одной формой."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
sequence = await sequences_repo.get_sequence(key)
if sequence is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
title = str(form.get("title", "")).strip()
description = str(form.get("description", "")).strip()
order = [token for token in str(form.get("item_order", "")).split(",") if token]
existing_items = await sequences_repo.get_items(key)
existing = {int(item["id"]): item for item in existing_items}
errors: list[str] = []
if not title:
errors.append("Название не может быть пустым")
if not order:
errors.append("Добавьте хотя бы одно сообщение")
submitted: list[dict[str, Any]] = []
pending_images: dict[str, tuple[bytes, str]] = {}
for position, token in enumerate(order):
item_id = int(token) if token.isdigit() else None
current = existing.get(item_id) if item_id is not None else None
text = str(form.get(f"text_{token}", ""))
raw_buttons = str(form.get(f"buttons_{token}", ""))
buttons, button_errors = parse_buttons(raw_buttons)
errors.extend(f"Сообщение {position + 1}: {error}" for error in button_errors)
enabled = form.get(f"enabled_{token}") is not None
image_name = current["image_name"] if current else None
has_image = bool(image_name)
upload = form.get(f"image_{token}")
if isinstance(upload, UploadFile) and upload.filename:
try:
data, _, extension = await read_image(upload)
pending_images[token] = (data, extension)
has_image = True
except ValueError as exc:
errors.append(f"Сообщение {position + 1}: {exc}")
elif form.get(f"remove_image_{token}") is not None:
image_name = None
has_image = False
unknown = validate(text)
if unknown:
errors.append(f"Сообщение {position + 1}: неизвестные теги " + ", ".join(unknown))
limit = MAX_CAPTION if has_image else MAX_MESSAGE_LENGTH
if len(text) > limit:
errors.append(
f"Сообщение {position + 1}: с фото — не больше {MAX_CAPTION} символов, "
f"без фото — {MAX_MESSAGE_LENGTH}"
)
if not text.strip() and not has_image:
errors.append(f"Сообщение {position + 1}: нужен текст или картинка")
submitted.append(
{
"id": item_id,
"token": token,
"text": text,
"enabled": enabled,
"image_name": image_name,
"buttons": buttons,
"buttons_text": raw_buttons,
}
)
if errors:
return await _editor_response(
request,
key,
errors=errors,
items_override=submitted,
status_code=400,
)
settings = request.app.state.settings
saved_files: list[str] = []
for item in submitted:
pending = pending_images.get(item["token"])
if pending is None:
continue
data, extension = pending
name = f"{key}_{item['token']}_{uuid.uuid4().hex}{extension}"
path = media_path(settings, name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
item["image_name"] = name
saved_files.append(name)
removed = await sequences_repo.save_items(
key,
submitted,
editor_id=_editor_id(request),
)
if removed is None:
for name in saved_files:
_delete_image(settings, name)
raise HTTPException(status_code=404, detail="Шаблон не найден")
for name in removed:
_delete_image(settings, name)
await sequences_repo.update_sequence(
key,
title,
description,
editor_id=_editor_id(request),
)
return RedirectResponse(url=f"/sequences/{key}?saved=all", status_code=303)
@router.post("/{key}")
async def update_sequence(request: Request, key: str) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
title = str(form.get("title", "")).strip()
description = str(form.get("description", "")).strip()
if not title:
return await _editor_response(request, key, errors=["Название не может быть пустым"], status_code=400)
if not await sequences_repo.update_sequence(
key,
title,
description,
editor_id=_editor_id(request),
):
raise HTTPException(status_code=404, detail="Шаблон не найден")
return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303)
@router.post("/{key}/items")
async def add_item(request: Request, key: str) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
@router.post("/{key}/items/{item_id}")
async def update_item(request: Request, key: str, item_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
items = await sequences_repo.get_items(key)
item = next((row for row in items if int(row["id"]) == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
text = str(form.get("text", ""))
enabled = form.get("enabled") is not None
raw_buttons = str(form.get("buttons", ""))
buttons, errors = parse_buttons(raw_buttons)
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
settings = request.app.state.settings
new_image: str | None = None
update_image = False
saved_file: str | None = None
upload = form.get("image")
if isinstance(upload, UploadFile) and upload.filename:
try:
saved_file = await _save_image(settings, upload, key, item_id)
new_image = saved_file
update_image = True
except ValueError as exc:
errors.append(str(exc))
elif form.get("remove_image") is not None and item["image_name"]:
update_image = True
new_image = None
effective_image = new_image if update_image else item["image_name"]
limit = MAX_CAPTION if effective_image else MAX_MESSAGE_LENGTH
if len(text) > limit:
errors.append(f"С подписью к фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}")
if not text.strip() and not effective_image:
errors.append("Нужен текст или картинка")
if errors:
if saved_file:
_delete_image(settings, saved_file)
return await _editor_response(
request,
key,
errors=errors,
overrides={
item_id: {
"text": text,
"enabled": enabled,
"buttons_text": raw_buttons,
}
},
status_code=400,
)
updated = await sequences_repo.update_item(
item_id,
text,
enabled,
buttons,
image_name=new_image,
update_image=update_image,
editor_id=_editor_id(request),
)
if updated is None:
if saved_file:
_delete_image(settings, saved_file)
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
_, old_image = updated
if update_image and old_image and old_image != new_image:
_delete_image(settings, old_image)
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
@router.post("/{key}/items/{item_id}/move")
async def move_item(request: Request, key: str, item_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
direction = str(form.get("direction", ""))
if direction not in {"up", "down"}:
raise HTTPException(status_code=400, detail="Неверное направление")
if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303)
@router.post("/{key}/items/{item_id}/delete")
async def delete_item(request: Request, key: str, item_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
items = await sequences_repo.get_items(key)
item = next((row for row in items if int(row["id"]) == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
_delete_image(request.app.state.settings, item["image_name"])
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)

124
admin/routes/titles.py Normal file
View File

@ -0,0 +1,124 @@
"""Раздел «Звания»: справочник магазина званий."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.security import check_csrf
from common import __version__
from common.repositories import titles_repo
router = APIRouter(prefix="/titles", tags=["titles"])
MAX_TITLE_LENGTH = 100
def _parse(title: str, price: str, visible: bool) -> tuple[dict[str, object] | None, list[str]]:
errors: list[str] = []
title = title.strip()
if not title:
errors.append("Название не может быть пустым")
elif len(title) > MAX_TITLE_LENGTH:
errors.append(f"Название длиннее {MAX_TITLE_LENGTH} символов")
try:
price_value = int(price)
except (TypeError, ValueError):
price_value = -1
errors.append("Цена должна быть целым числом")
if price_value < 0 and "Цена должна быть целым числом" not in errors:
errors.append("Цена не может быть отрицательной")
if errors:
return None, errors
return {"title": title, "price": price_value, "visible": visible}, []
async def _render(
request: Request,
*,
errors: list[str] | None = None,
form: dict[str, str] | None = None,
saved: str | None = None,
status_code: int = 200,
) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="titles/list.html",
context={
"version": __version__,
"titles": await titles_repo.list_titles(),
"errors": errors or [],
"form": form or {"title": "", "price": ""},
"saved": saved,
"max_title_length": MAX_TITLE_LENGTH,
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_titles(request: Request, saved: str | None = None) -> HTMLResponse:
return await _render(request, saved=saved)
@router.post("")
async def create_title(request: Request) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
raw_title = str(form.get("title", ""))
raw_price = str(form.get("price", ""))
data, errors = _parse(raw_title, raw_price, True)
if errors:
return await _render(
request,
errors=errors,
form={"title": raw_title, "price": raw_price},
status_code=400,
)
await titles_repo.create_title(str(data["title"]), int(data["price"]), True)
return RedirectResponse(url="/titles?saved=created", status_code=303)
@router.post("/{title_id}")
async def update_title(request: Request, title_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
data, errors = _parse(
str(form.get("title", "")),
str(form.get("price", "")),
form.get("visible") is not None,
)
if errors:
return await _render(request, errors=errors, status_code=400)
if not await titles_repo.update_title(
title_id,
str(data["title"]),
int(data["price"]),
bool(data["visible"]),
):
raise HTTPException(status_code=404, detail="Звание не найдено")
return RedirectResponse(url="/titles?saved=updated", status_code=303)
@router.post("/{title_id}/delete")
async def delete_title(request: Request, title_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
used = await titles_repo.usage_count(title_id)
if used:
return await _render(
request,
errors=[f"Звание используется {used} игроками — скройте его вместо удаления"],
status_code=400,
)
if not await titles_repo.delete_title(title_id):
raise HTTPException(status_code=404, detail="Звание не найдено")
return RedirectResponse(url="/titles?saved=deleted", status_code=303)

238
admin/routes/users.py Normal file
View File

@ -0,0 +1,238 @@
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin import telegram
from admin.security import check_csrf
from common import __version__
from common.eggs import is_egg_event_active
from common.repositories import users_admin_repo, users_repo
from common.texts import alerts as alerts_text
from common.texts.render import render
router = APIRouter(prefix="/users", tags=["users"])
#: Сколько пользователей показывать на странице.
PAGE_SIZE = 25
def _editor_id(request: Request) -> int:
"""Telegram-ID текущего администратора (ставит middleware)."""
return int(request.state.admin["telegram_id"])
def _tag(username: str | None, user_id: int) -> str:
"""``@username`` или числовой ID для уведомлений."""
return f"@{username}" if username else str(user_id)
def _victim_tag(user: dict[str, object]) -> str:
if user.get("username"):
return f"@{user['username']}"
if user.get("nick"):
return str(user["nick"])
return str(user.get("id_user"))
async def _card_response(
request: Request,
user_id: int,
*,
errors: list[str] | None = None,
saved: str | None = None,
status_code: int = 200,
) -> HTMLResponse:
"""Отрисовать карточку (общая точка для GET и ошибок валидации)."""
user = await users_admin_repo.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return request.app.state.templates.TemplateResponse(
request=request,
name="users/card.html",
context={
"version": __version__,
"user": user,
"money": await users_admin_repo.money_history(user_id),
"titles": await users_admin_repo.list_titles(),
"history": await users_admin_repo.user_history(user_id),
"errors": errors or [],
"saved": saved,
"eggs_visible": is_egg_event_active(request.app.state.settings),
"can_kick": bool(request.app.state.settings.bot_token and request.app.state.settings.chat_id),
"max_nick_length": users_admin_repo.MAX_NICK_LENGTH,
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_users(request: Request, q: str = "", page: int = 1, saved: str | None = None) -> HTMLResponse:
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
total = await users_admin_repo.count_users(q)
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
page = min(max(page, 1), pages)
return request.app.state.templates.TemplateResponse(
request=request,
name="users/list.html",
context={
"version": __version__,
"users": await users_admin_repo.list_users(
q,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
),
"q": q,
"page": page,
"pages": pages,
"total": total,
"saved": saved,
},
)
@router.get("/{user_id}", response_class=HTMLResponse)
async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse:
"""Карточка пользователя: сводка, управление, история и операции."""
return await _card_response(request, user_id, saved=saved)
@router.post("/{user_id}/nick")
async def update_nick(request: Request, user_id: int) -> Response:
"""Сменить ник игрока."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
nick = str(form.get("nick", "")).strip()
errors: list[str] = []
if not nick:
errors.append("Ник не может быть пустым")
elif len(nick) > users_admin_repo.MAX_NICK_LENGTH:
errors.append(f"Ник длиннее {users_admin_repo.MAX_NICK_LENGTH} символов")
elif any(char.isspace() for char in nick):
errors.append("Ник не должен содержать пробелы")
if errors:
return await _card_response(request, user_id, errors=errors, status_code=400)
if not await users_admin_repo.set_nick(user_id, nick, editor_id=_editor_id(request)):
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/users/{user_id}?saved=nick", status_code=303)
@router.post("/{user_id}/title")
async def update_title(request: Request, user_id: int) -> Response:
"""Сменить звание игрока."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
try:
title_id = int(str(form.get("title_id", "")))
except (TypeError, ValueError):
return await _card_response(request, user_id, errors=["Выберите звание"], status_code=400)
titles = await users_admin_repo.list_titles()
if not any(int(title["id_title"]) == title_id for title in titles):
return await _card_response(request, user_id, errors=["Звание не найдено"], status_code=400)
if not await users_admin_repo.set_title(user_id, title_id, editor_id=_editor_id(request)):
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/users/{user_id}?saved=title", status_code=303)
@router.post("/{user_id}/subscription")
async def update_subscription(request: Request, user_id: int) -> Response:
"""Подписать или отписать от рассылок."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
value = str(form.get("subscribed", ""))
if value not in {"0", "1"}:
return await _card_response(request, user_id, errors=["Некорректное значение подписки"], status_code=400)
if not await users_admin_repo.set_subscription(
user_id,
value == "1",
editor_id=_editor_id(request),
):
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/users/{user_id}?saved=subscription", status_code=303)
@router.post("/{user_id}/balance")
async def update_balance(request: Request, user_id: int) -> Response:
"""Скорректировать баланс записью в журнал money (действие «Корректировка админом»)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
raw_amount = str(form.get("amount", "")).strip()
reason = str(form.get("reason", "")).strip()
errors: list[str] = []
try:
amount = int(raw_amount)
except (TypeError, ValueError):
amount = 0
errors.append("Сумма должна быть целым числом")
if amount == 0 and "Сумма должна быть целым числом" not in errors:
errors.append("Сумма не может быть нулевой")
if not reason:
errors.append("Укажите причину корректировки")
elif len(reason) > users_admin_repo.MAX_COMMENT_LENGTH:
errors.append(f"Причина длиннее {users_admin_repo.MAX_COMMENT_LENGTH} символов")
if errors:
return await _card_response(request, user_id, errors=errors, status_code=400)
balance = await users_admin_repo.adjust_balance(
user_id,
amount,
reason,
editor_id=_editor_id(request),
)
if balance is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303)
@router.post("/{user_id}/kick")
async def kick_user(request: Request, user_id: int) -> Response:
"""Исключить пользователя из основного чата (бан + разбан)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
user = await users_admin_repo.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
settings = request.app.state.settings
try:
await telegram.kick_member(settings, user_id)
except telegram.TelegramError as exc:
return await _card_response(
request,
user_id,
errors=[f"Не удалось исключить: {exc}"],
status_code=400,
)
# Данные удаляем сразу, не полагаясь на событие chat_member (бот может быть остановлен).
await users_repo.del_user_by_id(user_id)
admin = request.state.admin
text = render(
alerts_text.MSG_KICK,
{
"actor": _tag(admin.get("username"), int(admin["telegram_id"])),
"username": _victim_tag(user),
"chat_title": await telegram.chat_title(settings),
},
)
await telegram.send_alert(settings, text)
return RedirectResponse(url="/users?saved=kick", status_code=303)

69
admin/security.py Normal file
View File

@ -0,0 +1,69 @@
"""Безопасность админки: проверка Telegram-логина, сессия и CSRF."""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from collections.abc import Mapping
from fastapi import HTTPException, Request
#: Сколько живёт подпись Telegram-логина (секунды).
TELEGRAM_AUTH_MAX_AGE = 24 * 60 * 60
#: Допустимое расхождение часов с Telegram (секунды).
FUTURE_SKEW = 300
def telegram_check_string(data: Mapping[str, object]) -> str:
"""Строка для проверки подписи: отсортированные ``key=value`` без ``hash``."""
pairs = sorted((key, str(value)) for key, value in data.items() if key != "hash")
return "\n".join(f"{key}={value}" for key, value in pairs)
def verify_telegram_auth(
data: Mapping[str, object],
bot_token: str,
*,
max_age: int = TELEGRAM_AUTH_MAX_AGE,
now: float | None = None,
) -> bool:
"""Проверить подпись Telegram Login Widget и свежесть ``auth_date``.
Секрет — ``SHA256(bot_token)``, подпись — HMAC-SHA256 от check-string.
"""
received = str(data.get("hash") or "")
if not received or not bot_token:
return False
expected = hmac.new(
hashlib.sha256(bot_token.encode()).digest(),
telegram_check_string(data).encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received):
return False
try:
auth_date = int(str(data.get("auth_date") or ""))
except (TypeError, ValueError):
return False
moment = time.time() if now is None else now
return -FUTURE_SKEW <= moment - auth_date <= max_age
def check_csrf(request: Request, token: str) -> None:
"""Проверить CSRF-токен формы; при несовпадении — 400."""
expected = request.session.get("csrf")
if not expected or not token or not secrets.compare_digest(str(expected), str(token)):
raise HTTPException(status_code=400, detail="CSRF-токен неверен или устарел")
def safe_redirect_target(value: str | None) -> str:
"""Разрешить редирект только на внутренний путь (защита от open redirect)."""
if value and value.startswith("/") and not value.startswith("//"):
return value
return "/"

154
admin/telegram.py Normal file
View File

@ -0,0 +1,154 @@
"""Минимальный клиент Telegram Bot API для админки.
Нужен для действий модерации (исключение из чата) и уведомлений в чат логов.
Специально без aiogram: админке не нужен весь фреймворк бота.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
from common.config import AdminSettings
logger = logging.getLogger(__name__)
_API = "https://api.telegram.org/bot{token}/{method}"
class TelegramError(RuntimeError):
"""Telegram отклонил запрос или недоступен."""
def _check(response: httpx.Response) -> Any:
try:
data = response.json()
except ValueError as exc:
raise TelegramError("Telegram вернул некорректный ответ") from exc
if not data.get("ok"):
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
return data.get("result")
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан")
url = _API.format(token=settings.bot_token, method=method)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=payload)
except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc
return _check(response)
async def _call_multipart(
settings: AdminSettings,
method: str,
data: dict[str, str],
files: dict[str, tuple[str, bytes, str]],
) -> Any:
if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан")
url = _API.format(token=settings.bot_token, method=method)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, data=data, files=files)
except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc
return _check(response)
async def kick_member(settings: AdminSettings, user_id: int) -> None:
"""Исключить пользователя из основного чата (бан + разбан, чтобы мог вернуться)."""
if not settings.chat_id:
raise TelegramError("CHAT_ID не задан")
await _call(
settings,
"banChatMember",
{"chat_id": settings.chat_id, "user_id": user_id},
)
await _call(
settings,
"unbanChatMember",
{"chat_id": settings.chat_id, "user_id": user_id, "only_if_banned": True},
)
async def chat_title(settings: AdminSettings) -> str:
"""Название основного чата (best-effort, для текста уведомления)."""
try:
chat = await _call(settings, "getChat", {"chat_id": settings.chat_id})
except TelegramError:
return "чат"
return str(chat.get("title") or chat.get("username") or "чат")
async def send_alert(settings: AdminSettings, text: str) -> None:
"""Отправить служебное сообщение в чат логов (ошибки только в лог)."""
if not settings.alert_chat_id:
return
try:
await _call(
settings,
"sendMessage",
{"chat_id": settings.alert_chat_id, "text": text},
)
except TelegramError:
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)
async def send_to_chat(
settings: AdminSettings,
*,
text: str,
buttons: list[tuple[str, str]],
photo: tuple[bytes, str, str] | None = None,
) -> None:
"""Отправить сообщение в основной чат от имени бота.
``photo`` — (данные, mime, имя файла). При фото длинный текст уходит
отдельным сообщением (лимит подписи 1024).
"""
if not settings.chat_id:
raise TelegramError("CHAT_ID не задан")
rows = [[{"text": title, "url": url}] for title, url in buttons]
markup = {"inline_keyboard": rows} if rows else None
if photo is None:
payload = {
"chat_id": settings.chat_id,
"text": text,
"parse_mode": "HTML",
}
if markup:
payload["reply_markup"] = markup
await _call(settings, "sendMessage", payload)
return
data, mime, filename = photo
fields: dict[str, str] = {"chat_id": str(settings.chat_id), "parse_mode": "HTML"}
caption = text if text and len(text) <= 1024 else ""
if caption:
fields["caption"] = caption
if markup:
fields["reply_markup"] = json.dumps(markup)
await _call_multipart(settings, "sendPhoto", fields, {"photo": (filename, data, mime)})
if text and not caption:
payload = {
"chat_id": settings.chat_id,
"text": text,
"parse_mode": "HTML",
}
if markup:
payload["reply_markup"] = markup
await _call(settings, "sendMessage", payload)

View File

@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Админы{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Админы</h1>
<p class="mt-1 text-sm text-slate-600">Доступ к веб-панели через Telegram.</p>
</div>
</div>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{% if saved == 'added' %}Администратор добавлен.
{% elif saved == 'role' %}Роль обновлена.
{% elif saved == 'toggle' %}Доступ обновлён.
{% elif saved == 'deleted' %}Администратор удалён.
{% else %}Изменения сохранены.{% endif %}
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="/admins" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<h2 class="text-lg font-medium">Добавить администратора</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-4">
<div>
<label for="telegram_id" class="text-xs text-slate-500">Telegram-ID</label>
<input
id="telegram_id"
name="telegram_id"
value="{{ form.telegram_id }}"
inputmode="numeric"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="username" class="text-xs text-slate-500">Username (необязательно)</label>
<input
id="username"
name="username"
value="{{ form.username }}"
placeholder="без @"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="role" class="text-xs text-slate-500">Роль</label>
<select
id="role"
name="role"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
>
{% for role in roles %}
<option value="{{ role }}" {% if role == form.role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Добавить
</button>
</div>
</div>
<p class="mt-2 text-xs text-slate-500">
Обе роли сейчас дают полный доступ; <span class="font-mono">moderator</span> — задел на ограничения.
</p>
</form>
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Telegram-ID</th>
<th class="px-4 py-3 font-medium">Username</th>
<th class="px-4 py-3 font-medium">Роль</th>
<th class="px-4 py-3 font-medium">Доступ</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">Последний вход</th>
<th class="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for admin in admins %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono text-xs">
{{ admin.telegram_id }}
{% if admin.telegram_id == current_id %}<span class="ml-1 text-slate-400">(вы)</span>{% endif %}
</td>
<td class="px-4 py-3">
{% if admin.username %}<span class="font-mono">@{{ admin.username }}</span>{% else %}—{% endif %}
</td>
<td class="px-4 py-3">
<form method="post" action="/admins/{{ admin.telegram_id }}/role" class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<select
name="role"
class="rounded-lg border border-slate-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
>
{% for role in roles %}
<option value="{{ role }}" {% if role == admin.role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
<button type="submit" class="text-xs text-sky-700 hover:underline">Сохранить</button>
</form>
</td>
<td class="px-4 py-3">
{% if admin.enabled %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включён</span>
{% else %}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
{% endif %}
</td>
<td class="hidden px-4 py-3 text-slate-600 lg:table-cell">
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-3">
{% if admin.telegram_id != current_id %}
<form method="post" action="/admins/{{ admin.telegram_id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-slate-600 hover:underline">
{% if admin.enabled %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="/admins/{{ admin.telegram_id }}/delete" onsubmit="return confirm('Удалить администратора?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Доступ закрыт{% endblock %}
{% block content %}
<div class="mx-auto mt-10 max-w-md rounded-lg border border-red-200 bg-white p-8 text-center">
<h1 class="text-2xl font-semibold text-red-700">Доступ закрыт</h1>
<p class="mt-3 text-sm text-slate-700">{{ message }}</p>
<p class="mt-6">
<a href="/login" class="text-sky-700 hover:underline">Вернуться к входу</a>
</p>
</div>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Вход{% endblock %}
{% block content %}
<div class="mx-auto mt-10 max-w-md rounded-lg border border-slate-200 bg-white p-8 text-center">
<h1 class="text-2xl font-semibold">Вход в админку</h1>
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p>
{% if deeplink_error %}
<p class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Deep-link вход не настроен: проверьте <span class="font-mono">BOT_USERNAME</span>.
</p>
{% endif %}
{% if configured %}
<div class="mt-8 flex justify-center">
<script
async
src="https://telegram.org/js/telegram-widget.js?22"
data-telegram-login="{{ bot_username }}"
data-size="large"
data-auth-url="{{ public_url }}/auth/telegram?next={{ next | urlencode }}"
data-request-access="write"
></script>
</div>
{% endif %}
{% if deeplink %}
<div class="mt-6 border-t border-slate-100 pt-5">
<a
href="/auth/deeplink/start"
class="inline-block rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Открыть Telegram
</a>
<p class="mt-2 text-xs text-slate-500">Подтвердите вход в установленном приложении Telegram.</p>
<p id="deeplink-status" class="mt-2 text-sm text-sky-700">
{% if deeplink_pending %}Ожидаем подтверждение в Telegram…{% endif %}
</p>
</div>
{% endif %}
{% if not configured and not deeplink %}
<p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>,
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
</p>
{% endif %}
</div>
{% if deeplink_pending %}
<script>
(function () {
var status = document.getElementById("deeplink-status");
var timer = setInterval(function () {
fetch("/auth/deeplink/status", { credentials: "same-origin" })
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.status === "ok") {
clearInterval(timer);
window.location.href = "/";
} else if (data.status === "denied") {
clearInterval(timer);
if (status) status.textContent = "У вас нет доступа к админке.";
}
})
.catch(function () {});
}, 2000);
setTimeout(function () { clearInterval(timer); }, 300000);
})();
</script>
{% endif %}
{% endblock %}

215
admin/templates/base.html Normal file
View File

@ -0,0 +1,215 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Админка{% endblock %} — DispelEvilBot</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto max-w-5xl px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
<button
id="nav-toggle"
type="button"
class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-600 sm:hidden"
aria-expanded="false"
>
Меню
</button>
<nav
id="nav-menu"
class="hidden w-full flex-col gap-2 border-t border-slate-100 pt-2 text-sm sm:flex sm:w-auto sm:flex-row sm:items-center sm:gap-1 sm:border-0 sm:pt-0"
>
<a href="/" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Главная</a>
<div class="relative" data-group>
<button
type="button"
class="flex w-full items-center justify-between gap-1 rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900 sm:w-auto"
data-group-toggle
aria-expanded="false"
>
Сообщения <span class="text-xs">▾</span>
</button>
<div
class="hidden flex-col gap-1 rounded-lg border border-slate-200 bg-white p-2 sm:absolute sm:left-0 sm:top-full sm:z-20 sm:min-w-48 sm:shadow-lg"
data-group-dropdown
>
<a href="/sequences" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Шаблоны</a>
<a href="/schedules" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Расписания</a>
<a href="/send" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Отправить</a>
</div>
</div>
<div class="relative" data-group>
<button
type="button"
class="flex w-full items-center justify-between gap-1 rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900 sm:w-auto"
data-group-toggle
aria-expanded="false"
>
Пользователи <span class="text-xs">▾</span>
</button>
<div
class="hidden flex-col gap-1 rounded-lg border border-slate-200 bg-white p-2 sm:absolute sm:left-0 sm:top-full sm:z-20 sm:min-w-48 sm:shadow-lg"
data-group-dropdown
>
<a href="/users" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Список</a>
<a href="/admins" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Админы</a>
<a href="/titles" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Звания</a>
</div>
</div>
<a href="/logs" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Логи</a>
{% if request.session.get('admin_id') %}
<form method="post" action="/logout" class="py-1 sm:ml-1">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">
Выйти
</button>
</form>
{% endif %}
</nav>
</div>
</div>
</header>
<main class="mx-auto max-w-5xl px-4 py-8">
{% block content %}{% endblock %}
</main>
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer>
<script>
var tags = {
b: ["<b>", "</b>"],
i: ["<i>", "</i>"],
u: ["<u>", "</u>"],
s: ["<s>", "</s>"],
code: ["<code>", "</code>"],
pre: ["<pre>", "</pre>"],
quote: ["<blockquote>", "</blockquote>"],
spoiler: ["<tg-spoiler>", "</tg-spoiler>"],
a: ['<a href="">', "</a>"],
};
function initEditor(root) {
if (!root || root.dataset.editorReady) return;
var textarea = root.querySelector("textarea");
if (!textarea) return;
root.dataset.editorReady = "1";
var counter = root.querySelector("[data-counter]");
var max = parseInt(root.dataset.max || "0", 10);
function updateCounter() {
if (counter) counter.textContent = textarea.value.length + (max ? " / " + max : "");
}
textarea.addEventListener("input", updateCounter);
updateCounter();
root.querySelectorAll("button[data-tag]").forEach(function (button) {
button.addEventListener("click", function () {
var pair = tags[button.dataset.tag];
if (!pair) return;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
var inserted = pair[0] + selected + pair[1];
textarea.value = textarea.value.slice(0, start) + inserted + textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + pair[0].length;
textarea.selectionEnd = start + pair[0].length + selected.length;
updateCounter();
});
});
textarea.addEventListener("keydown", function (event) {
if (!(event.ctrlKey || event.metaKey)) return;
var key = event.key.toLowerCase();
var tag = key === "b" ? "b" : key === "i" ? "i" : key === "u" ? "u" : null;
if (!tag) return;
event.preventDefault();
var button = root.querySelector('button[data-tag="' + tag + '"]');
if (button) button.click();
});
}
window.initEditor = initEditor;
document.addEventListener("DOMContentLoaded", function () {
var navToggle = document.getElementById("nav-toggle");
var navMenu = document.getElementById("nav-menu");
if (navToggle && navMenu) {
navToggle.addEventListener("click", function () {
var hidden = navMenu.classList.toggle("hidden");
navMenu.classList.toggle("flex", !hidden);
navToggle.setAttribute("aria-expanded", String(!hidden));
});
}
var groups = document.querySelectorAll("[data-group]");
function hideGroup(group) {
var dropdown = group.querySelector("[data-group-dropdown]");
var toggle = group.querySelector("[data-group-toggle]");
if (dropdown) {
dropdown.classList.add("hidden");
dropdown.classList.remove("flex");
}
if (toggle) toggle.setAttribute("aria-expanded", "false");
}
function hideOthers(current) {
groups.forEach(function (group) {
if (group !== current) hideGroup(group);
});
}
groups.forEach(function (group) {
var toggle = group.querySelector("[data-group-toggle]");
var dropdown = group.querySelector("[data-group-dropdown]");
if (!toggle || !dropdown) return;
var closeTimer = null;
function setOpen(open) {
dropdown.classList.toggle("hidden", !open);
dropdown.classList.toggle("flex", open);
toggle.setAttribute("aria-expanded", String(open));
}
toggle.addEventListener("click", function (event) {
event.stopPropagation();
if (closeTimer) clearTimeout(closeTimer);
var willOpen = dropdown.classList.contains("hidden");
if (willOpen) hideOthers(group);
setOpen(willOpen);
});
if (window.matchMedia("(min-width: 640px)").matches) {
group.addEventListener("mouseenter", function () {
if (closeTimer) clearTimeout(closeTimer);
hideOthers(group);
setOpen(true);
});
group.addEventListener("mouseleave", function () {
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(function () { setOpen(false); }, 150);
});
}
});
document.addEventListener("click", function () {
groups.forEach(hideGroup);
});
document.querySelectorAll("[data-editor]").forEach(initEditor);
});
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Главная{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold">Панель администрирования</h1>
<p class="mt-2 text-slate-600">
Окружение: <span class="font-mono">{{ app_env }}</span>. Это каркас админки: разделы
появятся по мере готовности (Блок 4.1–4.3).
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{% for section in sections %}
<section class="rounded-lg border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">
{% if section.url %}<a href="{{ section.url }}" class="hover:underline">{{ section.title }}</a>{% else %}{{ section.title }}{% endif %}
</h2>
{% if not section.url %}<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>{% endif %}
</div>
<p class="mt-2 text-sm text-slate-600">{{ section.description }}</p>
</section>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block title %}Логи{% endblock %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">Логи</h1>
<p class="mt-1 text-sm text-slate-600">
{% if view == "messages" %}Сообщения по дням{% else %}События из Postgres{% endif %}
· найдено: {{ total }}
</p>
</div>
</div>
<div class="mt-4 flex gap-1 border-b border-slate-200">
<a
href="/logs?date_from={{ date_from }}&date_to={{ date_to }}"
class="rounded-t-lg px-4 py-2 text-sm {% if view == 'events' %}border border-b-0 border-slate-200 bg-white font-medium text-slate-900{% else %}text-slate-600 hover:text-slate-900{% endif %}"
>
События
</a>
<a
href="/logs?view=messages&date_from={{ date_from }}&date_to={{ date_to }}"
class="rounded-t-lg px-4 py-2 text-sm {% if view == 'messages' %}border border-b-0 border-slate-200 bg-white font-medium text-slate-900{% else %}text-slate-600 hover:text-slate-900{% endif %}"
>
Сообщения по дням
</a>
</div>
<form method="get" action="/logs" class="flex flex-wrap items-end gap-3 rounded-b-lg border border-t-0 border-slate-200 bg-white p-4">
{% if view == "messages" %}
<input type="hidden" name="view" value="messages" />
{% endif %}
{% if view == "events" %}
<div>
<label for="event_type" class="text-xs text-slate-500">Тип</label>
<select
id="event_type"
name="event_type"
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
>
<option value="">Все типы</option>
{% for type in event_types %}
<option value="{{ type }}" {% if type == event_type %}selected{% endif %}>{{ type }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label for="user" class="text-xs text-slate-500">Пользователь</label>
<input
id="user"
name="user"
type="text"
value="{{ user }}"
placeholder="ID или username"
class="mt-1 block w-48 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="date_from" class="text-xs text-slate-500">С</label>
<input
id="date_from"
name="date_from"
type="date"
value="{{ date_from }}"
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="date_to" class="text-xs text-slate-500">По</label>
<input
id="date_to"
name="date_to"
type="date"
value="{{ date_to }}"
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Показать
</button>
<a href="/logs{% if view == 'messages' %}?view=messages{% endif %}" class="text-sm text-slate-600 hover:underline">Сбросить</a>
</form>
{% if error %}
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div>
{% endif %}
{% if view == "messages" %}
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">День</th>
<th class="px-4 py-3 font-medium">Пользователь</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">Чат</th>
<th class="px-4 py-3 text-right font-medium">Сообщений</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for row in messages %}
<tr class="hover:bg-slate-50">
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ row.day_local }}</td>
<td class="px-4 py-3">
{% if row.username %}<div>@{{ row.username }}</div>{% endif %}
<a href="/users/{{ row.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ row.user_id }}</a>
</td>
<td class="hidden px-4 py-3 font-mono text-xs text-slate-500 lg:table-cell">{{ row.chat_id or "—" }}</td>
<td class="px-4 py-3 text-right font-mono">{{ row.count }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-4 py-6 text-center text-slate-500">
{% if error %}Данные не загрузились.{% else %}За выбранный период сообщений нет.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Время</th>
<th class="px-4 py-3 font-medium">Событие</th>
<th class="px-4 py-3 font-medium">Пользователь</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">Чат</th>
<th class="hidden px-4 py-3 font-medium xl:table-cell">Версия</th>
<th class="px-4 py-3 font-medium">Payload</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for event in events %}
<tr class="align-top hover:bg-slate-50">
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ event.time_local }}</td>
<td class="px-4 py-3">
<span class="rounded bg-slate-100 px-2 py-0.5 font-mono text-xs">{{ event.event_type }}</span>
{% if event.prod %}<span class="ml-1 rounded bg-indigo-100 px-2 py-0.5 text-xs text-indigo-800">prod</span>{% endif %}
</td>
<td class="px-4 py-3">
{% if event.user_id %}
{% if event.username %}<div>@{{ event.username }}</div>{% endif %}
<a href="/users/{{ event.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ event.user_id }}</a>
{% else %}
<span class="text-slate-400">—</span>
{% endif %}
</td>
<td class="hidden px-4 py-3 font-mono text-xs text-slate-500 lg:table-cell">{{ event.chat_id or "—" }}</td>
<td class="hidden px-4 py-3 text-xs text-slate-500 xl:table-cell">{{ event.bot_version }}</td>
<td class="px-4 py-3">
{% if event.payload_pretty %}
<details>
<summary class="cursor-pointer text-xs text-slate-500">JSON</summary>
<pre class="mt-1 max-h-64 max-w-xl overflow-auto rounded bg-slate-50 p-2 text-xs">{{ event.payload_pretty }}</pre>
</details>
{% else %}
<span class="text-slate-400">—</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
{% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if pages > 1 %}
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
<div>Страница {{ page }} из {{ pages }}</div>
<div class="flex gap-3">
{% if page > 1 %}
<a href="/logs?{{ query_base }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
{% endif %}
{% if page < pages %}
<a href="/logs?{{ query_base }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,20 @@
<div class="editor-block" data-editor data-max="{{ editor_max }}">
<div class="flex flex-wrap gap-1 rounded-t-lg border border-b-0 border-slate-300 bg-slate-50 p-2">
<button type="button" data-tag="b" title="Жирный (Ctrl+B)" class="rounded px-2 py-1 text-sm font-bold hover:bg-white">Ж</button>
<button type="button" data-tag="i" title="Курсив (Ctrl+I)" class="rounded px-2 py-1 text-sm italic hover:bg-white">К</button>
<button type="button" data-tag="u" title="Подчёркнутый (Ctrl+U)" class="rounded px-2 py-1 text-sm underline hover:bg-white">Ч</button>
<button type="button" data-tag="s" title="Зачёркнутый" class="rounded px-2 py-1 text-sm line-through hover:bg-white">З</button>
<button type="button" data-tag="code" title="Код" class="rounded px-2 py-1 font-mono text-xs hover:bg-white">&lt;/&gt;</button>
<button type="button" data-tag="pre" title="Блок кода" class="rounded px-2 py-1 font-mono text-xs hover:bg-white">[ ]</button>
<button type="button" data-tag="quote" title="Цитата" class="rounded px-2 py-1 text-sm hover:bg-white">❝</button>
<button type="button" data-tag="spoiler" title="Спойлер" class="rounded px-2 py-1 text-sm hover:bg-white">👁</button>
<button type="button" data-tag="a" title="Ссылка" class="rounded px-2 py-1 text-sm hover:bg-white">🔗</button>
</div>
<textarea
id="{{ editor_id }}"
name="{{ editor_name }}"
rows="{{ editor_rows | default(14) }}"
class="w-full rounded-b-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
>{{ editor_value }}</textarea>
<p class="mt-1 text-xs text-slate-500"><span data-counter>0</span> / {{ editor_max }} символов</p>
</div>

View File

@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/schedules" class="hover:underline">← Расписания</a></p>
<h1 class="mt-1 text-2xl font-semibold">
{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}
</h1>
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="{{ schedule.action }}" class="mt-6 max-w-3xl space-y-5">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div>
<label for="template_key" class="text-sm font-medium text-slate-700">Шаблон сообщения</label>
<select
id="template_key"
name="template_key"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
>
{% for template in message_templates %}
<option value="{{ template.key }}" {% if template.key == schedule.template_key %}selected{% endif %}>
{{ template.key }} — {{ template.description }}
</option>
{% endfor %}
</select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="hour" class="text-sm font-medium text-slate-700">Час (0–23)</label>
<input
id="hour"
name="hour"
type="number"
min="0"
max="23"
value="{{ schedule.hour }}"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="minute" class="text-sm font-medium text-slate-700">Минуты (0–59)</label>
<input
id="minute"
name="minute"
type="number"
min="0"
max="59"
value="{{ schedule.minute }}"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
</div>
<fieldset>
<legend class="text-sm font-medium text-slate-700">Дни недели</legend>
<p class="text-xs text-slate-500">Ничего не выбрано — каждый день.</p>
<div class="mt-2 flex flex-wrap gap-4">
{% for day, name in weekday_names.items() %}
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="weekdays" value="{{ day }}" {% if day in schedule.weekdays %}checked{% endif %} />
{{ name }}
</label>
{% endfor %}
</div>
</fieldset>
<div>
<label for="timezone" class="text-sm font-medium text-slate-700">Таймзона</label>
<input
id="timezone"
name="timezone"
type="text"
value="{{ schedule.timezone }}"
list="timezones"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
/>
<datalist id="timezones">
<option value="Europe/Moscow"></option>
<option value="Europe/Berlin"></option>
<option value="UTC"></option>
</datalist>
<p class="mt-1 text-xs text-slate-500">Время указывается в этой таймзоне (IANA).</p>
</div>
<div>
<label for="description" class="text-sm font-medium text-slate-700">Описание (для себя)</label>
<input
id="description"
name="description"
type="text"
value="{{ schedule.description }}"
maxlength="200"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled" {% if schedule.enabled %}checked{% endif %} />
Включено
</label>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить
</button>
<a href="/schedules" class="text-sm text-slate-600 hover:underline">Отмена</a>
</div>
</form>
{% if not schedule.is_new %}
<div class="mt-10">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<ul class="mt-3 space-y-2">
{% for item in history %}
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
<div class="flex items-center justify-between text-slate-500">
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span>редактор: {{ item.editor_id or "—" }}</span>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
<div class="mt-2 grid gap-3 sm:grid-cols-2">
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.old_data | tojson(indent=2) if item.old_data else "—" }}</pre>
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.new_data | tojson(indent=2) if item.new_data else "—" }}</pre>
</div>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Расписания{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Расписания</h1>
<a href="/schedules/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Новое расписание
</a>
</div>
<p class="mt-2 text-slate-600">
Рассылки исполняет бот: он читает активные расписания из БД раз в минуту,
поэтому правки применяются без перезапуска.
</p>
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Время</th>
<th class="hidden px-4 py-3 font-medium sm:table-cell">Дни</th>
<th class="px-4 py-3 font-medium">Шаблон</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">Таймзона</th>
<th class="px-4 py-3 font-medium">Статус</th>
<th class="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for schedule in schedules %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td>
<td class="hidden px-4 py-3 sm:table-cell">{{ format_weekdays(schedule.weekdays) }}</td>
<td class="px-4 py-3">
<div class="font-mono text-sky-700">{{ schedule.template_key }}</div>
<div class="text-xs text-slate-500">{{ schedule.description or schedule.template_description }}</div>
</td>
<td class="hidden px-4 py-3 text-slate-600 lg:table-cell">{{ schedule.timezone }}</td>
<td class="px-4 py-3">
{% if schedule.enabled %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
{% else %}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-2">
<a href="/schedules/{{ schedule.id }}" class="text-sky-700 hover:underline">Изменить</a>
<form method="post" action="/schedules/{{ schedule.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-slate-600 hover:underline">
{% if schedule.enabled %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="/schedules/{{ schedule.id }}/delete" onsubmit="return confirm('Удалить расписание?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-500">Расписаний пока нет.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}Отправить сообщение{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold">Отправить сообщение</h1>
<p class="mt-1 text-sm text-slate-600">
Сообщение уйдёт в основной чат <span class="font-mono">{{ chat_id }}</span> от имени бота.
</p>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
Сообщение отправлено.
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form
method="post"
action="/send"
enctype="multipart/form-data"
class="mt-6 max-w-3xl space-y-4"
onsubmit="return confirm('Отправить сообщение в чат?');"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div>
<label class="text-sm font-medium text-slate-700">Текст</label>
<div class="mt-1">
{% with editor_id="send-text", editor_name="text", editor_value=text, editor_max=max_message, editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
</div>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div>
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
<textarea
name="buttons"
rows="3"
placeholder="Канал | https://t.me/…"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
>{{ buttons_text }}</textarea>
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок.</p>
</div>
<div>
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
<input type="file" name="image" accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full text-xs text-slate-600" />
<p class="mt-1 text-xs text-slate-500">С фото текст уходит подписью — не больше {{ max_caption }} символов.</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Отправить
</button>
<button
type="button"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
hx-post="/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"
>
Предпросмотр
</button>
</div>
</form>
<div class="mt-8 max-w-3xl">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
</div>
</div>
<div class="mt-10">
<h2 class="text-lg font-medium">Последние отправки</h2>
{% if recent %}
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Когда</th>
<th class="px-4 py-3 font-medium">Текст</th>
<th class="px-4 py-3 font-medium">Фото</th>
<th class="px-4 py-3 font-medium">Статус</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for item in recent %}
<tr>
<td class="px-4 py-3 text-slate-600">{{ item.sent_at.strftime("%d.%m.%Y %H:%M") }}</td>
<td class="px-4 py-3 text-slate-700">{{ item.preview }}</td>
<td class="px-4 py-3">{{ "да" if item.has_image else "—" }}</td>
<td class="px-4 py-3">
{% if item.success %}
<span class="text-emerald-700">отправлено</span>
{% else %}
<span class="text-red-600">{{ item.error or "ошибка" }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-2 text-sm text-slate-500">Отправок ещё не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,255 @@
{% extends "base.html" %}
{% block title %}{{ sequence.title }}{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/sequences" class="hover:underline">← Шаблоны сообщений</a></p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold">{{ sequence.title }}</h1>
<p class="font-mono text-xs text-slate-500">{{ sequence.key }}</p>
</div>
</div>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{% if saved == 'all' %}Шаблон сохранён целиком.
{% elif saved == 'meta' %}Параметры шаблона сохранены.
{% elif saved == 'order' %}Порядок сообщений обновлён.
{% else %}Сообщение сохранено.{% endif %}
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form
id="template-form"
method="post"
action="/sequences/{{ sequence.key }}/save"
enctype="multipart/form-data"
class="mt-6"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<input type="hidden" name="item_order" id="item-order" value="{{ item_order }}" />
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="text-xs text-slate-500">Название</label>
<input name="title" value="{{ sequence.title }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="sm:col-span-2">
<label class="text-xs text-slate-500">Описание</label>
<input name="description" value="{{ sequence.description }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-between">
<h2 class="text-lg font-medium">Сообщения ({{ items | length }})</h2>
<button
type="button"
id="add-item"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Добавить сообщение
</button>
</div>
<div id="items" class="mt-4 space-y-5">
{% for item in items %}
<section class="rounded-lg border border-slate-200 bg-white" data-token="{{ item.token }}">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
<div class="text-sm text-slate-600">
<span class="font-medium" data-position>№ {{ loop.index }}</span>
{% if item.id %}<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>{% endif %}
{% if item.enabled %}
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
{% else %}
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
{% endif %}
</div>
<div class="flex items-center gap-3 text-sm">
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
</div>
</div>
<div class="p-4">
{% with editor_id="text-" ~ item.token, editor_name="text_" ~ item.token, editor_value=item.text, editor_max=(max_caption if item.image_name else max_message), editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
<div class="mt-3 grid gap-3 lg:grid-cols-2">
<div>
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
<textarea
name="buttons_{{ item.token }}"
rows="3"
placeholder="Канал | https://t.me/…"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
>{{ item.buttons_text }}</textarea>
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
</div>
<div>
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
{% if item.image_name %}
<div class="mt-1 flex items-center gap-3">
<img src="/sequences/media/{{ item.image_name }}" alt="" class="h-20 w-20 rounded object-cover" />
<label class="flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" name="remove_image_{{ item.token }}" /> удалить
</label>
</div>
{% endif %}
<input type="file" name="image_{{ item.token }}" accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full text-xs text-slate-600" />
<p class="mt-1 text-xs text-slate-500">
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
</p>
</div>
</div>
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled_{{ item.token }}" {% if item.enabled %}checked{% endif %} /> включено
</label>
</div>
</section>
{% endfor %}
</div>
<div class="mt-6 flex flex-wrap items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить всё
</button>
<span class="text-xs text-slate-500">Сохраняет параметры и все сообщения сразу.</span>
</div>
</form>
<template id="item-template">
<section class="rounded-lg border border-slate-200 bg-white" data-token="__TOKEN__">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
<div class="text-sm text-slate-600">
<span class="font-medium" data-position>№</span>
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
</div>
<div class="flex items-center gap-3 text-sm">
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
</div>
</div>
<div class="p-4">
{% with editor_id="text-__TOKEN__", editor_name="text___TOKEN__", editor_value="", editor_max=max_message, editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
<div class="mt-3 grid gap-3 lg:grid-cols-2">
<div>
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
<textarea
name="buttons___TOKEN__"
rows="3"
placeholder="Канал | https://t.me/…"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
></textarea>
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
</div>
<div>
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
<input type="file" name="image___TOKEN__" accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full text-xs text-slate-600" />
<p class="mt-1 text-xs text-slate-500">
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
</p>
</div>
</div>
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled___TOKEN__" checked /> включено
</label>
</div>
</section>
</template>
<div class="mt-10">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<ul class="mt-3 space-y-2">
{% for change in history %}
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
<div class="flex items-center justify-between text-slate-500">
<span>{{ change.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span>редактор: {{ change.editor_id or "—" }}</span>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-slate-600">снимок</summary>
<pre class="mt-1 max-h-64 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ change.snapshot | tojson(indent=2) }}</pre>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
<script>
(function () {
var items = document.getElementById("items");
var orderInput = document.getElementById("item-order");
var template = document.getElementById("item-template");
var addButton = document.getElementById("add-item");
var counter = 0;
function updateOrder() {
var cards = items.querySelectorAll("[data-token]");
var tokens = [];
cards.forEach(function (card, index) {
tokens.push(card.dataset.token);
var position = card.querySelector("[data-position]");
if (position) position.textContent = "№ " + (index + 1);
});
orderInput.value = tokens.join(",");
}
addButton.addEventListener("click", function () {
counter += 1;
var token = "new_" + counter;
var wrapper = document.createElement("div");
wrapper.innerHTML = template.innerHTML.replace(/__TOKEN__/g, token).trim();
var card = wrapper.firstElementChild;
items.appendChild(card);
updateOrder();
if (window.initEditor) window.initEditor(card);
var textarea = card.querySelector("textarea");
if (textarea) textarea.focus();
});
items.addEventListener("click", function (event) {
var button = event.target.closest("button[data-action]");
if (!button) return;
var card = button.closest("[data-token]");
if (!card) return;
var action = button.dataset.action;
if (action === "delete") {
if (confirm("Удалить сообщение?")) card.remove();
} else if (action === "up" && card.previousElementSibling) {
items.insertBefore(card, card.previousElementSibling);
} else if (action === "down" && card.nextElementSibling) {
items.insertBefore(card.nextElementSibling, card);
}
updateOrder();
});
updateOrder();
})();
</script>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Шаблоны сообщений{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Шаблоны сообщений</h1>
<p class="mt-1 text-sm text-slate-600">Тексты бота: сообщения, картинки и кнопки.</p>
</div>
</div>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
Изменения сохранены.
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="/sequences" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<h2 class="text-lg font-medium">Новый шаблон</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-4">
<div>
<label for="key" class="text-xs text-slate-500">Ключ</label>
<input id="key" name="key" value="{{ form.key }}" placeholder="rules"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div>
<label for="title" class="text-xs text-slate-500">Название</label>
<input id="title" name="title" value="{{ form.title }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div>
<label for="description" class="text-xs text-slate-500">Описание</label>
<input id="description" name="description" value="{{ form.description }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="flex items-end">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Создать
</button>
</div>
</div>
</form>
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Ключ</th>
<th class="px-4 py-3 font-medium">Название</th>
<th class="px-4 py-3 font-medium">Сообщений</th>
<th class="px-4 py-3 font-medium">Изменён</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for sequence in sequences %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a>
</td>
<td class="px-4 py-3">
{{ sequence.title }}
{% if sequence.description and sequence.description != sequence.title %}
<div class="text-xs text-slate-500">{{ sequence.description }}</div>
{% endif %}
</td>
<td class="px-4 py-3">{{ sequence.item_count }}</td>
<td class="px-4 py-3 text-slate-500">{{ sequence.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Шаблонов пока нет.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Звания{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold">Звания</h1>
<p class="mt-1 text-sm text-slate-600">Справочник магазина: название, цена и видимость.</p>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{% if saved == 'created' %}Звание добавлено.
{% elif saved == 'updated' %}Звание обновлено.
{% elif saved == 'deleted' %}Звание удалено.
{% else %}Изменения сохранены.{% endif %}
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="/titles" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<h2 class="text-lg font-medium">Новое звание</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-4">
<div class="sm:col-span-2">
<label for="title" class="text-xs text-slate-500">Название</label>
<input id="title" name="title" value="{{ form.title }}" maxlength="{{ max_title_length }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div>
<label for="price" class="text-xs text-slate-500">Цена, DE</label>
<input id="price" name="price" type="number" min="0" value="{{ form.price }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="flex items-end">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Добавить
</button>
</div>
</div>
</form>
<div class="mt-6 space-y-3">
{% for title in titles %}
<div class="rounded-lg border border-slate-200 bg-white p-4">
<form method="post" action="/titles/{{ title.id_title }}" class="grid gap-3 sm:grid-cols-5">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div class="sm:col-span-2">
<label class="text-xs text-slate-500">Название</label>
<input name="title" value="{{ title.title }}" maxlength="{{ max_title_length }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div>
<label class="text-xs text-slate-500">Цена, DE</label>
<input name="price" type="number" min="0" value="{{ title.price }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<label class="flex items-end gap-2 pb-2 text-sm text-slate-700">
<input type="checkbox" name="visible" {% if title.visible %}checked{% endif %} /> видно
</label>
<div class="flex items-end">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить
</button>
</div>
</form>
<div class="mt-2 flex items-center justify-between text-xs text-slate-500">
<span>id {{ title.id_title }} · используется: {{ title.used }}</span>
<form method="post" action="/titles/{{ title.id_title }}/delete" onsubmit="return confirm('Удалить звание?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
</div>
</div>
{% else %}
<p class="text-sm text-slate-500">Званий пока нет — добавьте первое.</p>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,263 @@
{% extends "base.html" %}
{% block title %}{{ user.nick or user.id_user }}{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/users" class="hover:underline">← Пользователи</a></p>
<div class="mt-2 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">{{ user.nick or "Без ника" }}</h1>
<p class="mt-1 text-slate-600">
{% if user.username %}<span class="font-mono">@{{ user.username }}</span> · {% endif %}
<span class="font-mono">{{ user.id_user }}</span>
{% if user.first_name or user.last_name %}
· {{ user.first_name or "" }} {{ user.last_name or "" }}
{% endif %}
</p>
</div>
<div class="flex items-center gap-2">
{% if user.title %}
<span class="rounded bg-indigo-100 px-3 py-1 text-sm text-indigo-800">{{ user.title }}</span>
{% endif %}
{% if user.subscribed %}
<span class="rounded bg-emerald-100 px-3 py-1 text-sm text-emerald-800">подписан на рассылки</span>
{% else %}
<span class="rounded bg-slate-100 px-3 py-1 text-sm text-slate-500">без рассылок</span>
{% endif %}
</div>
</div>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{% if saved == 'nick' %}Ник обновлён.
{% elif saved == 'title' %}Звание обновлено.
{% elif saved == 'subscription' %}Подписка обновлена.
{% elif saved == 'balance' %}Баланс скорректирован.
{% else %}Изменения сохранены.{% endif %}
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Уровень</div>
<div class="mt-1 text-xl font-semibold">{{ user.lvl }}</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Опыт</div>
<div class="mt-1 text-xl font-semibold">{{ user.expr }}</div>
<div class="text-xs text-slate-500">серия: {{ user.expr_streak }}</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div>
<div class="mt-1 text-xl font-semibold">{{ user.balance }}</div>
</div>
{% if eggs_visible %}
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Яички</div>
<div class="mt-1 text-xl font-semibold">{{ user.eggs }}</div>
</div>
{% endif %}
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Сообщения</div>
<div class="mt-1 text-xl font-semibold">{{ user.msg_count }}</div>
<div class="text-xs text-slate-500">
последняя активность: {{ user.last_message_at.strftime("%d.%m.%Y") if user.last_message_at else "—" }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Очищения</div>
<div class="mt-1 text-xl font-semibold">{{ user.purification_count }}</div>
<div class="text-xs text-slate-500">
последнее: {{ user.last_purification_at.strftime("%d.%m.%Y %H:%M") if user.last_purification_at else "—" }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">В клане с</div>
<div class="mt-1 text-xl font-semibold">
{{ user.dt_create.strftime("%d.%m.%Y") if user.dt_create else "—" }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Telegram-ID</div>
<div class="mt-1 font-mono text-sm">{{ user.id_user }}</div>
</div>
</div>
<div class="mt-8">
<h2 class="text-lg font-medium">Управление</h2>
<div class="mt-3 grid gap-4 lg:grid-cols-2">
<form method="post" action="/users/{{ user.id_user }}/nick" class="rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<label for="nick" class="text-sm font-medium text-slate-700">Ник</label>
<div class="mt-2 flex gap-2">
<input
id="nick"
name="nick"
type="text"
value="{{ user.nick or '' }}"
maxlength="{{ max_nick_length }}"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить
</button>
</div>
</form>
<form method="post" action="/users/{{ user.id_user }}/title" class="rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<label for="title_id" class="text-sm font-medium text-slate-700">Звание</label>
<div class="mt-2 flex gap-2">
<select
id="title_id"
name="title_id"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
>
{% for title in titles %}
<option value="{{ title.id_title }}" {% if title.id_title == user.id_title %}selected{% endif %}>
{{ title.title }}
</option>
{% endfor %}
</select>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить
</button>
</div>
</form>
<form method="post" action="/users/{{ user.id_user }}/subscription" class="rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<input type="hidden" name="subscribed" value="{{ '0' if user.subscribed else '1' }}" />
<div class="text-sm font-medium text-slate-700">Рассылки</div>
<p class="mt-1 text-sm text-slate-600">
{% if user.subscribed %}Сейчас подписан.{% else %}Сейчас не подписан.{% endif %}
</p>
<button type="submit" class="mt-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
{% if user.subscribed %}Отписать{% else %}Подписать{% endif %}
</button>
</form>
<form method="post" action="/users/{{ user.id_user }}/balance" class="rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div class="text-sm font-medium text-slate-700">Корректировка баланса</div>
<div class="mt-2 flex gap-2">
<input
name="amount"
type="number"
step="1"
placeholder="+50 или -20"
class="w-32 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
<input
name="reason"
type="text"
maxlength="{{ max_comment_length }}"
placeholder="Причина"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Провести
</button>
</div>
<p class="mt-1 text-xs text-slate-500">Запись уйдёт в журнал денег с действием «Корректировка админом».</p>
</form>
</div>
</div>
{% if can_kick %}
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4">
<h2 class="text-lg font-medium text-red-800">Исключить из чата</h2>
<p class="mt-1 text-sm text-red-700">
Бот заблокирует пользователя и сразу разблокирует (чтобы мог вернуться),
а данные из бота будут удалены.
</p>
<form
method="post"
action="/users/{{ user.id_user }}/kick"
class="mt-3"
onsubmit="return confirm('Исключить {{ user.nick or user.id_user }} из чата?');"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
Выгнать из чата
</button>
</form>
</div>
{% endif %}
<div class="mt-8">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Когда</th>
<th class="px-4 py-3 font-medium">Что</th>
<th class="px-4 py-3 font-medium">Было → стало</th>
<th class="px-4 py-3 font-medium">Комментарий</th>
<th class="px-4 py-3 font-medium">Источник</th>
<th class="px-4 py-3 font-medium">Админ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% set field_labels = {"nick": "Ник", "title": "Звание", "subscription": "Подписка", "balance": "Баланс"} %}
{% for item in history %}
<tr>
<td class="px-4 py-3 text-slate-600">{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</td>
<td class="px-4 py-3">{{ field_labels.get(item.field, item.field) }}</td>
<td class="px-4 py-3 text-slate-700">{{ item.old_value or "—" }} → {{ item.new_value or "—" }}</td>
<td class="px-4 py-3 text-slate-600">{{ item.comment or "—" }}</td>
<td class="px-4 py-3 text-slate-600">
{% if item.source == 'user' %}Пользователь{% else %}Админ{% endif %}
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-2 text-sm text-slate-500">Ручных изменений ещё не было.</p>
{% endif %}
</div>
<div class="mt-8">
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
{% if money %}
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Когда</th>
<th class="px-4 py-3 font-medium">Сумма</th>
<th class="px-4 py-3 font-medium">Действие</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for item in money %}
<tr>
<td class="px-4 py-3 text-slate-600">
{{ item.dt_action.strftime("%d.%m.%Y %H:%M") if item.dt_action else "—" }}
</td>
<td class="px-4 py-3 font-medium {% if item.amount < 0 %}text-red-600{% else %}text-emerald-700{% endif %}">
{{ "%+d" | format(item.amount) }}
</td>
<td class="px-4 py-3 text-slate-700">{{ item.action or "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-2 text-sm text-slate-500">Операций по балансу не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Пользователи{% endblock %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">Пользователи</h1>
<p class="mt-1 text-sm text-slate-600">Всего: {{ total }}</p>
</div>
<form method="get" action="/users" class="flex items-center gap-2">
<input
type="search"
name="q"
value="{{ q }}"
placeholder="Ник, @username, имя или Telegram-ID"
class="w-80 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Найти
</button>
{% if q %}<a href="/users" class="text-sm text-slate-600 hover:underline">Сбросить</a>{% endif %}
</form>
</div>
{% if saved == 'kick' %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
Пользователь исключён из чата, данные удалены.
</div>
{% endif %}
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Ник</th>
<th class="px-4 py-3 font-medium">Telegram</th>
<th class="hidden px-4 py-3 font-medium sm:table-cell">Уровень</th>
<th class="px-4 py-3 font-medium">Баланс</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">Сообщения</th>
<th class="px-4 py-3 font-medium">Подписка</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for user in users %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/users/{{ user.id_user }}" class="font-medium text-sky-700 hover:underline">
{{ user.nick or "—" }}
</a>
<div class="text-xs text-slate-500">{{ user.title or "" }}</div>
</td>
<td class="px-4 py-3">
{% if user.username %}<div>@{{ user.username }}</div>{% endif %}
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
</td>
<td class="hidden px-4 py-3 sm:table-cell">{{ user.lvl }}</td>
<td class="px-4 py-3">{{ user.balance }}</td>
<td class="hidden px-4 py-3 md:table-cell">{{ user.msg_count }}</td>
<td class="px-4 py-3">
{% if user.subscribed %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>
{% else %}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">нет</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
{% if q %}Никого не нашли по запросу «{{ q }}».{% else %}Пользователей пока нет.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
<div>Страница {{ page }} из {{ pages }}</div>
<div class="flex gap-3">
{% if page > 1 %}
<a href="/users?q={{ q | urlencode }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
{% endif %}
{% if page < pages %}
<a href="/users?q={{ q | urlencode }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -6,9 +6,13 @@ import asyncio
import logging
from bot.bot_factory import create_bot, create_dispatcher
from bot.commands_menu import ensure_commands
from bot.services.heartbeat import run_heartbeat
from bot.services.notifications import run_notifications
from bot.services.runtime import uptime_seconds
from common import __version__
from common.analytics import EventType, close_analytics, init_analytics, record
from common.analytics.partitions import ensure_partitions, run_partitions
from common.config import get_settings
from common.db.pool import close_pool, init_pool
from common.logging_config import setup_logging
@ -22,19 +26,37 @@ async def main() -> None:
logger.info("Запуск DispelEvilBot v%s (APP_ENV=%s)", __version__, settings.app_env)
await init_pool()
await init_analytics()
try:
await ensure_partitions(settings)
except Exception:
logger.warning("Не удалось создать партиции аналитики", exc_info=True)
record(EventType.BOT_START, payload={"app_env": settings.app_env})
bot = create_bot()
dp = create_dispatcher()
# Сверяем меню команд при каждом старте (Telegram разрешает только ASCII-имена).
await ensure_commands(bot)
notifications_task = asyncio.create_task(run_notifications(bot))
heartbeat_task = asyncio.create_task(run_heartbeat())
heartbeat_task = asyncio.create_task(run_heartbeat(bot))
partitions_task = asyncio.create_task(run_partitions(settings))
try:
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
finally:
notifications_task.cancel()
heartbeat_task.cancel()
await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True)
partitions_task.cancel()
await asyncio.gather(
notifications_task,
heartbeat_task,
partitions_task,
return_exceptions=True,
)
record(EventType.BOT_STOP, payload={"app_env": settings.app_env, "uptime_seconds": uptime_seconds()})
await close_analytics()
await close_pool()
await bot.session.close()

View File

@ -25,11 +25,15 @@ def create_dispatcher() -> Dispatcher:
Порядок важен: сначала события участников и команды, затем магазин,
и в конце — экономика (в ней есть catch-all обработчик сообщений).
"""
from bot.handlers import commands, economy, member_events, shop
from bot.handlers import commands, economy, login, member_events, shop
from bot.handlers.errors import register_errors
from bot.middlewares import AnalyticsMiddleware
dp = Dispatcher(storage=MemoryStorage())
# Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`).
dp.message.outer_middleware(AnalyticsMiddleware())
dp.include_router(member_events.router)
dp.include_router(login.router)
dp.include_router(commands.router)
dp.include_router(shop.router)
dp.include_router(economy.router)

60
bot/commands_menu.py Normal file
View File

@ -0,0 +1,60 @@
"""Меню команд бота: латинские имена + русские описания.
Telegram разрешает в меню только ``a-z``, ``0-9`` и ``_``, поэтому русские
команды остаются доступны при вводе вручную, а в меню показываются алиасы.
"""
from __future__ import annotations
import logging
from aiogram import Bot
from aiogram.types import BotCommand
logger = logging.getLogger(__name__)
#: Ожидаемый набор команд в меню (порядок = порядок в Telegram).
BOT_COMMANDS: tuple[BotCommand, ...] = (
BotCommand(command="start", description="Начать"),
BotCommand(command="help", description="Помощь"),
BotCommand(command="info", description="Инфо"),
BotCommand(command="nick", description="Сменить ник"),
BotCommand(command="list", description="Список игроков"),
BotCommand(command="who", description="Кто это"),
BotCommand(command="purify", description="Очищение"),
BotCommand(command="eggs", description="Яички"),
BotCommand(command="shop", description="Магазин"),
BotCommand(command="top", description="Рейтинг"),
BotCommand(command="sub", description="Подписка"),
BotCommand(command="unsub", description="Отписка"),
)
def _key(commands: list[BotCommand]) -> list[tuple[str, str]]:
return [(command.command, command.description) for command in commands]
async def ensure_commands(bot: Bot) -> bool:
"""Сверить меню команд с ожидаемым и обновить при расхождении.
Возвращает ``True``, если набор пришлось обновить. Ошибки Telegram не
прерывают запуск бота — только пишутся в лог.
"""
try:
current = await bot.get_my_commands()
except Exception:
logger.warning("Не удалось прочитать меню команд Telegram", exc_info=True)
return False
if _key(current) == _key(list(BOT_COMMANDS)):
logger.info("Меню команд актуально (%s команд)", len(BOT_COMMANDS))
return False
try:
await bot.set_my_commands(list(BOT_COMMANDS))
except Exception:
logger.warning("Не удалось обновить меню команд Telegram", exc_info=True)
return False
logger.info("Меню команд обновлено (%s команд)", len(BOT_COMMANDS))
return True

View File

@ -11,15 +11,17 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions
from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings
from common.texts import economy, info, system
from common.texts.render import format_tops, format_user_list, render
from common.eggs import is_egg_event_active
from common.texts import system
from common.texts.render import escape, format_tops, format_user_list, render
logger = logging.getLogger(__name__)
router = Router(name="commands")
@router.message(Command("ник", ignore_case=True))
@router.message(Command("ник", "nick", ignore_case=True))
async def set_nick_handler(message: Message) -> None:
if not await access.check_user(message):
return
@ -32,16 +34,14 @@ async def set_nick_handler(message: Message) -> None:
nick = parts[1]
try:
await users_service.set_nick(message.from_user.id, nick)
await message.answer(
render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick})
)
await message.answer(render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick}))
await message.delete()
except Exception:
logger.exception("Не удалось установить ник")
await message.reply(system.MSG_NICK_ERROR)
@router.message(Command("список", ignore_case=True))
@router.message(Command("список", "list", ignore_case=True))
async def list_nicks_handler(message: Message) -> None:
if not await access.check_user(message):
return
@ -63,10 +63,10 @@ async def list_nicks_handler(message: Message) -> None:
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
"users_list_count": len(nicks),
}
await message.answer(render(economy.MSG_NICK_LIST, context))
await send_template(message.bot, message.chat.id, "economy.nick_list", context)
@router.message(Command("кто", ignore_case=True))
@router.message(Command("кто", "who", ignore_case=True))
async def get_nick_handler(message: Message) -> None:
if not await access.check_user(message):
return
@ -81,14 +81,14 @@ async def get_nick_handler(message: Message) -> None:
await message.reply(user_info.nick)
@router.message(Command("помощь", ignore_case=True))
@router.message(Command("помощь", "help", ignore_case=True))
async def help_handler(message: Message) -> None:
if not await access.check_user(message):
return
await message.answer(info.MSG_HELP)
await send_template(message.bot, message.chat.id, "info.help")
@router.message(Command("инфо", ignore_case=True))
@router.message(Command("инфо", "info", ignore_case=True))
async def info_handler(message: Message) -> None:
if not await access.check_user(message):
return
@ -109,20 +109,23 @@ async def info_handler(message: Message) -> None:
"dt": user_info.dt,
"msg_count": user_info.msg_count,
"eggs": user_info.eggs,
"eggs_line": (
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
),
}
await message.answer(render(info.MSG_INFO, context))
await send_template(message.bot, message.chat.id, "info.profile", context)
@router.message(Command("рейтинг", ignore_case=True))
@router.message(Command("рейтинг", "top", ignore_case=True))
async def top_handler(message: Message) -> None:
if not await access.check_user(message):
return
tops = await stats.top_users()
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
await message.answer(render(economy.MSG_TOP, context))
await send_template(message.bot, message.chat.id, "economy.top", context)
@router.message(Command("отписка", ignore_case=True))
@router.message(Command("отписка", "unsub", ignore_case=True))
async def unsubscribe_handler(message: Message) -> None:
if not await access.check_user(message):
return
@ -130,7 +133,7 @@ async def unsubscribe_handler(message: Message) -> None:
await message.reply(system.MSG_UNSUBSCRIBED)
@router.message(Command("подписка", ignore_case=True))
@router.message(Command("подписка", "sub", ignore_case=True))
async def subscribe_handler(message: Message) -> None:
if not await access.check_user(message):
return

View File

@ -3,29 +3,26 @@
from __future__ import annotations
import random
from datetime import date, datetime
from datetime import datetime
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from bot.services import access
from bot.services import access, alerts
from bot.services import economy as economy_service
from bot.services.sequences import send_template
from common.config import get_settings
from common.texts import economy, system
from common.texts.render import render
from common.eggs import egg_event_period, is_egg_event_active
from common.texts import system
router = Router(name="economy")
#: Окно сезонного события «яички».
EGG_EVENT_START = date(2025, 4, 1)
EGG_EVENT_END = date(2025, 4, 20)
#: Шанс выпадения монетки на сообщение.
COIN_CHANCE = 27 # из 100
@router.message(Command("очищение", ignore_case=True))
@router.message(Command("очищение", "purify", ignore_case=True))
async def purification_handler(message: Message) -> None:
if not await access.check_user(message):
return
@ -33,17 +30,25 @@ async def purification_handler(message: Message) -> None:
result = await economy_service.purify(message.from_user.id)
if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(economy.MSG_NO_PURIFICATION, context))
await send_template(message.bot, message.chat.id, "economy.no_purification", context)
else:
context = {"money_add": result.money_add, "exp_add": result.exp_add}
await message.answer(render(economy.MSG_PURIFICATION, context))
await send_template(message.bot, message.chat.id, "economy.purification", context)
await alerts.notify_purification(
message.bot,
username=message.from_user.username,
user_id=message.from_user.id,
result=result,
)
@router.message(Command("яички", ignore_case=True))
@router.message(Command("яички", "eggs", ignore_case=True))
async def eggs_handler(message: Message) -> None:
today = datetime.now(get_settings().tzinfo).date()
if not (EGG_EVENT_START <= today <= EGG_EVENT_END):
await message.answer(system.MSG_EGGS_TOO_EARLY)
settings = get_settings()
if not is_egg_event_active(settings):
today = datetime.now(settings.tzinfo).date()
start, _ = egg_event_period(settings, today)
await message.answer(system.MSG_EGGS_TOO_EARLY if today < start else system.MSG_EGGS_EVENT_OVER)
return
if not await access.check_user(message):
@ -52,9 +57,9 @@ async def eggs_handler(message: Message) -> None:
result = await economy_service.collect_eggs(message.from_user.id)
if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(economy.MSG_NO_EGGS, context))
await send_template(message.bot, message.chat.id, "economy.no_eggs", context)
else:
await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs}))
await send_template(message.bot, message.chat.id, "economy.eggs", {"eggs": result.eggs})
@router.message()

View File

@ -7,8 +7,13 @@ import logging
from aiogram import Dispatcher
from aiogram.types import ErrorEvent
from common.analytics import EventType, record
logger = logging.getLogger(__name__)
#: Максимальная длина текста ошибки в payload события.
ERROR_TEXT_MAX = 500
def register_errors(dp: Dispatcher) -> None:
"""Зарегистрировать обработчик, чтобы одна ошибка не роняла поллинг."""
@ -20,4 +25,15 @@ def register_errors(dp: Dispatcher) -> None:
"Необработанная ошибка при обработке апдейта",
exc_info=(type(exc), exc, exc.__traceback__),
)
user = getattr(event.update, "event_from_user", None)
record(
EventType.ERROR,
user_id=getattr(user, "id", 0) or 0,
username=getattr(user, "username", None),
payload={
"error_type": type(exc).__name__,
"error": str(exc)[:ERROR_TEXT_MAX],
"update_type": str(getattr(event.update, "event_type", "") or ""),
},
)
return True

50
bot/handlers/login.py Normal file
View File

@ -0,0 +1,50 @@
"""Вход в админку через deep-link из установленного Telegram."""
from __future__ import annotations
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
from common.config import get_settings
from common.repositories import admin_login_tokens_repo, admins_repo
from common.texts import system
router = Router(name="login")
@router.message(Command("start"))
async def start_handler(message: Message) -> None:
"""Обработать /start; payload ``login_<token>`` подтверждает вход в админку."""
if message.chat.type != "private" or message.from_user is None:
return
parts = (message.text or "").split(maxsplit=1)
payload = parts[1].strip() if len(parts) > 1 else ""
if not payload.startswith("login_"):
await message.answer(system.MSG_START)
return
admin = await admins_repo.get_admin(message.from_user.id)
if admin is None or not admin["enabled"]:
await message.answer(system.MSG_LOGIN_DENIED)
return
if not await admin_login_tokens_repo.confirm_token(payload[6:], message.from_user.id):
await message.answer(system.MSG_LOGIN_INVALID)
return
settings = get_settings()
markup = None
if settings.admin_public_url.startswith(("http://", "https://")):
markup = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Вернуться в админку",
url=settings.admin_public_url.rstrip("/") + "/login",
)
]
]
)
await message.answer(system.MSG_LOGIN_CONFIRMED, reply_markup=markup)

View File

@ -5,14 +5,19 @@ from __future__ import annotations
import logging
from aiogram import Router
from aiogram.enums import ChatMemberStatus
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
from aiogram.types import ChatMemberUpdated, FSInputFile
from aiogram.types import ChatMemberUpdated, FSInputFile, User
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.services import alerts
from bot.services import users as users_service
from bot.services.sequences import send_sequence
from common.analytics import EventType, record
from common.config import get_settings
from common.texts import system, welcome
from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="member_events")
@ -22,7 +27,7 @@ router = Router(name="member_events")
async def bot_added_handler(event: ChatMemberUpdated) -> None:
"""Бот добавлен в чат."""
settings = get_settings()
if event.chat.id not in {settings.prod_chat_id, settings.test_chat_id}:
if event.chat.id not in {settings.chat_id, settings.alert_chat_id}:
await event.bot.send_message(chat_id=event.chat.id, text=system.MSG_BOT_FOREIGN_CHAT)
await event.bot.leave_chat(event.chat.id)
else:
@ -35,47 +40,107 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
user = event.new_chat_member.user
if user.is_bot:
return
if event.chat.id != get_settings().chat_id:
return
await users_service.register_from_event(event)
await alerts.notify_join(
event.bot,
username=user.username,
user_id=user.id,
chat_title=event.chat.title or "",
)
if await send_sequence(event.bot, event.chat.id, "welcome", {"username": user.username}):
return
await _send_fallback_welcome(event, user)
async def _send_fallback_welcome(event: ChatMemberUpdated, user: User) -> None:
"""Приветствие по старым шаблонам, если последовательность не настроена."""
builder = InlineKeyboardBuilder()
builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL)
markup = builder.as_markup()
caption, body = welcome.split_welcome(
await get_text("welcome.text"),
await get_text("welcome.text2"),
await get_text("welcome.text3"),
)
caption = render(caption, {"username": user.username})
body = render(body, {"username": user.username})
if len(caption) > 1024:
body = caption + (f"\n\n{body}" if body else "")
caption = ""
if body:
await event.bot.send_photo(
chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"),
caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}),
caption=caption or None,
)
await event.bot.send_message(
chat_id=event.chat.id,
text=welcome.MSG_WELCOME_TEXT2,
reply_markup=builder.as_markup(),
text=body,
reply_markup=markup,
)
else:
await event.bot.send_photo(
chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"),
caption=caption or None,
reply_markup=markup,
)
await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3)
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
async def goodbye_handler(event: ChatMemberUpdated) -> None:
"""Пользователь вышел из чата — удалить запись."""
settings = get_settings()
user = event.from_user
if user is None or user.is_bot:
return
if event.chat.id == settings.test_chat_id:
"""Пользователь вышел или был исключён — удалить данные именно его."""
if event.chat.id != get_settings().chat_id:
return
await users_service.delete_user(user.id)
logger.info("Удалён пользователь %s после выхода из чата", user.id)
leaver = event.old_chat_member.user
if leaver.is_bot:
return
try:
await event.bot.send_message(
chat_id=settings.test_chat_id,
text=f"[goodbye_handler]\nУдалил {user.username or user.id}!",
actor = event.from_user
kicked = event.new_chat_member.status == ChatMemberStatus.KICKED
by_self = actor is None or actor.id == leaver.id
record(
EventType.LEAVE,
user_id=leaver.id,
username=leaver.username,
chat_id=event.chat.id,
payload={
"chat_title": event.chat.title or "",
"kicked": kicked,
"by_id": actor.id if actor and not by_self else 0,
"by_username": actor.username if actor and not by_self else "",
},
)
if settings.alert_chat_id:
await event.bot.send_message(
chat_id=settings.alert_chat_id,
text=f"Пользователь {user.username or user.id} покинул чат {event.chat.title}",
await users_service.delete_user(leaver.id)
logger.info("Удалён пользователь %s после выхода из чата", leaver.id)
# Исключение самим ботом (кнопка в админке): уведомление уже отправила админка.
if kicked and actor is not None and actor.id == event.bot.id:
return
if kicked and not by_self:
await alerts.notify_kick(
event.bot,
actor_username=actor.username if actor else None,
actor_id=actor.id if actor else 0,
username=leaver.username,
user_id=leaver.id,
chat_title=event.chat.title or "",
)
else:
await alerts.notify_leave(
event.bot,
username=leaver.username,
user_id=leaver.id,
chat_title=event.chat.title or "",
)
except Exception:
logger.exception("Не удалось отправить уведомление о выходе пользователя")

View File

@ -11,7 +11,8 @@ from aiogram.types import CallbackQuery, Message
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
from bot.services import access
from bot.services import shop as shop_service
from common.texts import shop as shop_texts
from bot.services.sequences import send_template, template_text
from common.config import get_settings
from common.texts import system
from common.texts.render import render
@ -19,28 +20,37 @@ logger = logging.getLogger(__name__)
router = Router(name="shop")
@router.message(Command("магазин", ignore_case=True))
@router.message(Command("магазин", "shop", ignore_case=True))
async def shop_handler(message: Message) -> None:
if not await access.check_user(message):
return
if message.chat.type != "private":
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
return
await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main())
await send_template(message.bot, message.chat.id, "shop.main", reply_markup=get_keyboard_main())
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
if chapter == "main":
return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
return await template_text("shop.main"), get_keyboard_main()
if chapter == "title":
return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title()
return await template_text("shop.title"), await get_keyboard_title()
if chapter == "donat":
return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat()
return await template_text("shop.donat"), await get_keyboard_donat()
return None
@router.callback_query()
async def callbacks_handler(callback: CallbackQuery) -> None:
settings = get_settings()
if (
callback.message is not None
and callback.message.chat.type != "private"
and callback.message.chat.id != settings.chat_id
):
await callback.answer()
return
answer_text = ""
args = (callback.data or "").split("_")
action = args[0] if args else ""

View File

@ -0,0 +1,7 @@
"""Middleware бота (транспортный слой)."""
from __future__ import annotations
from bot.middlewares.analytics import AnalyticsMiddleware
__all__ = ["AnalyticsMiddleware"]

View File

@ -0,0 +1,60 @@
"""Middleware аналитики: события ``message`` и ``command`` приходят с транспорта."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import Message, TelegramObject
from common.analytics import EventType, record, record_message, remember_user
from common.analytics.messages import describe_command
from common.config import get_settings
logger = logging.getLogger(__name__)
class AnalyticsMiddleware(BaseMiddleware):
"""Логирует сообщения и команды основного чата.
Текст сообщения не сохраняется — только метаданные (см. ``docs/events.md``).
Аналитика не должна мешать обработке: любые сбои логируются и глушатся.
"""
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
if isinstance(event, Message):
self._log_message(event)
return await handler(event, data)
@staticmethod
def _log_message(message: Message) -> None:
user = message.from_user
if user is None or user.is_bot:
return
if message.chat.id != get_settings().chat_id:
return
try:
remember_user(user.id, user.username)
record_message(user_id=user.id, username=user.username, chat_id=message.chat.id)
command = describe_command(message)
if command is not None:
record(
EventType.COMMAND,
user_id=user.id,
username=user.username,
chat_id=message.chat.id,
payload=command,
)
except Exception: # аналитика не должна ломать обработку сообщения
logger.exception("Не удалось записать события сообщения в аналитику")
__all__ = ["AnalyticsMiddleware"]

View File

@ -9,8 +9,12 @@ from aiogram import Bot
from aiogram.enums import ChatMemberStatus
from aiogram.types import Message
from bot.services import alerts
from bot.services import users as users_service
from common.analytics import EventType, record, remember_user
from common.config import get_settings
from common.texts import alerts as alerts_text
from common.texts.render import render
logger = logging.getLogger(__name__)
@ -21,6 +25,12 @@ _ALLOWED_STATUSES = {
ChatMemberStatus.RESTRICTED,
}
def is_allowed_chat(chat_id: int, chat_type: str, main_chat_id: int) -> bool:
"""Личные чаты и основной чат; чужие группы/каналы игнорируются полностью."""
return chat_type == "private" or chat_id == main_chat_id
#: TTL-кеш членства, чтобы не дёргать get_chat_member на каждое сообщение.
_MEMBERSHIP_TTL = 300.0
_membership_cache: dict[int, tuple[float, bool]] = {}
@ -39,23 +49,29 @@ async def _is_member(bot: Bot, user_id: int) -> bool:
async def check_user(message: Message) -> bool:
"""Пропустить пользователя: проверить членство и обновить его запись.
В тестовом чате доступ разрешён всегда.
"""
"""Пропустить пользователя: доступ только участникам основного чата (или админам)."""
settings = get_settings()
if message.chat.id == settings.test_chat_id:
return True
user = message.from_user
if user is None:
return False
# Сообщения из посторонних групп не обрабатываем и не шлём по ним уведомления.
if not is_allowed_chat(message.chat.id, message.chat.type, settings.chat_id):
return False
if user.id in settings.admin_ids or await _is_member(message.bot, user.id):
await users_service.register_from_message(message)
remember_user(user.id, user.username)
return True
record(
EventType.ACCESS_DENIED,
user_id=user.id,
username=user.username,
chat_id=message.chat.id,
payload={"reason": "not_member"},
)
await users_service.delete_user(user.id)
logger.warning("Пользователь %s стучится в чат, доступ запрещён", user.id)
await _notify_denied(message)
@ -63,18 +79,9 @@ async def check_user(message: Message) -> bool:
async def _notify_denied(message: Message) -> None:
settings = get_settings()
username = message.from_user.username if message.from_user else None
try:
await message.bot.send_message(
chat_id=settings.test_chat_id,
text=f"[check_user]\nСтучится в чат {username}!",
)
if settings.alert_chat_id:
await message.bot.forward_message(
chat_id=settings.alert_chat_id,
from_chat_id=message.chat.id,
message_id=message.message_id,
)
except Exception: # уведомление не должно ломать обработку
logger.exception("Не удалось отправить уведомление о запрете доступа")
"""Сообщить в чат логов (если задан) о попытке доступа извне."""
user = message.from_user
if user is None:
return
text = render(alerts_text.MSG_ACCESS_DENIED, {"user": alerts.user_tag(user.username, user.id)})
await alerts.notify_alert(message.bot, text, forward=message)

92
bot/services/alerts.py Normal file
View File

@ -0,0 +1,92 @@
"""Уведомления в чат логов (``ALERT_CHAT_ID``).
Единая точка отправки служебных сообщений бота: вход/выход участников,
успешные очищения, пинг «бот живой». Уведомления **необязательные**:
если чат не задан (``ALERT_CHAT_ID=0``) или отправка не удалась — это только
запись в лог, обработка не прерывается.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from common.config import get_settings
from common.texts import alerts as alerts_text
from common.texts.render import render
if TYPE_CHECKING:
from aiogram import Bot
from aiogram.types import Message
from common.models import PurificationResult
logger = logging.getLogger(__name__)
def user_tag(username: str | None, user_id: int) -> str:
"""Представление пользователя для служебного сообщения (``@username`` или id)."""
return f"@{username}" if username else str(user_id)
async def notify_alert(bot: Bot, text: str, *, forward: Message | None = None) -> bool:
"""Отправить текст в чат логов (при необходимости — с пересылкой сообщения).
Возвращает ``False``, если чат логов не задан или отправка не удалась.
"""
settings = get_settings()
if not settings.alert_chat_id:
return False
try:
await bot.send_message(chat_id=settings.alert_chat_id, text=text)
if forward is not None:
await bot.forward_message(
chat_id=settings.alert_chat_id,
from_chat_id=forward.chat.id,
message_id=forward.message_id,
)
except Exception: # уведомление не должно ломать обработку
logger.exception("Не удалось отправить уведомление в чат логов")
return False
return True
async def notify_join(bot: Bot, *, username: str | None, user_id: int, chat_title: str) -> bool:
"""Уведомление о входе участника в чат."""
context = {"username": user_tag(username, user_id), "chat_title": chat_title}
return await notify_alert(bot, render(alerts_text.MSG_JOIN, context))
async def notify_leave(bot: Bot, *, username: str | None, user_id: int, chat_title: str) -> bool:
"""Уведомление о выходе участника из чата."""
context = {"username": user_tag(username, user_id), "chat_title": chat_title}
return await notify_alert(bot, render(alerts_text.MSG_LEAVE, context))
async def notify_kick(
bot: Bot,
*,
actor_username: str | None,
actor_id: int,
username: str | None,
user_id: int,
chat_title: str,
) -> bool:
"""Уведомление об исключении участника: кто и кого выгнал."""
context = {
"actor": user_tag(actor_username, actor_id),
"username": user_tag(username, user_id),
"chat_title": chat_title,
}
return await notify_alert(bot, render(alerts_text.MSG_KICK, context))
async def notify_purification(bot: Bot, *, username: str | None, user_id: int, result: PurificationResult) -> bool:
"""Уведомление об успешном очищении."""
context = {
"username": user_tag(username, user_id),
"exp_add": result.exp_add,
"money_add": result.money_add,
}
return await notify_alert(bot, render(alerts_text.MSG_PURIFICATION, context))

View File

@ -2,42 +2,84 @@
from __future__ import annotations
from common.analytics import EventType, record
from common.models import EggsResult, PurificationResult
from common.repositories import economy_repo
#: id действия для начисления монеток.
ACTION_COIN = 1
#: id действия для награды за очищение.
ACTION_PURIFICATION = 4
#: id действия для обнуления баланса при удалении пользователя.
ACTION_USER_DELETED = 5
async def purify(id_user: int) -> PurificationResult:
"""Выполнить ежедневное очищение."""
"""Выполнить ежедневное очищение и записать события аналитики."""
row = await economy_repo.purification(id_user)
if not row:
return PurificationResult(success=False)
return PurificationResult(
result = PurificationResult(
success=int(row.get("res") or 0) != 0,
exp_add=int(row.get("expr_add") or 0),
money_add=int(row.get("mny_add") or 0),
hours=int(row.get("h") or 0),
minutes=int(row.get("m") or 0),
seconds=int(row.get("s") or 0),
level_before=int(row.get("lvl_before") or 0),
level=int(row.get("lvl_after") or 0),
streak=int(row.get("streak") or 0),
)
record(
EventType.PURIFICATION,
user_id=id_user,
payload={"success": result.success, "exp_add": result.exp_add, "money_add": result.money_add},
)
if not result.success:
return result
if result.exp_add:
record(EventType.EXP_ACCRUAL, user_id=id_user, payload={"amount": result.exp_add, "streak": result.streak})
if result.level_up:
record(
EventType.LEVEL_UP,
user_id=id_user,
payload={"level_from": result.level_before, "level_to": result.level},
)
if result.money_add:
record(
EventType.MONEY_ACCRUAL,
user_id=id_user,
payload={"amount": result.money_add, "reason": "purification", "action_id": ACTION_PURIFICATION},
)
return result
async def collect_eggs(id_user: int) -> EggsResult:
"""Получить ежедневные яички."""
row = await economy_repo.add_eggs(id_user)
if not row:
return EggsResult(success=False)
return EggsResult(
result = EggsResult(
success=int(row.get("res") or 0) != 0,
eggs=int(row.get("eggs_add") or 0),
hours=int(row.get("h") or 0),
minutes=int(row.get("m") or 0),
seconds=int(row.get("s") or 0),
)
if result.success:
record(EventType.EGGS, user_id=id_user, payload={"amount": result.eggs})
return result
async def add_coins(id_user: int, amount: int) -> None:
"""Начислить монеты."""
"""Начислить монеты (монетка за активность)."""
await economy_repo.money_action(id_user, amount, ACTION_COIN)
record(
EventType.MONEY_ACCRUAL,
user_id=id_user,
payload={"amount": amount, "reason": "coin", "action_id": ACTION_COIN},
)

View File

@ -1,28 +1,72 @@
"""Heartbeat для Docker healthcheck.
"""Heartbeat для Docker healthcheck и ежечасный пинг «бот живой».
Бот периодически обновляет mtime файла; healthcheck считает контейнер живым,
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
Раз в час в чат логов (``ALERT_CHAT_ID``) уходит короткое сообщение о работе бота,
и в аналитику пишется событие ``bot_alive``.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from bot.services.alerts import notify_alert
from bot.services.runtime import uptime_human, uptime_seconds
from common import __version__
from common.analytics import EventType, record
from common.config import get_settings
from common.texts import alerts as alerts_text
from common.texts.render import render
if TYPE_CHECKING:
from aiogram import Bot
logger = logging.getLogger(__name__)
#: Как часто обновлять heartbeat-файл.
HEARTBEAT_INTERVAL = 30
#: Час (в таймзоне бота), за который пинг уже отправлен.
_last_ping_hour: datetime | None = None
async def run_heartbeat() -> None:
"""Обновлять файл-heartbeat каждые ``HEARTBEAT_INTERVAL`` секунд."""
async def ping_hourly(bot: Bot, now: datetime | None = None) -> bool:
"""Записать событие ``bot_alive`` и отправить пинг в чат логов раз в час.
Возвращает ``True``, если в этом часу пинг отправлен (или предпринята попытка).
Повторные вызовы в течение того же часа ничего не делают.
"""
global _last_ping_hour
settings = get_settings()
current_hour = (now or datetime.now(settings.tzinfo)).replace(minute=0, second=0, microsecond=0)
if current_hour == _last_ping_hour:
return False
_last_ping_hour = current_hour
record(
EventType.BOT_ALIVE,
payload={"app_env": settings.app_env, "uptime_seconds": uptime_seconds()},
)
text = render(
alerts_text.MSG_ALIVE,
{"version": __version__, "uptime": uptime_human()},
)
return await notify_alert(bot, text)
async def run_heartbeat(bot: Bot) -> None:
"""Обновлять heartbeat-файл каждые ``HEARTBEAT_INTERVAL`` секунд и пинговать раз в час."""
path = Path(get_settings().heartbeat_path)
while True:
try:
path.touch()
except OSError:
logger.exception("Не удалось обновить heartbeat-файл %s", path)
await ping_hourly(bot)
await asyncio.sleep(HEARTBEAT_INTERVAL)

View File

@ -1,76 +1,131 @@
"""Планировщик рассылок.
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
редактироваться через админку.
Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке).
Если база недоступна, используется расписание по умолчанию из кода и последний
успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через
``common.texts.store`` (TTL-кеш, правки без перезапуска).
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from aiogram import Bot
from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings
from common.texts import format_usernames, render
from common.texts import notifications as notify_texts
from common.repositories import schedules_repo
from common.texts import format_usernames
logger = logging.getLogger(__name__)
#: Размер порции упоминаний в одном сообщении.
BATCH_SIZE = 10
#: Как часто писать в лог, если БД с расписаниями недоступна (секунды).
WARNING_INTERVAL = 300.0
@dataclass(frozen=True, slots=True)
class ScheduledMessage:
"""Одна запланированная рассылка.
"""Одна запланированная рассылка из БД (или из значений по умолчанию).
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно.
:param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно.
:param timezone: таймзона расписания (IANA), время в ней.
"""
hour: int
minute: int
template: str
weekdays: tuple[int, ...] | None = field(default=None)
template_key: str
weekdays: tuple[int, ...] = ()
timezone: str = "Europe/Moscow"
schedule_id: int | None = None
def matches(self, now: datetime) -> bool:
if self.hour != now.hour or self.minute != now.minute:
"""Совпадает ли момент ``now`` (aware) с этим расписанием."""
local = now.astimezone(ZoneInfo(self.timezone))
if self.hour != local.hour or self.minute != local.minute:
return False
return self.weekdays is None or now.weekday() in self.weekdays
return not self.weekdays or local.isoweekday() in self.weekdays
SCHEDULES: tuple[ScheduledMessage, ...] = (
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
#: Расписание по умолчанию — на случай недоступной БД (совпадает с V16-сидом).
DEFAULT_SCHEDULES: tuple[ScheduledMessage, ...] = (
ScheduledMessage(16, 30, "notify.blago_30"),
ScheduledMessage(16, 45, "notify.blago_45"),
ScheduledMessage(16, 55, "notify.blago_55"),
ScheduledMessage(17, 0, "notify.blago"),
ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница
ScheduledMessage(14, 0, "notify.vklad", weekdays=(7,)), # воскресенье
)
#: Последний успешно прочитанный набор — чтобы пережить временный сбой БД.
_last_good: tuple[ScheduledMessage, ...] | None = None
_last_warning_at = 0.0
def _from_row(row: dict) -> ScheduledMessage:
return ScheduledMessage(
hour=int(row["hour"]),
minute=int(row["minute"]),
template_key=str(row["template_key"]),
weekdays=tuple(int(day) for day in (row["weekdays"] or ())),
timezone=str(row["timezone"] or "Europe/Moscow"),
schedule_id=int(row["id"]) if row.get("id") is not None else None,
)
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
chunks = [
subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)
] or [[]]
async def _load_schedules() -> tuple[ScheduledMessage, ...]:
"""Прочитать активные расписания; при сбое — последние известные или дефолт."""
global _last_good, _last_warning_at
try:
rows = await schedules_repo.list_active()
except Exception:
now = time.monotonic()
if now - _last_warning_at > WARNING_INTERVAL:
logger.warning(
"Не удалось прочитать расписания из БД — использую последние известные",
exc_info=True,
)
_last_warning_at = now
return _last_good if _last_good is not None else DEFAULT_SCHEDULES
_last_good = tuple(_from_row(row) for row in rows)
return _last_good
async def _send_broadcast(bot: Bot, template_key: str, subscribers: list[dict]) -> None:
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
for chunk in chunks:
text = render(template, {"usernames": format_usernames(chunk)})
await bot.send_message(chat_id=get_settings().chat_id, text=text)
await send_template(
bot,
get_settings().chat_id,
template_key,
{"usernames": format_usernames(chunk)},
)
async def _tick(bot: Bot, now: datetime) -> None:
settings = get_settings()
if now.minute == 0:
await bot.send_message(chat_id=settings.test_chat_id, text="Я ЖЫФ!")
for schedule in await _load_schedules():
if not schedule.matches(now):
continue
# Защита от повторной отправки после перезапуска в ту же минуту.
if schedule.schedule_id is not None:
run_at = now.astimezone(UTC).replace(second=0, microsecond=0)
if not await schedules_repo.claim_run(schedule.schedule_id, run_at):
logger.info("Рассылка %s за %s уже отправлена — пропускаю", schedule.schedule_id, run_at)
continue
for schedule in SCHEDULES:
if schedule.matches(now):
subscribers = await users_service.list_subscribers()
await _send_broadcast(bot, schedule.template, subscribers)
await _send_broadcast(bot, schedule.template_key, subscribers)
async def run_notifications(bot: Bot) -> None:

27
bot/services/runtime.py Normal file
View File

@ -0,0 +1,27 @@
"""Общие runtime-данные процесса бота.
Используется для событий аналитики (``bot_start``/``bot_stop``/``bot_alive``)
и ежечасного пинга «бот живой» в чат логов.
"""
from __future__ import annotations
import time
#: Момент запуска процесса (монотонные секунды).
STARTED_AT = time.monotonic()
def uptime_seconds() -> int:
"""Сколько секунд работает процесс бота."""
return int(time.monotonic() - STARTED_AT)
def uptime_human() -> str:
"""Аптайм в виде ``3ч 05м`` или ``12м`` (для уведомлений)."""
hours, remainder = divmod(uptime_seconds(), 3600)
minutes = remainder // 60
return f"{hours}ч {minutes:02d}м" if hours else f"{minutes}м"
__all__ = ["STARTED_AT", "uptime_human", "uptime_seconds"]

120
bot/services/sequences.py Normal file
View File

@ -0,0 +1,120 @@
"""Отправка последовательностей сообщений (Блок 4).
Последовательность читается из БД; каждое сообщение отправляется по порядку,
с картинкой из общего media-volume и своими inline-кнопками.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from aiogram import Bot
from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup
from common.config import get_settings
from common.repositories import sequences_repo
from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
#: Лимит подписи к фото в Telegram.
CAPTION_LIMIT = 1024
def _markup(buttons: list[dict[str, Any]]) -> InlineKeyboardMarkup | None:
rows = [[InlineKeyboardButton(text=str(button["text"]), url=str(button["url"]))] for button in buttons]
return InlineKeyboardMarkup(inline_keyboard=rows) if rows else None
def _image_path(item: dict[str, Any]) -> Path | None:
name = item.get("image_name")
if not name:
return None
filename = Path(str(name)).name
candidates = [
Path(get_settings().media_dir) / filename,
Path("images") / filename,
]
for candidate in candidates:
if candidate.is_file():
return candidate
logger.warning("Картинка %s для последовательности не найдена", filename)
return None
async def send_sequence(
bot: Bot,
chat_id: int,
key: str,
context: dict[str, Any],
*,
reply_markup: Any = None,
) -> bool:
"""Отправить последовательность. False — если она не настроена/пуста."""
try:
items = await sequences_repo.get_items(key)
except Exception:
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
return False
enabled = [item for item in items if item.get("enabled")]
if not enabled:
return False
for index, item in enumerate(enabled):
text = render(str(item.get("text") or ""), context) if item.get("text") else ""
path = _image_path(item)
is_last = index == len(enabled) - 1
markup = reply_markup if is_last and reply_markup is not None else _markup(item.get("buttons") or [])
if path is not None:
if text and len(text) > CAPTION_LIMIT:
await bot.send_photo(chat_id=chat_id, photo=FSInputFile(path))
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
else:
await bot.send_photo(
chat_id=chat_id,
photo=FSInputFile(path),
caption=text or None,
reply_markup=markup,
)
elif text:
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
return True
async def template_text(key: str, context: dict[str, Any] | None = None) -> str:
"""Текст первого включённого сообщения последовательности (для edit_message_text)."""
try:
items = await sequences_repo.get_items(key)
except Exception:
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
items = []
for item in items:
if item.get("enabled") and item.get("text"):
return render(str(item["text"]), context or {})
return render(await get_text(key), context or {})
async def send_template(
bot: Bot,
chat_id: int,
key: str,
context: dict[str, Any] | None = None,
*,
reply_markup: Any = None,
) -> bool:
"""Отправить последовательность, а при её отсутствии — старый шаблон из кода."""
if await send_sequence(bot, chat_id, key, context or {}, reply_markup=reply_markup):
return True
text = render(await get_text(key), context or {})
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
return True

View File

@ -2,34 +2,65 @@
from __future__ import annotations
from common.analytics import EventType, record
from common.models import Donat, Title
from common.repositories import shop_repo
async def list_titles() -> list[Title]:
rows = await shop_repo.get_titles_list()
return [
Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0))
for row in rows
]
return [Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows]
async def list_donats() -> list[Donat]:
rows = await shop_repo.get_donats_list()
return [
Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0))
for row in rows
]
return [Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows]
async def buy_title(id_user: int, id_title: int) -> bool:
"""Купить звание. Возвращает True при успехе."""
return await shop_repo.buy_title(id_user, id_title) == 1
price = await _title_price(id_title)
success = await shop_repo.buy_title(id_user, id_title) == 1
record(
EventType.PURCHASE_TITLE,
user_id=id_user,
payload={"title_id": id_title, "price": price, "success": success},
)
if success:
record(
EventType.MONEY_SPEND,
user_id=id_user,
payload={"amount": price, "reason": "title", "product_id": id_title},
)
return success
async def buy_donat(id_user: int, id_donat: int) -> str | None:
"""Купить донат. Возвращает промокод или None при неуспехе."""
price = await _donat_price(id_donat)
result = await shop_repo.buy_donat(id_user, id_donat)
if result == "-1":
return None
return result
promo = None if result == "-1" else result
record(
EventType.PURCHASE_DONAT,
user_id=id_user,
payload={"donat_id": id_donat, "price": price, "success": promo is not None},
)
if promo is not None:
record(
EventType.MONEY_SPEND,
user_id=id_user,
payload={"amount": price, "reason": "donat", "product_id": id_donat},
)
return promo
async def _title_price(id_title: int) -> int:
"""Цена звания для события покупки (0, если позиция недоступна)."""
return next((title.price for title in await list_titles() if title.id == id_title), 0)
async def _donat_price(id_donat: int) -> int:
"""Цена доната для события покупки (0, если позиция недоступна)."""
return next((donat.price for donat in await list_donats() if donat.id == id_donat), 0)

View File

@ -9,6 +9,5 @@ from common.repositories import stats_repo
async def top_users() -> list[TopEntry]:
rows = await stats_repo.get_top_list()
return [
TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0))
for row in rows
TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0)) for row in rows
]

View File

@ -2,12 +2,15 @@
from __future__ import annotations
from common.analytics import EventType, record
from common.repositories import subscriptions_repo
async def subscribe(id_user: int) -> None:
await subscriptions_repo.subscribe(id_user)
record(EventType.SUBSCRIBE, user_id=id_user)
async def unsubscribe(id_user: int) -> None:
await subscriptions_repo.unsubscribe(id_user)
record(EventType.UNSUBSCRIBE, user_id=id_user)

View File

@ -4,8 +4,10 @@ from __future__ import annotations
from aiogram.types import ChatMemberUpdated, Message, User
from bot.services.economy import ACTION_USER_DELETED
from common.analytics import EventType, record
from common.models import NickEntry, UserInfo
from common.repositories import users_repo
from common.repositories import economy_repo, users_repo
def _user_fields(user: User) -> tuple[int, str, str, str]:
@ -31,14 +33,34 @@ async def register_from_event(event: ChatMemberUpdated) -> None:
if user.is_bot:
return
await users_repo.add_user(*_user_fields(user))
record(
EventType.JOIN,
user_id=user.id,
username=user.username,
chat_id=event.chat.id,
payload={"chat_title": event.chat.title or ""},
)
async def set_nick(id_user: int, nick: str) -> None:
await users_repo.add_nick(id_user, nick)
await users_repo.set_nick_with_history(id_user, nick, editor_id=id_user, source="user")
record(EventType.NICK_SET, user_id=id_user, payload={"nick": nick})
async def delete_user(id_user: int) -> None:
"""Удалить пользователя и зафиксировать обнуление баланса.
Баланс читается **до** удаления: ``bot.del_user_by_id`` обнуляет его записью
в журнал ``money``.
"""
balance = await economy_repo.get_money(id_user)
await users_repo.del_user_by_id(id_user)
if balance:
record(
EventType.MONEY_SPEND,
user_id=id_user,
payload={"amount": abs(balance), "reason": "user_deleted", "action_id": ACTION_USER_DELETED},
)
async def list_nicks() -> list[NickEntry]:

View File

@ -0,0 +1,35 @@
"""Аналитика: клиент Postgres и каталог событий.
Публичный интерфейс:
- :func:`init_analytics` / :func:`close_analytics` — жизненный цикл (вызывает бот при старте/остановке);
- :func:`record` — записать событие из сервисного слоя (неблокирующе);
- :func:`remember_user` — запомнить ``username`` для обогащения событий;
- :class:`EventType` / :data:`ALL_EVENT_TYPES` — каталог типов событий.
"""
from __future__ import annotations
from common.analytics.client import (
AnalyticsClient,
close_analytics,
get_analytics,
init_analytics,
record,
record_message,
remember_user,
)
from common.analytics.events import ALL_EVENT_TYPES, EVENT_COLUMNS, EventType
__all__ = [
"ALL_EVENT_TYPES",
"EVENT_COLUMNS",
"AnalyticsClient",
"EventType",
"close_analytics",
"get_analytics",
"init_analytics",
"record",
"record_message",
"remember_user",
]

289
common/analytics/client.py Normal file
View File

@ -0,0 +1,289 @@
"""Аналитика в Postgres: батч-запись событий и дневных счётчиков сообщений.
Свойства:
- запись неблокирующая: ``record``/``record_message`` только кладут данные в буфер;
- батчинг по размеру/таймеру;
- сбой Postgres не роняет бота: батч уходит в лог (fallback) и отбрасывается;
- сообщения не пишутся поштучно: инкрементится ``analytics.messages_daily``.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from collections import deque
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from psycopg.types.json import Jsonb
from common.analytics.events import EventType
from common.config import Settings, get_settings
from common.db.pool import get_pool
from common.version import __version__
logger = logging.getLogger(__name__)
#: Ограничение длины JSON-payload (защита от «толстых» событий).
PAYLOAD_MAX_LENGTH = 4000
#: Ограничение кэша ``id_user -> username``.
USERNAME_CACHE_MAX = 5000
#: Сколько строк события выводить в лог при fallback.
FALLBACK_SAMPLE = 5
#: Строка события: (time, type, user_id, username, chat_id, payload, version, prod).
EventRow = list[Any]
#: (day, user_id, chat_id) -> (username, count)
MessageCounts = dict[tuple[Any, int, int], list[Any]]
#: Куда сбрасывать батч (подменяется в тестах).
Writer = Callable[[list[EventRow], MessageCounts], Awaitable[None]]
async def _write_db(events: list[EventRow], messages: MessageCounts) -> None:
"""Записать батч в Postgres."""
async with get_pool().connection() as conn, conn.cursor() as cur:
if events:
await cur.executemany(
"""
INSERT INTO analytics.events
(event_time, event_type, user_id, username, chat_id, payload, bot_version, prod)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
[
[
row[0],
row[1],
row[2],
row[3],
row[4],
Jsonb(row[5]),
row[6],
row[7],
]
for row in events
],
)
for (day, user_id, chat_id), (username, count) in messages.items():
await cur.execute(
"""
INSERT INTO analytics.messages_daily (day, user_id, username, chat_id, count)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (day, user_id, chat_id) DO UPDATE
SET count = analytics.messages_daily.count + EXCLUDED.count,
username = EXCLUDED.username
""",
(day, user_id, username, chat_id, count),
)
class AnalyticsClient:
"""Батч-запись событий и счётчиков в Postgres с деградацией в лог."""
def __init__(
self,
settings: Settings | None = None,
*,
writer: Writer | None = None,
) -> None:
self._settings = settings or get_settings()
self._writer = writer or _write_db
self._rows: deque[EventRow] = deque()
self._messages: MessageCounts = {}
self._flush_now = asyncio.Event()
self._task: asyncio.Task[None] | None = None
self._usernames: dict[int, str] = {}
@property
def enabled(self) -> bool:
return self._settings.analytics_enabled
@property
def pending(self) -> int:
return len(self._rows) + sum(int(value[1]) for value in self._messages.values())
async def start(self) -> None:
if not self.enabled:
logger.info("Аналитика выключена (ANALYTICS_ENABLED=false)")
return
self._task = asyncio.create_task(self._run(), name="analytics-flush")
logger.info(
"Аналитика Postgres: батч %s, интервал %sс",
self._settings.analytics_batch_size,
self._settings.analytics_flush_interval,
)
def remember_user(self, user_id: int, username: str | None) -> None:
if not user_id or not username:
return
if user_id not in self._usernames and len(self._usernames) >= USERNAME_CACHE_MAX:
self._usernames.pop(next(iter(self._usernames)))
self._usernames[user_id] = username
def record(
self,
event_type: EventType | str,
*,
user_id: int = 0,
username: str | None = None,
chat_id: int = 0,
payload: dict[str, Any] | None = None,
) -> None:
"""Положить событие в буфер (никогда не бросает исключений)."""
if not self.enabled:
return
if username is None:
username = self._usernames.get(user_id, "")
self._rows.append(
[
datetime.now(UTC),
str(event_type),
int(user_id),
username or "",
int(chat_id),
self._payload(payload),
__version__,
bool(self._settings.is_prod),
]
)
while len(self._rows) > self._settings.analytics_buffer_max:
self._rows.popleft()
if self.pending >= self._settings.analytics_batch_size:
self._flush_now.set()
def record_message(self, *, user_id: int, username: str | None, chat_id: int) -> None:
"""Увеличить дневной счётчик сообщений пользователя."""
if not self.enabled or not user_id:
return
day = datetime.now(self._settings.tzinfo).date()
key = (day, int(user_id), int(chat_id))
entry = self._messages.get(key)
if entry is None:
self._messages[key] = [username or self._usernames.get(user_id, ""), 1]
else:
entry[0] = username or entry[0]
entry[1] = int(entry[1]) + 1
if self.pending >= self._settings.analytics_batch_size:
self._flush_now.set()
async def _run(self) -> None:
interval = self._settings.analytics_flush_interval
while True:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._flush_now.wait(), timeout=interval)
self._flush_now.clear()
await self.flush()
async def flush(self) -> None:
"""Отправить накопленное. При сбое — fallback в лог (без исключений)."""
if not self._rows and not self._messages:
return
events = list(self._rows)
messages = self._messages
self._rows.clear()
self._messages = {}
if not self.enabled:
return
try:
await self._writer(events, messages)
except Exception:
self._log_fallback(events, messages)
async def close(self) -> None:
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
await self.flush()
def _log_fallback(self, events: list[EventRow], messages: MessageCounts) -> None:
sample = " | ".join(
f"{row[0]:%Y-%m-%d %H:%M:%S} {row[1]} user={row[2]} chat={row[4]}" for row in events[:FALLBACK_SAMPLE]
)
logger.warning(
"Fallback: не записано в Postgres событий=%s, счётчиков=%s%s",
len(events),
len(messages),
f": {sample}" if sample else "",
)
@staticmethod
def _payload(payload: dict[str, Any] | None) -> dict[str, Any]:
if not payload:
return {}
try:
text = json.dumps(payload, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return {"_error": "payload не сериализуется"}
if len(text) > PAYLOAD_MAX_LENGTH:
return {"_truncated": text[:PAYLOAD_MAX_LENGTH]}
# Пересобираем из JSON: так в jsonb не попадёт объект, который не сериализуется.
return json.loads(text)
_client: AnalyticsClient | None = None
async def init_analytics(
settings: Settings | None = None,
*,
writer: Writer | None = None,
) -> AnalyticsClient:
"""Создать и запустить единственный клиент аналитики. Идемпотентно."""
global _client
if _client is not None:
return _client
_client = AnalyticsClient(settings, writer=writer)
await _client.start()
return _client
async def close_analytics() -> None:
global _client
if _client is not None:
await _client.close()
_client = None
def get_analytics() -> AnalyticsClient | None:
return _client
def record(
event_type: EventType | str,
*,
user_id: int = 0,
username: str | None = None,
chat_id: int = 0,
payload: dict[str, Any] | None = None,
) -> None:
if _client is None:
return
_client.record(event_type, user_id=user_id, username=username, chat_id=chat_id, payload=payload)
def record_message(*, user_id: int, username: str | None, chat_id: int) -> None:
if _client is None:
return
_client.record_message(user_id=user_id, username=username, chat_id=chat_id)
def remember_user(user_id: int, username: str | None) -> None:
if _client is not None:
_client.remember_user(user_id, username)

View File

@ -0,0 +1,62 @@
"""Каталог событий аналитики.
Единственный источник правды по типам событий. Список синхронизирован
с документацией (``docs/events.md``) — рассинхрон ловит ``tests/test_analytics.py``.
Правило: добавляешь новое событие — добавь его в ``EventType`` и в ``docs/events.md``.
"""
from __future__ import annotations
from enum import StrEnum
class EventType(StrEnum):
"""Типы событий (значение = колонка ``event_type`` в Postgres)."""
# --- Сообщения и команды ---
COMMAND = "command"
# --- Экономика ---
PURIFICATION = "purification"
EXP_ACCRUAL = "exp_accrual"
LEVEL_UP = "level_up"
MONEY_ACCRUAL = "money_accrual"
MONEY_SPEND = "money_spend"
EGGS = "eggs"
PURCHASE_TITLE = "purchase_title"
PURCHASE_DONAT = "purchase_donat"
# --- Участники ---
JOIN = "join"
LEAVE = "leave"
NICK_SET = "nick_set"
# --- Подписки и доступ ---
SUBSCRIBE = "subscribe"
UNSUBSCRIBE = "unsubscribe"
ACCESS_DENIED = "access_denied"
# --- Жизненный цикл бота и ошибки ---
BOT_START = "bot_start"
BOT_STOP = "bot_stop"
BOT_ALIVE = "bot_alive"
ERROR = "error"
#: Полный список типов событий (для проверки синхронности с документацией).
ALL_EVENT_TYPES: frozenset[str] = frozenset(member.value for member in EventType)
#: Колонки таблицы ``analytics.events`` в порядке вставки.
EVENT_COLUMNS: tuple[str, ...] = (
"event_time",
"event_type",
"user_id",
"username",
"chat_id",
"payload",
"bot_version",
"prod",
)
__all__ = ["ALL_EVENT_TYPES", "EVENT_COLUMNS", "EventType"]

View File

@ -0,0 +1,54 @@
"""Метаданные сообщения для событий аналитики.
Модуль намеренно **не зависит от aiogram**: работает с любым объектом, у которого
есть нужные атрибуты (``Message`` в боте или заглушка в тестах). Текст сообщения
не сохраняется — только метаданные (см. ``docs/events.md``).
"""
from __future__ import annotations
from typing import Any
#: Максимальная длина имени команды в payload.
COMMAND_MAX_LENGTH = 64
#: Префикс, разделяющий команду и бота в групповом чате (``/команда@bot``).
BOT_MENTION_SEPARATOR = "@"
def _text(message: Any) -> str:
"""Текст сообщения (или подпись) без лишних пробелов."""
text = getattr(message, "text", None) or getattr(message, "caption", None) or ""
return str(text).strip()
def _enum_value(value: Any) -> str:
"""Строковое значение enum-полей aiogram (``ChatType.PRIVATE`` -> ``private``)."""
return str(getattr(value, "value", value) or "")
def describe_message(message: Any) -> dict[str, Any]:
"""Метаданные сообщения для события ``message`` (без текста сообщения)."""
text = _text(message)
chat = getattr(message, "chat", None)
return {
"message_id": int(getattr(message, "message_id", 0) or 0),
"chat_type": _enum_value(getattr(chat, "type", "")) if chat is not None else "",
"content_type": _enum_value(getattr(message, "content_type", "")),
"text_len": len(text),
"is_command": text.startswith("/"),
}
def describe_command(message: Any) -> dict[str, Any] | None:
"""Метаданные команды для события ``command`` (``None``, если это не команда)."""
text = _text(message)
if not text.startswith("/"):
return None
parts = text.split()
name = parts[0].split(BOT_MENTION_SEPARATOR, 1)[0].lstrip("/").lower()[:COMMAND_MAX_LENGTH]
return {"command": name, "args_count": max(len(parts) - 1, 0)}
__all__ = ["describe_command", "describe_message"]

View File

@ -0,0 +1,64 @@
"""Партиции аналитики по дням.
Создаются заранее (вчера/сегодня/два дня вперёд), старые **не удаляются**:
история событий и дневных счётчиков хранится бессрочно.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, date, datetime, timedelta
from common.config import Settings
from common.db.pool import get_pool
logger = logging.getLogger(__name__)
#: Сколько дней вперёд создавать партиции.
DAYS_AHEAD = 2
#: Сколько дней назад проверять (на случай простоя).
DAYS_BACK = 1
#: Интервал фоновой проверки партиций.
PARTITION_INTERVAL = 6 * 60 * 60
def _event_partition(day: date) -> str:
start = f"{day.isoformat()} 00:00:00+00"
end = f"{(day + timedelta(days=1)).isoformat()} 00:00:00+00"
return (
f"CREATE TABLE IF NOT EXISTS analytics.events_{day:%Y%m%d} "
f"PARTITION OF analytics.events FOR VALUES FROM ('{start}') TO ('{end}')"
)
def _messages_partition(day: date) -> str:
start = day.isoformat()
end = (day + timedelta(days=1)).isoformat()
return (
f"CREATE TABLE IF NOT EXISTS analytics.messages_daily_{day:%Y%m%d} "
f"PARTITION OF analytics.messages_daily FOR VALUES FROM ('{start}') TO ('{end}')"
)
async def ensure_partitions(settings: Settings) -> None:
"""Создать недостающие партиции на несколько дней вперёд."""
utc_today = datetime.now(UTC).date()
local_today = datetime.now(settings.tzinfo).date()
async with get_pool().connection() as conn:
for offset in range(-DAYS_BACK, DAYS_AHEAD + 1):
await conn.execute(_event_partition(utc_today + timedelta(days=offset)))
await conn.execute(_messages_partition(local_today + timedelta(days=offset)))
async def run_partitions(settings: Settings) -> None:
"""Периодически проверять партиции; ошибки не роняют бота."""
while True:
try:
await ensure_partitions(settings)
except asyncio.CancelledError:
raise
except Exception:
logger.warning("Не удалось создать партиции аналитики", exc_info=True)
await asyncio.sleep(PARTITION_INTERVAL)

129
common/analytics/read.py Normal file
View File

@ -0,0 +1,129 @@
"""Чтение аналитики из Postgres для админки."""
from __future__ import annotations
import logging
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from common.analytics.events import ALL_EVENT_TYPES
from common.config import Settings
from common.db.pool import get_pool
logger = logging.getLogger(__name__)
#: Колонки, которые показываем в логах.
EVENT_FIELDS = (
"event_time",
"event_type",
"user_id",
"username",
"chat_id",
"payload",
"bot_version",
"prod",
)
def _dates(date_from: str, date_to: str, settings: Settings) -> tuple[date, date]:
today = datetime.now(settings.tzinfo).date()
start = datetime.strptime(date_from, "%Y-%m-%d").date() if date_from else today - timedelta(days=6)
end = datetime.strptime(date_to, "%Y-%m-%d").date() if date_to else today
return start, end
def _event_bounds(start: date, end: date, settings: Settings) -> tuple[datetime, datetime]:
start_dt = datetime.combine(start, time.min, tzinfo=settings.tzinfo).astimezone(UTC)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=settings.tzinfo).astimezone(UTC)
return start_dt, end_dt
def _user_clause(user: str, params: list[Any], column: str = "user_id") -> str:
user = user.strip()
if not user:
return ""
if user.isdigit():
params.append(int(user))
return f" AND {column} = %s"
params.append(f"%{user}%")
return " AND username ILIKE %s"
async def fetch_events(
settings: Settings,
*,
event_type: str = "",
user: str = "",
date_from: str = "",
date_to: str = "",
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""Вернуть события и общее количество по фильтрам."""
start, end = _dates(date_from, date_to, settings)
start_dt, end_dt = _event_bounds(start, end, settings)
params: list[Any] = [start_dt, end_dt]
where = " WHERE event_time >= %s AND event_time < %s"
if event_type and event_type in ALL_EVENT_TYPES:
where += " AND event_type = %s"
params.append(event_type)
where += _user_clause(user, params)
async with get_pool().connection() as conn:
cur = await conn.execute(f"SELECT count(*) AS total FROM analytics.events{where}", tuple(params))
row = await cur.fetchone()
total = int(row["total"]) if row else 0
cur = await conn.execute(
f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}"
" ORDER BY event_time DESC LIMIT %s OFFSET %s",
(*params, limit, offset),
)
rows = await cur.fetchall()
return rows, total
async def fetch_messages(
settings: Settings,
*,
user: str = "",
date_from: str = "",
date_to: str = "",
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""Вернуть дневные счётчики сообщений и общее количество строк."""
start, end = _dates(date_from, date_to, settings)
params: list[Any] = [start, end]
where = " WHERE day >= %s AND day <= %s"
where += _user_clause(user, params)
async with get_pool().connection() as conn:
cur = await conn.execute(
f"""
SELECT count(*) AS total
FROM (
SELECT 1 FROM analytics.messages_daily{where}
GROUP BY day, user_id
) AS grouped
""",
tuple(params),
)
row = await cur.fetchone()
total = int(row["total"]) if row else 0
cur = await conn.execute(
f"""
SELECT day, user_id, max(username) AS username, max(chat_id) AS chat_id,
sum(count)::bigint AS count
FROM analytics.messages_daily{where}
GROUP BY day, user_id
ORDER BY day DESC, sum(count) DESC
LIMIT %s OFFSET %s
""",
(*params, limit, offset),
)
rows = await cur.fetchall()
return rows, total

View File

@ -6,12 +6,14 @@
from __future__ import annotations
from datetime import date
from functools import lru_cache
from typing import Annotated
from urllib.parse import quote_plus
from zoneinfo import ZoneInfo
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
@ -37,17 +39,59 @@ class Settings(BaseSettings):
db_pool_max: int = 10
db_statement_timeout_ms: int = 15000
prod_chat_id: int = 0
test_chat_id: int = 0
#: Основной чат клана (единственный; контуры разделяются отдельными .env).
chat_id: int = 0
#: Доп. чат для служебных уведомлений/логов (опционально).
alert_chat_id: int = 0
admin_ids: list[int] = Field(default_factory=list)
#: Telegram-ID, которым доступ разрешён в обход проверки членства.
#: NoDecode — чтобы pydantic-settings не пытался JSON-декодировать значение
#: (поддерживаем формат "1,2,3" и пустое значение через валидатор ниже).
admin_ids: Annotated[list[int], NoDecode] = Field(default_factory=list)
timezone: str = "Europe/Moscow"
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
#: Общий каталог картинок (Docker volume), который видят бот и админка.
media_dir: str = "/app/media"
#: Публичный адрес админки (deep-link вход, ссылки из бота).
admin_public_url: str = "http://localhost:8000"
#: Аналитика событий в Postgres. ANALYTICS_ENABLED=false — полностью выключить.
analytics_enabled: bool = True
#: Батчинг записи событий: размер батча, интервал сброса и предел буфера.
analytics_batch_size: int = 200
analytics_flush_interval: float = 5.0
analytics_buffer_max: int = 5000
#: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки
#: применяются без перезапуска). 0 — читать БД при каждом обращении.
template_cache_ttl: float = 45.0
#: Окно пасхального события «Яички». Пусто — автоматически 1 апреля → Православная Пасха.
egg_event_start: date | None = None
egg_event_end: date | None = None
@field_validator("chat_id", "alert_chat_id", mode="before")
@classmethod
def _empty_int_to_zero(cls, value: object) -> object:
"""Пустое значение в .env трактуем как 0 (а не ошибку валидации)."""
if value is None or value == "":
return 0
return value
@field_validator("egg_event_start", "egg_event_end", mode="before")
@classmethod
def _empty_date_to_none(cls, value: object) -> object:
"""Пустое значение в .env — «даты не заданы» (работает авто-расчёт)."""
if value is None or value == "":
return None
return value
@field_validator("admin_ids", mode="before")
@classmethod
def _parse_admin_ids(cls, value: object) -> object:
@ -65,11 +109,6 @@ class Settings(BaseSettings):
def is_prod(self) -> bool:
return self.app_env.strip().lower() in {"prod", "production"}
@property
def chat_id(self) -> int:
"""Основной чат клана для текущего окружения."""
return self.prod_chat_id if self.is_prod else self.test_chat_id
@property
def tzinfo(self) -> ZoneInfo:
return ZoneInfo(self.timezone)
@ -79,13 +118,41 @@ class Settings(BaseSettings):
"""Строка подключения psycopg для async-пула (креды URL-экранируются)."""
user = quote_plus(self.db_user)
password = quote_plus(self.db_password)
return (
f"postgresql://{user}:{password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
return f"postgresql://{user}:{password}" f"@{self.db_host}:{self.db_port}/{self.db_name}"
@lru_cache
def get_settings() -> Settings:
"""Вернуть singleton настроек."""
"""Вернуть singleton настроек бота."""
return Settings() # type: ignore[call-arg]
class AdminSettings(Settings):
"""Настройки сервиса админки.
Наследует общие поля (окружение, Postgres, таймзона), но не требует
``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи
Telegram-логина (Блок 4.2).
"""
bot_token: str = ""
#: Адрес и порт, которые слушает uvicorn внутри контейнера.
admin_host: str = "0.0.0.0"
admin_port: int = 8000
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
#: Наследуется из базовых настроек: ADMIN_PUBLIC_URL.
#: Username бота без @ — из него виджет рисует кнопку входа.
bot_username: str = ""
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают
#: при перезапуске (генерируется временный).
admin_session_secret: str = ""
#: Время жизни сессии, секунды (по умолчанию 12 часов).
admin_session_max_age: int = 12 * 60 * 60
@lru_cache
def get_admin_settings() -> AdminSettings:
"""Вернуть singleton настроек админки."""
return AdminSettings() # type: ignore[call-arg]

View File

@ -11,20 +11,23 @@ import logging
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from common.config import get_settings
from common.config import Settings, get_settings
logger = logging.getLogger(__name__)
_pool: AsyncConnectionPool | None = None
async def init_pool() -> AsyncConnectionPool:
"""Создать и открыть пул. Идемпотентно."""
async def init_pool(settings: Settings | None = None) -> AsyncConnectionPool:
"""Создать и открыть пул. Идемпотентно.
``settings`` передаёт админка (свои настройки); по умолчанию — настройки бота.
"""
global _pool
if _pool is not None:
return _pool
settings = get_settings()
settings = settings or get_settings()
_pool = AsyncConnectionPool(
conninfo=settings.dsn,
min_size=settings.db_pool_min,

46
common/eggs.py Normal file
View File

@ -0,0 +1,46 @@
"""Пасхальное событие «Яички» (Блок 4: сезонность).
По умолчанию окно — с 1 апреля по православную Пасху включительно (как было
в исходном боте: 01.04–20.04.2025). Даты можно переопределить переменными
``EGG_EVENT_START``/``EGG_EVENT_END`` (например, для другого года или правила).
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from common.config import Settings
#: Разница между юлианским и григорианским календарями для 1900–2099.
_JULIAN_TO_GREGORIAN_DAYS = 13
def orthodox_easter(year: int) -> date:
"""Дата православной Пасхи (по юлианской пасхалии, переведённая в григорианский стиль)."""
a = year % 4
b = year % 7
c = year % 19
d = (19 * c + 15) % 30
e = (2 * a + 4 * b - d + 34) % 7
month = (d + e + 114) // 31
day = ((d + e + 114) % 31) + 1
return date(year, month, day) + timedelta(days=_JULIAN_TO_GREGORIAN_DAYS)
def egg_event_period(settings: Settings, today: date) -> tuple[date, date]:
"""Начало и конец окна «яичек» (включительно).
Если в настройках заданы обе даты — берём их, иначе 1 апреля → православная
Пасха года ``today``.
"""
if settings.egg_event_start and settings.egg_event_end:
return settings.egg_event_start, settings.egg_event_end
return date(today.year, 4, 1), orthodox_easter(today.year)
def is_egg_event_active(settings: Settings, today: date | None = None) -> bool:
"""Идёт ли сейчас пасхальное событие (в таймзоне настроек)."""
if today is None:
today = datetime.now(settings.tzinfo).date()
start, end = egg_event_period(settings, today)
return start <= today <= end

View File

@ -15,6 +15,16 @@ class PurificationResult:
hours: int = 0
minutes: int = 0
seconds: int = 0
#: Уровень до и после очищения (для события `level_up`).
level_before: int = 0
level: int = 0
#: Серия ежедневных очищений (для события `exp_accrual`).
streak: int = 0
@property
def level_up(self) -> bool:
"""Повысился ли уровень по итогам очищения."""
return self.level > self.level_before
@dataclass(slots=True)

View File

@ -0,0 +1,73 @@
"""Одноразовые токены входа в админку через Telegram deep-link."""
from __future__ import annotations
import hashlib
import secrets
from common.db.pool import get_pool
#: Время жизни токена.
TTL_MINUTES = 5
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def create_token() -> str:
"""Создать токен, вернуть его в открытом виде (в БД — только хеш)."""
token = secrets.token_urlsafe(32)
async with get_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO bot.admin_login_tokens (token_hash, expires_at)
VALUES (%s, now() + make_interval(mins => %s))
""",
(_hash(token), TTL_MINUTES),
)
await conn.execute("DELETE FROM bot.admin_login_tokens WHERE expires_at < now() - interval '1 day'")
return token
async def confirm_token(token: str, telegram_id: int) -> bool:
"""Отметить токен подтверждённым ботом. False — если истёк/уже подтверждён."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
UPDATE bot.admin_login_tokens
SET confirmed_at = now(), telegram_id = %s
WHERE token_hash = %s
AND confirmed_at IS NULL
AND used_at IS NULL
AND expires_at > now()
""",
(telegram_id, _hash(token)),
)
return bool(cur.rowcount)
async def consume_token(token: str) -> int | None:
"""Забрать подтверждённый токен один раз. Возвращает Telegram-ID или None."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT telegram_id
FROM bot.admin_login_tokens
WHERE token_hash = %s
AND confirmed_at IS NOT NULL
AND used_at IS NULL
AND expires_at > now()
FOR UPDATE
""",
(_hash(token),),
)
row = await cur.fetchone()
if row is None or row["telegram_id"] is None:
return None
await conn.execute(
"UPDATE bot.admin_login_tokens SET used_at = now() WHERE token_hash = %s",
(_hash(token),),
)
return int(row["telegram_id"])

View File

@ -0,0 +1,52 @@
"""Журнал ручных отправок сообщений из админки."""
from __future__ import annotations
from typing import Any
from psycopg.types.json import Jsonb
from common.db.pool import get_pool
async def log_message(
*,
editor_id: int | None,
chat_id: int,
text: str,
buttons: list[tuple[str, str]],
has_image: bool,
success: bool,
error: str | None = None,
) -> None:
async with get_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO bot.admin_messages (editor_id, chat_id, text, buttons, has_image, success, error)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
editor_id,
chat_id,
text,
Jsonb([{"text": button_text, "url": url} for button_text, url in buttons]),
has_image,
success,
error,
),
)
async def recent(*, limit: int = 10) -> list[dict[str, Any]]:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, editor_id, chat_id, left(text, 120) AS preview, buttons,
has_image, success, error, sent_at
FROM bot.admin_messages
ORDER BY sent_at DESC
LIMIT %s
""",
(limit,),
)
return await cur.fetchall()

View File

@ -0,0 +1,106 @@
"""Репозиторий администраторов веб-панели (Блок 4.2)."""
from __future__ import annotations
from typing import Any
from common.db.pool import get_pool
async def get_admin(telegram_id: int) -> dict[str, Any] | None:
"""Вернуть администратора по Telegram-ID (для проверки доступа)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT telegram_id, username, role, enabled, created_at, last_login_at
FROM bot.admin_users
WHERE telegram_id = %s
""",
(telegram_id,),
)
return await cur.fetchone()
async def ensure_admins(telegram_ids: list[int]) -> int:
"""Засеять администраторов из ADMIN_IDS (существующих не трогаем).
Возвращает количество вставленных строк.
"""
if not telegram_ids:
return 0
inserted = 0
async with get_pool().connection() as conn:
for telegram_id in dict.fromkeys(telegram_ids):
cur = await conn.execute(
"""
INSERT INTO bot.admin_users (telegram_id, role)
VALUES (%s, 'admin')
ON CONFLICT (telegram_id) DO NOTHING
""",
(telegram_id,),
)
inserted += cur.rowcount or 0
return inserted
async def touch_login(telegram_id: int) -> None:
"""Отметить успешный вход (не критично, ошибки не должны ломать логин)."""
async with get_pool().connection() as conn:
await conn.execute(
"UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s",
(telegram_id,),
)
async def list_admins() -> list[dict[str, Any]]:
"""Все администраторы веб-панели (для раздела «Админы»)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT telegram_id, username, role, enabled, created_at, last_login_at
FROM bot.admin_users
ORDER BY role, username NULLS LAST, telegram_id
"""
)
return await cur.fetchall()
async def upsert_admin(telegram_id: int, username: str | None, role: str) -> None:
"""Добавить администратора или обновить имя/роль существующего."""
async with get_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO bot.admin_users (telegram_id, username, role, enabled)
VALUES (%s, %s, %s, true)
ON CONFLICT (telegram_id) DO UPDATE
SET username = EXCLUDED.username,
role = EXCLUDED.role,
enabled = true
""",
(telegram_id, username, role),
)
async def set_admin_role(telegram_id: int, role: str) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"UPDATE bot.admin_users SET role = %s WHERE telegram_id = %s",
(role, telegram_id),
)
return bool(cur.rowcount)
async def set_admin_enabled(telegram_id: int, enabled: bool) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"UPDATE bot.admin_users SET enabled = %s WHERE telegram_id = %s",
(enabled, telegram_id),
)
return bool(cur.rowcount)
async def delete_admin(telegram_id: int) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute("DELETE FROM bot.admin_users WHERE telegram_id = %s", (telegram_id,))
return bool(cur.rowcount)

View File

@ -9,15 +9,13 @@ from common.db.pool import get_pool
async def money_action(id_user: int, amount: int, id_action: int = 1) -> None:
"""Начислить/списать деньги (запись в журнал money)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.money_action(%s, %s, %s)", (id_user, amount, id_action))
async def get_money(id_user: int) -> int:
"""Текущий баланс пользователя (SUM по журналу)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.get_money(%s) AS money", (id_user,))
row = await cur.fetchone()
return int(row["money"] or 0) if row else 0
@ -25,8 +23,7 @@ async def get_money(id_user: int) -> int:
async def get_eggs(id_user: int) -> int:
"""Количество яичек пользователя."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.get_eggs(%s) AS eggs", (id_user,))
row = await cur.fetchone()
return int(row["eggs"] or 0) if row else 0
@ -34,15 +31,13 @@ async def get_eggs(id_user: int) -> int:
async def purification(id_user: int) -> dict[str, Any] | None:
"""Выполнить ежедневное очищение."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.purification(%s)", (id_user,))
return await cur.fetchone()
async def add_eggs(id_user: int) -> dict[str, Any] | None:
"""Получить яички (раз в сутки)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,))
return await cur.fetchone()

View File

@ -0,0 +1,204 @@
"""Репозиторий расписаний рассылок (Блок 4.5).
Редактирует админка, исполняет бот: он читает активные строки раз в минуту.
Каждое изменение пишет снимок строки в ``schedules_history`` (аудит).
"""
from __future__ import annotations
from typing import Any
from psycopg.types.json import Jsonb
from common.db.pool import get_pool
#: Поля, которые попадают в историю и форму (без служебных отметок времени).
SNAPSHOT_FIELDS = ("template_key", "hour", "minute", "weekdays", "timezone", "enabled", "description")
def _snapshot(row: dict[str, Any]) -> dict[str, Any]:
return {field: row.get(field) for field in SNAPSHOT_FIELDS}
async def list_schedules() -> list[dict[str, Any]]:
"""Все расписания с описанием шаблона (для таблицы в админке)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT s.id, s.template_key, t.description AS template_description,
s.hour, s.minute, s.weekdays, s.timezone, s.enabled,
s.description, s.updated_at, s.updated_by
FROM bot.schedules AS s
JOIN bot.message_templates AS t ON t.key = s.template_key
ORDER BY s.hour, s.minute, s.id
"""
)
return await cur.fetchall()
async def list_active() -> list[dict[str, Any]]:
"""Активные расписания — для планировщика бота."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, template_key, hour, minute, weekdays, timezone
FROM bot.schedules
WHERE enabled
ORDER BY hour, minute, id
"""
)
return await cur.fetchall()
async def claim_run(schedule_id: int, run_at: Any) -> bool:
"""Атомарно отметить минуту как отработанную.
``True`` — эту минуту ещё никто не отправлял; ``False`` — уже была отправка
(например, до перезапуска бота).
"""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
INSERT INTO bot.schedule_runs (schedule_id, run_at)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(schedule_id, run_at),
)
claimed = bool(cur.rowcount)
if claimed:
await conn.execute("DELETE FROM bot.schedule_runs WHERE run_at < now() - interval '7 days'")
return claimed
async def get_schedule(schedule_id: int) -> dict[str, Any] | None:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, template_key, hour, minute, weekdays, timezone, enabled,
description, updated_at, updated_by
FROM bot.schedules
WHERE id = %s
""",
(schedule_id,),
)
return await cur.fetchone()
async def create_schedule(data: dict[str, Any], *, editor_id: int | None = None) -> int:
"""Создать расписание и записать его в историю. Возвращает id."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
INSERT INTO bot.schedules
(template_key, hour, minute, weekdays, timezone, enabled, description, updated_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
data["template_key"],
data["hour"],
data["minute"],
data["weekdays"],
data["timezone"],
data["enabled"],
data["description"],
editor_id,
),
)
row = await cur.fetchone()
schedule_id = int(row["id"])
await conn.execute(
"""
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
VALUES (%s, NULL, %s, %s)
""",
(schedule_id, Jsonb(data), editor_id),
)
return schedule_id
async def update_schedule(schedule_id: int, data: dict[str, Any], *, editor_id: int | None = None) -> bool:
"""Обновить расписание и записать старое/новое значение. False — если записи нет."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE",
(schedule_id,),
)
row = await cur.fetchone()
if row is None:
return False
old = _snapshot(row)
await conn.execute(
"""
UPDATE bot.schedules
SET template_key = %s, hour = %s, minute = %s, weekdays = %s,
timezone = %s, enabled = %s, description = %s,
updated_at = now(), updated_by = %s
WHERE id = %s
""",
(
data["template_key"],
data["hour"],
data["minute"],
data["weekdays"],
data["timezone"],
data["enabled"],
data["description"],
editor_id,
schedule_id,
),
)
await conn.execute(
"""
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
VALUES (%s, %s, %s, %s)
""",
(schedule_id, Jsonb(old), Jsonb(data), editor_id),
)
return True
async def set_enabled(schedule_id: int, enabled: bool, *, editor_id: int | None = None) -> bool:
"""Включить/выключить расписание (тоже с записью в историю)."""
schedule = await get_schedule(schedule_id)
if schedule is None:
return False
data = _snapshot(schedule)
data["enabled"] = enabled
return await update_schedule(schedule_id, data, editor_id=editor_id)
async def delete_schedule(schedule_id: int, *, editor_id: int | None = None) -> bool:
"""Удалить расписание, сохранив последний снимок в истории."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE", (schedule_id,))
row = await cur.fetchone()
if row is None:
return False
await conn.execute(
"""
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
VALUES (%s, %s, NULL, %s)
""",
(schedule_id, Jsonb(_snapshot(row)), editor_id),
)
await conn.execute("DELETE FROM bot.schedules WHERE id = %s", (schedule_id,))
return True
async def schedule_history(schedule_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, old_data, new_data, editor_id, changed_at
FROM bot.schedules_history
WHERE schedule_id = %s
ORDER BY changed_at DESC
LIMIT %s
""",
(schedule_id, limit),
)
return await cur.fetchall()

View File

@ -0,0 +1,404 @@
"""Репозиторий последовательностей сообщений (Блок 4).
Последовательность — несколько сообщений по порядку; у каждого может быть
картинка из общего volume и свои inline-кнопки. Бот отправляет их при входе
участника (пока используется для приветствия).
"""
from __future__ import annotations
from typing import Any
from psycopg.types.json import Jsonb
from common.db.pool import get_pool
#: Максимальный размер загружаемой картинки.
MAX_IMAGE_BYTES = 5 * 1024 * 1024
async def list_sequences() -> list[dict[str, Any]]:
"""Список последовательностей с количеством сообщений."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT s.key, s.title, s.description, s.updated_at, s.updated_by,
count(i.id) AS item_count
FROM bot.message_sequences AS s
LEFT JOIN bot.message_sequence_items AS i ON i.sequence_key = s.key
GROUP BY s.key, s.title, s.description, s.updated_at, s.updated_by
ORDER BY s.key
"""
)
return await cur.fetchall()
async def get_sequence(key: str) -> dict[str, Any] | None:
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT key, title, description, updated_at, updated_by FROM bot.message_sequences WHERE key = %s",
(key,),
)
return await cur.fetchone()
async def get_items(key: str) -> list[dict[str, Any]]:
"""Сообщения последовательности с кнопками, по порядку."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, position, text, enabled, image_name
FROM bot.message_sequence_items
WHERE sequence_key = %s
ORDER BY position
""",
(key,),
)
items = await cur.fetchall()
cur = await conn.execute(
"""
SELECT b.item_id, b.position, b.text, b.url
FROM bot.message_sequence_buttons AS b
JOIN bot.message_sequence_items AS i ON i.id = b.item_id
WHERE i.sequence_key = %s
ORDER BY b.item_id, b.position
""",
(key,),
)
buttons = await cur.fetchall()
by_item: dict[int, list[dict[str, Any]]] = {}
for button in buttons:
by_item.setdefault(int(button["item_id"]), []).append({"text": button["text"], "url": button["url"]})
for item in items:
item["buttons"] = by_item.get(int(item["id"]), [])
return items
async def _snapshot(conn: Any, key: str, editor_id: int | None) -> None:
"""Сохранить снимок последовательности в историю изменений."""
cur = await conn.execute(
"SELECT key, title, description FROM bot.message_sequences WHERE key = %s",
(key,),
)
sequence = await cur.fetchone()
if sequence is None:
return
cur = await conn.execute(
"""
SELECT id, position, text, enabled, image_name
FROM bot.message_sequence_items
WHERE sequence_key = %s
ORDER BY position
""",
(key,),
)
items = await cur.fetchall()
cur = await conn.execute(
"""
SELECT b.item_id, b.position, b.text, b.url
FROM bot.message_sequence_buttons AS b
JOIN bot.message_sequence_items AS i ON i.id = b.item_id
WHERE i.sequence_key = %s
ORDER BY b.item_id, b.position
""",
(key,),
)
buttons = await cur.fetchall()
by_item: dict[int, list[dict[str, Any]]] = {}
for button in buttons:
by_item.setdefault(int(button["item_id"]), []).append(
{"position": button["position"], "text": button["text"], "url": button["url"]}
)
snapshot = {
"sequence": sequence,
"items": [{**item, "buttons": by_item.get(int(item["id"]), [])} for item in items],
}
await conn.execute(
"""
INSERT INTO bot.message_sequence_history (sequence_key, snapshot, editor_id)
VALUES (%s, %s, %s)
""",
(key, Jsonb(snapshot), editor_id),
)
async def create_sequence(
key: str,
title: str,
description: str,
*,
editor_id: int | None = None,
) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.message_sequences WHERE key = %s", (key,))
if await cur.fetchone() is not None:
return False
await conn.execute(
"""
INSERT INTO bot.message_sequences (key, title, description, updated_by)
VALUES (%s, %s, %s, %s)
""",
(key, title, description, editor_id),
)
await _snapshot(conn, key, editor_id)
return True
async def update_sequence(
key: str,
title: str,
description: str,
*,
editor_id: int | None = None,
) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
UPDATE bot.message_sequences
SET title = %s, description = %s, updated_at = now(), updated_by = %s
WHERE key = %s
""",
(title, description, editor_id, key),
)
if not cur.rowcount:
return False
await _snapshot(conn, key, editor_id)
return True
async def add_item(key: str, *, editor_id: int | None = None) -> int | None:
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.message_sequences WHERE key = %s", (key,))
if await cur.fetchone() is None:
return None
cur = await conn.execute(
"SELECT coalesce(max(position), -1) + 1 AS next FROM bot.message_sequence_items WHERE sequence_key = %s",
(key,),
)
row = await cur.fetchone()
position = int(row["next"])
cur = await conn.execute(
"""
INSERT INTO bot.message_sequence_items (sequence_key, position, text, updated_by)
VALUES (%s, %s, '', %s)
RETURNING id
""",
(key, position, editor_id),
)
inserted = await cur.fetchone()
await _snapshot(conn, key, editor_id)
return int(inserted["id"])
async def update_item(
item_id: int,
text: str,
enabled: bool,
buttons: list[tuple[str, str]],
*,
image_name: str | None = None,
update_image: bool = False,
editor_id: int | None = None,
) -> tuple[str, str | None] | None:
"""Обновить сообщение и его кнопки. Возвращает (ключ, старое имя картинки)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT sequence_key, image_name FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
(item_id,),
)
item = await cur.fetchone()
if item is None:
return None
key = str(item["sequence_key"])
old_image = item["image_name"]
if update_image:
await conn.execute(
"""
UPDATE bot.message_sequence_items
SET text = %s, enabled = %s, image_name = %s, updated_at = now(), updated_by = %s
WHERE id = %s
""",
(text, enabled, image_name, editor_id, item_id),
)
else:
await conn.execute(
"""
UPDATE bot.message_sequence_items
SET text = %s, enabled = %s, updated_at = now(), updated_by = %s
WHERE id = %s
""",
(text, enabled, editor_id, item_id),
)
await conn.execute("DELETE FROM bot.message_sequence_buttons WHERE item_id = %s", (item_id,))
for position, (button_text, url) in enumerate(buttons):
await conn.execute(
"""
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
VALUES (%s, %s, %s, %s)
""",
(item_id, position, button_text, url),
)
await _snapshot(conn, key, editor_id)
return key, old_image
async def save_items(
key: str,
items: list[dict[str, Any]],
*,
editor_id: int | None = None,
) -> list[str] | None:
"""Сохранить весь набор сообщений одной транзакцией.
``items`` — по порядку: ``id`` (None для нового), ``text``, ``enabled``,
``image_name``, ``buttons`` (список пар текст/URL). Возвращает имена
картинок, которые больше не используются (их можно удалить с диска).
"""
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT 1 FROM bot.message_sequences WHERE key = %s",
(key,),
)
if await cur.fetchone() is None:
return None
cur = await conn.execute(
"SELECT id, image_name FROM bot.message_sequence_items WHERE sequence_key = %s",
(key,),
)
existing = {int(row["id"]): row["image_name"] for row in await cur.fetchall()}
submitted_ids = {int(item["id"]) for item in items if item.get("id")}
removed_images: list[str] = []
for item_id, image_name in existing.items():
if item_id not in submitted_ids:
await conn.execute("DELETE FROM bot.message_sequence_items WHERE id = %s", (item_id,))
if image_name:
removed_images.append(str(image_name))
# Освобождаем позиции, чтобы не нарушить UNIQUE(sequence_key, position).
await conn.execute(
"UPDATE bot.message_sequence_items SET position = -id WHERE sequence_key = %s",
(key,),
)
for position, item in enumerate(items):
item_id = int(item["id"]) if item.get("id") else None
if item_id is not None and item_id in existing:
old_image = existing[item_id]
if old_image and old_image != item.get("image_name"):
removed_images.append(str(old_image))
await conn.execute(
"""
UPDATE bot.message_sequence_items
SET position = %s, text = %s, enabled = %s, image_name = %s,
updated_at = now(), updated_by = %s
WHERE id = %s
""",
(position, item["text"], item["enabled"], item.get("image_name"), editor_id, item_id),
)
else:
cur = await conn.execute(
"""
INSERT INTO bot.message_sequence_items
(sequence_key, position, text, enabled, image_name, updated_by)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(key, position, item["text"], item["enabled"], item.get("image_name"), editor_id),
)
inserted = await cur.fetchone()
item_id = int(inserted["id"])
await conn.execute("DELETE FROM bot.message_sequence_buttons WHERE item_id = %s", (item_id,))
for button_position, (button_text, url) in enumerate(item.get("buttons") or []):
await conn.execute(
"""
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
VALUES (%s, %s, %s, %s)
""",
(item_id, button_position, button_text, url),
)
await _snapshot(conn, key, editor_id)
return removed_images
async def delete_item(item_id: int, *, editor_id: int | None = None) -> str | None:
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT sequence_key FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
(item_id,),
)
item = await cur.fetchone()
if item is None:
return None
key = str(item["sequence_key"])
await conn.execute("DELETE FROM bot.message_sequence_items WHERE id = %s", (item_id,))
await _snapshot(conn, key, editor_id)
return key
async def move_item(item_id: int, direction: str, *, editor_id: int | None = None) -> str | None:
"""Поменять сообщение с соседним (``up``/``down``)."""
delta = -1 if direction == "up" else 1
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT sequence_key, position FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
(item_id,),
)
item = await cur.fetchone()
if item is None:
return None
key = str(item["sequence_key"])
position = int(item["position"])
cur = await conn.execute(
"""
SELECT id, position FROM bot.message_sequence_items
WHERE sequence_key = %s AND position = %s
FOR UPDATE
""",
(key, position + delta),
)
neighbour = await cur.fetchone()
if neighbour is None:
return key
# Через -1, чтобы не нарушить UNIQUE(sequence_key, position).
await conn.execute("UPDATE bot.message_sequence_items SET position = -1 WHERE id = %s", (item_id,))
await conn.execute(
"UPDATE bot.message_sequence_items SET position = %s WHERE id = %s",
(position, neighbour["id"]),
)
await conn.execute(
"UPDATE bot.message_sequence_items SET position = %s WHERE id = %s",
(position + delta, item_id),
)
await _snapshot(conn, key, editor_id)
return key
async def history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, snapshot, editor_id, changed_at
FROM bot.message_sequence_history
WHERE sequence_key = %s
ORDER BY changed_at DESC
LIMIT %s
""",
(key, limit),
)
return await cur.fetchall()

View File

@ -9,24 +9,21 @@ from common.db.pool import get_pool
async def get_titles_list() -> list[dict[str, Any]]:
"""Доступные к покупке звания (id_title, title, price)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.get_titles_list()")
return await cur.fetchall()
async def get_donats_list() -> list[dict[str, Any]]:
"""Доступные к покупке донаты (id_donat, title, price)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.get_donats_list()")
return await cur.fetchall()
async def buy_title(id_user: int, id_title: int) -> int:
"""Купить звание. Возвращает 1 при успехе, -1 если не хватает денег."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.buy_title(%s, %s) AS res", (id_user, id_title))
row = await cur.fetchone()
return int(row["res"]) if row else -1
@ -34,8 +31,7 @@ async def buy_title(id_user: int, id_title: int) -> int:
async def buy_donat(id_user: int, id_donat: int) -> str:
"""Купить донат. Возвращает промокод или '-1' при неуспехе."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.buy_donat(%s, %s) AS res", (id_user, id_donat))
row = await cur.fetchone()
return str(row["res"]) if row else "-1"

View File

@ -9,7 +9,6 @@ from common.db.pool import get_pool
async def get_top_list() -> list[dict[str, Any]]:
"""Топ-10 пользователей по количеству сообщений (username, nick, count)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()")
return await cur.fetchall()

View File

@ -7,13 +7,11 @@ from common.db.pool import get_pool
async def subscribe(id_user: int) -> None:
"""Подписать пользователя на рассылки."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.subscribe(%s)", (id_user,))
async def unsubscribe(id_user: int) -> None:
"""Отписать пользователя от рассылок."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.unsubscribe(%s)", (id_user,))

View File

@ -0,0 +1,128 @@
"""Репозиторий шаблонов сообщений (Блок 4.1: редактирование в админке).
Каждое изменение текста пишет запись в ``message_template_history`` — это аудит
и материал для отката. Бот подключится к этим данным в Блоке 4.5.
"""
from __future__ import annotations
from typing import Any
from common.db.pool import get_pool
async def load_texts() -> dict[str, str]:
"""Актуальные тексты включённых шаблонов (для кеша бота в ``common.texts.store``)."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT key, text FROM bot.message_templates WHERE enabled ORDER BY key")
rows = await cur.fetchall()
return {row["key"]: row["text"] for row in rows}
async def list_templates() -> list[dict[str, Any]]:
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT key, description, enabled, updated_at, updated_by,
length(text) AS text_length
FROM bot.message_templates
ORDER BY key
"""
)
return await cur.fetchall()
async def get_template(key: str) -> dict[str, Any] | None:
"""Вернуть шаблон целиком (с текстом) или ``None``."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT key, text, description, enabled, updated_at, updated_by
FROM bot.message_templates
WHERE key = %s
""",
(key,),
)
return await cur.fetchone()
async def create_template(
key: str,
text: str,
description: str,
*,
enabled: bool = True,
editor_id: int | None = None,
) -> bool:
"""Создать новый шаблон. False — если ключ уже занят."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.message_templates WHERE key = %s", (key,))
if await cur.fetchone() is not None:
return False
await conn.execute(
"""
INSERT INTO bot.message_templates (key, text, description, enabled, updated_by)
VALUES (%s, %s, %s, %s, %s)
""",
(key, text, description, enabled, editor_id),
)
await conn.execute(
"""
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
VALUES (%s, NULL, %s, %s)
""",
(key, text, editor_id),
)
return True
async def update_template(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any] | None:
"""Обновить текст шаблона и записать историю.
Возвращает ``None``, если шаблона нет. Старое значение читается под
``FOR UPDATE``, поэтому параллельные правки не потеряют историю.
"""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT text FROM bot.message_templates WHERE key = %s FOR UPDATE", (key,))
row = await cur.fetchone()
if row is None:
return None
old_text = row["text"]
if old_text == text:
return {"key": key, "old_text": old_text, "text": text, "changed": False}
await conn.execute(
"""
UPDATE bot.message_templates
SET text = %s, updated_at = now(), updated_by = %s
WHERE key = %s
""",
(text, editor_id, key),
)
await conn.execute(
"""
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
VALUES (%s, %s, %s, %s)
""",
(key, old_text, text, editor_id),
)
return {"key": key, "old_text": old_text, "text": text, "changed": True}
async def template_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
"""Последние изменения шаблона (для блока истории в редакторе)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, old_text, new_text, editor_id, changed_at
FROM bot.message_template_history
WHERE key = %s
ORDER BY changed_at DESC
LIMIT %s
""",
(key, limit),
)
return await cur.fetchall()

View File

@ -0,0 +1,56 @@
"""Справочник званий: чтение, создание, изменение, удаление."""
from __future__ import annotations
from typing import Any
from common.db.pool import get_pool
async def list_titles() -> list[dict[str, Any]]:
"""Все звания с количеством игроков, у которых они стоят."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT t.id_title, t.title, t.price, t.visible,
(SELECT count(*) FROM bot.de_users AS d WHERE d.id_title = t.id_title) AS used
FROM bot.titles AS t
ORDER BY t.price DESC, t.id_title
"""
)
return await cur.fetchall()
async def create_title(title: str, price: int, visible: bool) -> int:
async with get_pool().connection() as conn:
cur = await conn.execute(
"INSERT INTO bot.titles (title, price, visible) VALUES (%s, %s, %s) RETURNING id_title",
(title, price, visible),
)
row = await cur.fetchone()
return int(row["id_title"])
async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"UPDATE bot.titles SET title = %s, price = %s, visible = %s WHERE id_title = %s",
(title, price, visible, title_id),
)
return bool(cur.rowcount)
async def delete_title(title_id: int) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute("DELETE FROM bot.titles WHERE id_title = %s", (title_id,))
return bool(cur.rowcount)
async def usage_count(title_id: int) -> int:
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT count(*) AS total FROM bot.de_users WHERE id_title = %s",
(title_id,),
)
row = await cur.fetchone()
return int(row["total"]) if row else 0

View File

@ -0,0 +1,284 @@
"""Чтение данных пользователей для админки (Блок 4.3).
Баланс — контракт ``SUM(bot.money.amount)``; яички — ``SUM(bot.eggs.amount)``.
Подписка на рассылки — отсутствие строки в ``bot.unsubscribe``.
"""
from __future__ import annotations
from typing import Any
from common.db.pool import get_pool
#: Общая часть выборки: агрегаты по сообщениям, деньгам, яичкам и очищениям.
_FROM = """
FROM bot.tg_users AS tg
LEFT JOIN bot.de_users AS du ON du.id_user = tg.id_user
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
LEFT JOIN (
SELECT user_id, sum(count) AS cnt, max(day) AS last_dt
FROM analytics.messages_daily GROUP BY user_id
) AS msg ON msg.user_id = tg.id_user
LEFT JOIN (
SELECT id_user, sum(amount) AS balance
FROM bot.money GROUP BY id_user
) AS money ON money.id_user = tg.id_user
LEFT JOIN (
SELECT id_user, sum(amount) AS eggs
FROM bot.eggs GROUP BY id_user
) AS eggs ON eggs.id_user = tg.id_user
LEFT JOIN (
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
FROM bot.purifications GROUP BY id_user
) AS pur ON pur.id_user = tg.id_user
LEFT JOIN bot.unsubscribe AS unsub ON unsub.id_user = tg.id_user
"""
_SELECT = """
SELECT tg.id_user, tg.username, tg.first_name, tg.last_name,
du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create,
du.id_title,
t.title,
coalesce(msg.cnt, 0) AS msg_count,
msg.last_dt AS last_message_at,
coalesce(money.balance, 0) AS balance,
coalesce(eggs.eggs, 0) AS eggs,
coalesce(pur.cnt, 0) AS purification_count,
pur.last_dt AS last_purification_at,
(unsub.id_user IS NULL) AS subscribed
"""
#: Колонки, по которым идёт поиск (в порядке подстановки параметров).
_SEARCH_COLUMNS = ("tg.username", "tg.first_name", "tg.last_name", "du.nick")
def _search_clause(term: str) -> tuple[str, list[Any]]:
"""WHERE по нику, username, имени и Telegram-ID."""
term = term.strip()
if not term:
return "", []
like = f"%{term}%"
clauses = [f"{column} ILIKE %s" for column in _SEARCH_COLUMNS]
params: list[Any] = [like] * len(_SEARCH_COLUMNS)
telegram_id = term.lstrip("@")
if telegram_id.isdigit():
clauses.append("tg.id_user = %s")
params.append(int(telegram_id))
return " WHERE (" + " OR ".join(clauses) + ")", params
async def count_users(search: str = "") -> int:
"""Сколько пользователей подходит под поиск."""
where, params = _search_clause(search)
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT count(*) AS total " + _FROM + where, tuple(params))
row = await cur.fetchone()
return int(row["total"]) if row else 0
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
"""Страница списка пользователей (с поиском)."""
where, params = _search_clause(search)
async with get_pool().connection() as conn:
cur = await conn.execute(
_SELECT + _FROM + where + " ORDER BY du.nick NULLS LAST, tg.id_user LIMIT %s OFFSET %s",
(*params, limit, offset),
)
return await cur.fetchall()
async def get_user(user_id: int) -> dict[str, Any] | None:
"""Карточка пользователя со сводными показателями."""
async with get_pool().connection() as conn:
cur = await conn.execute(_SELECT + _FROM + " WHERE tg.id_user = %s", (user_id,))
return await cur.fetchone()
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
"""Последние операции по балансу с названием действия."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT m.id_money, m.amount, m.dt_action, a.title AS action
FROM bot.money AS m
LEFT JOIN bot.actions AS a ON a.id_action = m.id_action
WHERE m.id_user = %s
ORDER BY m.dt_action DESC, m.id_money DESC
LIMIT %s
""",
(user_id, limit),
)
return await cur.fetchall()
# --- Управление пользователем (Блок 4.3) -------------------------------------
#: Действие «Корректировка админом» из V18 (ручное изменение баланса).
ADMIN_ACTION_ID = 6
#: Ограничение длины ника (как в команде /ник).
MAX_NICK_LENGTH = 32
#: Ограничение длины комментария к корректировке баланса.
MAX_COMMENT_LENGTH = 200
async def list_titles() -> list[dict[str, Any]]:
"""Доступные звания для выбора в карточке."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT id_title, title FROM bot.titles WHERE visible ORDER BY price DESC, id_title")
return await cur.fetchall()
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
"""История ручных изменений пользователя."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, field, old_value, new_value, comment, editor_id, source, changed_at
FROM bot.user_changes
WHERE id_user = %s
ORDER BY changed_at DESC
LIMIT %s
""",
(user_id, limit),
)
return await cur.fetchall()
async def _log_change(
conn: Any,
user_id: int,
field: str,
old_value: str | None,
new_value: str | None,
editor_id: int | None,
comment: str | None = None,
source: str = "admin",
) -> None:
await conn.execute(
"""
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id, source)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(user_id, field, old_value, new_value, comment, editor_id, source),
)
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
"""Сменить ник (создаёт запись de_users, если её ещё нет)."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (user_id,))
row = await cur.fetchone()
if row is None:
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (user_id, nick))
old_nick = None
else:
old_nick = row["nick"]
if old_nick == nick:
return True
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, user_id))
await _log_change(conn, user_id, "nick", old_nick, nick, editor_id)
return True
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
"""Сменить звание пользователя. False — если пользователя или звания нет."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT du.id_title, coalesce(t.title, '') AS title
FROM bot.de_users AS du
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
WHERE du.id_user = %s
FOR UPDATE OF du
""",
(user_id,),
)
current = await cur.fetchone()
if current is None:
return False
cur = await conn.execute("SELECT title FROM bot.titles WHERE id_title = %s", (title_id,))
title = await cur.fetchone()
if title is None:
return False
if current["id_title"] == title_id:
return True
await conn.execute("UPDATE bot.de_users SET id_title = %s WHERE id_user = %s", (title_id, user_id))
await _log_change(conn, user_id, "title", current["title"], title["title"], editor_id)
return True
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
"""Подписать или отписать пользователя от рассылок."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
if await cur.fetchone() is None:
return False
cur = await conn.execute("SELECT 1 FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
was_subscribed = await cur.fetchone() is None
if was_subscribed == subscribed:
return True
if subscribed:
await conn.execute("DELETE FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
else:
await conn.execute(
"INSERT INTO bot.unsubscribe (id_user) VALUES (%s) ON CONFLICT DO NOTHING",
(user_id,),
)
await _log_change(
conn,
user_id,
"subscription",
"да" if was_subscribed else "нет",
"да" if subscribed else "нет",
editor_id,
)
return True
async def adjust_balance(
user_id: int,
amount: int,
reason: str,
*,
editor_id: int | None = None,
) -> int | None:
"""Изменить баланс записью в money. Возвращает новый баланс или None."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
if await cur.fetchone() is None:
return None
cur = await conn.execute(
"SELECT coalesce(sum(amount), 0) AS balance FROM bot.money WHERE id_user = %s", (user_id,)
)
row = await cur.fetchone()
old_balance = int(row["balance"])
await conn.execute(
"INSERT INTO bot.money (id_user, amount, id_action) VALUES (%s, %s, %s)",
(user_id, amount, ADMIN_ACTION_ID),
)
new_balance = old_balance + amount
await _log_change(
conn,
user_id,
"balance",
str(old_balance),
str(new_balance),
editor_id,
comment=reason,
)
return new_balance

View File

@ -8,9 +8,8 @@ from common.db.pool import get_pool
async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None:
"""Создать/обновить пользователя и добавить запись о сообщении."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
"""Создать/обновить пользователя (счётчик сообщений ведёт аналитика)."""
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute(
"CALL bot.add_user(%s, %s, %s, %s)",
(id_user, username, first_name, last_name),
@ -19,37 +18,67 @@ async def add_user(id_user: int, username: str, first_name: str, last_name: str)
async def add_nick(id_user: int, nick: str) -> None:
"""Установить ник пользователя."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick))
async def set_nick_with_history(
id_user: int,
nick: str,
*,
editor_id: int | None = None,
source: str = "user",
) -> str | None:
"""Сменить ник и записать изменение в ``user_changes``.
Возвращает старый ник (``None``, если записи не было). Используется командой
``/ник``, чтобы смена ника пользователем была видна в админке.
"""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (id_user,))
row = await cur.fetchone()
if row is None:
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (id_user, nick))
old_nick = None
else:
old_nick = row["nick"]
if old_nick == nick:
return old_nick
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, id_user))
await conn.execute(
"""
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, editor_id, source)
VALUES (%s, 'nick', %s, %s, %s, %s)
""",
(id_user, old_nick, nick, editor_id, source),
)
return old_nick
async def get_nick_list() -> list[dict[str, Any]]:
"""Список всех игроков (username, nick), отсортированный по нику."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT username, nick FROM bot.get_nick_list()")
return await cur.fetchall()
async def get_nick_list_subscribe() -> list[dict[str, Any]]:
"""Список подписанных на рассылки (username, nick)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT username, nick FROM bot.get_nick_list_subscribe()")
return await cur.fetchall()
async def get_info_by_id(id_user: int) -> dict[str, Any] | None:
"""Сводная информация о пользователе."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.get_info_by_id(%s)", (id_user,))
return await cur.fetchone()
async def del_user_by_id(id_user: int) -> None:
"""Удалить пользователя (при выходе из чата)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,))

14
common/texts/alerts.py Normal file
View File

@ -0,0 +1,14 @@
"""Тексты уведомлений в чат логов (``ALERT_CHAT_ID``).
Служебные сообщения для админов (не для участников), поэтому в ``TEMPLATES``
(редактируемые шаблоны Блока 4) они не входят.
"""
from __future__ import annotations
MSG_JOIN = "[вход] {username} вошёл в чат «{chat_title}»"
MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»"
MSG_KICK = "[кик] {actor} выгнал {username} из чата «{chat_title}»"
MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}"
MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}"
MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат"

View File

@ -9,7 +9,7 @@ MSG_INFO = """<b>👤Информация о пользователе {username}
<b>💰 Деньги:</b> {money}
<b>📈 Начало активности в боте:</b> {dt}
<b>📊 Количество сообщений в чате:</b> {msg_count}
<b>🥚 Количество яичек:</b> {eggs}
{eggs_line}
"""
MSG_HELP = """<b><u>Список команд бота</u></b>

View File

@ -18,13 +18,12 @@ from typing import Any
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
RAW_TAGS: frozenset[str] = frozenset(
{"usernames", "users_list", "users_list_count", "tops"}
)
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops", "eggs_line"})
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
TAG_REGISTRY: dict[str, str] = {
"username": "Имя пользователя (@username или имя)",
"actor": "Кто выполнил действие (для уведомлений)",
"nick": "Игровой ник",
"title": "Звание",
"lvl": "Уровень",
@ -34,6 +33,7 @@ TAG_REGISTRY: dict[str, str] = {
"dt": "Дата начала активности",
"msg_count": "Количество сообщений",
"eggs": "Количество яичек",
"eggs_line": "Готовая строка про яички (пусто вне пасхального события)",
"h": "Часы до следующего действия",
"m": "Минуты до следующего действия",
"s": "Секунды до следующего действия",
@ -44,6 +44,10 @@ TAG_REGISTRY: dict[str, str] = {
"users_list_count": "Количество игроков в списке",
"tops": "Топ игроков по сообщениям",
"promo": "Промокод доната",
# --- Служебные теги уведомлений в чат логов (ALERT_CHAT_ID) ---
"chat_title": "Название чата",
"version": "Версия бота",
"uptime": "Время работы бота",
}
SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY)

98
common/texts/store.py Normal file
View File

@ -0,0 +1,98 @@
"""Шаблоны сообщений из БД с TTL-кешем (Блок 4.5).
Бот читает тексты из ``bot.message_templates``: правка в админке применяется
без перезапуска — в течение ``TEMPLATE_CACHE_TTL`` секунд. Если БД недоступна
(или таблицы ещё нет), используются тексты по умолчанию из ``common.texts.TEMPLATES``.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from common.config import get_settings
from common.texts import TEMPLATES
logger = logging.getLogger(__name__)
#: Загрузчик «ключ -> текст» (по умолчанию — из Postgres).
Loader = Callable[[], Awaitable[dict[str, str]]]
async def _load_from_db() -> dict[str, str]:
# Импорт внутри функции: не тянем слой БД на этапе импорта текстов.
from common.repositories.templates_repo import load_texts
return await load_texts()
class TemplateStore:
"""Кеш шаблонов с временем жизни; при ошибке БД — тексты по умолчанию."""
def __init__(self, loader: Loader | None = None, ttl: float | None = None) -> None:
self._loader = loader or _load_from_db
self._ttl = ttl
self._texts: dict[str, str] = {}
self._loaded_at: float | None = None
self._lock = asyncio.Lock()
@property
def ttl(self) -> float:
"""TTL кеша в секундах (из настроек, если не задан явно)."""
if self._ttl is None:
self._ttl = get_settings().template_cache_ttl
return max(self._ttl, 0.0)
async def get(self, key: str) -> str:
"""Вернуть текст шаблона (из БД или из кода, если его там нет)."""
await self._ensure_fresh()
text = self._texts.get(key)
if text is None:
return TEMPLATES.get(key, "")
return text
def clear(self) -> None:
"""Сбросить кеш: следующий ``get`` перечитает БД."""
self._loaded_at = None
async def _ensure_fresh(self) -> None:
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
return
async with self._lock:
# Пока ждали лок, кеш мог обновить другой вызов.
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
return
try:
texts = await self._loader()
except Exception:
logger.warning(
"Не удалось прочитать шаблоны из БД — использую тексты по умолчанию",
exc_info=True,
)
# Не долбим БД каждым сообщением: следующая попытка через TTL.
self._loaded_at = time.monotonic()
return
self._texts = texts
self._loaded_at = time.monotonic()
logger.debug("Шаблоны обновлены из БД: %s", len(texts))
_store: TemplateStore | None = None
def get_template_store() -> TemplateStore:
"""Вернуть общий кеш шаблонов процесса."""
global _store
if _store is None:
_store = TemplateStore()
return _store
async def get_text(key: str) -> str:
"""Короткий доступ к шаблону: ``await get_text("info.help")``."""
return await get_template_store().get(key)

View File

@ -25,6 +25,12 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с"
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
MSG_EGGS_TOO_EARLY = "Слишком рано..."
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
MSG_START = "Привет! Это бот Dispel Evil."
MSG_LOGIN_CONFIRMED = "Вход в админку подтверждён. Вернитесь на сайт."
MSG_LOGIN_DENIED = "У вас нет доступа к админке."
MSG_LOGIN_INVALID = "Ссылка для входа устарела. Начните вход заново на сайте."
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"

View File

@ -36,3 +36,18 @@ MSG_WELCOME_TEXT3 = """❗️<b>По игре</b>:
MSG_WELCOME_BUTTON_CAPTION = "Канал"
MSG_WELCOME_BUTTON_URL = "https://t.me/+DQk8MyN2y6QzNTM6"
def split_welcome(*parts: str) -> tuple[str, str]:
"""Разделить приветствие на подпись к фото и одно общее сообщение.
Первая строка первого шаблона становится подписью (персональное приветствие),
всё остальное (включая ``text2``/``text3``) — одним сообщением по порядку.
"""
if not parts:
return "", ""
caption, _, rest = parts[0].partition("\n")
body_parts = [rest.rstrip(), *parts[1:]]
body = "\n\n".join(part.strip() for part in body_parts if part and part.strip())
return caption.strip(), body

View File

@ -2,9 +2,9 @@
Единый источник правды. Git-тег совпадает с версией с префиксом ``v``
(например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут
тегироваться этой версией, а события в ClickHouse получат поле ``bot_version``.
тегироваться этой версией, а события аналитики получат поле ``bot_version``.
"""
from __future__ import annotations
__version__ = "0.2"
__version__ = "0.7.2"

View File

@ -46,6 +46,37 @@ services:
environment:
DB_HOST: postgres
DB_PORT: "5432"
volumes:
- media:/app/media
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Админка (FastAPI + Jinja2/HTMX), образ из Gitea Registry.
# Наружу пока не публикуется: только loopback хоста; домен/TLS — Блок 2.4/4.2.
admin:
image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecex/dispelevilbot-admin:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file:
- ${ENV_FILE:-env/prod.env}
environment:
DB_HOST: postgres
DB_PORT: "5432"
ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000"
volumes:
- media:/app/media
ports:
# По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1:
# порт остаётся недоступен из интернета, но виден контейнерам на bridge.
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8101}:8000"
depends_on:
postgres:
condition: service_healthy
@ -74,5 +105,6 @@ services:
volumes:
pgdata:
media:
# caddy_data:
# caddy_config:

View File

@ -51,6 +51,38 @@ services:
# Внутри сети compose БД доступна по имени сервиса.
DB_HOST: postgres
DB_PORT: "5432"
volumes:
- media:/app/media
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Админка (FastAPI + HTMX). Порт — только loopback: наружу выйдет через reverse proxy
# вместе с TLS и доменом (Блок 2.4/4.2).
admin:
build:
context: .
dockerfile: admin/Dockerfile
image: dispelevilbot-admin:local
restart: unless-stopped
env_file:
- .env
environment:
DB_HOST: postgres
DB_PORT: "5432"
ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000"
volumes:
- media:/app/media
ports:
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000"
depends_on:
postgres:
condition: service_healthy
@ -64,3 +96,4 @@ services:
volumes:
pgdata:
media:

127
docs/events.md Normal file
View File

@ -0,0 +1,127 @@
# События аналитики (Postgres)
Бот пишет значимые действия в Postgres, схема `analytics`. Дашборд (Superset) и ETL —
отдельный финальный этап; здесь описан **контракт** событий: какие бывают `event_type`
и что лежит в `payload`.
> Источник правды в коде — `common/analytics/events.py` (`EventType`).
> Расхождение со списком ниже ловит `tests/test_analytics.py`.
> Добавляешь событие — добавь строку в таблицу.
## Схема в Postgres
DDL — `sql/V26__analytics_schema.sql`.
- `analytics.events` — события, `PARTITION BY RANGE (event_time)` по дням.
Партиции создаёт приложение (бот и админка) на несколько дней вперёд;
**старые партиции не удаляются** — история хранится бессрочно.
- `analytics.messages_daily` — количество сообщений пользователя по дням:
`day`, `user_id`, `username`, `chat_id`, `count`; PK `(day, user_id, chat_id)`.
На каждое сообщение выполняется `INSERT ... ON CONFLICT DO UPDATE count + 1`,
поэтому вместо строки на каждое сообщение растёт только счётчик за день.
Сообщения **не** пишутся в `analytics.events` отдельным событием `message`.
Построчная таблица `bot.messages` удалена миграцией `V27`: старые строки один раз
пересчитаны в `analytics.messages_daily`, новые пишутся только счётчиком.
## Хранение
| Что | Где | Сколько |
| --- | --- | --- |
| События | Postgres, `analytics.events` | бессрочно (партиции по дням) |
| Счётчики сообщений | Postgres, `analytics.messages_daily` | бессрочно (партиции по дням) |
Бэкап — общий бэкап Postgres. Отдельного пайплайна ClickHouse/Parquet больше нет.
При росте базы нужно следить за диском: автоматической очистки нет.
## Уведомления в чат логов
Помимо записи в Postgres часть событий дублируется сообщением в `ALERT_CHAT_ID`
(если он задан; `0` — выключено). Отправка не критична: ошибки только логируются.
| Уведомление | Когда | Событие |
| --- | --- | --- |
| Вход участника | кто-то вошёл в основной чат | `join` |
| Выход участника | кто-то вышел из основного чата | `leave` |
| Очищение | успешное `/очищение` (`+опыт`, `+монеты`) | `purification` |
| Пинг «бот живой» | раз в час | `bot_alive` |
| Попытка доступа извне | не участник чата стучится в бота | `access_denied` |
## Поля события
| Поле | Тип | Описание |
| --- | --- | --- |
| **event_time** | `timestamptz` | Момент события (UTC) |
| **event_type** | `text` | Тип события (см. каталог ниже) |
| **user_id** | `bigint` | Telegram-ID пользователя (0 — системное событие) |
| **username** | `text` | `@username` на момент события (может быть пустым) |
| **chat_id** | `bigint` | ID чата (обычно основной чат клана) |
| **payload** | `jsonb` | Дополнительные данные события |
| **bot_version** | `text` | Версия бота из `common/version.py` |
| **prod** | `boolean` | `true` — прод-контур, `false` — тестовый |
## Каталог событий
| Тип события | Когда пишется | `payload` |
| --- | --- | --- |
| `command` | Использование команды (`/инфо`, `/очищение`, …) | `command`, `args_count` |
| `purification` | Ежедневное очищение (`/очищение`) | `success`, `exp_add`, `money_add` |
| `exp_accrual` | Начисление опыта за очищение | `amount`, `streak` |
| `level_up` | Повышение уровня по итогам очищения | `level_from`, `level_to` |
| `money_accrual` | Начисление валюты (монетка за активность, награда за очищение) | `amount`, `reason`, `action_id` |
| `money_spend` | Трата валюты (покупка, обнуление баланса при удалении) | `amount`, `reason`, `product_id` |
| `eggs` | Получение яичек (`/яички`) | `amount` |
| `purchase_title` | Покупка звания | `title_id`, `price`, `success` |
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
| `join` | Вход участника в основной чат | `chat_title` |
| `leave` | Выход/исключение участника | `chat_title`, `kicked`, `by_id`, `by_username` |
| `nick_set` | Смена ника (`/ник`) | `nick` |
| `subscribe` | Подписка на рассылки (`/подписка`) | — |
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — |
| `access_denied` | Отказ в доступе (не участник чата и не админ) | `reason` |
| `bot_start` | Старт бота | `app_env` |
| `bot_stop` | Остановка бота | `app_env`, `uptime_seconds` |
| `bot_alive` | Ежечасный пинг «бот живой» | `app_env`, `uptime_seconds` |
| `error` | Необработанная ошибка при обработке апдейта | `error_type`, `error`, `update_type` |
## Поведение при недоступности Postgres
Аналитика — **некритичная** подсистема: её сбой не должен влиять на бота.
- события и счётчики копятся в буфере и записываются батчами (по размеру и по таймеру);
- если Postgres недоступен, буфер не растёт бесконечно: при переполнении самые
старые события отбрасываются;
- если записать батч не удалось, он выводится в stdout (`WARNING`, первые несколько
строк + общее количество) и отбрасывается — данные не пропадают молча;
- при остановке бота выполняется финальный сброс буфера;
- выключение целиком — `ANALYTICS_ENABLED=false` (тогда `record()`/`record_message()` — no-op).
## Примеры запросов
```sql
-- события по дням
SELECT date_trunc('day', event_time) AS day, count(*)
FROM analytics.events GROUP BY day ORDER BY day;
-- топ команд за неделю
SELECT payload->>'command' AS cmd, count(*) AS uses
FROM analytics.events
WHERE event_type = 'command' AND event_time >= now() - interval '7 days'
GROUP BY cmd ORDER BY uses DESC;
-- покупки за месяц
SELECT event_type, count(*), sum((payload->>'price')::int)
FROM analytics.events
WHERE event_type IN ('purchase_title', 'purchase_donat')
AND event_time >= now() - interval '30 days'
GROUP BY event_type;
-- сообщения по дням
SELECT day, sum(count) FROM analytics.messages_daily
WHERE day >= current_date - 30 GROUP BY day ORDER BY day;
-- ошибки
SELECT event_time, payload->>'error_type', payload->>'error'
FROM analytics.events WHERE event_type = 'error'
ORDER BY event_time DESC LIMIT 50;
```

Some files were not shown because too many files have changed in this diff Show More