Compare commits

...

23 Commits

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
111 changed files with 5698 additions and 1501 deletions

View File

@ -35,15 +35,9 @@ ADMIN_IDS=
# Часовой пояс для расписаний # Часовой пояс для расписаний
TIMEZONE=Europe/Moscow TIMEZONE=Europe/Moscow
# Аналитика (ClickHouse). Логируются все действия бота (см. docs/events.md). # Аналитика (Postgres, схема analytics). Логируются действия бота (см. docs/events.md).
# CLICKHOUSE_ENABLED=false — полностью выключить аналитику. # ANALYTICS_ENABLED=false — полностью выключить аналитику.
CLICKHOUSE_ENABLED=true ANALYTICS_ENABLED=true
# Для локального запуска — localhost; в compose бот ходит по имени сервиса.
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_DB=analytics
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
# Батчинг записи событий: размер батча, интервал сброса (сек), предел буфера. # Батчинг записи событий: размер батча, интервал сброса (сек), предел буфера.
ANALYTICS_BATCH_SIZE=200 ANALYTICS_BATCH_SIZE=200
ANALYTICS_FLUSH_INTERVAL=5.0 ANALYTICS_FLUSH_INTERVAL=5.0

View File

@ -16,16 +16,14 @@ cd DispelEvilBot
```bash ```bash
cp env/test.env.example env/test.env cp env/test.env.example env/test.env
# заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.), # заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.),
# ADMIN_IDS (опц.), CLICKHOUSE_PASSWORD (аналитика событий) # ADMIN_IDS (опц.)
# плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен), # плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен),
# ADMIN_SESSION_SECRET (openssl rand -hex 32) # ADMIN_SESSION_SECRET (openssl rand -hex 32)
``` ```
Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться: Порт админки (`ADMIN_HTTP_PORT`) у контуров должен различаться: `prod` — `8101`,
`prod` — `8123`, `test` — `8124` (иначе контейнеры конфликтуют на одной VPS). `test` — `8100` (8000 на VPS занят Portainer). `ADMIN_BIND_IP` по умолчанию `127.0.0.1`;
Порт админки (`ADMIN_HTTP_PORT`) — так же: `prod` — `8101`, `test` — `8100` чтобы nginx-proxy-manager (контейнер) видел админку, ставьте `172.17.0.1`.
(8000 на VPS занят Portainer). `ADMIN_BIND_IP` по умолчанию `127.0.0.1`; чтобы
nginx-proxy-manager (контейнер) видел админку, ставьте `172.17.0.1`.
## 3. Логин в registry (если пакет приватный) ## 3. Логин в registry (если пакет приватный)
@ -55,35 +53,36 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs -
1. `postgres` поднимается и проходит healthcheck; 1. `postgres` поднимается и проходит healthcheck;
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля; 2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
3. `clickhouse` поднимается и применяет `clickhouse/init/001_events.sql` — 3. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же;
**только при первой инициализации** пустого data-каталога; 4. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста).
4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же;
5. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста).
Проверка контура (сервисы, миграции, Postgres, ClickHouse): Проверка контура (сервисы, миграции, Postgres, схема аналитики):
```bash ```bash
make check E=test make check E=test
``` ```
## ClickHouse (аналитика событий) ## Аналитика (Postgres)
События всех действий бота пишутся в `analytics.events` (контракт — [`docs/events.md`](docs/events.md)). События и дневные счётчики сообщений пишутся в схему `analytics` той же БД Postgres
ClickHouse наружу **не публикуется**: только loopback хоста (`127.0.0.1:${CLICKHOUSE_HTTP_PORT}`). (контракт — [`docs/events.md`](docs/events.md)). Отдельного сервиса нет.
```bash ```bash
make ch E=test # clickhouse-client контура make psql E=test
# пример: события за сегодня по типам # пример: события за сегодня по типам
docker compose -p test --env-file env/test.env -f docker-compose.prod.yml exec -T clickhouse \ SELECT event_type, count(*) FROM analytics.events
sh -lc 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" \ WHERE event_time >= current_date GROUP BY event_type;
--query "SELECT event_type, count() FROM analytics.events WHERE toDate(event_time) = today() GROUP BY event_type"'
# сообщения по дням
SELECT day, sum(count) FROM analytics.messages_daily
WHERE day >= current_date - 7 GROUP BY day ORDER BY day;
``` ```
Если ClickHouse недоступен, бот продолжает работать: события уходят в stdout (WARNING), Если Postgres недоступен, бот продолжает работать: батч аналитики уходит в stdout
полностью выключить можно флагом `CLICKHOUSE_ENABLED=false`. (WARNING) и отбрасывается. Полностью выключить можно флагом `ANALYTICS_ENABLED=false`.
Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге, Партиции создаются приложением при старте и раз в 6 часов; старые **не удаляются**.
поэтому на существующем контуре DDL нужно применить вручную (`make ch E=...`). Схема создаётся миграцией `V26__analytics_schema.sql`.
## Админка (FastAPI) ## Админка (FastAPI)
@ -119,51 +118,11 @@ reverse proxy. Сессия живёт 12 часов (`ADMIN_SESSION_MAX_AGE`),
Роль администратора перечитывается из БД на каждом запросе — отключение пользователя Роль администратора перечитывается из БД на каждом запросе — отключение пользователя
(`enabled = false`) или удаление записи сразу закрывает доступ. (`enabled = false`) или удаление записи сразу закрывает доступ.
## Бэкап событий (забирает домашний сервер) ## Бэкап
Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает** Аналитика теперь лежит в Postgres, поэтому отдельного бэкапа событий больше нет.
вчерашнюю партицию (`pull`), складывает её в `Parquet.gz`, импортирует в свой ClickHouse и Бэкап БД — стандартными средствами Postgres (`pg_dump`/`pg_basebackup`); при росте
сверяет количество строк — **только после сверки** на VPS удаляются партиции старше истории следите за размером `analytics` и диском.
`BACKUP_GRACE_DAYS` (по умолчанию 7). На VPS остаются агрегаты (`events_daily`, `money_daily`).
Входящий SSH на домашний сервер не нужен: он инициирует соединение сам.
Разовая настройка:
```bash
# на домашнем сервере
git clone ssh://git@<gitea-host>:2222/mpakobecex/DispelEvilBot.git
cd DispelEvilBot
cp env/backup.env.example env/backup.env # VPS_SSH, VPS_PORT, VPS_PROJECT_DIR, VPS_SSH_KEY, BACKUP_DIR, CLICKHOUSE_*
```
Требования: на VPS — пользователь с доступом к `docker` и каталог с репозиторием
(`VPS_PROJECT_DIR`, там запускается `docker compose`); на домашнем сервере — `clickhouse-client`
и локальный ClickHouse. Настройка SSH-доступа — `deploy/ssh-access.md`.
Если ставить `clickhouse-client` на хост не хочется, укажите в `env/backup.env` переменную
`CLICKHOUSE_CLIENT` — путь к обёртке, которая выполняет клиент внутри контейнера:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
exec docker compose --env-file env/backup.env -f docker-compose.yml exec -T clickhouse clickhouse-client "$@"
```
Проверочный прогон (без cron): `make backup E=test` или `./scripts/clickhouse_backup.sh test`.
Cron на домашнем сервере (вчерашний день уже закрыт, время московское):
```cron
0 4 * * * cd /srv/DispelEvilBot && ./scripts/clickhouse_backup.sh test >> /var/log/clickhouse-backup-test.log 2>&1
```
Ошибка на любом шаге оставляет данные на VPS (партиции не удаляются) и пишет понятный лог.
Импорт дня идемпотентен: повторный прогон не дублирует строки.
Если контур на VPS не запущен (нет работающего `clickhouse` или не найден каталог проекта),
скрипт пишет об этом в лог и завершается с кодом 0 — для cron это не ошибка.
## Обновление и откат ## Обновление и откат
@ -184,13 +143,13 @@ docker compose -p <контур> --env-file env/<контур>.env -f docker-com
## Переменные окружения ## Переменные окружения
См. `env/prod.env.example` / `env/test.env.example` (контуры) и `env/backup.env.example` (бэкап событий). См. `env/prod.env.example` / `env/test.env.example` (контуры).
## Дашборд (позже) ## Дашборд (позже)
Superset поднимается на домашнем сервере и читает **локальный** ClickHouse с полной Superset поднимается отдельно и читает Postgres (схема `analytics`) с полной историей
историей событий (сюда ежедневно приезжают архивы). Отдельный read-only пользователь событий. Отдельный read-only пользователь Postgres и сам дашборд — финальный этап,
ClickHouse и сам дашборд — финальный этап, в этот контур не входят. в этот контур не входят.
## Перенос данных на прод (позже, короткое окно простоя) ## Перенос данных на прод (позже, короткое окно простоя)

View File

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

View File

@ -9,7 +9,7 @@ COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.RECIPEPREFIX := > .RECIPEPREFIX := >
.PHONY: help up pull down restart logs ps images check psql ch backup .PHONY: help up pull down restart logs ps images check psql
help: help:
> @echo "Контур задаётся через E=test|prod (по умолчанию test):" > @echo "Контур задаётся через E=test|prod (по умолчанию test):"
@ -22,8 +22,6 @@ help:
> @echo " make images E=test образы контура" > @echo " make images E=test образы контура"
> @echo " make check E=test проверка контура (скрипт)" > @echo " make check E=test проверка контура (скрипт)"
> @echo " make psql E=test psql в БД контура" > @echo " make psql E=test psql в БД контура"
> @echo " make ch E=test clickhouse-client контура"
> @echo " make backup E=test бэкап событий с VPS (запускать на домашнем сервере)"
up: up:
> $(COMPOSE) up -d > $(COMPOSE) up -d
@ -51,9 +49,3 @@ check:
psql: psql:
> $(COMPOSE) exec postgres sh -lc 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"' > $(COMPOSE) exec postgres sh -lc 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'
ch:
> $(COMPOSE) exec clickhouse sh -lc 'clickhouse-client --user "$$CLICKHOUSE_USER" --password "$$CLICKHOUSE_PASSWORD" --database analytics'
backup:
> ./scripts/clickhouse_backup.sh $(E)

117
README.md
View File

@ -9,10 +9,9 @@ Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепо
common/ # общий пакет: config, logging, db, repositories, models, texts, analytics common/ # общий пакет: config, logging, db, repositories, models, texts, analytics
bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications
admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile
sql/ # миграции Flyway (V__*.sql, R__*.sql) sql/ # миграции Flyway (V__*.sql, R__*.sql), включая схему analytics
clickhouse/ # init-скрипты ClickHouse (схема аналитики)
docs/ # документация (docs/events.md — события аналитики) docs/ # документация (docs/events.md — события аналитики)
scripts/ # эксплуатационные скрипты (проверка контура, бэкап событий) scripts/ # эксплуатационные скрипты (проверка контура)
images/ # статичные картинки (приветствие) images/ # статичные картинки (приветствие)
deploy/ # материалы деплоя (Caddyfile, инструкции) deploy/ # материалы деплоя (Caddyfile, инструкции)
tests/ # тесты tests/ # тесты
@ -24,8 +23,7 @@ docker-compose.yml / docker-compose.prod.yml
## Требования ## Требования
- Python 3.12 - Python 3.12
- PostgreSQL 14+ - PostgreSQL 14+ (аналитика — в схеме `analytics`)
- ClickHouse 25.3 (аналитика событий; можно выключить — `CLICKHOUSE_ENABLED=false`)
## Настройка ## Настройка
@ -55,12 +53,10 @@ APP_ENV=prod python -m bot # prod
## Админка (Блок 4) ## Админка (Блок 4)
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`. Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение Все тексты бота — **шаблоны сообщений** (`bot.message_sequences`): миграция `V24`
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты — переносит каждый старый шаблон в последовательность из одного сообщения, а бот
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их). отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
Предпросмотр использует тот же `render()`, что и бот.
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ **Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе). есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
@ -68,9 +64,16 @@ APP_ENV=prod python -m bot # prod
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`. `BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
Дополнительно — **вход через установленный Telegram**: кнопка «Открыть Telegram»
создаёт одноразовый токен (5 минут) и открывает `t.me/<bot>?start=login_<token>`;
бот подтверждает вход, сайт забирает токен и открывает сессию. Токен в БД хранится
как SHA-256 и используется один раз.
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели, Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`). база недоступна — использует расписание из кода (совпадает с сидом `V16`). Каждая
отправленная минута фиксируется в `bot.schedule_runs`, поэтому перезапуск бота в ту же
минуту не повторяет рассылку.
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка: Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения, уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
@ -79,21 +82,48 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
`bot.user_changes`: кто, когда, было → стало и причина. `bot.user_changes`: кто, когда, было → стало и причина.
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username) Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON, видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом; не стоит; используемое — скрывается (`visible = false`).
если он недоступен, страница показывает ошибку, а не падает.
## Аналитика (ClickHouse) Раздел «Логи» — события из Postgres и отдельная вкладка «Сообщения по дням»: фильтры
по типу (для событий), пользователю (ID или username) и датам, пагинация по 50.
Время показывается в `TIMEZONE`, payload раскрывается как JSON, ID пользователя ведёт
в его карточку. Если Postgres недоступен, страница показывает ошибку, а не падает.
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
Контракт событий — [`docs/events.md`](docs/events.md) (типы, payload, примеры запросов). В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
пользователь через `/ник` или админ.
- схема — `clickhouse/init/001_events.sql` (применяется при первой инициализации контейнера); Раздел «Шаблоны сообщений» — набор сообщений по порядку (приветствие и любые другие):
- клиент и батчинг — `common/analytics/` (запись неблокирующая, сбой ClickHouse не влияет на бота); у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
- сырые события живут на VPS 7 дней и ежедневно уезжают архивом на домашний сервер, Все правки — текст, кнопки, картинки, добавление/удаление и порядок сообщений —
на VPS остаются агрегаты (`events_daily`, `money_daily`) — см. [DEPLOY.md](DEPLOY.md); хранятся на странице и применяются одной кнопкой «Сохранить всё».
- выключается флагом `CLICKHOUSE_ENABLED=false`. Редактор текста — 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`.
## Уведомления в чат логов ## Уведомления в чат логов
@ -102,7 +132,7 @@ ID пользователя ведёт в его карточку. ClickHouse ч
## Docker ## Docker
Локальный запуск (postgres + migrate + clickhouse + bot + admin): Локальный запуск (postgres + migrate + bot + admin):
```bash ```bash
cp .env.example .env # заполнить cp .env.example .env # заполнить
@ -112,7 +142,8 @@ docker compose up --build
CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose
по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 / по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 /
prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет
вместе с доменом и TLS (Блок 4.2). вместе с доменом и TLS (Блок 4.2). Картинки шаблонов лежат в общем
Docker-volume `media` (`/app/media`), который монтируется и боту, и админке.
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом: Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
@ -132,22 +163,17 @@ black --check .
pytest pytest
``` ```
## Бэкап событий ## Бэкап
Сырые события ClickHouse ежедневно забираются с VPS домашним сервером (`pull`), на VPS Аналитика теперь в Postgres, отдельного бэкапа событий нет. Бэкап БД — стандартными
остаются только агрегаты и последние дни сырых данных: средствами Postgres; история `analytics` не чистится, поэтому следите за размером БД
и диском. Подробности — [`DEPLOY.md`](DEPLOY.md).
```bash
make backup E=test # на домашнем сервере: ./scripts/clickhouse_backup.sh test
```
Настройка, cron и SSH-доступ — см. [`DEPLOY.md`](DEPLOY.md).
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.11`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.7.2`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Схема — 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`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
## Процесс работы с git ## Процесс работы с git
@ -174,6 +200,13 @@ git tag -a vX.Y -m "Release vX.Y"
git push origin master --tags git push origin master --tags
``` ```
## Приветствие новых участников
Бот отправляет фото с короткой персональной подписью (первая строка шаблона `welcome.text`),
а затем одно сообщение со всем остальным текстом (`welcome.text` без первой строки +
`welcome.text2` + `welcome.text3`) и кнопкой на канал. Так приветствие остаётся одним
смысловым блоком, но не упирается в лимит подписи к фото (1024 символа).
## Пасхальное событие «Яички» ## Пасхальное событие «Яички»
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху** `/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**
@ -187,8 +220,12 @@ git push origin master --tags
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`, `/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
`/магазин`, `/рейтинг`, `/отписка`, `/подписка`. `/магазин`, `/рейтинг`, `/отписка`, `/подписка`.
В меню Telegram показываются латинские алиасы (`/help`, `/info`, `/nick`, `/list`,
`/who`, `/purify`, `/eggs`, `/shop`, `/top`, `/sub`, `/unsub`) с русскими описаниями:
Telegram не принимает кириллицу в именах команд. Русские команды работают при вводе
вручную. Набор сверяется с ожидаемым при каждом старте бота (`ensure_commands`).
## Переменные окружения ## Переменные окружения
- общие — `.env.example` (включая `CLICKHOUSE_*` и параметры батчинга `ANALYTICS_*`); - общие — `.env.example` (включая `ANALYTICS_*`);
- контуры — `env/prod.env.example`, `env/test.env.example`; - контуры — `env/prod.env.example`, `env/test.env.example`.
- бэкап событий — `env/backup.env.example`.

View File

@ -17,6 +17,9 @@ RUN pip install --no-cache-dir -r requirements-admin.txt
COPY common ./common COPY common ./common
COPY admin ./admin COPY admin ./admin
COPY images ./images
RUN mkdir -p /app/media && chown app:app /app/media
USER app USER app

View File

@ -12,10 +12,11 @@ from fastapi import FastAPI
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from admin.media import ensure_media
from admin.middleware import AdminAuthMiddleware from admin.middleware import AdminAuthMiddleware
from admin.routes import auth, health, logs, messages, pages, schedules, users from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, titles, users
from common import __version__ from common import __version__
from common.analytics import read as analytics_read from common.analytics.partitions import ensure_partitions
from common.config import AdminSettings, get_admin_settings from common.config import AdminSettings, get_admin_settings
from common.db.pool import close_pool, init_pool from common.db.pool import close_pool, init_pool
from common.logging_config import setup_logging from common.logging_config import setup_logging
@ -40,6 +41,11 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
if init_db: if init_db:
await init_pool(app.state.settings) 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: try:
inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids) inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids)
if inserted: if inserted:
@ -54,7 +60,6 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
finally: finally:
if init_db: if init_db:
await close_pool() await close_pool()
await analytics_read.close_client()
app = FastAPI( app = FastAPI(
title="DispelEvilBot Admin", title="DispelEvilBot Admin",
@ -87,8 +92,12 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(messages.router) app.include_router(preview.router)
app.include_router(sequences.router)
app.include_router(titles.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(admins.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(send.router)
return app 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

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

View File

@ -15,7 +15,15 @@ from common.repositories import admins_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Эндпоинты без авторизации. #: Эндпоинты без авторизации.
PUBLIC_PATHS = frozenset({"/healthz", "/login", "/auth/telegram"}) PUBLIC_PATHS = frozenset(
{
"/healthz",
"/login",
"/auth/telegram",
"/auth/deeplink/start",
"/auth/deeplink/status",
}
)
class AdminAuthMiddleware(BaseHTTPMiddleware): class AdminAuthMiddleware(BaseHTTPMiddleware):

View File

@ -12,6 +12,7 @@ from common import __version__
SAMPLE_CONTEXT: dict[str, Any] = { SAMPLE_CONTEXT: dict[str, Any] = {
"username": "@test_user", "username": "@test_user",
"actor": "@admin_user",
"nick": "ТестовыйНик", "nick": "ТестовыйНик",
"title": "Ветеран", "title": "Ветеран",
"lvl": 3, "lvl": 3,

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)

View File

@ -6,11 +6,11 @@ import logging
import secrets import secrets
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
from common import __version__ from common import __version__
from common.repositories import admins_repo from common.repositories import admin_login_tokens_repo, admins_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,11 +45,81 @@ async def login_page(request: Request, next: str = "/") -> Response:
"bot_username": settings.bot_username, "bot_username": settings.bot_username,
"public_url": settings.admin_public_url.rstrip("/"), "public_url": settings.admin_public_url.rstrip("/"),
"configured": bool(settings.bot_token and settings.bot_username), "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, "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") @router.get("/auth/telegram")
async def telegram_callback(request: Request) -> Response: async def telegram_callback(request: Request) -> Response:
"""Проверить подпись Telegram, роль в БД и открыть сессию.""" """Проверить подпись Telegram, роль в БД и открыть сессию."""

View File

@ -1,4 +1,4 @@
"""Раздел «Логи»: события из ClickHouse с фильтрами и пагинацией.""" """Раздел «Логи»: события и дневные счётчики сообщений из Postgres."""
from __future__ import annotations from __future__ import annotations
@ -19,16 +19,18 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/logs", tags=["logs"]) router = APIRouter(prefix="/logs", tags=["logs"])
#: Сколько событий показывать на странице. #: Сколько строк показывать на странице.
PAGE_SIZE = 50 PAGE_SIZE = 50
def _pretty_payload(payload: str) -> str: def _pretty_payload(payload: Any) -> str:
"""Отформатировать JSON-payload для просмотра; битый JSON — как есть.""" """Отформатировать JSON-payload для просмотра; битый JSON — как есть."""
if isinstance(payload, dict):
return json.dumps(payload, ensure_ascii=False, indent=2)
try: try:
return json.dumps(json.loads(payload), ensure_ascii=False, indent=2) return json.dumps(json.loads(str(payload or "")), ensure_ascii=False, indent=2)
except (TypeError, ValueError): except (TypeError, ValueError):
return payload or "" return str(payload or "")
def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]: def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]:
@ -44,31 +46,52 @@ def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]:
return { return {
**row, **row,
"time_local": time_local, "time_local": time_local,
"payload_pretty": _pretty_payload(str(row.get("payload") or "")), "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) @router.get("", response_class=HTMLResponse)
async def logs_page( async def logs_page(
request: Request, request: Request,
view: str = "events",
event_type: str = "", event_type: str = "",
user: str = "", user: str = "",
date_from: str = "", date_from: str = "",
date_to: str = "", date_to: str = "",
page: int = 1, page: int = 1,
) -> HTMLResponse: ) -> HTMLResponse:
"""Показать события с фильтрами по типу, пользователю и датам.""" """Показать события или дневные счётчики сообщений с фильтрами."""
settings = request.app.state.settings settings = request.app.state.settings
view = view if view in {"events", "messages"} else "events"
today = datetime.now(settings.tzinfo).date() today = datetime.now(settings.tzinfo).date()
date_from = date_from or (today - timedelta(days=6)).isoformat() date_from = date_from or (today - timedelta(days=6)).isoformat()
date_to = date_to or today.isoformat() date_to = date_to or today.isoformat()
page = max(page, 1) page = max(page, 1)
events: list[dict[str, Any]] = [] events: list[dict[str, Any]] = []
messages: list[dict[str, Any]] = []
total = 0 total = 0
error: str | None = None error: str | None = None
try: 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( events, total = await analytics_read.fetch_events(
settings, settings,
event_type=event_type, event_type=event_type,
@ -81,29 +104,27 @@ async def logs_page(
except ValueError: except ValueError:
error = "Даты должны быть в формате ГГГГ-ММ-ДД." error = "Даты должны быть в формате ГГГГ-ММ-ДД."
except Exception: except Exception:
logger.exception("Не удалось прочитать события из ClickHouse") logger.exception("Не удалось прочитать аналитику из Postgres")
error = "ClickHouse недоступен — логи временно не читаются." error = "Postgres недоступен — логи временно не читаются."
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1) pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
query_base = urlencode( query_params = {
{ "view": view if view != "events" else "",
key: value "event_type": event_type if view == "events" else "",
for key, value in {
"event_type": event_type,
"user": user, "user": user,
"date_from": date_from, "date_from": date_from,
"date_to": date_to, "date_to": date_to,
}.items()
if value
} }
) query_base = urlencode({key: value for key, value in query_params.items() if value})
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
request=request, request=request,
name="logs/list.html", name="logs/list.html",
context={ context={
"version": __version__, "version": __version__,
"view": view,
"events": [_decorate(row, settings) for row in events], "events": [_decorate(row, settings) for row in events],
"messages": [_decorate_message(row) for row in messages],
"event_types": sorted(ALL_EVENT_TYPES), "event_types": sorted(ALL_EVENT_TYPES),
"event_type": event_type, "event_type": event_type,
"user": user, "user": user,

View File

@ -1,132 +0,0 @@
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (HTMX)."""
from __future__ import annotations
import html
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.preview import SAMPLE_CONTEXT
from admin.security import check_csrf
from common import __version__
from common.repositories import templates_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError, render, validate
router = APIRouter(prefix="/messages", tags=["messages"])
def _editor_context(
request: Request,
template: dict[str, object],
*,
text: str | None = None,
errors: list[str] | None = None,
saved: bool = False,
history: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""Контекст страницы редактора (общий для GET и повторного показа после ошибки)."""
return {
"version": __version__,
"template": template,
"text": text if text is not None else template["text"],
"errors": errors or [],
"saved": saved,
"history": history or [],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
}
@router.get("", response_class=HTMLResponse)
async def list_messages(request: Request) -> HTMLResponse:
"""Список шаблонов: ключ, описание, длина и время последней правки."""
templates = request.app.state.templates
return templates.TemplateResponse(
request=request,
name="messages/list.html",
context={"version": __version__, "templates": await templates_repo.list_templates()},
)
@router.post("/preview", response_class=HTMLResponse)
async def preview_message(request: Request) -> HTMLResponse:
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
Результат кладётся в ``iframe sandbox``: разметка Telegram видна «как в чате»,
но скрипты из введённого текста не выполняются.
"""
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>'
)
@router.post("/{key}")
async def save_message(request: Request, key: str) -> Response:
"""Сохранить текст шаблона; при ошибках вернуть редактор с подсветкой."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
text = str(form.get("text", ""))
template = await templates_repo.get_template(key)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
errors: list[str] = []
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
if not text.strip():
errors.append("Текст не может быть пустым")
if len(text) > MAX_MESSAGE_LENGTH:
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
if errors:
history = await templates_repo.template_history(key)
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/edit.html",
context=_editor_context(request, template, text=text, errors=errors, history=history),
status_code=400,
)
editor_id = int(request.state.admin["telegram_id"])
await templates_repo.update_template(key, text, editor_id=editor_id)
return RedirectResponse(url=f"/messages/{key}?saved=1", status_code=303)
@router.get("/{key}", response_class=HTMLResponse)
async def edit_message(request: Request, key: str, saved: bool = False) -> HTMLResponse:
"""Редактор шаблона: текст, подсказка по тегам, предпросмотр и история."""
template = await templates_repo.get_template(key)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
history = await templates_repo.template_history(key)
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/edit.html",
context=_editor_context(request, template, saved=saved, history=history),
)

View File

@ -12,9 +12,9 @@ router = APIRouter(tags=["pages"])
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов). #: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
SECTIONS: tuple[dict[str, str | None], ...] = ( SECTIONS: tuple[dict[str, str | None], ...] = (
{ {
"title": "Сообщения", "title": "Шаблоны сообщений",
"description": "Тексты бота и предпросмотр с тегами {tag}", "description": "Тексты бота: сообщения, картинки и кнопки",
"url": "/messages", "url": "/sequences",
}, },
{ {
"title": "Расписания", "title": "Расписания",
@ -26,11 +26,26 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
"description": "Ники, звания, баланс и подписка", "description": "Ники, звания, баланс и подписка",
"url": "/users", "url": "/users",
}, },
{
"title": "Звания",
"description": "Справочник магазина званий",
"url": "/titles",
},
{
"title": "Админы",
"description": "Доступ к веб-панели",
"url": "/admins",
},
{ {
"title": "Логи", "title": "Логи",
"description": "События из ClickHouse с фильтрами", "description": "События и сообщения из Postgres с фильтрами",
"url": "/logs", "url": "/logs",
}, },
{
"title": "Отправить",
"description": "Сообщение в чат от имени бота",
"url": "/send",
},
) )

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>'
)

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)

View File

@ -5,10 +5,13 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from admin import telegram
from admin.security import check_csrf from admin.security import check_csrf
from common import __version__ from common import __version__
from common.eggs import is_egg_event_active from common.eggs import is_egg_event_active
from common.repositories import users_admin_repo 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"]) router = APIRouter(prefix="/users", tags=["users"])
@ -21,6 +24,19 @@ def _editor_id(request: Request) -> int:
return int(request.state.admin["telegram_id"]) 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( async def _card_response(
request: Request, request: Request,
user_id: int, user_id: int,
@ -46,6 +62,7 @@ async def _card_response(
"errors": errors or [], "errors": errors or [],
"saved": saved, "saved": saved,
"eggs_visible": is_egg_event_active(request.app.state.settings), "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_nick_length": users_admin_repo.MAX_NICK_LENGTH,
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH, "max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
}, },
@ -54,7 +71,7 @@ async def _card_response(
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse: async def list_users(request: Request, q: str = "", page: int = 1, saved: str | None = None) -> HTMLResponse:
"""Список пользователей: поиск по нику/username/имени/ID и пагинация.""" """Список пользователей: поиск по нику/username/имени/ID и пагинация."""
total = await users_admin_repo.count_users(q) total = await users_admin_repo.count_users(q)
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1) pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
@ -74,6 +91,7 @@ async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLRespon
"page": page, "page": page,
"pages": pages, "pages": pages,
"total": total, "total": total,
"saved": saved,
}, },
) )
@ -181,3 +199,40 @@ async def update_balance(request: Request, user_id: int) -> Response:
if balance is None: if balance is None:
raise HTTPException(status_code=404, detail="Пользователь не найден") raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303) 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)

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

@ -7,6 +7,12 @@
<h1 class="text-2xl font-semibold">Вход в админку</h1> <h1 class="text-2xl font-semibold">Вход в админку</h1>
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p> <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 %} {% if configured %}
<div class="mt-8 flex justify-center"> <div class="mt-8 flex justify-center">
<script <script
@ -18,11 +24,51 @@
data-request-access="write" data-request-access="write"
></script> ></script>
</div> </div>
{% else %} {% 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"> <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_TOKEN</span>,
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>. <span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
</p> </p>
{% endif %} {% endif %}
</div> </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 %} {% endblock %}

View File

@ -9,24 +9,74 @@
</head> </head>
<body class="min-h-screen bg-slate-100 text-slate-900"> <body class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white"> <header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4"> <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> <a href="/" class="text-lg font-semibold">DispelEvilBot</a>
<nav class="flex items-center gap-4 text-sm"> <button
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a> id="nav-toggle"
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a> type="button"
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a> class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-600 sm:hidden"
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a> aria-expanded="false"
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a> >
Меню
</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') %} {% if request.session.get('admin_id') %}
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span> <form method="post" action="/logout" class="py-1 sm:ml-1">
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" /> <input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-slate-600 hover:text-slate-900">Выйти</button> <button type="submit" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">
Выйти
</button>
</form> </form>
{% endif %} {% endif %}
<span class="text-slate-500">админка · v{{ version }}</span>
</nav> </nav>
</div> </div>
</div>
</header> </header>
<main class="mx-auto max-w-5xl px-4 py-8"> <main class="mx-auto max-w-5xl px-4 py-8">
@ -34,5 +84,132 @@
</main> </main>
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer> <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> </body>
</html> </html>

View File

@ -6,11 +6,33 @@
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold">Логи</h1> <h1 class="text-2xl font-semibold">Логи</h1>
<p class="mt-1 text-sm text-slate-600">События из ClickHouse · найдено: {{ total }}</p> <p class="mt-1 text-sm text-slate-600">
{% if view == "messages" %}Сообщения по дням{% else %}События из Postgres{% endif %}
· найдено: {{ total }}
</p>
</div> </div>
</div> </div>
<form method="get" action="/logs" class="mt-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4"> <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> <div>
<label for="event_type" class="text-xs text-slate-500">Тип</label> <label for="event_type" class="text-xs text-slate-500">Тип</label>
<select <select
@ -24,6 +46,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{% endif %}
<div> <div>
<label for="user" class="text-xs text-slate-500">Пользователь</label> <label for="user" class="text-xs text-slate-500">Пользователь</label>
<input <input
@ -31,7 +54,7 @@
name="user" name="user"
type="text" type="text"
value="{{ user }}" value="{{ user }}"
placeholder="ID или @username" 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" 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>
@ -58,13 +81,46 @@
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"> <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>
<a href="/logs" class="text-sm text-slate-600 hover:underline">Сбросить</a> <a href="/logs{% if view == 'messages' %}?view=messages{% endif %}" class="text-sm text-slate-600 hover:underline">Сбросить</a>
</form> </form>
{% if error %} {% if error %}
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div> <div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div>
{% endif %} {% 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"> <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"> <table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600"> <thead class="bg-slate-50 text-left text-slate-600">
@ -72,8 +128,8 @@
<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>
<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="hidden px-4 py-3 font-medium xl:table-cell">Версия</th>
<th class="px-4 py-3 font-medium">Payload</th> <th class="px-4 py-3 font-medium">Payload</th>
</tr> </tr>
</thead> </thead>
@ -93,8 +149,8 @@
<span class="text-slate-400">—</span> <span class="text-slate-400">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ event.chat_id or "—" }}</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="px-4 py-3 text-xs text-slate-500">{{ event.bot_version }}</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"> <td class="px-4 py-3">
{% if event.payload_pretty %} {% if event.payload_pretty %}
<details> <details>
@ -116,6 +172,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% endif %}
{% if pages > 1 %} {% if pages > 1 %}
<div class="mt-4 flex items-center justify-between text-sm text-slate-600"> <div class="mt-4 flex items-center justify-between text-sm text-slate-600">

View File

@ -1,102 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ template.key }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
<h1 class="mt-1 font-mono text-xl font-semibold">{{ template.key }}</h1>
<p class="text-sm text-slate-600">{{ template.description }}</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="/messages/{{ template.key }}" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div>
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
<textarea
id="text"
name="text"
rows="18"
class="mt-1 w-full rounded-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
>{{ text }}</textarea>
<p class="mt-1 text-xs text-slate-500">
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
</p>
</div>
<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>
<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="/messages/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"
>
Предпросмотр
</button>
</div>
</form>
<div class="mt-8">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<p class="text-sm text-slate-600">Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.</p>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.</p>
</div>
</div>
<details class="mt-8 rounded-lg border border-slate-200 bg-white p-4">
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
{% for name, description in tags.items() %}
<div class="flex gap-2">
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
<dd class="text-slate-600">{{ description }}</dd>
</div>
{% endfor %}
</dl>
</details>
<div class="mt-8">
<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 whitespace-pre-wrap">{{ item.old_text or "" }}</pre>
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.new_text }}</pre>
</div>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,45 +0,0 @@
{% extends "base.html" %}
{% block title %}Сообщения{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Сообщения</h1>
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
</div>
<p class="mt-2 text-slate-600">
Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов
<span class="font-mono">{tag}</span> на тестовых данных.
</p>
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
Изменения применяются ботом без перезапуска — в течение <span class="font-mono">TEMPLATE_CACHE_TTL</span>
секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
</div>
<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 template in templates %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/messages/{{ template.key }}" class="font-mono text-sky-700 hover:underline">{{ template.key }}</a>
</td>
<td class="px-4 py-3 text-slate-700">{{ template.description }}</td>
<td class="px-4 py-3 text-slate-500">{{ template.text_length }}</td>
<td class="px-4 py-3 text-slate-500">{{ template.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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

@ -20,9 +20,9 @@
<thead class="bg-slate-50 text-left text-slate-600"> <thead class="bg-slate-50 text-left text-slate-600">
<tr> <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 sm:table-cell">Дни</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="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>
<th class="px-4 py-3 font-medium"></th> <th class="px-4 py-3 font-medium"></th>
</tr> </tr>
@ -31,12 +31,12 @@
{% for schedule in schedules %} {% for schedule in schedules %}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td> <td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td>
<td class="px-4 py-3">{{ format_weekdays(schedule.weekdays) }}</td> <td class="hidden px-4 py-3 sm:table-cell">{{ format_weekdays(schedule.weekdays) }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="font-mono text-sky-700">{{ schedule.template_key }}</div> <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> <div class="text-xs text-slate-500">{{ schedule.description or schedule.template_description }}</div>
</td> </td>
<td class="px-4 py-3 text-slate-600">{{ schedule.timezone }}</td> <td class="hidden px-4 py-3 text-slate-600 lg:table-cell">{{ schedule.timezone }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{% if schedule.enabled %} {% if schedule.enabled %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span> <span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>

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

@ -68,7 +68,7 @@
<div class="text-xs uppercase tracking-wide text-slate-500">Сообщения</div> <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="mt-1 text-xl font-semibold">{{ user.msg_count }}</div>
<div class="text-xs text-slate-500"> <div class="text-xs text-slate-500">
последнее: {{ user.last_message_at.strftime("%d.%m.%Y %H:%M") if user.last_message_at else "—" }} последняя активность: {{ user.last_message_at.strftime("%d.%m.%Y") if user.last_message_at else "—" }}
</div> </div>
</div> </div>
<div class="rounded-lg border border-slate-200 bg-white p-4"> <div class="rounded-lg border border-slate-200 bg-white p-4">
@ -171,6 +171,27 @@
</div> </div>
</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"> <div class="mt-8">
<h2 class="text-lg font-medium">История изменений</h2> <h2 class="text-lg font-medium">История изменений</h2>
{% if history %} {% if history %}
@ -182,6 +203,7 @@
<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>
<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> </tr>
</thead> </thead>
@ -193,6 +215,9 @@
<td class="px-4 py-3">{{ field_labels.get(item.field, item.field) }}</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-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">{{ 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> <td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -23,15 +23,21 @@
</form> </form>
</div> </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"> <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"> <table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600"> <thead class="bg-slate-50 text-left text-slate-600">
<tr> <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">Telegram</th> <th class="px-4 py-3 font-medium">Telegram</th>
<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="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 md:table-cell">Сообщения</th>
<th class="px-4 py-3 font-medium">Подписка</th> <th class="px-4 py-3 font-medium">Подписка</th>
</tr> </tr>
</thead> </thead>
@ -48,9 +54,9 @@
{% if user.username %}<div>@{{ user.username }}</div>{% endif %} {% if user.username %}<div>@{{ user.username }}</div>{% endif %}
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div> <div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
</td> </td>
<td class="px-4 py-3">{{ user.lvl }}</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="px-4 py-3">{{ user.balance }}</td>
<td class="px-4 py-3">{{ user.msg_count }}</td> <td class="hidden px-4 py-3 md:table-cell">{{ user.msg_count }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{% if user.subscribed %} {% if user.subscribed %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span> <span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>

View File

@ -6,11 +6,13 @@ import asyncio
import logging import logging
from bot.bot_factory import create_bot, create_dispatcher 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.heartbeat import run_heartbeat
from bot.services.notifications import run_notifications from bot.services.notifications import run_notifications
from bot.services.runtime import uptime_seconds from bot.services.runtime import uptime_seconds
from common import __version__ from common import __version__
from common.analytics import EventType, close_analytics, init_analytics, record 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.config import get_settings
from common.db.pool import close_pool, init_pool from common.db.pool import close_pool, init_pool
from common.logging_config import setup_logging from common.logging_config import setup_logging
@ -25,20 +27,34 @@ async def main() -> None:
await init_pool() await init_pool()
await init_analytics() 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}) record(EventType.BOT_START, payload={"app_env": settings.app_env})
bot = create_bot() bot = create_bot()
dp = create_dispatcher() dp = create_dispatcher()
# Сверяем меню команд при каждом старте (Telegram разрешает только ASCII-имена).
await ensure_commands(bot)
notifications_task = asyncio.create_task(run_notifications(bot)) notifications_task = asyncio.create_task(run_notifications(bot))
heartbeat_task = asyncio.create_task(run_heartbeat(bot)) heartbeat_task = asyncio.create_task(run_heartbeat(bot))
partitions_task = asyncio.create_task(run_partitions(settings))
try: try:
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
finally: finally:
notifications_task.cancel() notifications_task.cancel()
heartbeat_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()}) record(EventType.BOT_STOP, payload={"app_env": settings.app_env, "uptime_seconds": uptime_seconds()})
await close_analytics() await close_analytics()
await close_pool() await close_pool()

View File

@ -25,7 +25,7 @@ def create_dispatcher() -> Dispatcher:
Порядок важен: сначала события участников и команды, затем магазин, Порядок важен: сначала события участников и команды, затем магазин,
и в конце — экономика (в ней есть catch-all обработчик сообщений). и в конце — экономика (в ней есть 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.handlers.errors import register_errors
from bot.middlewares import AnalyticsMiddleware from bot.middlewares import AnalyticsMiddleware
@ -33,6 +33,7 @@ def create_dispatcher() -> Dispatcher:
# Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`). # Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`).
dp.message.outer_middleware(AnalyticsMiddleware()) dp.message.outer_middleware(AnalyticsMiddleware())
dp.include_router(member_events.router) dp.include_router(member_events.router)
dp.include_router(login.router)
dp.include_router(commands.router) dp.include_router(commands.router)
dp.include_router(shop.router) dp.include_router(shop.router)
dp.include_router(economy.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,17 +11,17 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions from bot.services import access, stats, subscriptions
from bot.services import users as users_service from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings from common.config import get_settings
from common.eggs import is_egg_event_active from common.eggs import is_egg_event_active
from common.texts import system from common.texts import system
from common.texts.render import escape, format_tops, format_user_list, render from common.texts.render import escape, format_tops, format_user_list, render
from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router(name="commands") 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: async def set_nick_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
@ -41,7 +41,7 @@ async def set_nick_handler(message: Message) -> None:
await message.reply(system.MSG_NICK_ERROR) 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: async def list_nicks_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return 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": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
"users_list_count": len(nicks), "users_list_count": len(nicks),
} }
await message.answer(render(await get_text("economy.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: async def get_nick_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
@ -81,14 +81,14 @@ async def get_nick_handler(message: Message) -> None:
await message.reply(user_info.nick) 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: async def help_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
await message.answer(await get_text("info.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: async def info_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
@ -113,19 +113,19 @@ async def info_handler(message: Message) -> None:
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else "" f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
), ),
} }
await message.answer(render(await get_text("info.profile"), 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: async def top_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
tops = await stats.top_users() tops = await stats.top_users()
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])} context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
await message.answer(render(await get_text("economy.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: async def unsubscribe_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
@ -133,7 +133,7 @@ async def unsubscribe_handler(message: Message) -> None:
await message.reply(system.MSG_UNSUBSCRIBED) 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: async def subscribe_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return

View File

@ -11,11 +11,10 @@ from aiogram.types import Message
from bot.services import access, alerts from bot.services import access, alerts
from bot.services import economy as economy_service from bot.services import economy as economy_service
from bot.services.sequences import send_template
from common.config import get_settings from common.config import get_settings
from common.eggs import egg_event_period, is_egg_event_active from common.eggs import egg_event_period, is_egg_event_active
from common.texts import system from common.texts import system
from common.texts.render import render
from common.texts.store import get_text
router = Router(name="economy") router = Router(name="economy")
@ -23,7 +22,7 @@ router = Router(name="economy")
COIN_CHANCE = 27 # из 100 COIN_CHANCE = 27 # из 100
@router.message(Command("очищение", ignore_case=True)) @router.message(Command("очищение", "purify", ignore_case=True))
async def purification_handler(message: Message) -> None: async def purification_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
@ -31,10 +30,10 @@ async def purification_handler(message: Message) -> None:
result = await economy_service.purify(message.from_user.id) result = await economy_service.purify(message.from_user.id)
if not result.success: if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds} context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(await get_text("economy.no_purification"), context)) await send_template(message.bot, message.chat.id, "economy.no_purification", context)
else: else:
context = {"money_add": result.money_add, "exp_add": result.exp_add} context = {"money_add": result.money_add, "exp_add": result.exp_add}
await message.answer(render(await get_text("economy.purification"), context)) await send_template(message.bot, message.chat.id, "economy.purification", context)
await alerts.notify_purification( await alerts.notify_purification(
message.bot, message.bot,
username=message.from_user.username, username=message.from_user.username,
@ -43,7 +42,7 @@ async def purification_handler(message: Message) -> None:
) )
@router.message(Command("яички", ignore_case=True)) @router.message(Command("яички", "eggs", ignore_case=True))
async def eggs_handler(message: Message) -> None: async def eggs_handler(message: Message) -> None:
settings = get_settings() settings = get_settings()
if not is_egg_event_active(settings): if not is_egg_event_active(settings):
@ -58,9 +57,9 @@ async def eggs_handler(message: Message) -> None:
result = await economy_service.collect_eggs(message.from_user.id) result = await economy_service.collect_eggs(message.from_user.id)
if not result.success: if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds} context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(await get_text("economy.no_eggs"), context)) await send_template(message.bot, message.chat.id, "economy.no_eggs", context)
else: else:
await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs})) await send_template(message.bot, message.chat.id, "economy.eggs", {"eggs": result.eggs})
@router.message() @router.message()

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,12 +5,14 @@ from __future__ import annotations
import logging import logging
from aiogram import Router from aiogram import Router
from aiogram.enums import ChatMemberStatus
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter 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 aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.services import alerts from bot.services import alerts
from bot.services import users as users_service from bot.services import users as users_service
from bot.services.sequences import send_sequence
from common.analytics import EventType, record from common.analytics import EventType, record
from common.config import get_settings from common.config import get_settings
from common.texts import system, welcome from common.texts import system, welcome
@ -49,42 +51,96 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
chat_title=event.chat.title or "", 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 = InlineKeyboardBuilder()
builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL) 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( await event.bot.send_photo(
chat_id=event.chat.id, chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"), photo=FSInputFile("images/welcome_image.jpg"),
caption=render(await get_text("welcome.text"), {"username": user.username}), caption=caption or None,
) )
await event.bot.send_message( await event.bot.send_message(
chat_id=event.chat.id, chat_id=event.chat.id,
text=await get_text("welcome.text2"), text=body,
reply_markup=builder.as_markup(), 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=await get_text("welcome.text3"))
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION)) @router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
async def goodbye_handler(event: ChatMemberUpdated) -> None: async def goodbye_handler(event: ChatMemberUpdated) -> None:
"""Пользователь вышел из чата — удалить запись.""" """Пользователь вышел или был исключён — удалить данные именно его."""
user = event.from_user if event.chat.id != get_settings().chat_id:
if user is None or user.is_bot:
return return
leaver = event.old_chat_member.user
if leaver.is_bot:
return
actor = event.from_user
kicked = event.new_chat_member.status == ChatMemberStatus.KICKED
by_self = actor is None or actor.id == leaver.id
record( record(
EventType.LEAVE, EventType.LEAVE,
user_id=user.id, user_id=leaver.id,
username=user.username, username=leaver.username,
chat_id=event.chat.id, chat_id=event.chat.id,
payload={"chat_title": event.chat.title or ""}, 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 "",
},
) )
await users_service.delete_user(user.id) await users_service.delete_user(leaver.id)
logger.info("Удалён пользователь %s после выхода из чата", user.id) logger.info("Удалён пользователь %s после выхода из чата", leaver.id)
await alerts.notify_leave( # Исключение самим ботом (кнопка в админке): уведомление уже отправила админка.
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, event.bot,
username=user.username, actor_username=actor.username if actor else None,
user_id=user.id, 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 "", chat_title=event.chat.title or "",
) )

View File

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

View File

@ -9,8 +9,8 @@ from typing import Any
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message, TelegramObject from aiogram.types import Message, TelegramObject
from common.analytics import EventType, record, remember_user from common.analytics import EventType, record, record_message, remember_user
from common.analytics.messages import describe_command, describe_message from common.analytics.messages import describe_command
from common.config import get_settings from common.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,13 +43,7 @@ class AnalyticsMiddleware(BaseMiddleware):
try: try:
remember_user(user.id, user.username) remember_user(user.id, user.username)
record( record_message(user_id=user.id, username=user.username, chat_id=message.chat.id)
EventType.MESSAGE,
user_id=user.id,
username=user.username,
chat_id=message.chat.id,
payload=describe_message(message),
)
command = describe_command(message) command = describe_command(message)
if command is not None: if command is not None:
record( record(

View File

@ -25,6 +25,12 @@ _ALLOWED_STATUSES = {
ChatMemberStatus.RESTRICTED, 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 на каждое сообщение. #: TTL-кеш членства, чтобы не дёргать get_chat_member на каждое сообщение.
_MEMBERSHIP_TTL = 300.0 _MEMBERSHIP_TTL = 300.0
_membership_cache: dict[int, tuple[float, bool]] = {} _membership_cache: dict[int, tuple[float, bool]] = {}
@ -50,6 +56,10 @@ async def check_user(message: Message) -> bool:
if user is None: if user is None:
return False 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): if user.id in settings.admin_ids or await _is_member(message.bot, user.id):
await users_service.register_from_message(message) await users_service.register_from_message(message)
remember_user(user.id, user.username) remember_user(user.id, user.username)

View File

@ -64,6 +64,24 @@ async def notify_leave(bot: Bot, *, username: str | None, user_id: int, chat_tit
return await notify_alert(bot, render(alerts_text.MSG_LEAVE, context)) 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: async def notify_purification(bot: Bot, *, username: str | None, user_id: int, result: PurificationResult) -> bool:
"""Уведомление об успешном очищении.""" """Уведомление об успешном очищении."""
context = { context = {

View File

@ -4,7 +4,7 @@
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться. если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
Раз в час в чат логов (``ALERT_CHAT_ID``) уходит короткое сообщение о работе бота, Раз в час в чат логов (``ALERT_CHAT_ID``) уходит короткое сообщение о работе бота,
и в ClickHouse пишется событие ``bot_alive``. и в аналитику пишется событие ``bot_alive``.
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -12,16 +12,16 @@ import asyncio
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from aiogram import Bot from aiogram import Bot
from bot.services import users as users_service from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings from common.config import get_settings
from common.repositories import schedules_repo from common.repositories import schedules_repo
from common.texts import format_usernames, render from common.texts import format_usernames
from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,6 +45,7 @@ class ScheduledMessage:
template_key: str template_key: str
weekdays: tuple[int, ...] = () weekdays: tuple[int, ...] = ()
timezone: str = "Europe/Moscow" timezone: str = "Europe/Moscow"
schedule_id: int | None = None
def matches(self, now: datetime) -> bool: def matches(self, now: datetime) -> bool:
"""Совпадает ли момент ``now`` (aware) с этим расписанием.""" """Совпадает ли момент ``now`` (aware) с этим расписанием."""
@ -76,6 +77,7 @@ def _from_row(row: dict) -> ScheduledMessage:
template_key=str(row["template_key"]), template_key=str(row["template_key"]),
weekdays=tuple(int(day) for day in (row["weekdays"] or ())), weekdays=tuple(int(day) for day in (row["weekdays"] or ())),
timezone=str(row["timezone"] or "Europe/Moscow"), timezone=str(row["timezone"] or "Europe/Moscow"),
schedule_id=int(row["id"]) if row.get("id") is not None else None,
) )
@ -99,23 +101,31 @@ async def _load_schedules() -> tuple[ScheduledMessage, ...]:
return _last_good return _last_good
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None: 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 [[]] chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
for chunk in chunks: for chunk in chunks:
text = render(template, {"usernames": format_usernames(chunk)}) await send_template(
await bot.send_message(chat_id=get_settings().chat_id, text=text) bot,
get_settings().chat_id,
template_key,
{"usernames": format_usernames(chunk)},
)
async def _tick(bot: Bot, now: datetime) -> None: async def _tick(bot: Bot, now: datetime) -> None:
for schedule in await _load_schedules(): for schedule in await _load_schedules():
if not schedule.matches(now): if not schedule.matches(now):
continue continue
template = await get_text(schedule.template_key)
if not template: # Защита от повторной отправки после перезапуска в ту же минуту.
logger.warning("Шаблон %s пуст или не найден — рассылка пропущена", schedule.template_key) 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 continue
subscribers = await users_service.list_subscribers() subscribers = await users_service.list_subscribers()
await _send_broadcast(bot, template, subscribers) await _send_broadcast(bot, schedule.template_key, subscribers)
async def run_notifications(bot: Bot) -> None: async def run_notifications(bot: Bot) -> None:

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

@ -43,7 +43,7 @@ async def register_from_event(event: ChatMemberUpdated) -> None:
async def set_nick(id_user: int, nick: str) -> None: 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}) record(EventType.NICK_SET, user_id=id_user, payload={"nick": nick})

View File

@ -1,72 +0,0 @@
-- Схема аналитики ClickHouse.
--
-- Этот каталог монтируется в контейнер как /docker-entrypoint-initdb.d,
-- поэтому скрипт применяется ТОЛЬКО при первой инициализации пустого data-каталога.
-- Flyway у нас только для Postgres; изменения схемы здесь — новым файлом
-- 00N_*.sql (и применением вручную на существующем контуре) плюс раздел в DEPLOY.md.
CREATE DATABASE IF NOT EXISTS analytics;
-- Единая широкая таблица событий (см. docs/events.md).
-- Партиционирование по дню: сырые партиции удаляются после ежедневного бэкапа
-- на домашний сервер (scripts/clickhouse_backup.sh), поэтому удаление должно
-- быть дешёвым (DROP PARTITION, без mutation).
CREATE TABLE IF NOT EXISTS analytics.events
(
event_time DateTime('UTC'),
event_type LowCardinality(String),
user_id Int64,
username String,
chat_id Int64,
payload String,
bot_version String,
prod UInt8
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (event_time, event_type, user_id);
-- Агрегат: события по дням и типам. Остаётся на VPS, когда сырые удалены.
CREATE TABLE IF NOT EXISTS analytics.events_daily
(
day Date,
event_type LowCardinality(String),
user_id Int64,
chat_id Int64,
events UInt64
)
ENGINE = SummingMergeTree
ORDER BY (day, event_type, user_id, chat_id);
CREATE MATERIALIZED VIEW IF NOT EXISTS analytics.events_daily_mv TO analytics.events_daily AS
SELECT
toDate(event_time) AS day,
event_type,
user_id,
chat_id,
count() AS events
FROM analytics.events
GROUP BY day, event_type, user_id, chat_id;
-- Агрегат: денежные потоки по дням (сумма amount из payload событий экономики).
CREATE TABLE IF NOT EXISTS analytics.money_daily
(
day Date,
event_type LowCardinality(String),
user_id Int64,
amount_sum Int64,
events UInt64
)
ENGINE = SummingMergeTree
ORDER BY (day, event_type, user_id);
CREATE MATERIALIZED VIEW IF NOT EXISTS analytics.money_daily_mv TO analytics.money_daily AS
SELECT
toDate(event_time) AS day,
event_type,
user_id,
sum(JSONExtractInt(payload, 'amount')) AS amount_sum,
count() AS events
FROM analytics.events
WHERE event_type IN ('money_accrual', 'money_spend')
GROUP BY day, event_type, user_id;

View File

@ -1,4 +1,4 @@
"""Аналитика: клиент ClickHouse и каталог событий. """Аналитика: клиент Postgres и каталог событий.
Публичный интерфейс: Публичный интерфейс:
@ -16,6 +16,7 @@ from common.analytics.client import (
get_analytics, get_analytics,
init_analytics, init_analytics,
record, record,
record_message,
remember_user, remember_user,
) )
from common.analytics.events import ALL_EVENT_TYPES, EVENT_COLUMNS, EventType from common.analytics.events import ALL_EVENT_TYPES, EVENT_COLUMNS, EventType
@ -29,5 +30,6 @@ __all__ = [
"get_analytics", "get_analytics",
"init_analytics", "init_analytics",
"record", "record",
"record_message",
"remember_user", "remember_user",
] ]

View File

@ -1,13 +1,10 @@
"""Клиент ClickHouse: батч-запись событий без блокировки бота. """Аналитика в Postgres: батч-запись событий и дневных счётчиков сообщений.
Свойства: Свойства:
- запись неблокирующая: :meth:`AnalyticsClient.record` только кладёт строку в буфер; - запись неблокирующая: ``record``/``record_message`` только кладут данные в буфер;
- батчинг: отправка по размеру буфера и по таймеру; - батчинг по размеру/таймеру;
- недоступность ClickHouse **не роняет бота**: события попадают в stdout (fallback); - сбой Postgres не роняет бота: батч уходит в лог (fallback) и отбрасывается;
- graceful-flush при остановке (:func:`close_analytics`). - сообщения не пишутся поштучно: инкрементится ``analytics.messages_daily``.
Импорт ``clickhouse_connect`` ленивый (внутри :meth:`AnalyticsClient._ensure_client`),
поэтому пакет не является обязательным для импорта модуля (нужно для тестов/админки).
""" """
from __future__ import annotations from __future__ import annotations
@ -17,11 +14,15 @@ import contextlib
import json import json
import logging import logging
from collections import deque from collections import deque
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Protocol from typing import Any
from common.analytics.events import EVENT_COLUMNS, EventType from psycopg.types.json import Jsonb
from common.analytics.events import EventType
from common.config import Settings, get_settings from common.config import Settings, get_settings
from common.db.pool import get_pool
from common.version import __version__ from common.version import __version__
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,90 +36,90 @@ USERNAME_CACHE_MAX = 5000
#: Сколько строк события выводить в лог при fallback. #: Сколько строк события выводить в лог при fallback.
FALLBACK_SAMPLE = 5 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]]
class ClickHouseClient(Protocol): #: Куда сбрасывать батч (подменяется в тестах).
"""Минимальный контракт клиента ClickHouse (используется и для подмены в тестах).""" Writer = Callable[[list[EventRow], MessageCounts], Awaitable[None]]
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> Any:
"""Вставить строки в таблицу."""
async def close(self) -> 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: class AnalyticsClient:
"""Батч-запись событий в ClickHouse с деградацией в лог.""" """Батч-запись событий и счётчиков в Postgres с деградацией в лог."""
def __init__(self, settings: Settings | None = None, *, client: ClickHouseClient | None = None) -> None: def __init__(
self,
settings: Settings | None = None,
*,
writer: Writer | None = None,
) -> None:
self._settings = settings or get_settings() self._settings = settings or get_settings()
self._client = client self._writer = writer or _write_db
self._rows: deque[list[Any]] = deque() self._rows: deque[EventRow] = deque()
self._messages: MessageCounts = {}
self._flush_now = asyncio.Event() self._flush_now = asyncio.Event()
self._task: asyncio.Task[None] | None = None self._task: asyncio.Task[None] | None = None
self._usernames: dict[int, str] = {} self._usernames: dict[int, str] = {}
self._unavailable_logged = False
self._overflow = 0
self._table = f"{self._settings.clickhouse_db}.events"
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
"""Включена ли аналитика (``CLICKHOUSE_ENABLED``).""" return self._settings.analytics_enabled
return self._settings.clickhouse_enabled
@property @property
def pending(self) -> int: def pending(self) -> int:
"""Сколько событий ждёт отправки (для тестов и диагностики).""" return len(self._rows) + sum(int(value[1]) for value in self._messages.values())
return len(self._rows)
async def start(self) -> None: async def start(self) -> None:
"""Подключиться к ClickHouse (best-effort) и запустить фоновый сброс буфера."""
if not self.enabled: if not self.enabled:
logger.info("Аналитика ClickHouse выключена (CLICKHOUSE_ENABLED=false)") logger.info("Аналитика выключена (ANALYTICS_ENABLED=false)")
return return
await self._ensure_client()
self._task = asyncio.create_task(self._run(), name="analytics-flush") self._task = asyncio.create_task(self._run(), name="analytics-flush")
logger.info( logger.info(
"Аналитика ClickHouse: %s:%s/%s (батч %s, интервал %sс)", "Аналитика Postgres: батч %s, интервал %sс",
self._settings.clickhouse_host,
self._settings.clickhouse_port,
self._settings.clickhouse_db,
self._settings.analytics_batch_size, self._settings.analytics_batch_size,
self._settings.analytics_flush_interval, self._settings.analytics_flush_interval,
) )
async def _ensure_client(self) -> bool:
"""Вернуть True, если клиент ClickHouse доступен."""
if self._client is not None:
return True
settings = self._settings
try:
import clickhouse_connect
self._client = await clickhouse_connect.get_async_client(
host=settings.clickhouse_host,
port=settings.clickhouse_port,
username=settings.clickhouse_user,
password=settings.clickhouse_password,
database=settings.clickhouse_db,
)
except Exception:
if not self._unavailable_logged:
self._unavailable_logged = True
logger.warning(
"ClickHouse %s:%s недоступен: события пишутся в stdout (fallback)",
settings.clickhouse_host,
settings.clickhouse_port,
exc_info=True,
)
return False
self._unavailable_logged = False
return True
def remember_user(self, user_id: int, username: str | None) -> None: def remember_user(self, user_id: int, username: str | None) -> None:
"""Запомнить связку ``id_user -> username`` для обогащения будущих событий."""
if not user_id or not username: if not user_id or not username:
return return
if user_id not in self._usernames and len(self._usernames) >= USERNAME_CACHE_MAX: if user_id not in self._usernames and len(self._usernames) >= USERNAME_CACHE_MAX:
@ -143,26 +144,41 @@ class AnalyticsClient:
self._rows.append( self._rows.append(
[ [
datetime.now(UTC).replace(tzinfo=None), datetime.now(UTC),
str(event_type), str(event_type),
int(user_id), int(user_id),
username or "", username or "",
int(chat_id), int(chat_id),
self._dump_payload(payload), self._payload(payload),
__version__, __version__,
1 if self._settings.is_prod else 0, bool(self._settings.is_prod),
] ]
) )
while len(self._rows) > self._settings.analytics_buffer_max: while len(self._rows) > self._settings.analytics_buffer_max:
self._rows.popleft() self._rows.popleft()
self._overflow += 1
if len(self._rows) >= self._settings.analytics_batch_size: 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() self._flush_now.set()
async def _run(self) -> None: async def _run(self) -> None:
"""Периодически (или по достижении батча) сбрасывать буфер в ClickHouse."""
interval = self._settings.analytics_flush_interval interval = self._settings.analytics_flush_interval
while True: while True:
with contextlib.suppress(TimeoutError): with contextlib.suppress(TimeoutError):
@ -171,105 +187,74 @@ class AnalyticsClient:
await self.flush() await self.flush()
async def flush(self) -> None: async def flush(self) -> None:
"""Отправить накопленные события. При сбое — fallback в лог (без исключений).""" """Отправить накопленное. При сбое — fallback в лог (без исключений)."""
overflow, self._overflow = self._overflow, 0 if not self._rows and not self._messages:
if overflow:
logger.warning("Буфер событий переполнен, потеряно %s событий (не записаны в ClickHouse)", overflow)
if not self._rows:
return return
events = list(self._rows)
messages = self._messages
self._rows.clear()
self._messages = {}
if not self.enabled: if not self.enabled:
self._rows.clear()
return
rows = list(self._rows)
self._rows.clear()
if not await self._ensure_client():
self._log_fallback(rows, "ClickHouse недоступен")
return
client = self._client
if client is None:
self._log_fallback(rows, "клиент ClickHouse не создан")
return return
try: try:
await client.insert(self._table, rows, list(EVENT_COLUMNS)) await self._writer(events, messages)
except Exception: except Exception:
logger.exception("Не удалось записать %s событий в ClickHouse", len(rows)) self._log_fallback(events, messages)
self._log_fallback(rows, "ошибка записи в ClickHouse")
async def close(self) -> None: async def close(self) -> None:
"""Остановить фоновый таск, сбросить остаток и закрыть клиент."""
if self._task is not None: if self._task is not None:
self._task.cancel() self._task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await self._task await self._task
self._task = None self._task = None
if self._client is not None:
await self.flush() await self.flush()
client, self._client = self._client, None
try:
await client.close()
except Exception:
logger.warning("Не удалось закрыть клиент ClickHouse", exc_info=True)
elif self._rows:
# Клиента нет (ClickHouse недоступен) — не пробуем подключиться на выходе.
rows = list(self._rows)
self._rows.clear()
self._log_fallback(rows, "остановка бота")
def _log_fallback(self, rows: list[list[Any]], reason: str) -> None: def _log_fallback(self, events: list[EventRow], messages: MessageCounts) -> None:
"""Вывести потерянные события в лог, чтобы данные не пропадали молча.""" sample = " | ".join(
if not rows: f"{row[0]:%Y-%m-%d %H:%M:%S} {row[1]} user={row[2]} chat={row[4]}" for row in events[:FALLBACK_SAMPLE]
return
lines = " | ".join(
f"{row[0]:%Y-%m-%d %H:%M:%S} {row[1]} user={row[2]} chat={row[4]} {row[5]}"
for row in rows[:FALLBACK_SAMPLE]
) )
logger.warning( logger.warning(
"Fallback (%s): %s событий не записаны в ClickHouse: %s%s", "Fallback: не записано в Postgres событий=%s, счётчиков=%s%s",
reason, len(events),
len(rows), len(messages),
lines, f": {sample}" if sample else "",
" ..." if len(rows) > FALLBACK_SAMPLE else "",
) )
@staticmethod @staticmethod
def _dump_payload(payload: dict[str, Any] | None) -> str: def _payload(payload: dict[str, Any] | None) -> dict[str, Any]:
"""Сериализовать payload в JSON-строку с обрезкой по длине."""
if not payload: if not payload:
return "{}" return {}
try: try:
text = json.dumps(payload, ensure_ascii=False, default=str) text = json.dumps(payload, ensure_ascii=False, default=str)
except (TypeError, ValueError): except (TypeError, ValueError):
text = json.dumps({"_error": "payload не сериализуется"}) return {"_error": "payload не сериализуется"}
return text[:PAYLOAD_MAX_LENGTH] if len(text) > PAYLOAD_MAX_LENGTH:
return {"_truncated": text[:PAYLOAD_MAX_LENGTH]}
# Пересобираем из JSON: так в jsonb не попадёт объект, который не сериализуется.
return json.loads(text)
#: Активный клиент (если аналитика не инициализирована — None).
_client: AnalyticsClient | None = None _client: AnalyticsClient | None = None
async def init_analytics( async def init_analytics(
settings: Settings | None = None, settings: Settings | None = None,
*, *,
client: ClickHouseClient | None = None, writer: Writer | None = None,
) -> AnalyticsClient: ) -> AnalyticsClient:
"""Создать и запустить единственный клиент аналитики. Идемпотентно.""" """Создать и запустить единственный клиент аналитики. Идемпотентно."""
global _client global _client
if _client is not None: if _client is not None:
return _client return _client
_client = AnalyticsClient(settings, client=client) _client = AnalyticsClient(settings, writer=writer)
await _client.start() await _client.start()
return _client return _client
async def close_analytics() -> None: async def close_analytics() -> None:
"""Остановить клиент аналитики и сбросить буфер при остановке приложения."""
global _client global _client
if _client is not None: if _client is not None:
await _client.close() await _client.close()
@ -277,7 +262,6 @@ async def close_analytics() -> None:
def get_analytics() -> AnalyticsClient | None: def get_analytics() -> AnalyticsClient | None:
"""Вернуть активный клиент аналитики (или None)."""
return _client return _client
@ -289,13 +273,17 @@ def record(
chat_id: int = 0, chat_id: int = 0,
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
) -> None: ) -> None:
"""Записать событие. Если аналитика не инициализирована — no-op."""
if _client is None: if _client is None:
return return
_client.record(event_type, user_id=user_id, username=username, chat_id=chat_id, payload=payload) _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: def remember_user(user_id: int, username: str | None) -> None:
"""Запомнить username пользователя (для обогащения событий из сервисов)."""
if _client is not None: if _client is not None:
_client.remember_user(user_id, username) _client.remember_user(user_id, username)

View File

@ -1,6 +1,6 @@
"""Каталог событий аналитики. """Каталог событий аналитики.
Единственный источник правды по типам событий ClickHouse. Список синхронизирован Единственный источник правды по типам событий. Список синхронизирован
с документацией (``docs/events.md``) — рассинхрон ловит ``tests/test_analytics.py``. с документацией (``docs/events.md``) — рассинхрон ловит ``tests/test_analytics.py``.
Правило: добавляешь новое событие — добавь его в ``EventType`` и в ``docs/events.md``. Правило: добавляешь новое событие — добавь его в ``EventType`` и в ``docs/events.md``.
@ -12,10 +12,9 @@ from enum import StrEnum
class EventType(StrEnum): class EventType(StrEnum):
"""Типы событий (значение = колонка ``event_type`` в ClickHouse).""" """Типы событий (значение = колонка ``event_type`` в Postgres)."""
# --- Сообщения и команды --- # --- Сообщения и команды ---
MESSAGE = "message"
COMMAND = "command" COMMAND = "command"
# --- Экономика --- # --- Экономика ---

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)

View File

@ -1,17 +1,14 @@
"""Чтение событий ClickHouse для админки (Блок 4.3). """Чтение аналитики из Postgres для админки."""
Клиент создаётся лениво и только на чтение; недоступность ClickHouse не роняет
админку — страница покажет ошибку. Запись событий живёт в ``client.py``.
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import UTC, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from typing import Any from typing import Any
from common.analytics.events import ALL_EVENT_TYPES from common.analytics.events import ALL_EVENT_TYPES
from common.config import Settings from common.config import Settings
from common.db.pool import get_pool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,62 +24,29 @@ EVENT_FIELDS = (
"prod", "prod",
) )
_client: Any | None = None
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
async def _ensure_client(settings: Settings) -> Any: def _event_bounds(start: date, end: date, settings: Settings) -> tuple[datetime, datetime]:
"""Создать (один раз) асинхронный клиент ClickHouse.""" start_dt = datetime.combine(start, time.min, tzinfo=settings.tzinfo).astimezone(UTC)
global _client end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=settings.tzinfo).astimezone(UTC)
if _client is not None: return start_dt, end_dt
return _client
import clickhouse_connect
_client = await clickhouse_connect.get_async_client(
host=settings.clickhouse_host,
port=settings.clickhouse_port,
username=settings.clickhouse_user,
password=settings.clickhouse_password,
database=settings.clickhouse_db,
)
return _client
async def close_client() -> None: def _user_clause(user: str, params: list[Any], column: str = "user_id") -> str:
"""Закрыть клиент при остановке админки."""
global _client
if _client is not None:
client, _client = _client, None
try:
await client.close()
except Exception:
logger.warning("Не удалось закрыть клиент ClickHouse", exc_info=True)
def _where(
*,
event_type: str,
user: str,
start: datetime,
end: datetime,
) -> tuple[str, dict[str, Any]]:
clauses = ["event_time >= {start:DateTime}", "event_time < {end:DateTime}"]
params: dict[str, Any] = {"start": start, "end": end}
if event_type and event_type in ALL_EVENT_TYPES:
clauses.append("event_type = {event_type:String}")
params["event_type"] = event_type
user = user.strip() user = user.strip()
if user: if not user:
return ""
if user.isdigit(): if user.isdigit():
clauses.append("user_id = {user_id:Int64}") params.append(int(user))
params["user_id"] = int(user) return f" AND {column} = %s"
else: params.append(f"%{user}%")
clauses.append("positionCaseInsensitive(username, {user_text:String}) > 0") return " AND username ILIKE %s"
params["user_text"] = user
return " WHERE " + " AND ".join(clauses), params
async def fetch_events( async def fetch_events(
@ -95,31 +59,71 @@ async def fetch_events(
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
) -> tuple[list[dict[str, Any]], int]: ) -> tuple[list[dict[str, Any]], int]:
"""Вернуть события и общее количество по фильтрам. """Вернуть события и общее количество по фильтрам."""
start, end = _dates(date_from, date_to, settings)
start_dt, end_dt = _event_bounds(start, end, settings)
Даты — ``YYYY-MM-DD`` в таймзоне админки; внутри переводятся в UTC. params: list[Any] = [start_dt, end_dt]
""" where = " WHERE event_time >= %s AND event_time < %s"
today = datetime.now(settings.tzinfo).date() if event_type and event_type in ALL_EVENT_TYPES:
start_date = datetime.strptime(date_from, "%Y-%m-%d").date() if date_from else today - timedelta(days=6) where += " AND event_type = %s"
end_date = datetime.strptime(date_to, "%Y-%m-%d").date() if date_to else today params.append(event_type)
where += _user_clause(user, params)
start = datetime.combine(start_date, time.min, tzinfo=settings.tzinfo).astimezone(UTC).replace(tzinfo=None) async with get_pool().connection() as conn:
end = ( cur = await conn.execute(f"SELECT count(*) AS total FROM analytics.events{where}", tuple(params))
datetime.combine(end_date + timedelta(days=1), time.min, tzinfo=settings.tzinfo) row = await cur.fetchone()
.astimezone(UTC) total = int(row["total"]) if row else 0
.replace(tzinfo=None)
)
where, params = _where(event_type=event_type, user=user, start=start, end=end) cur = await conn.execute(
client = await _ensure_client(settings)
count_result = await client.query(f"SELECT count() FROM analytics.events{where}", parameters=params)
total = int(count_result.result_rows[0][0]) if count_result.result_rows else 0
result = await client.query(
f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}" f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}"
" ORDER BY event_time DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32}", " ORDER BY event_time DESC LIMIT %s OFFSET %s",
parameters={**params, "limit": limit, "offset": offset}, (*params, limit, offset),
) )
rows = [dict(zip(result.column_names, row, strict=False)) for row in result.result_rows] 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 return rows, total

View File

@ -54,13 +54,14 @@ class Settings(BaseSettings):
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически). #: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat" heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить. #: Общий каталог картинок (Docker volume), который видят бот и админка.
clickhouse_enabled: bool = True media_dir: str = "/app/media"
clickhouse_host: str = "localhost"
clickhouse_port: int = 8123 #: Публичный адрес админки (deep-link вход, ссылки из бота).
clickhouse_db: str = "analytics" admin_public_url: str = "http://localhost:8000"
clickhouse_user: str = "default"
clickhouse_password: str = "" #: Аналитика событий в Postgres. ANALYTICS_ENABLED=false — полностью выключить.
analytics_enabled: bool = True
#: Батчинг записи событий: размер батча, интервал сброса и предел буфера. #: Батчинг записи событий: размер батча, интервал сброса и предел буфера.
analytics_batch_size: int = 200 analytics_batch_size: int = 200
@ -129,7 +130,7 @@ def get_settings() -> Settings:
class AdminSettings(Settings): class AdminSettings(Settings):
"""Настройки сервиса админки. """Настройки сервиса админки.
Наследует общие поля (окружение, Postgres, таймзона, ClickHouse), но не требует Наследует общие поля (окружение, Postgres, таймзона), но не требует
``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи ``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи
Telegram-логина (Блок 4.2). Telegram-логина (Блок 4.2).
""" """
@ -141,7 +142,7 @@ class AdminSettings(Settings):
admin_port: int = 8000 admin_port: int = 8000
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS). #: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
admin_public_url: str = "http://localhost:8000" #: Наследуется из базовых настроек: ADMIN_PUBLIC_URL.
#: Username бота без @ — из него виджет рисует кнопку входа. #: Username бота без @ — из него виджет рисует кнопку входа.
bot_username: str = "" bot_username: str = ""
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают #: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают

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

@ -51,3 +51,56 @@ async def touch_login(telegram_id: int) -> None:
"UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s", "UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s",
(telegram_id,), (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

@ -50,6 +50,27 @@ async def list_active() -> list[dict[str, Any]]:
return await cur.fetchall() 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 def get_schedule(schedule_id: int) -> dict[str, Any] | None:
async with get_pool().connection() as conn: async with get_pool().connection() as conn:
cur = await conn.execute( cur = await conn.execute(

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

@ -47,6 +47,37 @@ async def get_template(key: str) -> dict[str, Any] | None:
return await cur.fetchone() 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: async def update_template(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any] | None:
"""Обновить текст шаблона и записать историю. """Обновить текст шаблона и записать историю.

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

@ -16,9 +16,9 @@ _FROM = """
LEFT JOIN bot.de_users AS du ON du.id_user = tg.id_user 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 bot.titles AS t ON t.id_title = du.id_title
LEFT JOIN ( LEFT JOIN (
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt SELECT user_id, sum(count) AS cnt, max(day) AS last_dt
FROM bot.messages GROUP BY id_user FROM analytics.messages_daily GROUP BY user_id
) AS msg ON msg.id_user = tg.id_user ) AS msg ON msg.user_id = tg.id_user
LEFT JOIN ( LEFT JOIN (
SELECT id_user, sum(amount) AS balance SELECT id_user, sum(amount) AS balance
FROM bot.money GROUP BY id_user FROM bot.money GROUP BY id_user
@ -138,7 +138,7 @@ async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]
async with get_pool().connection() as conn: async with get_pool().connection() as conn:
cur = await conn.execute( cur = await conn.execute(
""" """
SELECT id, field, old_value, new_value, comment, editor_id, changed_at SELECT id, field, old_value, new_value, comment, editor_id, source, changed_at
FROM bot.user_changes FROM bot.user_changes
WHERE id_user = %s WHERE id_user = %s
ORDER BY changed_at DESC ORDER BY changed_at DESC
@ -157,13 +157,14 @@ async def _log_change(
new_value: str | None, new_value: str | None,
editor_id: int | None, editor_id: int | None,
comment: str | None = None, comment: str | None = None,
source: str = "admin",
) -> None: ) -> None:
await conn.execute( await conn.execute(
""" """
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id) INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id, source)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", """,
(user_id, field, old_value, new_value, comment, editor_id), (user_id, field, old_value, new_value, comment, editor_id, source),
) )

View File

@ -8,7 +8,7 @@ from common.db.pool import get_pool
async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None: async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None:
"""Создать/обновить пользователя и добавить запись о сообщении.""" """Создать/обновить пользователя (счётчик сообщений ведёт аналитика)."""
async with get_pool().connection() as conn, conn.cursor() as cur: async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute( await cur.execute(
"CALL bot.add_user(%s, %s, %s, %s)", "CALL bot.add_user(%s, %s, %s, %s)",
@ -22,6 +22,41 @@ async def add_nick(id_user: int, nick: str) -> None:
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick)) 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]]: async def get_nick_list() -> list[dict[str, Any]]:
"""Список всех игроков (username, nick), отсортированный по нику.""" """Список всех игроков (username, nick), отсортированный по нику."""
async with get_pool().connection() as conn, conn.cursor() as cur: async with get_pool().connection() as conn, conn.cursor() as cur:

View File

@ -8,6 +8,7 @@ from __future__ import annotations
MSG_JOIN = "[вход] {username} вошёл в чат «{chat_title}»" MSG_JOIN = "[вход] {username} вошёл в чат «{chat_title}»"
MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»" MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»"
MSG_KICK = "[кик] {actor} выгнал {username} из чата «{chat_title}»"
MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}" MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}"
MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}" MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}"
MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат" MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат"

View File

@ -23,6 +23,7 @@ RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_cou
#: Полный реестр поддерживаемых тегов: имя -> короткое описание. #: Полный реестр поддерживаемых тегов: имя -> короткое описание.
TAG_REGISTRY: dict[str, str] = { TAG_REGISTRY: dict[str, str] = {
"username": "Имя пользователя (@username или имя)", "username": "Имя пользователя (@username или имя)",
"actor": "Кто выполнил действие (для уведомлений)",
"nick": "Игровой ник", "nick": "Игровой ник",
"title": "Звание", "title": "Звание",
"lvl": "Уровень", "lvl": "Уровень",

View File

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

View File

@ -36,3 +36,18 @@ MSG_WELCOME_TEXT3 = """❗️<b>По игре</b>:
MSG_WELCOME_BUTTON_CAPTION = "Канал" MSG_WELCOME_BUTTON_CAPTION = "Канал"
MSG_WELCOME_BUTTON_URL = "https://t.me/+DQk8MyN2y6QzNTM6" 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`` Единый источник правды. Git-тег совпадает с версией с префиксом ``v``
(например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут (например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут
тегироваться этой версией, а события в ClickHouse получат поле ``bot_version``. тегироваться этой версией, а события аналитики получат поле ``bot_version``.
""" """
from __future__ import annotations from __future__ import annotations
__version__ = "0.4.11" __version__ = "0.7.2"

View File

@ -46,16 +46,13 @@ services:
environment: environment:
DB_HOST: postgres DB_HOST: postgres
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse volumes:
CLICKHOUSE_PORT: "8123" - media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
# Без ожидания здоровья: аналитика некритична, бот работает и при недоступном ClickHouse.
clickhouse:
condition: service_started
logging: logging:
driver: json-file driver: json-file
options: options:
@ -72,10 +69,10 @@ services:
environment: environment:
DB_HOST: postgres DB_HOST: postgres
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT: "8123"
ADMIN_HOST: 0.0.0.0 ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000" ADMIN_PORT: "8000"
volumes:
- media:/app/media
ports: ports:
# По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1: # По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1:
# порт остаётся недоступен из интернета, но виден контейнерам на bridge. # порт остаётся недоступен из интернета, но виден контейнерам на bridge.
@ -91,35 +88,6 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
# Аналитика событий (docs/events.md). Наружу не публикуется:
# только loopback хоста для бэкапа/туннеля; бот ходит внутрь сети compose.
clickhouse:
image: clickhouse/clickhouse-server:25.3
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_DB:-analytics}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-de_bot}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?задайте CLICKHOUSE_PASSWORD}
# Нужно для создания пользователей/грантов (в финале — read-only пользователь для Superset).
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
volumes:
- clickhousedata:/var/lib/clickhouse
# DDL применяется один раз при первой инициализации (пустой data-каталог).
- ./clickhouse/init:/docker-entrypoint-initdb.d:ro
ports:
# Порт контура задаётся в env (prod 8123, test 8124) — иначе конфликт на одной VPS.
- "127.0.0.1:${CLICKHOUSE_HTTP_PORT:-8123}:8123"
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD-SHELL", "wget --spider -q -T 5 http://127.0.0.1:8123/ping || exit 1"]
interval: 15s
timeout: 10s
retries: 10
start_period: 20s
# --- Reverse proxy и TLS (Блок 2.4) --- # --- Reverse proxy и TLS (Блок 2.4) ---
# Включается, когда появится админка/Mini App (Блоки 4–5). # Включается, когда появится админка/Mini App (Блоки 4–5).
# Раскомментировать и положить deploy/Caddyfile: # Раскомментировать и положить deploy/Caddyfile:
@ -137,6 +105,6 @@ services:
volumes: volumes:
pgdata: pgdata:
clickhousedata: media:
# caddy_data: # caddy_data:
# caddy_config: # caddy_config:

View File

@ -51,16 +51,13 @@ services:
# Внутри сети compose БД доступна по имени сервиса. # Внутри сети compose БД доступна по имени сервиса.
DB_HOST: postgres DB_HOST: postgres
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse volumes:
CLICKHOUSE_PORT: "8123" - media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
# Аналитика некритична: бот стартует, даже если ClickHouse ещё не готов.
clickhouse:
condition: service_started
logging: logging:
driver: json-file driver: json-file
options: options:
@ -80,10 +77,10 @@ services:
environment: environment:
DB_HOST: postgres DB_HOST: postgres
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT: "8123"
ADMIN_HOST: 0.0.0.0 ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000" ADMIN_PORT: "8000"
volumes:
- media:/app/media
ports: ports:
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000" - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000"
depends_on: depends_on:
@ -97,31 +94,6 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
# Аналитика событий (Блок 3). DDL — в clickhouse/init/.
clickhouse:
image: clickhouse/clickhouse-server:25.3
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_DB:-analytics}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-de_bot}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?задайте CLICKHOUSE_PASSWORD в .env}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
volumes:
- clickhousedata:/var/lib/clickhouse
- ./clickhouse/init:/docker-entrypoint-initdb.d:ro
ports:
- "127.0.0.1:8123:8123"
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD-SHELL", "wget --spider -q -T 5 http://127.0.0.1:8123/ping || exit 1"]
interval: 15s
timeout: 10s
retries: 10
start_period: 20s
volumes: volumes:
pgdata: pgdata:
clickhousedata: media:

View File

@ -1,48 +1,45 @@
# События аналитики (ClickHouse) # События аналитики (Postgres)
Бот пишет все значимые действия в ClickHouse — единую широкую таблицу Бот пишет значимые действия в Postgres, схема `analytics`. Дашборд (Superset) и ETL —
`analytics.events`. Дашборд (Superset) и ETL — отдельный финальный этап; здесь отдельный финальный этап; здесь описан **контракт** событий: какие бывают `event_type`
описан **контракт** событий: какие бывают `event_type` и что лежит в `payload`. и что лежит в `payload`.
> Источник правды в коде — `common/analytics/events.py` (`EventType`). > Источник правды в коде — `common/analytics/events.py` (`EventType`).
> Расхождение со списком ниже ловит `tests/test_analytics.py`. > Расхождение со списком ниже ловит `tests/test_analytics.py`.
> Добавляешь событие — добавь строку в таблицу. > Добавляешь событие — добавь строку в таблицу.
## Схема в ClickHouse ## Схема в Postgres
DDL — `clickhouse/init/001_events.sql` (применяется при первой инициализации контейнера). DDL — `sql/V26__analytics_schema.sql`.
- `analytics.events` — сырые события, `MergeTree`, `PARTITION BY toYYYYMMDD(event_time)`, - `analytics.events` — события, `PARTITION BY RANGE (event_time)` по дням.
`ORDER BY (event_time, event_type, user_id)`. Партиционирование по дню выбрано специально: Партиции создаёт приложение (бот и админка) на несколько дней вперёд;
сырые партиции ежедневно удаляются после бэкапа (дешёвый `DROP PARTITION`, без mutation). **старые партиции не удаляются** — история хранится бессрочно.
- `analytics.events_daily` — агрегат (`SummingMergeTree`): `day`, `event_type`, `user_id`, - `analytics.messages_daily` — количество сообщений пользователя по дням:
`chat_id`, `events`. Строится материализованным представлением `events_daily_mv`. `day`, `user_id`, `username`, `chat_id`, `count`; PK `(day, user_id, chat_id)`.
- `analytics.money_daily` — агрегат по деньгам: `day`, `event_type`, `user_id`, На каждое сообщение выполняется `INSERT ... ON CONFLICT DO UPDATE count + 1`,
`amount_sum`, `events` (из `payload.amount` событий `money_accrual`/`money_spend`). поэтому вместо строки на каждое сообщение растёт только счётчик за день.
Агрегаты остаются на VPS навсегда, поэтому активность, топ команд и денежные потоки Сообщения **не** пишутся в `analytics.events` отдельным событием `message`.
доступны и после удаления сырых событий. Запросы к агрегатам суммируют `events`/`amount_sum` Построчная таблица `bot.messages` удалена миграцией `V27`: старые строки один раз
(фон слияний `SummingMergeTree` не гарантирует полную свёртку в любой момент). пересчитаны в `analytics.messages_daily`, новые пишутся только счётчиком.
## Хранение и бэкап ## Хранение
| Что | Где | Сколько | | Что | Где | Сколько |
| --- | --- | --- | | --- | --- | --- |
| Сырые события | VPS, `analytics.events` | последние `BACKUP_GRACE_DAYS` дней (по умолчанию 7) | | События | Postgres, `analytics.events` | бессрочно (партиции по дням) |
| Агрегаты | VPS | постоянно | | Счётчики сообщений | Postgres, `analytics.messages_daily` | бессрочно (партиции по дням) |
| Полная история | домашний сервер, свой ClickHouse | архивы `events_YYYY-MM-DD.parquet.gz` (ежедневно) |
Каждую ночь (по Москве) домашний сервер **сам забирает** вчерашнюю партицию с VPS (`pull` Бэкап — общий бэкап Postgres. Отдельного пайплайна ClickHouse/Parquet больше нет.
по ssh), импортирует её в свой ClickHouse и **сверяет количество строк**; только после При росте базы нужно следить за диском: автоматической очистки нет.
успешной сверки он просит VPS удалить партиции старше грейса. Ошибка бэкапа данные не
удаляет. Подробности — `DEPLOY.md` и `deploy/ssh-access.md`.
## Уведомления в чат логов ## Уведомления в чат логов
Помимо записи в ClickHouse часть событий дублируется сообщением в `ALERT_CHAT_ID` Помимо записи в Postgres часть событий дублируется сообщением в `ALERT_CHAT_ID`
(если он задан; `0` — выключено). Отправка не критична: ошибки только логируются. (если он задан; `0` — выключено). Отправка не критична: ошибки только логируются.
| Уведомление | Когда | Событие в ClickHouse | | Уведомление | Когда | Событие |
| --- | --- | --- | | --- | --- | --- |
| Вход участника | кто-то вошёл в основной чат | `join` | | Вход участника | кто-то вошёл в основной чат | `join` |
| Выход участника | кто-то вышел из основного чата | `leave` | | Выход участника | кто-то вышел из основного чата | `leave` |
@ -54,20 +51,19 @@ DDL — `clickhouse/init/001_events.sql` (применяется при перв
| Поле | Тип | Описание | | Поле | Тип | Описание |
| --- | --- | --- | | --- | --- | --- |
| **event_time** | `DateTime('UTC')` | Момент события (UTC, без таймзоны) | | **event_time** | `timestamptz` | Момент события (UTC) |
| **event_type** | `LowCardinality(String)` | Тип события (см. каталог ниже) | | **event_type** | `text` | Тип события (см. каталог ниже) |
| **user_id** | `Int64` | Telegram-ID пользователя (0 — системное событие) | | **user_id** | `bigint` | Telegram-ID пользователя (0 — системное событие) |
| **username** | `String` | `@username` на момент события (может быть пустым) | | **username** | `text` | `@username` на момент события (может быть пустым) |
| **chat_id** | `Int64` | ID чата (обычно основной чат клана) | | **chat_id** | `bigint` | ID чата (обычно основной чат клана) |
| **payload** | `String` | Дополнительные данные события в виде JSON | | **payload** | `jsonb` | Дополнительные данные события |
| **bot_version** | `String` | Версия бота из `common/version.py` | | **bot_version** | `text` | Версия бота из `common/version.py` |
| **prod** | `UInt8` | 1 — прод-контур, 0 — тестовый | | **prod** | `boolean` | `true` — прод-контур, `false` — тестовый |
## Каталог событий ## Каталог событий
| Тип события | Когда пишется | `payload` | | Тип события | Когда пишется | `payload` |
| --- | --- | --- | | --- | --- | --- |
| `message` | Любое сообщение в основном чате | `message_id`, `chat_type`, `content_type`, `text_len`, `is_command` |
| `command` | Использование команды (`/инфо`, `/очищение`, …) | `command`, `args_count` | | `command` | Использование команды (`/инфо`, `/очищение`, …) | `command`, `args_count` |
| `purification` | Ежедневное очищение (`/очищение`) | `success`, `exp_add`, `money_add` | | `purification` | Ежедневное очищение (`/очищение`) | `success`, `exp_add`, `money_add` |
| `exp_accrual` | Начисление опыта за очищение | `amount`, `streak` | | `exp_accrual` | Начисление опыта за очищение | `amount`, `streak` |
@ -78,7 +74,7 @@ DDL — `clickhouse/init/001_events.sql` (применяется при перв
| `purchase_title` | Покупка звания | `title_id`, `price`, `success` | | `purchase_title` | Покупка звания | `title_id`, `price`, `success` |
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` | | `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
| `join` | Вход участника в основной чат | `chat_title` | | `join` | Вход участника в основной чат | `chat_title` |
| `leave` | Выход участника из основного чата | `chat_title` | | `leave` | Выход/исключение участника | `chat_title`, `kicked`, `by_id`, `by_username` |
| `nick_set` | Смена ника (`/ник`) | `nick` | | `nick_set` | Смена ника (`/ник`) | `nick` |
| `subscribe` | Подписка на рассылки (`/подписка`) | — | | `subscribe` | Подписка на рассылки (`/подписка`) | — |
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — | | `unsubscribe` | Отписка от рассылок (`/отписка`) | — |
@ -88,37 +84,44 @@ DDL — `clickhouse/init/001_events.sql` (применяется при перв
| `bot_alive` | Ежечасный пинг «бот живой» | `app_env`, `uptime_seconds` | | `bot_alive` | Ежечасный пинг «бот живой» | `app_env`, `uptime_seconds` |
| `error` | Необработанная ошибка при обработке апдейта | `error_type`, `error`, `update_type` | | `error` | Необработанная ошибка при обработке апдейта | `error_type`, `error`, `update_type` |
## Поведение при недоступности ClickHouse ## Поведение при недоступности Postgres
Аналитика — **некритичная** подсистема: её сбой не должен влиять на бота. Аналитика — **некритичная** подсистема: её сбой не должен влиять на бота.
- события копятся в буфере и отправляются батчами (по размеру и по таймеру); - события и счётчики копятся в буфере и записываются батчами (по размеру и по таймеру);
- если ClickHouse недоступен, буфер не растёт бесконечно: при переполнении самые - если Postgres недоступен, буфер не растёт бесконечно: при переполнении самые
старые записи отбрасываются со счётчиком в логе; старые события отбрасываются;
- если записать батч не удалось, события выводятся в stdout (`WARNING`, первые - если записать батч не удалось, он выводится в stdout (`WARNING`, первые несколько
несколько строк + общее количество) и отбрасываются — данные не пропадают молча; строк + общее количество) и отбрасывается — данные не пропадают молча;
- при остановке бота выполняется финальный сброс буфера; - при остановке бота выполняется финальный сброс буфера;
- выключение целиком — `CLICKHOUSE_ENABLED=false` (тогда `record()` — no-op). - выключение целиком — `ANALYTICS_ENABLED=false` (тогда `record()`/`record_message()` — no-op).
## Примеры запросов ## Примеры запросов
```sql ```sql
-- активность по дням -- события по дням
SELECT toDate(event_time) AS day, count() FROM analytics.events GROUP BY day ORDER BY day; SELECT date_trunc('day', event_time) AS day, count(*)
FROM analytics.events GROUP BY day ORDER BY day;
-- топ команд за неделю -- топ команд за неделю
SELECT JSONExtractString(payload, 'command') AS cmd, count() AS uses SELECT payload->>'command' AS cmd, count(*) AS uses
FROM analytics.events FROM analytics.events
WHERE event_type = 'command' AND event_time >= now() - INTERVAL 7 DAY WHERE event_type = 'command' AND event_time >= now() - interval '7 days'
GROUP BY cmd ORDER BY uses DESC; GROUP BY cmd ORDER BY uses DESC;
-- покупки за месяц -- покупки за месяц
SELECT event_type, count(), sum(JSONExtractInt(payload, 'price')) SELECT event_type, count(*), sum((payload->>'price')::int)
FROM analytics.events FROM analytics.events
WHERE event_type IN ('purchase_title', 'purchase_donat') AND event_time >= now() - INTERVAL 30 DAY WHERE event_type IN ('purchase_title', 'purchase_donat')
AND event_time >= now() - interval '30 days'
GROUP BY event_type; 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, JSONExtractString(payload, 'error_type'), JSONExtractString(payload, 'error') SELECT event_time, payload->>'error_type', payload->>'error'
FROM analytics.events WHERE event_type = 'error' ORDER BY event_time DESC LIMIT 50; FROM analytics.events WHERE event_type = 'error'
ORDER BY event_time DESC LIMIT 50;
``` ```

59
docs/message-sequences.md Normal file
View File

@ -0,0 +1,59 @@
# Последовательности сообщений
Реализовано в 0.5.0. Позволяет собирать приветствие (и любые другие наборы) из
нескольких сообщений по порядку, с картинками и кнопками.
## Модель
- `bot.message_sequences` — `key`, `title`, `description`, `updated_at/by`.
- `bot.message_sequence_items` — сообщения: `sequence_key`, `position`, `text`,
`enabled`, `image_name` (файл в общем volume `media/`).
- `bot.message_sequence_buttons` — кнопки конкретного сообщения: `item_id`,
`position`, `text`, `url`. Кнопок может быть несколько, каждая на своей строке.
- `bot.message_sequence_history` — JSON-снимок последовательности при каждом изменении.
Картинки не лежат в Postgres: они в Docker-volume `media`, который монтируется в
`/app/media` и боту, и админке. При старте админка копирует туда встроенное
`images/welcome_image.jpg`, если файла ещё нет.
## Отправка ботом
`bot/services/sequences.py` читает включённые сообщения по `position` и отправляет:
- с картинкой → `send_photo` (текст подписью, если ≤ 1024 символов; иначе фото без
подписи и отдельное сообщение);
- без картинки → `send_message`;
- кнопки — `InlineKeyboardMarkup`, по одной в ряд.
Для `welcome` при отсутствии/пустоте последовательности остаётся фолбэк на старые
шаблоны `welcome.text*` из кода.
## Админка
`/sequences` — список и создание; `/sequences/{key}` — редактор:
- параметры последовательности;
- сообщения по порядку: текст, картинка (загрузка/удаление), вкл/выкл, кнопки,
перемещение вверх/вниз, удаление;
- предпросмотр и история изменений.
Текст редактируется в textarea с тулбаром: жирный `<b>`, курсив `<i>`,
подчёркивание `<u>`, зачёркивание `<s>`, код `<code>`, блок `<pre>`,
цитата `<blockquote>`, спойлер `<tg-spoiler>`, ссылка `<a href>`. Есть счётчик
символов (1024 для подписи к фото, 4096 для обычного сообщения) и предпросмотр
через тот же `render()`, что использует бот.
Картинки отдаются по `/sequences/media/{filename}` (только после входа).
## Миграции
- `V20__message_sequences.sql` — таблицы и история.
- `V21__seed_welcome_sequence.sql` — перенос текущего приветствия в `welcome`
(подпись к фото + остальной текст с кнопкой «Канал») и удаление старых
шаблонов `welcome.text*` из `message_templates`.
## Проверки
`tests/test_admin_sequences.py` — список, редактор, создание, параметры, добавление,
сохранение текста/кнопок, лимиты, загрузка картинки, порядок и удаление;
`tests/test_welcome_split.py` — фолбэк приветствия.

View File

@ -1,32 +0,0 @@
# Параметры бэкапа событий ClickHouse (pull: домашний сервер забирает данные с VPS).
# Скопируйте в env/backup.env (env/*.env в git не попадает) и заполните.
#
# Запускается НА ДОМАШНЕМ СЕРВЕРЕ по cron:
# scripts/clickhouse_backup.sh test|prod
#
# SSH-доступ к VPS настраивается отдельно (инструкция — deploy/ssh-access.md).
# --- VPS ---
# user@host (хост указывайте без скобок; порт задаётся отдельно)
VPS_SSH=deploy@vps.example
VPS_PORT=27
# Каталог с репозиторием на VPS (там запускается docker compose)
VPS_PROJECT_DIR=/opt/DispelEvilBot
# Приватный ключ для ssh на VPS (публичная часть добавлена в authorized_keys на VPS)
VPS_SSH_KEY=/root/.ssh/id_ed25519_vps
# --- Домашний сервер ---
# Куда складывать архивы events_YYYY-MM-DD.parquet.gz
BACKUP_DIR=/srv/dispelevilbot/events
# --- Хранение ---
# Сколько дней сырых событий остаётся на VPS (остальное удаляется после успешной сверки)
BACKUP_GRACE_DAYS=7
# Таймзона расчёта «вчерашнего» дня
BACKUP_TZ=Europe/Moscow
# --- ClickHouse домашнего сервера (сюда импортируются архивы) ---
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=9000
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=

10
env/prod.env.example vendored
View File

@ -37,14 +37,8 @@ ALERT_CHAT_ID=0
ADMIN_IDS= ADMIN_IDS=
TIMEZONE=Europe/Moscow TIMEZONE=Europe/Moscow
# ClickHouse (аналитика событий, см. docs/events.md). # Аналитика (Postgres, схема analytics; см. docs/events.md).
# Наружу не публикуется: только loopback хоста для бэкапа/туннеля; ANALYTICS_ENABLED=true
# бот ходит внутрь сети compose по имени сервиса clickhouse:8123.
CLICKHOUSE_DB=analytics
CLICKHOUSE_USER=de_bot
CLICKHOUSE_PASSWORD=
# Порт на хосте (для бэкапа). У test и prod он должен различаться.
CLICKHOUSE_HTTP_PORT=8123
# Админка (FastAPI). Порт на хосте; у test и prod должен различаться # Админка (FastAPI). Порт на хосте; у test и prod должен различаться
# (test 8100, prod 8101 — 8000 занят Portainer). # (test 8100, prod 8101 — 8000 занят Portainer).

10
env/test.env.example vendored
View File

@ -35,14 +35,8 @@ ALERT_CHAT_ID=0
ADMIN_IDS= ADMIN_IDS=
TIMEZONE=Europe/Moscow TIMEZONE=Europe/Moscow
# ClickHouse (аналитика событий, см. docs/events.md). # Аналитика (Postgres, схема analytics; см. docs/events.md).
# Наружу не публикуется: только loopback хоста для бэкапа/туннеля; ANALYTICS_ENABLED=true
# бот ходит внутрь сети compose по имени сервиса clickhouse:8123.
CLICKHOUSE_DB=analytics
CLICKHOUSE_USER=de_bot
CLICKHOUSE_PASSWORD=
# Порт на хосте: у test он отличается от prod (8123), иначе конфликт на одной VPS.
CLICKHOUSE_HTTP_PORT=8124
# Админка (FastAPI). Порт на хосте; у test и prod должен различаться # Админка (FastAPI). Порт на хосте; у test и prod должен различаться
# (test 8100, prod 8101 — 8000 занят Portainer). # (test 8100, prod 8101 — 8000 занят Portainer).

View File

@ -17,5 +17,8 @@ tzdata>=2024.1
# Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей. # Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей.
psycopg[binary,pool]>=3.2,<4.0 psycopg[binary,pool]>=3.2,<4.0
# Telegram Bot API для модерации (исключение из чата) и уведомлений.
httpx>=0.27,<1.0
# Разбор form-data (HTMX-формы). # Разбор form-data (HTMX-формы).
python-multipart>=0.0.9,<1.0 python-multipart>=0.0.9,<1.0

View File

@ -10,7 +10,5 @@ python-dotenv>=1.0,<2.0
tzdata>=2024.1 tzdata>=2024.1
psycopg[binary,pool]>=3.2,<4.0 psycopg[binary,pool]>=3.2,<4.0
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.
clickhouse-connect[async]>=1.0,<2.0
# Блок 4 (расписания из БД) — при необходимости: # Блок 4 (расписания из БД) — при необходимости:
# APScheduler>=3.10,<4.0 # APScheduler>=3.10,<4.0

View File

@ -25,7 +25,7 @@ echo "== проверки =="
running="$($COMPOSE ps --services --status running || true)" running="$($COMPOSE ps --services --status running || true)"
fail=0 fail=0
for svc in postgres clickhouse bot; do for svc in postgres bot admin; do
if echo "$running" | grep -qx "$svc"; then if echo "$running" | grep -qx "$svc"; then
echo "OK ${svc}: running" echo "OK ${svc}: running"
else else
@ -49,17 +49,11 @@ else
fail=1 fail=1
fi fi
# ClickHouse отвечает и схема аналитики применена (initdb применяется один раз, # Схема аналитики в Postgres (создаётся миграцией V26)
# при первой инициализации пустого data-каталога) if $COMPOSE exec -T postgres sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT to_regclass('"'"'analytics.events'"'"') IS NOT NULL AND to_regclass('"'"'analytics.messages_daily'"'"') IS NOT NULL"' | grep -qx t; then
if $COMPOSE exec -T clickhouse sh -lc 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" --query "SELECT 1"' >/dev/null 2>&1; then echo "OK postgres: схема analytics доступна"
echo "OK clickhouse: принимает запросы"
if $COMPOSE exec -T clickhouse sh -lc 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" --query "SELECT count() FROM analytics.events"' >/dev/null 2>&1; then
echo "OK clickhouse: таблица analytics.events доступна"
else else
echo "WARN clickhouse: нет доступа к analytics.events (см. clickhouse/init/001_events.sql)" >&2 echo "FAIL postgres: нет таблиц analytics.events/messages_daily (см. sql/V26__analytics_schema.sql)" >&2
fi
else
echo "FAIL clickhouse: не отвечает" >&2
fail=1 fail=1
fi fi

View File

@ -1,129 +0,0 @@
#!/usr/bin/env bash
# Бэкап сырых событий ClickHouse: домашний сервер забирает данные с VPS (pull).
#
# Запускается НА ДОМАШНЕМ СЕРВЕРЕ по cron. Входящий SSH на домашний сервер не нужен:
# скрипт сам ходит на VPS по ключу и запускает там docker compose.
#
# Что делает:
# 1) по ssh выгружает на VPS вчерашнюю партицию analytics.events в Parquet;
# 2) складывает events_YYYY-MM-DD.parquet.gz в BACKUP_DIR;
# 3) импортирует архив в локальный ClickHouse (идемпотентно, scripts/clickhouse_import.sh);
# 4) сверяет количество строк на VPS и локально;
# 5) только после успешной сверки просит VPS удалить партиции старше BACKUP_GRACE_DAYS.
#
# Ошибка на любом шаге оставляет данные на VPS (партиции не удаляются) и возвращает ненулевой код.
#
# Использование:
# ./scripts/clickhouse_backup.sh test # или prod
# make backup E=test
#
# Настройки — env/backup.env (см. env/backup.env.example).
set -euo pipefail
E="${1:-test}"
BACKUP_ENV="${BACKUP_ENV:-env/backup.env}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
fail() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ОШИБКА: $*" >&2
exit 1
}
[ -f "$BACKUP_ENV" ] || fail "нет файла настроек ${BACKUP_ENV} (скопируйте env/backup.env.example)"
# shellcheck disable=SC1090
set -a && . "./${BACKUP_ENV}" && set +a
: "${VPS_SSH:?задайте VPS_SSH в env/backup.env}"
: "${BACKUP_DIR:?задайте BACKUP_DIR в env/backup.env}"
VPS_PORT="${VPS_PORT:-22}"
VPS_PROJECT_DIR="${VPS_PROJECT_DIR:?задайте VPS_PROJECT_DIR в env/backup.env}"
VPS_SSH_KEY="${VPS_SSH_KEY:-$HOME/.ssh/id_ed25519_vps}"
VPS_COMPOSE_FILE="${VPS_COMPOSE_FILE:-docker-compose.prod.yml}"
BACKUP_GRACE_DAYS="${BACKUP_GRACE_DAYS:-7}"
BACKUP_TZ="${BACKUP_TZ:-Europe/Moscow}"
IMPORT_SCRIPT="${IMPORT_SCRIPT:-$(dirname "$0")/clickhouse_import.sh}"
SCHEMA_FILE="${SCHEMA_FILE:-$(dirname "$0")/../clickhouse/init/001_events.sql}"
SSH=(ssh -i "$VPS_SSH_KEY" -p "$VPS_PORT" -o BatchMode=yes -o ServerAliveInterval=30 "$VPS_SSH")
# Запустить команду на VPS. Пароль ClickHouse читается из окружения контейнера,
# поэтому клиент вызывается через `sh -c` уже внутри контейнера.
# ssh склеивает аргументы в одну строку и отдаёт её удалённому shell, поэтому каждый
# аргумент экранируем: в SQL есть пробелы, скобки и кавычки.
vps_ch() {
local quoted=() arg
for arg in "$@"; do
quoted+=("$(printf '%q' "$arg")")
done
"${SSH[@]}" bash -s "$VPS_PROJECT_DIR" "$E" "$VPS_COMPOSE_FILE" "${quoted[@]}" <<'REMOTE'
set -euo pipefail
project_dir="$1"; contour="$2"; compose_file="$3"; shift 3
cd "$project_dir"
exec docker compose -p "$contour" --env-file "env/$contour.env" -f "$compose_file" exec -T clickhouse \
sh -c 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" "$@"' sh "$@"
REMOTE
}
# Какие сервисы контура запущены на VPS (пусто, если проекта/контура нет).
vps_running_services() {
"${SSH[@]}" bash -s "$VPS_PROJECT_DIR" "$E" "$VPS_COMPOSE_FILE" <<'REMOTE'
set -uo pipefail
if [ ! -d "$1" ]; then
echo "каталог проекта $1 на VPS не найден" >&2
exit 0
fi
cd "$1" || exit 0
docker compose -p "$2" --env-file "env/$2.env" -f "$3" ps --services --status running 2>/dev/null || true
REMOTE
}
DAY="$(TZ="$BACKUP_TZ" date -d 'yesterday' '+%Y-%m-%d')"
PARTITION="${DAY//-/}"
GRACE_CUTOFF="$(TZ="$BACKUP_TZ" date -d "${BACKUP_GRACE_DAYS} days ago" '+%Y%m%d')"
log "Бэкап событий контура ${E} за ${DAY} (грейс ${BACKUP_GRACE_DAYS} дней, порог ${GRACE_CUTOFF})"
# Бот может быть не запущен (или ещё не развёрнут) — это не ошибка, просто пропускаем прогон.
if ! RUNNING="$(vps_running_services)"; then
fail "не удалось получить состояние контура ${E} на VPS (проверьте ssh и VPS_SSH)"
fi
if ! grep -qx 'clickhouse' <<<"$RUNNING"; then
log "Контур ${E} на VPS не запущен — бэкап пропущен, на VPS ничего не меняем"
exit 0
fi
mkdir -p "$BACKUP_DIR"
ARCHIVE="${BACKUP_DIR}/events_${DAY}.parquet.gz"
ROWS_VPS="$(vps_ch --param_day="$DAY" --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate({day:String})" | tr -d '[:space:]')"
log "На VPS строк за ${DAY}: ${ROWS_VPS}"
if [ "$ROWS_VPS" = "0" ]; then
log "ВНИМАНИЕ: за ${DAY} событий нет — партиция пустая, продолжаю (сверка 0 = 0)"
fi
log "Выгружаю партицию в ${ARCHIVE}"
vps_ch --param_day="$DAY" --query "SELECT * FROM analytics.events WHERE toDate(event_time) = toDate({day:String}) FORMAT Parquet" |
gzip -9 >"${ARCHIVE}.part"
[ -s "${ARCHIVE}.part" ] || fail "не удалось выгрузить архив ${DAY}"
mv "${ARCHIVE}.part" "$ARCHIVE"
log "Размер архива: $(du -h "$ARCHIVE" | cut -f1)"
log "Импортирую архив в ClickHouse домашнего сервера"
ROWS_HOME="$(BACKUP_DIR="$BACKUP_DIR" SCHEMA_FILE="$SCHEMA_FILE" "$IMPORT_SCRIPT" "$DAY" | tail -n1 | tr -d '[:space:]')"
log "На домашнем сервере строк за ${DAY}: ${ROWS_HOME}"
[ "$ROWS_VPS" = "$ROWS_HOME" ] || fail "расхождение строк (VPS ${ROWS_VPS}, домашний ${ROWS_HOME}) — партиции на VPS не удаляю"
log "Удаляю на VPS партиции старше ${GRACE_CUTOFF}"
DROPPED="$(vps_ch --query "SELECT DISTINCT partition FROM system.parts WHERE database='analytics' AND table='events' AND active AND partition < '${GRACE_CUTOFF}'")"
if [ -z "$DROPPED" ]; then
log "Удалять нечего"
else
for partition in $DROPPED; do
log "DROP PARTITION ID '${partition}'"
vps_ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${partition}'"
done
fi
log "Готово: события за ${DAY} в архиве ${ARCHIVE}, на VPS остались агрегаты и последние ${BACKUP_GRACE_DAYS} дней сырых событий"

View File

@ -1,58 +0,0 @@
#!/usr/bin/env bash
# Импорт дневного архива событий в ClickHouse домашнего сервера.
#
# Запускается на домашнем сервере; обычно вызывается из scripts/clickhouse_backup.sh,
# но можно и вручную — например, чтобы повторно залить архив после сбоя.
#
# Использование:
# ./scripts/clickhouse_import.sh 2026-01-01
# В stdout печатается количество строк загруженного дня (для сверки), диагностика — в stderr.
#
# Настройки (окружение):
# BACKUP_DIR каталог с архивами events_YYYY-MM-DD.parquet.gz
# SCHEMA_FILE DDL схемы (по умолчанию clickhouse/init/001_events.sql рядом с репозиторием)
# CLICKHOUSE_CLIENT бинарь клиента (по умолчанию clickhouse-client)
# CLICKHOUSE_HOST/PORT/USER/PASSWORD доступ к ClickHouse домашнего сервера
set -euo pipefail
DAY="${1:?укажите дату в формате YYYY-MM-DD}"
PARTITION="${DAY//-/}"
ARCHIVE_DIR="${BACKUP_DIR:-.}"
ARCHIVE="${ARCHIVE_DIR}/events_${DAY}.parquet.gz"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCHEMA_FILE="${SCHEMA_FILE:-${SCRIPT_DIR}/../clickhouse/init/001_events.sql}"
CLIENT="${CLICKHOUSE_CLIENT:-clickhouse-client}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2; }
ch() {
"$CLIENT" \
--host "${CLICKHOUSE_HOST:-localhost}" \
--port "${CLICKHOUSE_PORT:-9000}" \
--user "${CLICKHOUSE_USER:-default}" \
--password "${CLICKHOUSE_PASSWORD:-}" "$@"
}
[ -f "$ARCHIVE" ] || {
log "нет файла архива ${ARCHIVE}"
exit 1
}
[ -f "$SCHEMA_FILE" ] || {
log "нет файла схемы ${SCHEMA_FILE} (канонический DDL — clickhouse/init/001_events.sql)"
exit 1
}
log "Применяю схему из ${SCHEMA_FILE}"
ch --multiquery <"$SCHEMA_FILE"
# Повторный импорт того же дня не должен дублировать строки.
log "Очищаю партицию ${PARTITION} перед импортом"
ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${PARTITION}'" 2>/dev/null || true
log "Импортирую ${ARCHIVE} в analytics.events"
gzip -dc "$ARCHIVE" | ch --query "INSERT INTO analytics.events FORMAT Parquet"
log "Импорт завершён"
ch --param_day="$DAY" --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate({day:String})"

View File

@ -6,10 +6,10 @@ CREATE OR REPLACE FUNCTION bot.get_top_list()
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
SELECT tu.username, du.nick, count(*) AS cnt SELECT tu.username, du.nick, sum(m.count)::bigint AS cnt
FROM bot.messages m FROM analytics.messages_daily m
INNER JOIN bot.tg_users tu ON m.id_user = tu.id_user INNER JOIN bot.tg_users tu ON m.user_id = tu.id_user
INNER JOIN bot.de_users du ON m.id_user = du.id_user INNER JOIN bot.de_users du ON m.user_id = du.id_user
GROUP BY tu.username, du.nick GROUP BY tu.username, du.nick
ORDER BY 3 DESC ORDER BY 3 DESC
LIMIT 10; LIMIT 10;

View File

@ -75,15 +75,15 @@ BEGIN
LEFT JOIN bot.de_users de ON tg.id_user = de.id_user LEFT JOIN bot.de_users de ON tg.id_user = de.id_user
LEFT JOIN bot.titles t ON de.id_title = t.id_title LEFT JOIN bot.titles t ON de.id_title = t.id_title
LEFT JOIN ( LEFT JOIN (
SELECT id_user, count(*) AS msg_count SELECT user_id, sum(count)::bigint AS msg_count
FROM bot.messages FROM analytics.messages_daily
GROUP BY id_user GROUP BY user_id
) m ON tg.id_user = m.id_user ) m ON tg.id_user = m.user_id
WHERE tg.id_user = p_id_user; WHERE tg.id_user = p_id_user;
END; END;
$$; $$;
-- Создать/обновить пользователя и записать сообщение. -- Создать/обновить пользователя.
CREATE OR REPLACE PROCEDURE bot.add_user( CREATE OR REPLACE PROCEDURE bot.add_user(
IN p_id_user bigint, IN p_id_user bigint,
IN p_username character varying, IN p_username character varying,
@ -102,8 +102,6 @@ BEGIN
INSERT INTO bot.de_users (id_user, nick) INSERT INTO bot.de_users (id_user, nick)
VALUES (p_id_user, NULL) VALUES (p_id_user, NULL)
ON CONFLICT (id_user) DO NOTHING; ON CONFLICT (id_user) DO NOTHING;
INSERT INTO bot.messages (id_user) VALUES (p_id_user);
END; END;
$$; $$;
@ -127,7 +125,7 @@ DECLARE
BEGIN BEGIN
DELETE FROM bot.tg_users WHERE id_user = p_id_user; DELETE FROM bot.tg_users WHERE id_user = p_id_user;
DELETE FROM bot.de_users WHERE id_user = p_id_user; DELETE FROM bot.de_users WHERE id_user = p_id_user;
DELETE FROM bot.messages WHERE id_user = p_id_user; DELETE FROM analytics.messages_daily WHERE user_id = p_id_user;
DELETE FROM bot.purifications WHERE id_user = p_id_user; DELETE FROM bot.purifications WHERE id_user = p_id_user;
v_money := bot.get_money(p_id_user); v_money := bot.get_money(p_id_user);

View File

@ -0,0 +1,5 @@
-- Источник изменения пользователя: действие самого пользователя (бот) или админа.
ALTER TABLE bot.user_changes
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'admin';
COMMENT ON COLUMN bot.user_changes.source IS 'Кто изменил: user (сам через бота) или admin (веб-панель)';

View File

@ -0,0 +1,48 @@
-- Последовательности сообщений (Блок 4): несколько сообщений по порядку,
-- у каждого — опциональная картинка (файл в общем volume) и свои кнопки.
CREATE TABLE IF NOT EXISTS bot.message_sequences (
key text PRIMARY KEY,
title text NOT NULL,
description text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by bigint
);
COMMENT ON TABLE bot.message_sequences IS 'Именованные последовательности сообщений (приветствие и т.п.)';
CREATE TABLE IF NOT EXISTS bot.message_sequence_items (
id bigserial PRIMARY KEY,
sequence_key text NOT NULL REFERENCES bot.message_sequences (key) ON DELETE CASCADE,
position integer NOT NULL,
text text NOT NULL DEFAULT '',
enabled boolean NOT NULL DEFAULT true,
image_name text,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by bigint,
UNIQUE (sequence_key, position)
);
COMMENT ON TABLE bot.message_sequence_items IS 'Сообщения последовательности по порядку';
COMMENT ON COLUMN bot.message_sequence_items.image_name IS 'Имя файла в общем volume media/ (необязательно)';
CREATE TABLE IF NOT EXISTS bot.message_sequence_buttons (
id bigserial PRIMARY KEY,
item_id bigint NOT NULL REFERENCES bot.message_sequence_items (id) ON DELETE CASCADE,
position integer NOT NULL,
text text NOT NULL,
url text NOT NULL,
UNIQUE (item_id, position)
);
COMMENT ON TABLE bot.message_sequence_buttons IS 'Inline-кнопки конкретного сообщения последовательности';
CREATE TABLE IF NOT EXISTS bot.message_sequence_history (
id bigserial PRIMARY KEY,
sequence_key text NOT NULL,
snapshot jsonb NOT NULL,
editor_id bigint,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sequence_history_key_time
ON bot.message_sequence_history (sequence_key, changed_at DESC);

View File

@ -0,0 +1,37 @@
-- Переносим текущее приветствие в последовательность `welcome`:
-- 1) короткая подпись к фото, 2) остальной текст одним сообщением с кнопкой «Канал».
INSERT INTO bot.message_sequences (key, title, description)
VALUES ('welcome', 'Приветствие', 'Сообщения при входе участника в чат')
ON CONFLICT (key) DO NOTHING;
INSERT INTO bot.message_sequence_items (sequence_key, position, text, image_name)
SELECT 'welcome', 0, split_part(t.text, E'\n', 1), 'welcome_image.jpg'
FROM bot.message_templates AS t
WHERE t.key = 'welcome.text'
ON CONFLICT (sequence_key, position) DO NOTHING;
INSERT INTO bot.message_sequence_items (sequence_key, position, text)
SELECT
'welcome',
1,
trim(both E'\n' from (
substr(src.text, strpos(src.text, E'\n') + 1)
|| E'\n\n' || coalesce(t2.text, '')
|| E'\n\n' || coalesce(t3.text, '')
))
FROM bot.message_templates AS src
LEFT JOIN bot.message_templates AS t2 ON t2.key = 'welcome.text2'
LEFT JOIN bot.message_templates AS t3 ON t3.key = 'welcome.text3'
WHERE src.key = 'welcome.text'
ON CONFLICT (sequence_key, position) DO NOTHING;
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
SELECT i.id, 0, 'Канал', 'https://t.me/+DQk8MyN2y6QzNTM6'
FROM bot.message_sequence_items AS i
WHERE i.sequence_key = 'welcome'
AND i.position = 1
AND NOT EXISTS (SELECT 1 FROM bot.message_sequence_buttons AS b WHERE b.item_id = i.id);
-- Старые шаблоны приветствия больше не редактируются: источник правды — последовательность.
DELETE FROM bot.message_templates
WHERE key IN ('welcome.text', 'welcome.text2', 'welcome.text3');

View File

@ -0,0 +1,17 @@
-- Журнал сообщений, отправленных администратором из веб-панели в чат.
CREATE TABLE IF NOT EXISTS bot.admin_messages (
id bigserial PRIMARY KEY,
editor_id bigint,
chat_id bigint NOT NULL,
text text NOT NULL DEFAULT '',
buttons jsonb NOT NULL DEFAULT '[]'::jsonb,
has_image boolean NOT NULL DEFAULT false,
success boolean NOT NULL,
error text,
sent_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_messages_sent_at
ON bot.admin_messages (sent_at DESC);
COMMENT ON TABLE bot.admin_messages IS 'Аудит ручных отправок сообщений из админки';

View File

@ -0,0 +1,14 @@
-- Одноразовые токены для входа в админку через установленный Telegram (deep-link).
CREATE TABLE IF NOT EXISTS bot.admin_login_tokens (
token_hash text PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
confirmed_at timestamptz,
used_at timestamptz,
telegram_id bigint
);
CREATE INDEX IF NOT EXISTS idx_admin_login_tokens_expires
ON bot.admin_login_tokens (expires_at);
COMMENT ON TABLE bot.admin_login_tokens IS 'Одноразовые токены deep-link входа в админку (хранится SHA-256)';

View File

@ -0,0 +1,19 @@
-- Переводим все текущие шаблоны сообщений в последовательности (по одному сообщению).
-- Старые строки message_templates оставляем как фолбэк для кода.
INSERT INTO bot.message_sequences (key, title, description)
SELECT t.key,
coalesce(nullif(t.description, ''), t.key),
t.description
FROM bot.message_templates AS t
WHERE NOT EXISTS (SELECT 1 FROM bot.message_sequences AS s WHERE s.key = t.key)
ON CONFLICT (key) DO NOTHING;
INSERT INTO bot.message_sequence_items (sequence_key, position, text, enabled)
SELECT t.key, 0, t.text, t.enabled
FROM bot.message_templates AS t
WHERE NOT EXISTS (
SELECT 1
FROM bot.message_sequence_items AS i
WHERE i.sequence_key = t.key AND i.position = 0
)
ON CONFLICT (sequence_key, position) DO NOTHING;

View File

@ -0,0 +1,11 @@
-- Отметки отправленных минут: защита от повторной рассылки после перезапуска бота.
CREATE TABLE IF NOT EXISTS bot.schedule_runs (
schedule_id bigint NOT NULL,
run_at timestamptz NOT NULL,
PRIMARY KEY (schedule_id, run_at)
);
CREATE INDEX IF NOT EXISTS idx_schedule_runs_run_at
ON bot.schedule_runs (run_at);
COMMENT ON TABLE bot.schedule_runs IS 'Какие минуты рассылок уже отработаны (атомарная защита от дублей)';

View File

@ -0,0 +1,33 @@
-- Аналитика в Postgres: события и дневные счётчики сообщений.
-- Партиции по дням создаёт приложение; старые партиции не удаляются.
CREATE SCHEMA IF NOT EXISTS analytics;
CREATE TABLE IF NOT EXISTS analytics.events (
event_time timestamptz NOT NULL,
event_type text NOT NULL,
user_id bigint NOT NULL DEFAULT 0,
username text NOT NULL DEFAULT '',
chat_id bigint NOT NULL DEFAULT 0,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
bot_version text NOT NULL DEFAULT '',
prod boolean NOT NULL DEFAULT false
) PARTITION BY RANGE (event_time);
CREATE INDEX IF NOT EXISTS idx_events_time ON analytics.events (event_time);
CREATE INDEX IF NOT EXISTS idx_events_type_time ON analytics.events (event_type, event_time);
CREATE INDEX IF NOT EXISTS idx_events_user_time ON analytics.events (user_id, event_time);
COMMENT ON TABLE analytics.events IS 'События бота (партиции по дням, без автоудаления)';
CREATE TABLE IF NOT EXISTS analytics.messages_daily (
day date NOT NULL,
user_id bigint NOT NULL,
username text NOT NULL DEFAULT '',
chat_id bigint NOT NULL DEFAULT 0,
count bigint NOT NULL DEFAULT 0,
PRIMARY KEY (day, user_id, chat_id)
) PARTITION BY RANGE (day);
CREATE INDEX IF NOT EXISTS idx_messages_daily_user_day ON analytics.messages_daily (user_id, day);
COMMENT ON TABLE analytics.messages_daily IS 'Количество сообщений пользователя по дням (upsert)';

View File

@ -0,0 +1,51 @@
-- Переход на дневные счётчики: переносим старые построчные сообщения
-- в analytics.messages_daily и удаляем журнал bot.messages.
--
-- Для старых строк chat_id неизвестен (в bot.messages его не было), поэтому
-- пишем 0. Чтение группирует по (day, user_id) и суммирует count, так что
-- старые и новые записи одного дня не дублируются в интерфейсе.
DO $$
DECLARE
min_day date;
max_day date;
d date;
BEGIN
SELECT min((dt AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow')::date),
max((dt AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow')::date)
INTO min_day, max_day
FROM bot.messages
WHERE dt IS NOT NULL;
IF min_day IS NULL THEN
RETURN;
END IF;
-- Партиции на все исторические дни, иначе вставка ниже не найдёт партицию.
FOR d IN
SELECT min_day + i
FROM generate_series(0, max_day - min_day) AS i
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS analytics.messages_daily_%s '
'PARTITION OF analytics.messages_daily '
'FOR VALUES FROM (%L) TO (%L)',
to_char(d, 'YYYYMMDD'), d, d + 1
);
END LOOP;
END $$;
INSERT INTO analytics.messages_daily (day, user_id, username, chat_id, count)
SELECT (m.dt AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow')::date AS day,
m.id_user,
coalesce(tg.username, '') AS username,
0 AS chat_id,
count(*) AS count
FROM bot.messages AS m
LEFT JOIN bot.tg_users AS tg ON tg.id_user = m.id_user
WHERE m.dt IS NOT NULL
GROUP BY 1, 2, 3
ON CONFLICT (day, user_id, chat_id) DO UPDATE
SET count = analytics.messages_daily.count + EXCLUDED.count,
username = EXCLUDED.username;
DROP TABLE IF EXISTS bot.messages;

23
tests/test_access_chat.py Normal file
View File

@ -0,0 +1,23 @@
"""Тесты фильтра чатов: бот игнорирует чужие группы."""
from __future__ import annotations
from bot.services.access import is_allowed_chat
def test_private_chat_is_allowed() -> None:
assert is_allowed_chat(123, "private", 100) is True
def test_main_chat_is_allowed() -> None:
assert is_allowed_chat(100, "supergroup", 100) is True
assert is_allowed_chat(100, "group", 100) is True
def test_foreign_group_is_ignored() -> None:
assert is_allowed_chat(200, "supergroup", 100) is False
assert is_allowed_chat(200, "group", 100) is False
def test_foreign_channel_is_ignored() -> None:
assert is_allowed_chat(200, "channel", 100) is False

View File

@ -1,144 +0,0 @@
"""Тесты админки: каркас, раздел «Сообщения» (без БД — репозиторий подменяется)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.repositories import templates_repo
TEMPLATE: dict[str, Any] = {
"key": "info.help",
"text": "Справка по командам",
"description": "Справка (/помощь)",
"enabled": True,
"updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
"updated_by": None,
}
def _patch_template(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get(key: str) -> dict[str, Any] | None:
return TEMPLATE if key == TEMPLATE["key"] else None
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
return []
monkeypatch.setattr(templates_repo, "get_template", fake_get)
monkeypatch.setattr(templates_repo, "template_history", fake_history)
def test_healthz(client_factory) -> None:
response = client_factory(authenticated=False).get("/healthz")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["app_env"] == "dev"
def test_index_renders_sections(client_factory) -> None:
response = client_factory().get("/")
assert response.status_code == 200
assert "Панель администрирования" in response.text
assert "Расписания" in response.text
def test_messages_list(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_list() -> list[dict[str, Any]]:
return [{**TEMPLATE, "text_length": len(TEMPLATE["text"])}]
monkeypatch.setattr(templates_repo, "list_templates", fake_list)
response = client_factory().get("/messages")
assert response.status_code == 200
assert "info.help" in response.text
assert "Справка (/помощь)" in response.text
def test_message_editor_and_history(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
_patch_template(monkeypatch)
response = client_factory().get("/messages/info.help")
assert response.status_code == 200
assert "Справка по командам" in response.text
assert "Доступные теги" in response.text
def test_message_editor_404(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get(key: str) -> dict[str, Any] | None:
return None
monkeypatch.setattr(templates_repo, "get_template", fake_get)
assert client_factory().get("/messages/unknown").status_code == 404
def test_preview_renders_tags(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
_patch_template(monkeypatch)
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/info.help").text)
response = client.post("/messages/preview", data={"text": "Привет, {nick}!", "csrf_token": token})
assert response.status_code == 200
assert "ТестовыйНик" in response.text
assert "<iframe" in response.text
def test_preview_reports_unknown_tag(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
_patch_template(monkeypatch)
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/info.help").text)
response = client.post("/messages/preview", data={"text": "Привет, {unknown_tag}!", "csrf_token": token})
assert response.status_code == 200
assert "Неизвестные теги" in response.text
def test_preview_requires_csrf(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
_patch_template(monkeypatch)
response = client_factory().post("/messages/preview", data={"text": "Привет"})
assert response.status_code == 400
def test_save_rejects_unknown_tag(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
_patch_template(monkeypatch)
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/info.help").text)
response = client.post(
"/messages/info.help",
data={"text": "текст {unknown_tag}", "csrf_token": token},
)
assert response.status_code == 400
assert "Неизвестные теги" in response.text
def test_save_updates_template(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
saved: dict[str, Any] = {}
async def fake_update(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any]:
saved.update(key=key, text=text, editor_id=editor_id)
return {"key": key, "text": text, "old_text": TEMPLATE["text"], "changed": True}
_patch_template(monkeypatch)
monkeypatch.setattr(templates_repo, "update_template", fake_update)
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/info.help").text)
response = client.post("/messages/info.help", data={"text": "Новая справка", "csrf_token": token})
assert response.status_code == 200
assert "Текст сохранён" in response.text
assert saved == {"key": "info.help", "text": "Новая справка", "editor_id": 1}

161
tests/test_admin_admins.py Normal file
View File

@ -0,0 +1,161 @@
"""Тесты раздела «Админы»: добавление, роли, включение и удаление."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.repositories import admins_repo
ADMIN_1: dict[str, Any] = {
"telegram_id": 1,
"username": "admin",
"role": "admin",
"enabled": True,
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
"last_login_at": datetime(2026, 10, 25, 12, 0, tzinfo=UTC),
}
ADMIN_2: dict[str, Any] = {
"telegram_id": 2,
"username": "second",
"role": "moderator",
"enabled": True,
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
"last_login_at": None,
}
@pytest.fixture
def fake_admins(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"admins": [ADMIN_1, ADMIN_2]}
async def list_admins() -> list[dict[str, Any]]:
return state["admins"]
async def upsert_admin(telegram_id: int, username: str | None, role: str) -> None:
state["upsert"] = (telegram_id, username, role)
async def set_admin_role(telegram_id: int, role: str) -> bool:
state["role"] = (telegram_id, role)
return True
async def set_admin_enabled(telegram_id: int, enabled: bool) -> bool:
state["enabled"] = (telegram_id, enabled)
return True
async def delete_admin(telegram_id: int) -> bool:
state["deleted"] = telegram_id
return True
monkeypatch.setattr(admins_repo, "list_admins", list_admins)
monkeypatch.setattr(admins_repo, "upsert_admin", upsert_admin)
monkeypatch.setattr(admins_repo, "set_admin_role", set_admin_role)
monkeypatch.setattr(admins_repo, "set_admin_enabled", set_admin_enabled)
monkeypatch.setattr(admins_repo, "delete_admin", delete_admin)
return state
def _csrf(client: TestClient, csrf_token) -> str:
return csrf_token(client.get("/admins").text)
def test_admins_list(fake_admins: dict[str, Any], client_factory) -> None:
response = client_factory().get("/admins")
assert response.status_code == 200
assert "second" in response.text
assert "(вы)" in response.text
def test_add_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post(
"/admins",
data={"telegram_id": "12345", "username": "@third", "role": "moderator", "csrf_token": token},
)
assert response.status_code == 200
assert "Администратор добавлен" in response.text
assert fake_admins["upsert"] == (12345, "third", "moderator")
def test_add_admin_rejects_bad_id(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins", data={"telegram_id": "abc", "role": "admin", "csrf_token": token})
assert response.status_code == 400
assert "Telegram-ID должен быть числом" in response.text
assert "upsert" not in fake_admins
def test_change_role(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/role", data={"role": "admin", "csrf_token": token})
assert response.status_code == 200
assert "Роль обновлена" in response.text
assert fake_admins["role"] == (2, "admin")
def test_cannot_toggle_self(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/1/toggle", data={"csrf_token": token})
assert response.status_code == 400
assert "самого себя" in response.text
assert "enabled" not in fake_admins
def test_toggle_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/toggle", data={"csrf_token": token})
assert response.status_code == 200
assert "Доступ обновлён" in response.text
assert fake_admins["enabled"] == (2, False)
def test_cannot_delete_self(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/1/delete", data={"csrf_token": token})
assert response.status_code == 400
assert "удалить самого себя" in response.text
def test_cannot_delete_last_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
fake_admins["admins"] = [ADMIN_2]
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/delete", data={"csrf_token": token})
assert response.status_code == 400
assert "последнего администратора" in response.text
assert "deleted" not in fake_admins
def test_delete_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/delete", data={"csrf_token": token})
assert response.status_code == 200
assert "Администратор удалён" in response.text
assert fake_admins["deleted"] == 2

View File

@ -1,8 +1,8 @@
"""Тесты раздела «Логи» (без ClickHouse — чтение подменяется).""" """Тесты раздела «Логи» (Postgres не требуется — чтение подменяется)."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import date, datetime
from typing import Any from typing import Any
import pytest import pytest
@ -10,26 +10,39 @@ import pytest
from common.analytics import read as analytics_read from common.analytics import read as analytics_read
EVENT: dict[str, Any] = { EVENT: dict[str, Any] = {
"event_time": datetime(2026, 10, 18, 17, 0, 0), # UTC, без tz — как отдаёт clickhouse-connect "event_time": datetime(2026, 10, 18, 17, 0, 0), # UTC без tz — как отдаёт psycopg
"event_type": "command", "event_type": "command",
"user_id": 42, "user_id": 42,
"username": "test_user", "username": "test_user",
"chat_id": -100123, "chat_id": -100123,
"payload": '{"command": "инфо", "args_count": 0}', "payload": {"command": "инфо", "args_count": 0},
"bot_version": "0.4.8", "bot_version": "0.4.8",
"prod": 0, "prod": False,
}
MESSAGE: dict[str, Any] = {
"day": date(2026, 10, 18),
"user_id": 42,
"username": "test_user",
"chat_id": -100123,
"count": 17,
} }
@pytest.fixture @pytest.fixture
def fake_read(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: def fake_read(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"total": 1} state: dict[str, Any] = {"total": 1, "messages_total": 1}
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]: async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
state["kwargs"] = kwargs state["kwargs"] = kwargs
return [EVENT], state["total"] return [EVENT], state["total"]
async def fetch_messages(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
state["message_kwargs"] = kwargs
return [MESSAGE], state["messages_total"]
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events) monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
monkeypatch.setattr(analytics_read, "fetch_messages", fetch_messages)
return state return state
@ -43,6 +56,16 @@ def test_logs_page(fake_read: dict[str, Any], client_factory) -> None:
assert "20:00:00" in response.text # 17:00 UTC -> 20:00 МСК assert "20:00:00" in response.text # 17:00 UTC -> 20:00 МСК
def test_logs_messages_view(fake_read: dict[str, Any], client_factory) -> None:
response = client_factory().get("/logs", params={"view": "messages"})
assert response.status_code == 200
assert "Сообщения по дням" in response.text
assert "18.10.2026" in response.text
assert "17" in response.text
assert fake_read["message_kwargs"]["limit"] == 50
def test_logs_filters_passed(fake_read: dict[str, Any], client_factory) -> None: def test_logs_filters_passed(fake_read: dict[str, Any], client_factory) -> None:
client_factory().get( client_factory().get(
"/logs", "/logs",
@ -73,16 +96,16 @@ def test_logs_pagination(fake_read: dict[str, Any], client_factory) -> None:
assert fake_read["kwargs"]["offset"] == 50 assert fake_read["kwargs"]["offset"] == 50
def test_logs_clickhouse_error(client_factory, monkeypatch: pytest.MonkeyPatch) -> None: def test_logs_postgres_error(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]: async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
raise RuntimeError("ClickHouse недоступен") raise RuntimeError("Postgres недоступен")
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events) monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
response = client_factory().get("/logs") response = client_factory().get("/logs")
assert response.status_code == 200 assert response.status_code == 200
assert "ClickHouse недоступен" in response.text assert "Postgres недоступен" in response.text
def test_logs_bad_date(client_factory, monkeypatch: pytest.MonkeyPatch) -> None: def test_logs_bad_date(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:

166
tests/test_admin_send.py Normal file
View File

@ -0,0 +1,166 @@
"""Тесты ручной отправки сообщения из админки."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from admin import telegram
from common.config import AdminSettings
from common.repositories import admin_messages_repo
@pytest.fixture
def fake_send(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {}
async def recent(*, limit: int = 10) -> list[dict[str, Any]]:
return []
async def log_message(**kwargs: Any) -> None:
state["audit"] = kwargs
async def send_to_chat(settings: Any, *, text: str, buttons: list, photo: Any = None) -> None:
state["send"] = {"text": text, "buttons": buttons, "photo": photo}
monkeypatch.setattr(admin_messages_repo, "recent", recent)
monkeypatch.setattr(admin_messages_repo, "log_message", log_message)
monkeypatch.setattr(telegram, "send_to_chat", send_to_chat)
return state
def _client(client_factory, settings_factory) -> TestClient:
return client_factory(settings=settings_factory(chat_id=-100123), follow_redirects=False)
def test_send_form(fake_send: dict[str, Any], client_factory, settings_factory) -> None:
response = _client(client_factory, settings_factory).get("/send")
assert response.status_code == 200
assert 'data-tag="b"' in response.text
assert "-100123" in response.text
def test_send_text(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post(
"/send",
data={"text": "Привет, клан!", "buttons": "Канал | https://t.me/x", "csrf_token": token},
)
assert response.status_code == 303
assert response.headers["location"] == "/send?saved=true"
assert fake_send["send"]["text"] == "Привет, клан!"
assert fake_send["send"]["buttons"] == [("Канал", "https://t.me/x")]
assert fake_send["send"]["photo"] is None
assert fake_send["audit"]["success"] is True
def test_send_rejects_bad_buttons(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/send", data={"text": "Текст", "buttons": "без ссылки", "csrf_token": token})
assert response.status_code == 400
assert "Кнопка без разделителя" in response.text
assert "send" not in fake_send
def test_send_requires_text_or_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/send", data={"text": " ", "csrf_token": token})
assert response.status_code == 400
assert "Нужен текст или картинка" in response.text
def test_send_with_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post(
"/send",
data={"text": "С картинкой", "csrf_token": token},
files={"image": ("pic.png", b"fake-png", "image/png")},
)
assert response.status_code == 303
assert fake_send["send"]["photo"] is not None
assert fake_send["send"]["photo"][1] == "image/png"
assert fake_send["audit"]["has_image"] is True
def test_send_telegram_error(
fake_send: dict[str, Any],
client_factory,
settings_factory,
csrf_token,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def send_to_chat(*args: Any, **kwargs: Any) -> None:
raise telegram.TelegramError("chat not found")
monkeypatch.setattr(telegram, "send_to_chat", send_to_chat)
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/send", data={"text": "Текст", "csrf_token": token})
assert response.status_code == 400
assert "chat not found" in response.text
assert fake_send["audit"]["success"] is False
assert fake_send["audit"]["error"] == "chat not found"
async def test_telegram_omits_reply_markup_without_buttons(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, Any]] = []
async def fake_call(settings: Any, method: str, payload: dict[str, Any]) -> None:
calls.append(payload)
monkeypatch.setattr(telegram, "_call", fake_call)
settings = AdminSettings(_env_file=None, bot_token="1:a", chat_id=-100)
await telegram.send_to_chat(settings, text="Привет", buttons=[], photo=None)
assert "reply_markup" not in calls[0]
async def test_telegram_adds_reply_markup_with_buttons(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, Any]] = []
async def fake_call(settings: Any, method: str, payload: dict[str, Any]) -> None:
calls.append(payload)
monkeypatch.setattr(telegram, "_call", fake_call)
settings = AdminSettings(_env_file=None, bot_token="1:a", chat_id=-100)
await telegram.send_to_chat(settings, text="Привет", buttons=[("Канал", "https://t.me/x")], photo=None)
assert calls[0]["reply_markup"]["inline_keyboard"][0][0]["url"] == "https://t.me/x"
async def test_telegram_photo_omits_reply_markup_without_buttons(monkeypatch: pytest.MonkeyPatch) -> None:
fields: list[dict[str, str]] = []
async def fake_multipart(settings: Any, method: str, data: dict[str, str], files: dict[str, Any]) -> None:
fields.append(data)
monkeypatch.setattr(telegram, "_call_multipart", fake_multipart)
settings = AdminSettings(_env_file=None, bot_token="1:a", chat_id=-100)
await telegram.send_to_chat(
settings,
text="Подпись",
buttons=[],
photo=(b"data", "image/png", "pic.png"),
)
assert "reply_markup" not in fields[0]

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