Compare commits
17 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
448ab837fa | |
|
|
cf14dfa80d | |
|
|
d7bbacb67c | |
|
|
4403a4bb21 | |
|
|
1ab434d8b0 | |
|
|
afeed5b67e | |
|
|
92d2d1efcc | |
|
|
0206ab5212 | |
|
|
73e83e2413 | |
|
|
d85bc5b0f4 | |
|
|
5c6c49685a | |
|
|
3347dacbb5 | |
|
|
ae6faeee1e | |
|
|
84fc104717 | |
|
|
08c57f9b39 | |
|
|
cfa0e29bbf | |
|
|
1c85590f6f |
12
.env.example
12
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
99
DEPLOY.md
99
DEPLOY.md
|
|
@ -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 и сам дашборд — финальный этап, в этот контур не входят.
|
в этот контур не входят.
|
||||||
|
|
||||||
## Перенос данных на прод (позже, короткое окно простоя)
|
## Перенос данных на прод (позже, короткое окно простоя)
|
||||||
|
|
||||||
|
|
|
||||||
10
Makefile
10
Makefile
|
|
@ -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)
|
|
||||||
|
|
|
||||||
103
README.md
103
README.md
|
|
@ -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,33 +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`).
|
||||||
если он недоступен, страница показывает ошибку, а не падает.
|
|
||||||
|
Раздел «Логи» — события из Postgres и отдельная вкладка «Сообщения по дням»: фильтры
|
||||||
|
по типу (для событий), пользователю (ID или username) и датам, пагинация по 50.
|
||||||
|
Время показывается в `TIMEZONE`, payload раскрывается как JSON, ID пользователя ведёт
|
||||||
|
в его карточку. Если Postgres недоступен, страница показывает ошибку, а не падает.
|
||||||
|
|
||||||
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
|
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
|
||||||
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
|
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
|
||||||
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
|
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
|
||||||
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
|
пользователь через `/ник` или админ.
|
||||||
описание, текст и предпросмотр.
|
|
||||||
|
|
||||||
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие):
|
Раздел «Шаблоны сообщений» — набор сообщений по порядку (приветствие и любые другие):
|
||||||
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
|
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
|
||||||
|
Все правки — текст, кнопки, картинки, добавление/удаление и порядок сообщений —
|
||||||
|
хранятся на странице и применяются одной кнопкой «Сохранить всё».
|
||||||
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
|
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
|
||||||
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
|
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
|
||||||
(1024 для подписи к фото, 4096 для обычного сообщения).
|
(1024 для подписи к фото, 4096 для обычного сообщения).
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц
|
||||||
|
скрываются на узких экранах, формы складываются в одну колонку.
|
||||||
|
|
||||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же
|
||||||
Контракт событий — [`docs/events.md`](docs/events.md) (типы, payload, примеры запросов).
|
редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка
|
||||||
|
(успешная или нет) пишется в `bot.admin_messages`.
|
||||||
|
|
||||||
- схема — `clickhouse/init/001_events.sql` (применяется при первой инициализации контейнера);
|
## Аналитика (Postgres)
|
||||||
- клиент и батчинг — `common/analytics/` (запись неблокирующая, сбой ClickHouse не влияет на бота);
|
|
||||||
- сырые события живут на VPS 7 дней и ежедневно уезжают архивом на домашний сервер,
|
Все значимые действия бота пишутся в Postgres, схема `analytics`:
|
||||||
на VPS остаются агрегаты (`events_daily`, `money_daily`) — см. [DEPLOY.md](DEPLOY.md);
|
`analytics.events` (события, партиции по дням) и `analytics.messages_daily`
|
||||||
- выключается флагом `CLICKHOUSE_ENABLED=false`.
|
(количество сообщений пользователя по дням). Контракт событий —
|
||||||
|
[`docs/events.md`](docs/events.md) (типы, payload, примеры запросов).
|
||||||
|
|
||||||
|
- схема — `sql/V26__analytics_schema.sql`;
|
||||||
|
- клиент и батчинг — `common/analytics/` (запись неблокирующая, сбой Postgres не влияет на бота);
|
||||||
|
- сообщения не пишутся поштучно: на каждое сообщение инкрементится дневной счётчик;
|
||||||
|
- построчная `bot.messages` удалена (V27), старые данные перенесены в `analytics.messages_daily`;
|
||||||
|
- партиции создаются приложением и **не удаляются** — история хранится бессрочно;
|
||||||
|
- выключается флагом `ANALYTICS_ENABLED=false`.
|
||||||
|
|
||||||
## Уведомления в чат логов
|
## Уведомления в чат логов
|
||||||
|
|
||||||
|
|
@ -114,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 # заполнить
|
||||||
|
|
@ -124,7 +142,7 @@ 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`), который монтируется и боту, и админке.
|
Docker-volume `media` (`/app/media`), который монтируется и боту, и админке.
|
||||||
|
|
||||||
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
|
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
|
||||||
|
|
@ -145,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.5.1`.
|
- Единый источник версии — `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
|
||||||
|
|
@ -207,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`.
|
|
||||||
|
|
|
||||||
13
admin/app.py
13
admin/app.py
|
|
@ -14,9 +14,9 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from admin.media import ensure_media
|
from admin.media import ensure_media
|
||||||
from admin.middleware import AdminAuthMiddleware
|
from admin.middleware import AdminAuthMiddleware
|
||||||
from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, 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
|
||||||
|
|
@ -42,6 +42,10 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
|
||||||
if init_db:
|
if init_db:
|
||||||
await init_pool(app.state.settings)
|
await init_pool(app.state.settings)
|
||||||
ensure_media(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:
|
||||||
|
|
@ -56,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",
|
||||||
|
|
@ -89,10 +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(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(admins.router)
|
||||||
app.include_router(logs.router)
|
app.include_router(logs.router)
|
||||||
|
app.include_router(send.router)
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -6,6 +6,8 @@ import logging
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from starlette.datastructures import UploadFile
|
||||||
|
|
||||||
from common.config import AdminSettings
|
from common.config import AdminSettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -16,6 +18,23 @@ BUILTIN_WELCOME = Path("images/welcome_image.jpg")
|
||||||
#: Имя файла в volume, которое ждёт сид последовательности `welcome`.
|
#: Имя файла в volume, которое ждёт сид последовательности `welcome`.
|
||||||
WELCOME_IMAGE_NAME = "welcome_image.jpg"
|
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:
|
def ensure_media(settings: AdminSettings) -> Path:
|
||||||
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
|
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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, роль в БД и открыть сессию."""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (HTMX)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
import re
|
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
#: Формат ключа нового шаблона (латиница, цифры, точка, подчёркивание, дефис).
|
|
||||||
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,63}$")
|
|
||||||
|
|
||||||
#: Максимальная длина описания шаблона.
|
|
||||||
MAX_DESCRIPTION_LENGTH = 200
|
|
||||||
|
|
||||||
|
|
||||||
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.get("/new", response_class=HTMLResponse)
|
|
||||||
async def new_template(request: Request) -> HTMLResponse:
|
|
||||||
"""Форма создания нового шаблона."""
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="messages/new.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"key": "",
|
|
||||||
"description": "",
|
|
||||||
"text": "",
|
|
||||||
"enabled": True,
|
|
||||||
"errors": [],
|
|
||||||
"tags": TAG_REGISTRY,
|
|
||||||
"max_length": MAX_MESSAGE_LENGTH,
|
|
||||||
"max_description_length": MAX_DESCRIPTION_LENGTH,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def create_template(request: Request) -> Response:
|
|
||||||
"""Создать шаблон (ключ проверяется на формат и занятость)."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
|
|
||||||
key = str(form.get("key", "")).strip()
|
|
||||||
description = str(form.get("description", "")).strip()
|
|
||||||
text = str(form.get("text", ""))
|
|
||||||
enabled = form.get("enabled") is not None
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
if not KEY_RE.match(key):
|
|
||||||
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–64 символа)")
|
|
||||||
if not text.strip():
|
|
||||||
errors.append("Текст не может быть пустым")
|
|
||||||
unknown = validate(text)
|
|
||||||
if unknown:
|
|
||||||
errors.append("Неизвестные теги: " + ", ".join(unknown))
|
|
||||||
if len(text) > MAX_MESSAGE_LENGTH:
|
|
||||||
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
|
|
||||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
|
||||||
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="messages/new.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"key": key,
|
|
||||||
"description": description,
|
|
||||||
"text": text,
|
|
||||||
"enabled": enabled,
|
|
||||||
"errors": errors,
|
|
||||||
"tags": TAG_REGISTRY,
|
|
||||||
"max_length": MAX_MESSAGE_LENGTH,
|
|
||||||
"max_description_length": MAX_DESCRIPTION_LENGTH,
|
|
||||||
},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
created = await templates_repo.create_template(
|
|
||||||
key,
|
|
||||||
text,
|
|
||||||
description,
|
|
||||||
enabled=enabled,
|
|
||||||
editor_id=int(request.state.admin["telegram_id"]),
|
|
||||||
)
|
|
||||||
if not created:
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="messages/new.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"key": key,
|
|
||||||
"description": description,
|
|
||||||
"text": text,
|
|
||||||
"enabled": enabled,
|
|
||||||
"errors": ["Шаблон с таким ключом уже существует"],
|
|
||||||
"tags": TAG_REGISTRY,
|
|
||||||
"max_length": MAX_MESSAGE_LENGTH,
|
|
||||||
"max_description_length": MAX_DESCRIPTION_LENGTH,
|
|
||||||
},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
return RedirectResponse(url=f"/messages/{key}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@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),
|
|
||||||
)
|
|
||||||
|
|
@ -12,13 +12,8 @@ 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",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Последовательности",
|
|
||||||
"description": "Приветствие и другие наборы сообщений по порядку",
|
|
||||||
"url": "/sequences",
|
"url": "/sequences",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -31,6 +26,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
"description": "Ники, звания, баланс и подписка",
|
"description": "Ники, звания, баланс и подписка",
|
||||||
"url": "/users",
|
"url": "/users",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Звания",
|
||||||
|
"description": "Справочник магазина званий",
|
||||||
|
"url": "/titles",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "Админы",
|
"title": "Админы",
|
||||||
"description": "Доступ к веб-панели",
|
"description": "Доступ к веб-панели",
|
||||||
|
|
@ -38,9 +38,14 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Логи",
|
"title": "Логи",
|
||||||
"description": "События из ClickHouse с фильтрами",
|
"description": "События и сообщения из Postgres с фильтрами",
|
||||||
"url": "/logs",
|
"url": "/logs",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Отправить",
|
||||||
|
"description": "Сообщение в чат от имени бота",
|
||||||
|
"url": "/send",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>'
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from starlette.datastructures import UploadFile
|
from starlette.datastructures import UploadFile
|
||||||
|
|
||||||
from admin.media import media_path
|
from admin.buttons import MAX_BUTTONS, parse_buttons
|
||||||
|
from admin.media import media_path, read_image
|
||||||
from admin.security import check_csrf
|
from admin.security import check_csrf
|
||||||
from common import __version__
|
from common import __version__
|
||||||
from common.repositories import sequences_repo
|
from common.repositories import sequences_repo
|
||||||
|
|
@ -26,12 +27,6 @@ KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
|
||||||
|
|
||||||
#: Лимит подписи к фото.
|
#: Лимит подписи к фото.
|
||||||
MAX_CAPTION = 1024
|
MAX_CAPTION = 1024
|
||||||
#: Сколько кнопок можно повесить на одно сообщение.
|
|
||||||
MAX_BUTTONS = 8
|
|
||||||
#: Допустимые типы картинок.
|
|
||||||
ALLOWED_IMAGES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
|
||||||
#: Максимальный размер картинки.
|
|
||||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _editor_id(request: Request) -> int:
|
def _editor_id(request: Request) -> int:
|
||||||
|
|
@ -39,57 +34,16 @@ def _editor_id(request: Request) -> int:
|
||||||
|
|
||||||
|
|
||||||
def _prepare_items(items: list[dict[str, Any]]) -> None:
|
def _prepare_items(items: list[dict[str, Any]]) -> None:
|
||||||
"""Добавить к каждому сообщению строку кнопок для textarea."""
|
"""Добавить к каждому сообщению строку кнопок и токен для формы."""
|
||||||
for item in items:
|
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"])
|
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"])
|
||||||
|
|
||||||
|
|
||||||
def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]:
|
|
||||||
"""Разобрать кнопки: по одной в строке, формат ``Текст | https://…``."""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _image_error(upload: UploadFile) -> str | None:
|
|
||||||
content_type = (upload.content_type or "").lower()
|
|
||||||
if content_type not in ALLOWED_IMAGES:
|
|
||||||
return "Картинка должна быть JPEG, PNG или WebP"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
|
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
|
||||||
error = _image_error(upload)
|
data, _, extension = await read_image(upload)
|
||||||
if error:
|
|
||||||
raise ValueError(error)
|
|
||||||
|
|
||||||
data = await upload.read()
|
name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
|
||||||
if len(data) > MAX_IMAGE_BYTES:
|
|
||||||
raise ValueError("Картинка больше 5 МБ")
|
|
||||||
|
|
||||||
name = f"{key}_{item_id}_{uuid.uuid4().hex}{ALLOWED_IMAGES[(upload.content_type or '').lower()]}"
|
|
||||||
path = media_path(settings, name)
|
path = media_path(settings, name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_bytes(data)
|
path.write_bytes(data)
|
||||||
|
|
@ -134,19 +88,25 @@ async def _editor_response(
|
||||||
errors: list[str] | None = None,
|
errors: list[str] | None = None,
|
||||||
saved: str | None = None,
|
saved: str | None = None,
|
||||||
overrides: dict[int, dict[str, Any]] | None = None,
|
overrides: dict[int, dict[str, Any]] | None = None,
|
||||||
|
items_override: list[dict[str, Any]] | None = None,
|
||||||
status_code: int = 200,
|
status_code: int = 200,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
sequence = await sequences_repo.get_sequence(key)
|
sequence = await sequences_repo.get_sequence(key)
|
||||||
if sequence is None:
|
if sequence is None:
|
||||||
raise HTTPException(status_code=404, detail="Последовательность не найдена")
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
|
|
||||||
|
if items_override is not None:
|
||||||
|
items = items_override
|
||||||
|
else:
|
||||||
items = await sequences_repo.get_items(key)
|
items = await sequences_repo.get_items(key)
|
||||||
_prepare_items(items)
|
|
||||||
for item in items:
|
for item in items:
|
||||||
override = (overrides or {}).get(int(item["id"]))
|
override = (overrides or {}).get(int(item["id"]))
|
||||||
if override:
|
if override:
|
||||||
item.update(override)
|
item.update(override)
|
||||||
|
|
||||||
|
_prepare_items(items)
|
||||||
|
item_order = ",".join(str(item["token"]) for item in items)
|
||||||
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
return request.app.state.templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="sequences/edit.html",
|
name="sequences/edit.html",
|
||||||
|
|
@ -154,6 +114,7 @@ async def _editor_response(
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"sequence": sequence,
|
"sequence": sequence,
|
||||||
"items": items,
|
"items": items,
|
||||||
|
"item_order": item_order,
|
||||||
"history": await sequences_repo.history(key, limit=5),
|
"history": await sequences_repo.history(key, limit=5),
|
||||||
"errors": errors or [],
|
"errors": errors or [],
|
||||||
"saved": saved,
|
"saved": saved,
|
||||||
|
|
@ -198,7 +159,7 @@ async def create_sequence(request: Request) -> Response:
|
||||||
):
|
):
|
||||||
return await _list_response(
|
return await _list_response(
|
||||||
request,
|
request,
|
||||||
errors=["Последовательность с таким ключом уже существует"],
|
errors=["Шаблон с таким ключом уже существует"],
|
||||||
form=raw_form,
|
form=raw_form,
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
@ -219,6 +180,126 @@ async def edit_sequence(request: Request, key: str, saved: str | None = None) ->
|
||||||
return await _editor_response(request, key, saved=saved)
|
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}")
|
@router.post("/{key}")
|
||||||
async def update_sequence(request: Request, key: str) -> Response:
|
async def update_sequence(request: Request, key: str) -> Response:
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|
@ -235,7 +316,7 @@ async def update_sequence(request: Request, key: str) -> Response:
|
||||||
description,
|
description,
|
||||||
editor_id=_editor_id(request),
|
editor_id=_editor_id(request),
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=404, detail="Последовательность не найдена")
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303)
|
return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -245,7 +326,7 @@ async def add_item(request: Request, key: str) -> Response:
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
check_csrf(request, str(form.get("csrf_token", "")))
|
||||||
|
|
||||||
if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None:
|
if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None:
|
||||||
raise HTTPException(status_code=404, detail="Последовательность не найдена")
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -257,7 +338,7 @@ async def update_item(request: Request, key: str, item_id: int) -> Response:
|
||||||
items = await sequences_repo.get_items(key)
|
items = await sequences_repo.get_items(key)
|
||||||
item = next((row for row in items if int(row["id"]) == item_id), None)
|
item = next((row for row in items if int(row["id"]) == item_id), None)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||||
|
|
||||||
text = str(form.get("text", ""))
|
text = str(form.get("text", ""))
|
||||||
enabled = form.get("enabled") is not None
|
enabled = form.get("enabled") is not None
|
||||||
|
|
@ -321,7 +402,7 @@ async def update_item(request: Request, key: str, item_id: int) -> Response:
|
||||||
if updated is None:
|
if updated is None:
|
||||||
if saved_file:
|
if saved_file:
|
||||||
_delete_image(settings, saved_file)
|
_delete_image(settings, saved_file)
|
||||||
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||||
|
|
||||||
_, old_image = updated
|
_, old_image = updated
|
||||||
if update_image and old_image and old_image != new_image:
|
if update_image and old_image and old_image != new_image:
|
||||||
|
|
@ -339,7 +420,7 @@ async def move_item(request: Request, key: str, item_id: int) -> Response:
|
||||||
if direction not in {"up", "down"}:
|
if direction not in {"up", "down"}:
|
||||||
raise HTTPException(status_code=400, detail="Неверное направление")
|
raise HTTPException(status_code=400, detail="Неверное направление")
|
||||||
if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None:
|
if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None:
|
||||||
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||||
return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303)
|
return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -351,10 +432,10 @@ async def delete_item(request: Request, key: str, item_id: int) -> Response:
|
||||||
items = await sequences_repo.get_items(key)
|
items = await sequences_repo.get_items(key)
|
||||||
item = next((row for row in items if int(row["id"]) == item_id), None)
|
item = next((row for row in items if int(row["id"]) == item_id), None)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||||
|
|
||||||
if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None:
|
if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None:
|
||||||
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||||
|
|
||||||
_delete_image(request.app.state.settings, item["image_name"])
|
_delete_image(request.app.state.settings, item["image_name"])
|
||||||
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -22,6 +23,16 @@ class TelegramError(RuntimeError):
|
||||||
"""Telegram отклонил запрос или недоступен."""
|
"""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:
|
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
|
||||||
if not settings.bot_token:
|
if not settings.bot_token:
|
||||||
raise TelegramError("BOT_TOKEN не задан")
|
raise TelegramError("BOT_TOKEN не задан")
|
||||||
|
|
@ -32,15 +43,25 @@ async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -
|
||||||
response = await client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise TelegramError(f"Telegram недоступен: {exc}") from 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:
|
try:
|
||||||
data = response.json()
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
except ValueError as exc:
|
response = await client.post(url, data=data, files=files)
|
||||||
raise TelegramError("Telegram вернул некорректный ответ") from exc
|
except httpx.HTTPError as exc:
|
||||||
|
raise TelegramError(f"Telegram недоступен: {exc}") from exc
|
||||||
if not data.get("ok"):
|
return _check(response)
|
||||||
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
|
|
||||||
return data.get("result")
|
|
||||||
|
|
||||||
|
|
||||||
async def kick_member(settings: AdminSettings, user_id: int) -> None:
|
async def kick_member(settings: AdminSettings, user_id: int) -> None:
|
||||||
|
|
@ -81,3 +102,53 @@ async def send_alert(settings: AdminSettings, text: str) -> None:
|
||||||
)
|
)
|
||||||
except TelegramError:
|
except TelegramError:
|
||||||
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
<th class="px-4 py-3 font-medium">Username</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="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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -116,7 +116,7 @@
|
||||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
|
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-slate-600">
|
<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 "—" }}
|
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -9,26 +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="/sequences" 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="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
aria-expanded="false"
|
||||||
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
|
>
|
||||||
<a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a>
|
Меню
|
||||||
<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">
|
||||||
|
|
@ -38,7 +86,6 @@
|
||||||
<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>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
var tags = {
|
var tags = {
|
||||||
b: ["<b>", "</b>"],
|
b: ["<b>", "</b>"],
|
||||||
i: ["<i>", "</i>"],
|
i: ["<i>", "</i>"],
|
||||||
|
|
@ -51,8 +98,12 @@
|
||||||
a: ['<a href="">', "</a>"],
|
a: ['<a href="">', "</a>"],
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll("[data-editor]").forEach(function (root) {
|
function initEditor(root) {
|
||||||
|
if (!root || root.dataset.editorReady) return;
|
||||||
var textarea = root.querySelector("textarea");
|
var textarea = root.querySelector("textarea");
|
||||||
|
if (!textarea) return;
|
||||||
|
root.dataset.editorReady = "1";
|
||||||
|
|
||||||
var counter = root.querySelector("[data-counter]");
|
var counter = root.querySelector("[data-counter]");
|
||||||
var max = parseInt(root.dataset.max || "0", 10);
|
var max = parseInt(root.dataset.max || "0", 10);
|
||||||
|
|
||||||
|
|
@ -87,7 +138,77 @@
|
||||||
var button = root.querySelector('button[data-tag="' + tag + '"]');
|
var button = root.querySelector('button[data-tag="' + tag + '"]');
|
||||||
if (button) button.click();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -1,107 +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 class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %}
|
|
||||||
{% include "partials/editor.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
|
||||||
</p>
|
|
||||||
{% if template.key == 'welcome.text' %}
|
|
||||||
<p class="mt-2 rounded border border-sky-200 bg-sky-50 p-2 text-xs text-sky-800">
|
|
||||||
Первая строка этого шаблона уходит подписью к фото, остальное — одним сообщением
|
|
||||||
вместе с <span class="font-mono">welcome.text2</span> и <span class="font-mono">welcome.text3</span>.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</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 %}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Сообщения{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-2xl font-semibold">Сообщения</h1>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
|
|
||||||
<a href="/messages/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Новый шаблон
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</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 %}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Новый шаблон{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
|
|
||||||
<h1 class="mt-1 text-2xl font-semibold">Новый шаблон</h1>
|
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/messages" class="mt-6 max-w-3xl space-y-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label for="key" class="text-sm font-medium text-slate-700">Ключ</label>
|
|
||||||
<input
|
|
||||||
id="key"
|
|
||||||
name="key"
|
|
||||||
value="{{ key }}"
|
|
||||||
placeholder="например, shop.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"
|
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="description" class="text-sm font-medium text-slate-700">Описание</label>
|
|
||||||
<input
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
value="{{ description }}"
|
|
||||||
maxlength="{{ max_description_length }}"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %}
|
|
||||||
{% include "partials/editor.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input type="checkbox" name="enabled" {% if enabled %}checked{% endif %} />
|
|
||||||
Включён (бот читает текст)
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Создать
|
|
||||||
</button>
|
|
||||||
<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>
|
|
||||||
<a href="/messages" class="text-sm text-slate-600 hover:underline">Отмена</a>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<details class="mt-8 max-w-3xl 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>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}{{ sequence.title }}{% endblock %}
|
{% block title %}{{ sequence.title }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="text-sm text-slate-500"><a href="/sequences" class="hover:underline">← Последовательности</a></p>
|
<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 class="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold">{{ sequence.title }}</h1>
|
<h1 class="text-2xl font-semibold">{{ sequence.title }}</h1>
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
|
|
||||||
{% if saved %}
|
{% if saved %}
|
||||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
{% if saved == 'meta' %}Параметры последовательности сохранены.
|
{% if saved == 'all' %}Шаблон сохранён целиком.
|
||||||
|
{% elif saved == 'meta' %}Параметры шаблона сохранены.
|
||||||
{% elif saved == 'order' %}Порядок сообщений обновлён.
|
{% elif saved == 'order' %}Порядок сообщений обновлён.
|
||||||
{% else %}Сообщение сохранено.{% endif %}
|
{% else %}Сообщение сохранено.{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,8 +26,17 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/sequences/{{ sequence.key }}" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
<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="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 class="grid gap-3 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-slate-500">Название</label>
|
<label class="text-xs text-slate-500">Название</label>
|
||||||
|
|
@ -39,61 +49,41 @@
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
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>
|
||||||
<button type="submit" class="mt-3 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Сохранить параметры
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-medium">Сообщения ({{ items | length }})</h2>
|
|
||||||
<form method="post" action="/sequences/{{ sequence.key }}/items">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<button type="submit" class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
|
||||||
Добавить сообщение
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-5">
|
<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 %}
|
{% for item in items %}
|
||||||
<section class="rounded-lg border border-slate-200 bg-white">
|
<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="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">
|
<div class="text-sm text-slate-600">
|
||||||
<span class="font-medium">№ {{ loop.index }}</span>
|
<span class="font-medium" data-position>№ {{ loop.index }}</span>
|
||||||
<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>
|
{% if item.id %}<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>{% endif %}
|
||||||
{% if item.enabled %}
|
{% if item.enabled %}
|
||||||
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
|
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/move">
|
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
|
||||||
<input type="hidden" name="direction" value="up" />
|
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
|
||||||
<button type="submit" class="text-slate-600 hover:underline" {% if loop.first %}disabled{% endif %}>Выше</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/move">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<input type="hidden" name="direction" value="down" />
|
|
||||||
<button type="submit" class="text-slate-600 hover:underline" {% if loop.last %}disabled{% endif %}>Ниже</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/delete" onsubmit="return confirm('Удалить сообщение?');">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<div class="p-4">
|
||||||
method="post"
|
{% 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 %}
|
||||||
action="/sequences/{{ sequence.key }}/items/{{ item.id }}"
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
|
|
||||||
{% with editor_id="text-" ~ item.id, editor_name="text", editor_value=item.text, editor_max=(max_caption if item.image_name else max_message), editor_rows=12 %}
|
|
||||||
{% include "partials/editor.html" %}
|
{% include "partials/editor.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|
@ -101,7 +91,7 @@
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
|
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
|
||||||
<textarea
|
<textarea
|
||||||
name="buttons"
|
name="buttons_{{ item.token }}"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Канал | https://t.me/…"
|
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"
|
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
|
||||||
|
|
@ -114,11 +104,11 @@
|
||||||
<div class="mt-1 flex items-center gap-3">
|
<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" />
|
<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">
|
<label class="flex items-center gap-2 text-xs text-slate-600">
|
||||||
<input type="checkbox" name="remove_image" /> удалить
|
<input type="checkbox" name="remove_image_{{ item.token }}" /> удалить
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="file" name="image" accept="image/jpeg,image/png,image/webp"
|
<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" />
|
class="mt-2 block w-full text-xs text-slate-600" />
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
|
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
|
||||||
|
|
@ -126,21 +116,69 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between">
|
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
<input type="checkbox" name="enabled_{{ item.token }}" {% if item.enabled %}checked{% endif %} /> включено
|
||||||
<input type="checkbox" name="enabled" {% if item.enabled %}checked{% endif %} /> включено
|
|
||||||
</label>
|
</label>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-slate-500">Сообщений пока нет — добавьте первое.</p>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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">
|
<div class="mt-10">
|
||||||
<h2 class="text-lg font-medium">История изменений</h2>
|
<h2 class="text-lg font-medium">История изменений</h2>
|
||||||
{% if history %}
|
{% if history %}
|
||||||
|
|
@ -162,4 +200,56 @@
|
||||||
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Последовательности{% endblock %}
|
{% block title %}Шаблоны сообщений{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<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">Несколько сообщений по порядку: текст, картинка и кнопки.</p>
|
<p class="mt-1 text-sm text-slate-600">Тексты бота: сообщения, картинки и кнопки.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
<form method="post" action="/sequences" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
<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', '') }}" />
|
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||||
<h2 class="text-lg font-medium">Новая последовательность</h2>
|
<h2 class="text-lg font-medium">Новый шаблон</h2>
|
||||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="key" class="text-xs text-slate-500">Ключ</label>
|
<label for="key" class="text-xs text-slate-500">Ключ</label>
|
||||||
|
|
@ -56,7 +56,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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
|
@ -65,12 +65,17 @@
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a>
|
<a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">{{ sequence.title }}<div class="text-xs text-slate-500">{{ sequence.description }}</div></td>
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">{{ sequence.items }}</td>
|
{{ 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>
|
<td class="px-4 py-3 text-slate-500">{{ sequence.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Последовательностей пока нет.</td></tr>
|
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Шаблонов пока нет.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,9 @@
|
||||||
<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>
|
||||||
|
|
@ -54,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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -11,32 +11,32 @@ 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.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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarku
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.repositories import sequences_repo
|
from common.repositories import sequences_repo
|
||||||
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__)
|
||||||
|
|
||||||
|
|
@ -46,7 +47,14 @@ def _image_path(item: dict[str, Any]) -> Path | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any]) -> bool:
|
async def send_sequence(
|
||||||
|
bot: Bot,
|
||||||
|
chat_id: int,
|
||||||
|
key: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
*,
|
||||||
|
reply_markup: Any = None,
|
||||||
|
) -> bool:
|
||||||
"""Отправить последовательность. False — если она не настроена/пуста."""
|
"""Отправить последовательность. False — если она не настроена/пуста."""
|
||||||
try:
|
try:
|
||||||
items = await sequences_repo.get_items(key)
|
items = await sequences_repo.get_items(key)
|
||||||
|
|
@ -58,10 +66,11 @@ async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for item in enabled:
|
for index, item in enumerate(enabled):
|
||||||
text = render(str(item.get("text") or ""), context) if item.get("text") else ""
|
text = render(str(item.get("text") or ""), context) if item.get("text") else ""
|
||||||
path = _image_path(item)
|
path = _image_path(item)
|
||||||
markup = _markup(item.get("buttons") or [])
|
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 path is not None:
|
||||||
if text and len(text) > CAPTION_LIMIT:
|
if text and len(text) > CAPTION_LIMIT:
|
||||||
|
|
@ -78,3 +87,34 @@ async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any
|
||||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
|
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
|
||||||
|
|
||||||
return True
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<clickhouse>
|
|
||||||
<!--
|
|
||||||
system.trace_log отключён: на тестовом контуре он генерировал
|
|
||||||
~1.3 ГБ в сутки (~6.6 ГБ за 5 дней). Профилировщик здесь не нужен.
|
|
||||||
Чтобы вернуть: удалить этот файл и перезапустить контейнер.
|
|
||||||
-->
|
|
||||||
<trace_log remove="1"/>
|
|
||||||
</clickhouse>
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
# --- Экономика ---
|
# --- Экономика ---
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,11 @@ class Settings(BaseSettings):
|
||||||
#: Общий каталог картинок (Docker volume), который видят бот и админка.
|
#: Общий каталог картинок (Docker volume), который видят бот и админка.
|
||||||
media_dir: str = "/app/media"
|
media_dir: str = "/app/media"
|
||||||
|
|
||||||
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
|
#: Публичный адрес админки (deep-link вход, ссылки из бота).
|
||||||
clickhouse_enabled: bool = True
|
admin_public_url: str = "http://localhost:8000"
|
||||||
clickhouse_host: str = "localhost"
|
|
||||||
clickhouse_port: int = 8123
|
#: Аналитика событий в Postgres. ANALYTICS_ENABLED=false — полностью выключить.
|
||||||
clickhouse_db: str = "analytics"
|
analytics_enabled: bool = True
|
||||||
clickhouse_user: str = "default"
|
|
||||||
clickhouse_password: str = ""
|
|
||||||
|
|
||||||
#: Батчинг записи событий: размер батча, интервал сброса и предел буфера.
|
#: Батчинг записи событий: размер батча, интервал сброса и предел буфера.
|
||||||
analytics_batch_size: int = 200
|
analytics_batch_size: int = 200
|
||||||
|
|
@ -132,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).
|
||||||
"""
|
"""
|
||||||
|
|
@ -144,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-сессии. В проде обязателен, иначе сессии слетают
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ async def list_sequences() -> list[dict[str, Any]]:
|
||||||
cur = await conn.execute(
|
cur = await conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT s.key, s.title, s.description, s.updated_at, s.updated_by,
|
SELECT s.key, s.title, s.description, s.updated_at, s.updated_by,
|
||||||
count(i.id) AS items
|
count(i.id) AS item_count
|
||||||
FROM bot.message_sequences AS s
|
FROM bot.message_sequences AS s
|
||||||
LEFT JOIN bot.message_sequence_items AS i ON i.sequence_key = s.key
|
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
|
GROUP BY s.key, s.title, s.description, s.updated_at, s.updated_by
|
||||||
|
|
@ -252,6 +252,88 @@ async def update_item(
|
||||||
return key, old_image
|
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 def delete_item(item_id: int, *, editor_id: int | None = None) -> str | None:
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn:
|
||||||
cur = await conn.execute(
|
cur = await conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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 = "Создатель запретил ходить в другие чатики :с"
|
||||||
|
|
|
||||||
|
|
@ -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.5.1"
|
__version__ = "0.7.2"
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: "5432"
|
DB_PORT: "5432"
|
||||||
CLICKHOUSE_HOST: clickhouse
|
|
||||||
CLICKHOUSE_PORT: "8123"
|
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
- media:/app/media
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -55,9 +53,6 @@ services:
|
||||||
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:
|
||||||
|
|
@ -74,8 +69,6 @@ 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:
|
volumes:
|
||||||
|
|
@ -95,37 +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
|
|
||||||
# trace_log не нужен и на тестовом контуре съедал гигабайты в сутки.
|
|
||||||
- ./clickhouse/config.d/disable-trace-log.xml:/etc/clickhouse-server/config.d/disable-trace-log.xml: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:
|
||||||
|
|
@ -143,7 +105,6 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
clickhousedata:
|
|
||||||
media:
|
media:
|
||||||
# caddy_data:
|
# caddy_data:
|
||||||
# caddy_config:
|
# caddy_config:
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,6 @@ services:
|
||||||
# Внутри сети compose БД доступна по имени сервиса.
|
# Внутри сети compose БД доступна по имени сервиса.
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: "5432"
|
DB_PORT: "5432"
|
||||||
CLICKHOUSE_HOST: clickhouse
|
|
||||||
CLICKHOUSE_PORT: "8123"
|
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
- media:/app/media
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -60,9 +58,6 @@ services:
|
||||||
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:
|
||||||
|
|
@ -82,8 +77,6 @@ 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:
|
volumes:
|
||||||
|
|
@ -101,34 +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
|
|
||||||
# trace_log не нужен: на тестовом контуре съедал гигабайты в сутки.
|
|
||||||
- ./clickhouse/config.d/disable-trace-log.xml:/etc/clickhouse-server/config.d/disable-trace-log.xml: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:
|
media:
|
||||||
|
|
|
||||||
103
docs/events.md
103
docs/events.md
|
|
@ -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` |
|
||||||
|
|
@ -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;
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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=
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,6 @@ 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
|
||||||
|
|
||||||
# ClickHouse — чтение логов в админке (раздел «Логи»).
|
|
||||||
clickhouse-connect[async]>=1.0,<2.0
|
|
||||||
|
|
||||||
# Telegram Bot API для модерации (исключение из чата) и уведомлений.
|
# Telegram Bot API для модерации (исключение из чата) и уведомлений.
|
||||||
httpx>=0.27,<1.0
|
httpx>=0.27,<1.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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} дней сырых событий"
|
|
||||||
|
|
@ -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})"
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 'Аудит ручных отправок сообщений из админки';
|
||||||
|
|
@ -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)';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 'Какие минуты рассылок уже отработаны (атомарная защита от дублей)';
|
||||||
|
|
@ -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)';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,249 +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}
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_template_form(client_factory) -> None:
|
|
||||||
response = client_factory().get("/messages/new")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Новый шаблон" in response.text
|
|
||||||
assert "shop.rules" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_template(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
|
||||||
created: dict[str, Any] = {}
|
|
||||||
|
|
||||||
async def fake_create(
|
|
||||||
key: str,
|
|
||||||
text: str,
|
|
||||||
description: str,
|
|
||||||
*,
|
|
||||||
enabled: bool = True,
|
|
||||||
editor_id: int | None = None,
|
|
||||||
) -> bool:
|
|
||||||
created.update(key=key, text=text, description=description, enabled=enabled, editor_id=editor_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def fake_get(key: str) -> dict[str, Any] | None:
|
|
||||||
return {
|
|
||||||
"key": key,
|
|
||||||
"text": "Правила магазина",
|
|
||||||
"description": "Правила",
|
|
||||||
"enabled": True,
|
|
||||||
"updated_at": datetime(2026, 1, 1, tzinfo=UTC),
|
|
||||||
"updated_by": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(templates_repo, "create_template", fake_create)
|
|
||||||
monkeypatch.setattr(templates_repo, "get_template", fake_get)
|
|
||||||
monkeypatch.setattr(templates_repo, "template_history", fake_history)
|
|
||||||
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/new").text)
|
|
||||||
response = client.post(
|
|
||||||
"/messages",
|
|
||||||
data={
|
|
||||||
"key": "shop.rules",
|
|
||||||
"description": "Правила",
|
|
||||||
"text": "Правила магазина",
|
|
||||||
"enabled": "on",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert created == {
|
|
||||||
"key": "shop.rules",
|
|
||||||
"text": "Правила магазина",
|
|
||||||
"description": "Правила",
|
|
||||||
"enabled": True,
|
|
||||||
"editor_id": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_template_duplicate(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
|
||||||
async def fake_create(*args: Any, **kwargs: Any) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr(templates_repo, "create_template", fake_create)
|
|
||||||
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/new").text)
|
|
||||||
response = client.post(
|
|
||||||
"/messages",
|
|
||||||
data={"key": "shop.rules", "text": "Текст", "csrf_token": token},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "уже существует" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_template_rejects_bad_key(client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/new").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/messages",
|
|
||||||
data={"key": "Плохой ключ", "text": "Текст", "csrf_token": token},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Ключ:" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_template_rejects_unknown_tag(client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/new").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/messages",
|
|
||||||
data={"key": "shop.rules", "text": "Текст {nope}", "csrf_token": token},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Неизвестные теги" in response.text
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -15,6 +15,7 @@ SEQUENCE: dict[str, Any] = {
|
||||||
"key": "welcome",
|
"key": "welcome",
|
||||||
"title": "Приветствие",
|
"title": "Приветствие",
|
||||||
"description": "Сообщения при входе",
|
"description": "Сообщения при входе",
|
||||||
|
"item_count": 2,
|
||||||
"updated_at": datetime(2026, 10, 25, tzinfo=UTC),
|
"updated_at": datetime(2026, 10, 25, tzinfo=UTC),
|
||||||
"updated_by": None,
|
"updated_by": None,
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +96,10 @@ def fake_sequences(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
state["deleted"] = item_id
|
state["deleted"] = item_id
|
||||||
return "welcome"
|
return "welcome"
|
||||||
|
|
||||||
|
async def save_items(key: str, items: list[dict[str, Any]], *, editor_id: int | None = None) -> list[str]:
|
||||||
|
state["saved_all"] = items
|
||||||
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr(sequences_repo, "list_sequences", list_sequences)
|
monkeypatch.setattr(sequences_repo, "list_sequences", list_sequences)
|
||||||
monkeypatch.setattr(sequences_repo, "get_sequence", get_sequence)
|
monkeypatch.setattr(sequences_repo, "get_sequence", get_sequence)
|
||||||
monkeypatch.setattr(sequences_repo, "get_items", get_items)
|
monkeypatch.setattr(sequences_repo, "get_items", get_items)
|
||||||
|
|
@ -105,6 +110,7 @@ def fake_sequences(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
monkeypatch.setattr(sequences_repo, "update_item", update_item)
|
monkeypatch.setattr(sequences_repo, "update_item", update_item)
|
||||||
monkeypatch.setattr(sequences_repo, "move_item", move_item)
|
monkeypatch.setattr(sequences_repo, "move_item", move_item)
|
||||||
monkeypatch.setattr(sequences_repo, "delete_item", delete_item)
|
monkeypatch.setattr(sequences_repo, "delete_item", delete_item)
|
||||||
|
monkeypatch.setattr(sequences_repo, "save_items", save_items)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -132,6 +138,8 @@ def test_sequences_list(fake_sequences: dict[str, Any], client_factory) -> None:
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "welcome" in response.text
|
assert "welcome" in response.text
|
||||||
assert "Приветствие" in response.text
|
assert "Приветствие" in response.text
|
||||||
|
assert "<built-in method" not in response.text
|
||||||
|
assert "Шаблоны сообщений" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_sequence_editor_has_toolbar(fake_sequences: dict[str, Any], client_factory) -> None:
|
def test_sequence_editor_has_toolbar(fake_sequences: dict[str, Any], client_factory) -> None:
|
||||||
|
|
@ -266,3 +274,51 @@ def test_move_and_delete_item(fake_sequences: dict[str, Any], client_factory, cs
|
||||||
assert deleted.status_code == 303
|
assert deleted.status_code == 303
|
||||||
assert fake_sequences["move"] == (1, "down")
|
assert fake_sequences["move"] == (1, "down")
|
||||||
assert fake_sequences["deleted"] == 2
|
assert fake_sequences["deleted"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_items(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory(follow_redirects=False)
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/sequences/welcome/save",
|
||||||
|
data={
|
||||||
|
"title": "Приветствие",
|
||||||
|
"description": "Описание",
|
||||||
|
"item_order": "1,2",
|
||||||
|
"text_1": "Первый",
|
||||||
|
"buttons_1": "",
|
||||||
|
"enabled_1": "on",
|
||||||
|
"text_2": "Второй",
|
||||||
|
"buttons_2": "Канал | https://t.me/x",
|
||||||
|
"enabled_2": "on",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/sequences/welcome?saved=all"
|
||||||
|
saved = fake_sequences["saved_all"]
|
||||||
|
assert [item["text"] for item in saved] == ["Первый", "Второй"]
|
||||||
|
assert saved[1]["buttons"] == [("Канал", "https://t.me/x")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_rejects_bad_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/sequences/welcome/save",
|
||||||
|
data={
|
||||||
|
"title": "Приветствие",
|
||||||
|
"item_order": "1",
|
||||||
|
"text_1": "Текст",
|
||||||
|
"buttons_1": "без ссылки",
|
||||||
|
"enabled_1": "on",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Кнопка без разделителя" in response.text
|
||||||
|
assert "saved_all" not in fake_sequences
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Тесты справочника званий."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from common.repositories import titles_repo
|
||||||
|
|
||||||
|
TITLE: dict[str, Any] = {
|
||||||
|
"id_title": 1,
|
||||||
|
"title": "Ветеран",
|
||||||
|
"price": 500,
|
||||||
|
"visible": True,
|
||||||
|
"used": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_titles(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
|
state: dict[str, Any] = {"used": 2}
|
||||||
|
|
||||||
|
async def list_titles() -> list[dict[str, Any]]:
|
||||||
|
return [TITLE]
|
||||||
|
|
||||||
|
async def create_title(title: str, price: int, visible: bool) -> int:
|
||||||
|
state["created"] = (title, price, visible)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool:
|
||||||
|
state["updated"] = (title_id, title, price, visible)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_title(title_id: int) -> bool:
|
||||||
|
state["deleted"] = title_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def usage_count(title_id: int) -> int:
|
||||||
|
return state["used"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(titles_repo, "list_titles", list_titles)
|
||||||
|
monkeypatch.setattr(titles_repo, "create_title", create_title)
|
||||||
|
monkeypatch.setattr(titles_repo, "update_title", update_title)
|
||||||
|
monkeypatch.setattr(titles_repo, "delete_title", delete_title)
|
||||||
|
monkeypatch.setattr(titles_repo, "usage_count", usage_count)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _client(client_factory) -> TestClient:
|
||||||
|
return client_factory(follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_titles_list(fake_titles: dict[str, Any], client_factory) -> None:
|
||||||
|
response = _client(client_factory).get("/titles")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Ветеран" in response.text
|
||||||
|
assert "используется: 2" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client = _client(client_factory)
|
||||||
|
token = csrf_token(client.get("/titles").text)
|
||||||
|
|
||||||
|
response = client.post("/titles", data={"title": "Новичок", "price": "100", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert fake_titles["created"] == ("Новичок", 100, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client = _client(client_factory)
|
||||||
|
token = csrf_token(client.get("/titles").text)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/titles/1",
|
||||||
|
data={"title": "Ветеран+", "price": "700", "visible": "on", "csrf_token": token},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert fake_titles["updated"] == (1, "Ветеран+", 700, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_used_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client = _client(client_factory)
|
||||||
|
token = csrf_token(client.get("/titles").text)
|
||||||
|
|
||||||
|
response = client.post("/titles/1/delete", data={"csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "используется 2 игроками" in response.text
|
||||||
|
assert "deleted" not in fake_titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_unused_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
fake_titles["used"] = 0
|
||||||
|
client = _client(client_factory)
|
||||||
|
token = csrf_token(client.get("/titles").text)
|
||||||
|
|
||||||
|
response = client.post("/titles/1/delete", data={"csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert fake_titles["deleted"] == 1
|
||||||
|
|
@ -35,17 +35,14 @@ class FakeBot:
|
||||||
self.forwarded.append((chat_id, from_chat_id, message_id))
|
self.forwarded.append((chat_id, from_chat_id, message_id))
|
||||||
|
|
||||||
|
|
||||||
class FakeClickHouse:
|
class FakeWriter:
|
||||||
"""Заглушка клиента ClickHouse (см. тесты аналитики)."""
|
"""Заглушка записи аналитики в Postgres (см. тесты аналитики)."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.rows: list[list[Any]] = []
|
self.events: list[list[Any]] = []
|
||||||
|
|
||||||
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> None:
|
async def __call__(self, events: list[list[Any]], messages: dict[Any, Any]) -> None:
|
||||||
self.rows.extend(data)
|
self.events.extend(events)
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -147,8 +144,8 @@ async def test_ping_hourly_sends_once_per_hour(
|
||||||
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
|
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100, app_env="prod"))
|
settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100, app_env="prod"))
|
||||||
fake = FakeClickHouse()
|
fake = FakeWriter()
|
||||||
await init_analytics(settings, client=fake)
|
await init_analytics(settings, writer=fake)
|
||||||
bot = FakeBot()
|
bot = FakeBot()
|
||||||
|
|
||||||
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is True
|
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is True
|
||||||
|
|
@ -159,15 +156,15 @@ async def test_ping_hourly_sends_once_per_hour(
|
||||||
|
|
||||||
assert len(bot.messages) == 2
|
assert len(bot.messages) == 2
|
||||||
assert "бот живой" in bot.messages[0][1]
|
assert "бот живой" in bot.messages[0][1]
|
||||||
assert [row[1] for row in fake.rows] == [EventType.BOT_ALIVE, EventType.BOT_ALIVE]
|
assert [row[1] for row in fake.events] == [EventType.BOT_ALIVE, EventType.BOT_ALIVE]
|
||||||
|
|
||||||
|
|
||||||
async def test_ping_hourly_records_event_without_alert_chat(
|
async def test_ping_hourly_records_event_without_alert_chat(
|
||||||
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
|
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=0))
|
settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=0))
|
||||||
fake = FakeClickHouse()
|
fake = FakeWriter()
|
||||||
await init_analytics(settings, client=fake)
|
await init_analytics(settings, writer=fake)
|
||||||
bot = FakeBot()
|
bot = FakeBot()
|
||||||
|
|
||||||
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is False
|
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is False
|
||||||
|
|
@ -176,4 +173,4 @@ async def test_ping_hourly_records_event_without_alert_chat(
|
||||||
await close_analytics()
|
await close_analytics()
|
||||||
|
|
||||||
assert bot.messages == []
|
assert bot.messages == []
|
||||||
assert [row[1] for row in fake.rows] == [EventType.BOT_ALIVE]
|
assert [row[1] for row in fake.events] == [EventType.BOT_ALIVE]
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"""Тесты аналитики: каталог событий, буферизация и деградация без ClickHouse.
|
"""Тесты аналитики: каталог событий, буферизация и запись в Postgres.
|
||||||
|
|
||||||
Тесты не требуют ни ClickHouse, ни aiogram: клиент подменяется фейком, а
|
Postgres не требуется: writer подменяется фейком.
|
||||||
``clickhouse_connect`` импортируется лениво внутри клиента.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -16,7 +13,15 @@ from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from common.analytics import ALL_EVENT_TYPES, EventType, close_analytics, get_analytics, init_analytics, record
|
from common.analytics import (
|
||||||
|
ALL_EVENT_TYPES,
|
||||||
|
EventType,
|
||||||
|
close_analytics,
|
||||||
|
get_analytics,
|
||||||
|
init_analytics,
|
||||||
|
record,
|
||||||
|
record_message,
|
||||||
|
)
|
||||||
from common.analytics.client import PAYLOAD_MAX_LENGTH, AnalyticsClient
|
from common.analytics.client import PAYLOAD_MAX_LENGTH, AnalyticsClient
|
||||||
from common.analytics.messages import describe_command, describe_message
|
from common.analytics.messages import describe_command, describe_message
|
||||||
from common.config import Settings
|
from common.config import Settings
|
||||||
|
|
@ -35,25 +40,69 @@ class _FakeEnum:
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
class FakeClickHouse:
|
class FakeWriter:
|
||||||
"""Заглушка клиента ClickHouse: пишет в список или падает."""
|
"""Заглушка записи в Postgres: складывает батчи в списки."""
|
||||||
|
|
||||||
def __init__(self, *, fail: bool = False) -> None:
|
def __init__(self, *, fail: bool = False) -> None:
|
||||||
self.rows: list[list[Any]] = []
|
self.events: list[list[Any]] = []
|
||||||
self.table: str | None = None
|
self.messages: dict[tuple[Any, int, int], list[Any]] = {}
|
||||||
self.columns: list[str] | None = None
|
|
||||||
self.closed = False
|
|
||||||
self._fail = fail
|
self._fail = fail
|
||||||
|
|
||||||
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> None:
|
async def __call__(self, events: list[list[Any]], messages: dict[tuple[Any, int, int], list[Any]]) -> None:
|
||||||
if self._fail:
|
if self._fail:
|
||||||
raise RuntimeError("ClickHouse недоступен")
|
raise RuntimeError("Postgres недоступен")
|
||||||
self.table = table
|
self.events.extend(events)
|
||||||
self.columns = list(column_names)
|
for key, value in messages.items():
|
||||||
self.rows.extend(data)
|
if key in self.messages:
|
||||||
|
self.messages[key][1] += value[1]
|
||||||
|
self.messages[key][0] = value[0]
|
||||||
|
else:
|
||||||
|
self.messages[key] = list(value)
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
self.closed = True
|
class FakeCursor:
|
||||||
|
"""Курсор psycopg: запоминает вызовы."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, Any, Any]] = []
|
||||||
|
|
||||||
|
async def executemany(self, query: str, rows: list[Any]) -> None:
|
||||||
|
self.calls.append(("executemany", query, rows))
|
||||||
|
|
||||||
|
async def execute(self, query: str, params: Any) -> None:
|
||||||
|
self.calls.append(("execute", query, params))
|
||||||
|
|
||||||
|
async def __aenter__(self) -> FakeCursor:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
"""Соединение psycopg с курсором."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cursor_obj = FakeCursor()
|
||||||
|
|
||||||
|
def cursor(self) -> FakeCursor:
|
||||||
|
return self.cursor_obj
|
||||||
|
|
||||||
|
async def __aenter__(self) -> FakeConnection:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakePool:
|
||||||
|
"""Пул с одним соединением."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.conn = FakeConnection()
|
||||||
|
|
||||||
|
def connection(self) -> FakeConnection:
|
||||||
|
return self.conn
|
||||||
|
|
||||||
|
|
||||||
def _settings(**overrides: Any) -> Settings:
|
def _settings(**overrides: Any) -> Settings:
|
||||||
|
|
@ -68,9 +117,9 @@ async def _clean_singleton():
|
||||||
await close_analytics()
|
await close_analytics()
|
||||||
|
|
||||||
|
|
||||||
def _client(**overrides: Any) -> tuple[AnalyticsClient, FakeClickHouse]:
|
def _client(**overrides: Any) -> tuple[AnalyticsClient, FakeWriter]:
|
||||||
fake = FakeClickHouse()
|
fake = FakeWriter()
|
||||||
return AnalyticsClient(_settings(**overrides), client=fake), fake
|
return AnalyticsClient(_settings(**overrides), writer=fake), fake
|
||||||
|
|
||||||
|
|
||||||
# --- каталог событий ---
|
# --- каталог событий ---
|
||||||
|
|
@ -141,41 +190,46 @@ def test_describe_command_returns_none_for_plain_message() -> None:
|
||||||
|
|
||||||
async def test_record_without_init_is_noop() -> None:
|
async def test_record_without_init_is_noop() -> None:
|
||||||
assert get_analytics() is None
|
assert get_analytics() is None
|
||||||
record(EventType.MESSAGE, user_id=1, chat_id=2, payload={"a": 1}) # не должно бросать
|
record(EventType.COMMAND, user_id=1, chat_id=2, payload={"a": 1}) # не должно бросать
|
||||||
|
record_message(user_id=1, username="vasya", chat_id=2)
|
||||||
|
|
||||||
|
|
||||||
async def test_client_buffers_and_flushes_rows() -> None:
|
async def test_client_buffers_and_flushes_rows() -> None:
|
||||||
client, fake = _client()
|
client, fake = _client()
|
||||||
client.record(EventType.MESSAGE, user_id=11, username="vasya", chat_id=100, payload={"message_id": 5})
|
client.record(EventType.COMMAND, user_id=11, username="vasya", chat_id=100, payload={"command": "инфо"})
|
||||||
client.record(EventType.COMMAND, user_id=11, username="vasya", chat_id=100, payload={"command": "/инфо"})
|
client.record(EventType.PURIFICATION, user_id=11, username="vasya", chat_id=100, payload={"success": True})
|
||||||
|
|
||||||
assert client.pending == 2
|
assert client.pending == 2
|
||||||
|
|
||||||
await client.flush()
|
await client.flush()
|
||||||
|
|
||||||
assert client.pending == 0
|
assert client.pending == 0
|
||||||
assert fake.table == "analytics.events"
|
assert len(fake.events) == 2
|
||||||
assert fake.columns == [
|
|
||||||
"event_time",
|
|
||||||
"event_type",
|
|
||||||
"user_id",
|
|
||||||
"username",
|
|
||||||
"chat_id",
|
|
||||||
"payload",
|
|
||||||
"bot_version",
|
|
||||||
"prod",
|
|
||||||
]
|
|
||||||
assert len(fake.rows) == 2
|
|
||||||
|
|
||||||
row = fake.rows[0]
|
row = fake.events[0]
|
||||||
assert isinstance(row[0], datetime)
|
assert isinstance(row[0], datetime)
|
||||||
assert row[1] == "message"
|
assert row[1] == "command"
|
||||||
assert row[2] == 11
|
assert row[2] == 11
|
||||||
assert row[3] == "vasya"
|
assert row[3] == "vasya"
|
||||||
assert row[4] == 100
|
assert row[4] == 100
|
||||||
assert '"message_id": 5' in row[5]
|
assert row[5] == {"command": "инфо"}
|
||||||
assert row[6] == __version__
|
assert row[6] == __version__
|
||||||
assert row[7] == 0
|
assert row[7] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_counter_is_aggregated_per_day() -> None:
|
||||||
|
client, fake = _client()
|
||||||
|
client.record_message(user_id=11, username="vasya", chat_id=100)
|
||||||
|
client.record_message(user_id=11, username="vasya", chat_id=100)
|
||||||
|
client.record_message(user_id=12, username=None, chat_id=100)
|
||||||
|
|
||||||
|
assert client.pending == 3
|
||||||
|
await client.flush()
|
||||||
|
|
||||||
|
assert client.pending == 0
|
||||||
|
assert len(fake.messages) == 2
|
||||||
|
vasya = next(value for key, value in fake.messages.items() if key[1] == 11)
|
||||||
|
assert vasya == ["vasya", 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_remember_user_enriches_username() -> None:
|
async def test_remember_user_enriches_username() -> None:
|
||||||
|
|
@ -185,17 +239,17 @@ async def test_remember_user_enriches_username() -> None:
|
||||||
|
|
||||||
await client.flush()
|
await client.flush()
|
||||||
|
|
||||||
assert fake.rows[0][3] == "nickname"
|
assert fake.events[0][3] == "nickname"
|
||||||
|
|
||||||
|
|
||||||
async def test_record_triggers_flush_at_batch_size() -> None:
|
async def test_record_triggers_flush_at_batch_size() -> None:
|
||||||
client, _ = _client(analytics_batch_size=3)
|
client, _ = _client(analytics_batch_size=3)
|
||||||
|
|
||||||
client.record(EventType.MESSAGE, user_id=1)
|
client.record(EventType.COMMAND, user_id=1)
|
||||||
client.record(EventType.MESSAGE, user_id=1)
|
client.record(EventType.COMMAND, user_id=1)
|
||||||
assert not client._flush_now.is_set()
|
assert not client._flush_now.is_set()
|
||||||
|
|
||||||
client.record(EventType.MESSAGE, user_id=1)
|
client.record(EventType.COMMAND, user_id=1)
|
||||||
assert client._flush_now.is_set()
|
assert client._flush_now.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -203,64 +257,76 @@ async def test_overflow_drops_oldest_events() -> None:
|
||||||
client, _ = _client(analytics_buffer_max=2, analytics_batch_size=100)
|
client, _ = _client(analytics_buffer_max=2, analytics_batch_size=100)
|
||||||
|
|
||||||
for user_id in range(1, 5):
|
for user_id in range(1, 5):
|
||||||
client.record(EventType.MESSAGE, user_id=user_id)
|
client.record(EventType.COMMAND, user_id=user_id)
|
||||||
|
|
||||||
assert client.pending == 2
|
assert client.pending == 2
|
||||||
assert [row[2] for row in client._rows] == [3, 4]
|
assert [row[2] for row in client._rows] == [3, 4]
|
||||||
|
|
||||||
|
|
||||||
async def test_disabled_analytics_ignores_events() -> None:
|
async def test_disabled_analytics_ignores_events() -> None:
|
||||||
client, fake = _client(clickhouse_enabled=False)
|
client, fake = _client(analytics_enabled=False)
|
||||||
|
|
||||||
client.record(EventType.MESSAGE, user_id=1)
|
client.record(EventType.COMMAND, user_id=1)
|
||||||
|
client.record_message(user_id=1, username="vasya", chat_id=1)
|
||||||
await client.flush()
|
await client.flush()
|
||||||
|
|
||||||
assert client.pending == 0
|
assert client.pending == 0
|
||||||
assert fake.rows == []
|
assert fake.events == []
|
||||||
|
assert fake.messages == {}
|
||||||
|
|
||||||
|
|
||||||
# --- деградация без ClickHouse ---
|
# --- запись в Postgres ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_db_uses_cursor_executemany(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from common.analytics import client as analytics_client
|
||||||
|
|
||||||
|
pool = FakePool()
|
||||||
|
monkeypatch.setattr(analytics_client, "get_pool", lambda: pool)
|
||||||
|
|
||||||
|
await analytics_client._write_db(
|
||||||
|
[[datetime.now(UTC), "command", 1, "vasya", 100, {"command": "инфо"}, "0.7.2", False]],
|
||||||
|
{(datetime.now(UTC).date(), 1, 100): ["vasya", 3]},
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = pool.conn.cursor_obj.calls
|
||||||
|
assert calls[0][0] == "executemany"
|
||||||
|
assert calls[1][0] == "execute"
|
||||||
|
assert "messages_daily" in calls[1][1]
|
||||||
|
|
||||||
|
|
||||||
|
# --- деградация без Postgres ---
|
||||||
|
|
||||||
|
|
||||||
async def test_flush_failure_logs_fallback_and_drops(caplog: pytest.LogCaptureFixture) -> None:
|
async def test_flush_failure_logs_fallback_and_drops(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
fake = FakeClickHouse(fail=True)
|
fake = FakeWriter(fail=True)
|
||||||
client = AnalyticsClient(_settings(), client=fake)
|
client = AnalyticsClient(_settings(), writer=fake)
|
||||||
client.record(EventType.MESSAGE, user_id=1, username="vasya", chat_id=100)
|
client.record(EventType.COMMAND, user_id=1, username="vasya", chat_id=100)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level("WARNING"):
|
||||||
await client.flush() # не должно бросать
|
await client.flush() # не должно бросать
|
||||||
|
|
||||||
assert client.pending == 0
|
assert client.pending == 0
|
||||||
assert any("Fallback" in message for message in caplog.messages)
|
assert any("Fallback" in message for message in caplog.messages)
|
||||||
|
|
||||||
|
|
||||||
async def test_unavailable_client_falls_back_to_log(caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
settings = _settings(clickhouse_host="127.0.0.1", clickhouse_port=1, clickhouse_password="x")
|
|
||||||
client = AnalyticsClient(settings)
|
|
||||||
client.record(EventType.BOT_START, payload={"app_env": "dev"})
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
await client.flush()
|
|
||||||
|
|
||||||
assert client.pending == 0
|
|
||||||
assert any("Fallback" in message for message in caplog.messages)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_payload_is_truncated() -> None:
|
async def test_payload_is_truncated() -> None:
|
||||||
client, fake = _client()
|
client, fake = _client()
|
||||||
client.record(EventType.MESSAGE, user_id=1, payload={"text": "x" * (PAYLOAD_MAX_LENGTH * 2)})
|
client.record(EventType.COMMAND, user_id=1, payload={"text": "x" * (PAYLOAD_MAX_LENGTH * 2)})
|
||||||
|
|
||||||
await client.flush()
|
await client.flush()
|
||||||
|
|
||||||
assert len(fake.rows[0][5]) == PAYLOAD_MAX_LENGTH
|
payload = fake.events[0][5]
|
||||||
|
assert "_truncated" in payload
|
||||||
|
assert len(payload["_truncated"]) == PAYLOAD_MAX_LENGTH
|
||||||
|
|
||||||
|
|
||||||
# --- жизненный цикл ---
|
# --- жизненный цикл ---
|
||||||
|
|
||||||
|
|
||||||
async def test_init_and_close_flush_pending_events() -> None:
|
async def test_init_and_close_flush_pending_events() -> None:
|
||||||
fake = FakeClickHouse()
|
fake = FakeWriter()
|
||||||
client = await init_analytics(_settings(), client=fake)
|
client = await init_analytics(_settings(), writer=fake)
|
||||||
|
|
||||||
record(EventType.BOT_START, payload={"app_env": "dev"})
|
record(EventType.BOT_START, payload={"app_env": "dev"})
|
||||||
record(EventType.BOT_STOP, payload={"app_env": "dev"})
|
record(EventType.BOT_STOP, payload={"app_env": "dev"})
|
||||||
|
|
@ -271,8 +337,7 @@ async def test_init_and_close_flush_pending_events() -> None:
|
||||||
await close_analytics()
|
await close_analytics()
|
||||||
|
|
||||||
assert get_analytics() is None
|
assert get_analytics() is None
|
||||||
assert len(fake.rows) == 2
|
assert len(fake.events) == 2
|
||||||
assert fake.closed is True
|
|
||||||
|
|
||||||
|
|
||||||
# --- middleware (транспортные события) ---
|
# --- middleware (транспортные события) ---
|
||||||
|
|
@ -297,22 +362,23 @@ async def _handler(event: Any, data: dict[str, Any]) -> str:
|
||||||
return "handled"
|
return "handled"
|
||||||
|
|
||||||
|
|
||||||
async def test_middleware_records_message_and_command(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_middleware_counts_message_and_records_command(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
from bot.middlewares.analytics import AnalyticsMiddleware
|
from bot.middlewares.analytics import AnalyticsMiddleware
|
||||||
|
|
||||||
settings = _settings(chat_id=-100)
|
settings = _settings(chat_id=-100)
|
||||||
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
|
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
|
||||||
|
|
||||||
fake = FakeClickHouse()
|
fake = FakeWriter()
|
||||||
client = await init_analytics(settings, client=fake)
|
client = await init_analytics(settings, writer=fake)
|
||||||
|
|
||||||
assert await AnalyticsMiddleware()(_handler, _message(), {}) == "handled"
|
assert await AnalyticsMiddleware()(_handler, _message(), {}) == "handled"
|
||||||
await client.flush()
|
await client.flush()
|
||||||
|
|
||||||
assert [row[1] for row in fake.rows] == ["message", "command"]
|
assert [row[1] for row in fake.events] == ["command"]
|
||||||
assert json.loads(fake.rows[0][5])["is_command"] is True
|
assert fake.events[0][5] == {"command": "инфо", "args_count": 0}
|
||||||
assert json.loads(fake.rows[1][5]) == {"command": "инфо", "args_count": 0}
|
assert fake.events[0][3] == "vasya"
|
||||||
assert fake.rows[0][3] == "vasya"
|
assert len(fake.messages) == 1
|
||||||
|
assert next(iter(fake.messages.values())) == ["vasya", 1]
|
||||||
|
|
||||||
|
|
||||||
async def test_middleware_skips_other_chats_and_bots(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_middleware_skips_other_chats_and_bots(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
@ -321,8 +387,8 @@ async def test_middleware_skips_other_chats_and_bots(monkeypatch: pytest.MonkeyP
|
||||||
settings = _settings(chat_id=-100)
|
settings = _settings(chat_id=-100)
|
||||||
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
|
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
|
||||||
|
|
||||||
fake = FakeClickHouse()
|
fake = FakeWriter()
|
||||||
client = await init_analytics(settings, client=fake)
|
client = await init_analytics(settings, writer=fake)
|
||||||
|
|
||||||
from aiogram.types import Chat, User
|
from aiogram.types import Chat, User
|
||||||
|
|
||||||
|
|
@ -334,7 +400,8 @@ async def test_middleware_skips_other_chats_and_bots(monkeypatch: pytest.MonkeyP
|
||||||
)
|
)
|
||||||
await client.flush()
|
await client.flush()
|
||||||
|
|
||||||
assert fake.rows == []
|
assert fake.events == []
|
||||||
|
assert fake.messages == {}
|
||||||
|
|
||||||
|
|
||||||
async def test_middleware_never_breaks_handling(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_middleware_never_breaks_handling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
@ -346,6 +413,6 @@ async def test_middleware_never_breaks_handling(monkeypatch: pytest.MonkeyPatch)
|
||||||
def boom(*args: Any, **kwargs: Any) -> None:
|
def boom(*args: Any, **kwargs: Any) -> None:
|
||||||
raise RuntimeError("аналитика сломалась")
|
raise RuntimeError("аналитика сломалась")
|
||||||
|
|
||||||
monkeypatch.setattr("bot.middlewares.analytics.record", boom)
|
monkeypatch.setattr("bot.middlewares.analytics.record_message", boom)
|
||||||
|
|
||||||
assert await AnalyticsMiddleware()(_handler, _message(), {}) == "handled"
|
assert await AnalyticsMiddleware()(_handler, _message(), {}) == "handled"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Тесты меню команд: допустимые имена и синхронизация при старте."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from bot.commands_menu import BOT_COMMANDS, ensure_commands
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBot:
|
||||||
|
def __init__(self, current: list) -> None:
|
||||||
|
self.current = current
|
||||||
|
self.set_calls: list[list] = []
|
||||||
|
|
||||||
|
async def get_my_commands(self) -> list:
|
||||||
|
return self.current
|
||||||
|
|
||||||
|
async def set_my_commands(self, commands: list) -> None:
|
||||||
|
self.set_calls.append(commands)
|
||||||
|
|
||||||
|
|
||||||
|
class BrokenBot:
|
||||||
|
async def get_my_commands(self) -> list:
|
||||||
|
raise RuntimeError("telegram unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_names_are_telegram_compatible() -> None:
|
||||||
|
names = [command.command for command in BOT_COMMANDS]
|
||||||
|
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
for name in names:
|
||||||
|
assert re.fullmatch(r"[a-z0-9_]{1,32}", name), name
|
||||||
|
for command in BOT_COMMANDS:
|
||||||
|
assert command.description
|
||||||
|
|
||||||
|
|
||||||
|
async def test_menu_is_set_when_empty() -> None:
|
||||||
|
bot = FakeBot([])
|
||||||
|
|
||||||
|
assert await ensure_commands(bot) is True
|
||||||
|
assert len(bot.set_calls) == 1
|
||||||
|
assert [command.command for command in bot.set_calls[0]] == [command.command for command in BOT_COMMANDS]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_menu_is_not_touched_when_actual() -> None:
|
||||||
|
bot = FakeBot(list(BOT_COMMANDS))
|
||||||
|
|
||||||
|
assert await ensure_commands(bot) is False
|
||||||
|
assert bot.set_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_menu_updates_when_different() -> None:
|
||||||
|
bot = FakeBot([BOT_COMMANDS[0]])
|
||||||
|
|
||||||
|
assert await ensure_commands(bot) is True
|
||||||
|
assert len(bot.set_calls[0]) == len(BOT_COMMANDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_telegram_error_does_not_crash_startup() -> None:
|
||||||
|
assert await ensure_commands(BrokenBot()) is False # type: ignore[arg-type]
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Тесты deep-link входа в админку через Telegram."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from common.repositories import admin_login_tokens_repo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_tokens(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
|
state: dict[str, Any] = {"confirmed": None}
|
||||||
|
|
||||||
|
async def create_token() -> str:
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
async def consume_token(token: str) -> int | None:
|
||||||
|
return state["confirmed"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_login_tokens_repo, "create_token", create_token)
|
||||||
|
monkeypatch.setattr(admin_login_tokens_repo, "consume_token", consume_token)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _client(client_factory, settings_factory) -> TestClient:
|
||||||
|
return client_factory(
|
||||||
|
authenticated=False,
|
||||||
|
follow_redirects=False,
|
||||||
|
settings=settings_factory(
|
||||||
|
bot_username="debot_test_bot",
|
||||||
|
admin_public_url="https://debot-test.mpakosrv.ru",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_has_deeplink(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
|
||||||
|
response = _client(client_factory, settings_factory).get("/login")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Открыть Telegram" in response.text
|
||||||
|
assert "/auth/deeplink/start" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_deeplink_start_redirects_to_telegram(
|
||||||
|
fake_tokens: dict[str, Any],
|
||||||
|
client_factory,
|
||||||
|
settings_factory,
|
||||||
|
) -> None:
|
||||||
|
client = _client(client_factory, settings_factory)
|
||||||
|
|
||||||
|
response = client.get("/auth/deeplink/start")
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "https://t.me/debot_test_bot?start=login_test-token"
|
||||||
|
assert "debot_login_token" in response.headers["set-cookie"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deeplink_status_without_cookie(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
|
||||||
|
response = _client(client_factory, settings_factory).get("/auth/deeplink/status")
|
||||||
|
|
||||||
|
assert response.json() == {"status": "none"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deeplink_status_pending(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
|
||||||
|
client = _client(client_factory, settings_factory)
|
||||||
|
client.get("/auth/deeplink/start")
|
||||||
|
|
||||||
|
response = client.get("/auth/deeplink/status")
|
||||||
|
|
||||||
|
assert response.json() == {"status": "pending"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deeplink_status_confirms_session(
|
||||||
|
fake_tokens: dict[str, Any],
|
||||||
|
client_factory,
|
||||||
|
settings_factory,
|
||||||
|
) -> None:
|
||||||
|
fake_tokens["confirmed"] = 1
|
||||||
|
client = _client(client_factory, settings_factory)
|
||||||
|
client.get("/auth/deeplink/start")
|
||||||
|
|
||||||
|
response = client.get("/auth/deeplink/status")
|
||||||
|
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
assert client.get("/").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_deeplink_status_denied_for_unknown_admin(
|
||||||
|
fake_tokens: dict[str, Any],
|
||||||
|
client_factory,
|
||||||
|
settings_factory,
|
||||||
|
) -> None:
|
||||||
|
fake_tokens["confirmed"] = 2
|
||||||
|
client = _client(client_factory, settings_factory)
|
||||||
|
client.get("/auth/deeplink/start")
|
||||||
|
|
||||||
|
response = client.get("/auth/deeplink/status")
|
||||||
|
|
||||||
|
assert response.json() == {"status": "denied"}
|
||||||
|
|
@ -6,6 +6,7 @@ import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot.services import notifications
|
||||||
from bot.services.notifications import DEFAULT_SCHEDULES, ScheduledMessage, _from_row
|
from bot.services.notifications import DEFAULT_SCHEDULES, ScheduledMessage, _from_row
|
||||||
|
|
||||||
SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V16__seed_schedules.sql"
|
SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V16__seed_schedules.sql"
|
||||||
|
|
@ -66,3 +67,58 @@ def test_default_schedules_match_seed() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
assert seeded == list(DEFAULT_SCHEDULES)
|
assert seeded == list(DEFAULT_SCHEDULES)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tick_skips_already_sent_minute(monkeypatch) -> None:
|
||||||
|
schedule = ScheduledMessage(12, 0, "notify.blago", schedule_id=1)
|
||||||
|
sent: list[str] = []
|
||||||
|
|
||||||
|
async def load() -> tuple[ScheduledMessage, ...]:
|
||||||
|
return (schedule,)
|
||||||
|
|
||||||
|
async def claim(schedule_id: int, run_at: datetime) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def subscribers() -> list[dict]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def broadcast(bot, key: str, rows: list[dict]) -> None:
|
||||||
|
sent.append(key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(notifications, "_load_schedules", load)
|
||||||
|
monkeypatch.setattr(notifications.schedules_repo, "claim_run", claim)
|
||||||
|
monkeypatch.setattr(notifications.users_service, "list_subscribers", subscribers)
|
||||||
|
monkeypatch.setattr(notifications, "_send_broadcast", broadcast)
|
||||||
|
|
||||||
|
await notifications._tick(None, datetime(2026, 1, 1, 9, 0, tzinfo=UTC))
|
||||||
|
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tick_sends_once_when_claimed(monkeypatch) -> None:
|
||||||
|
schedule = ScheduledMessage(12, 0, "notify.blago", schedule_id=1)
|
||||||
|
sent: list[str] = []
|
||||||
|
claims: list[tuple[int, datetime]] = []
|
||||||
|
|
||||||
|
async def load() -> tuple[ScheduledMessage, ...]:
|
||||||
|
return (schedule,)
|
||||||
|
|
||||||
|
async def claim(schedule_id: int, run_at: datetime) -> bool:
|
||||||
|
claims.append((schedule_id, run_at))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def subscribers() -> list[dict]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def broadcast(bot, key: str, rows: list[dict]) -> None:
|
||||||
|
sent.append(key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(notifications, "_load_schedules", load)
|
||||||
|
monkeypatch.setattr(notifications.schedules_repo, "claim_run", claim)
|
||||||
|
monkeypatch.setattr(notifications.users_service, "list_subscribers", subscribers)
|
||||||
|
monkeypatch.setattr(notifications, "_send_broadcast", broadcast)
|
||||||
|
|
||||||
|
await notifications._tick(None, datetime(2026, 1, 1, 9, 0, tzinfo=UTC))
|
||||||
|
|
||||||
|
assert sent == ["notify.blago"]
|
||||||
|
assert claims == [(1, datetime(2026, 1, 1, 9, 0, tzinfo=UTC))]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Тесты общего предпросмотра сообщений."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _client(client_factory) -> TestClient:
|
||||||
|
return client_factory()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_renders_tags(client_factory, csrf_token) -> None:
|
||||||
|
client = _client(client_factory)
|
||||||
|
token = csrf_token(client.get("/send").text)
|
||||||
|
|
||||||
|
response = client.post("/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, csrf_token) -> None:
|
||||||
|
client = _client(client_factory)
|
||||||
|
token = csrf_token(client.get("/send").text)
|
||||||
|
|
||||||
|
response = client.post("/preview", data={"text": "Привет, {unknown_tag}!", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Неизвестные теги" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_requires_csrf(client_factory) -> None:
|
||||||
|
response = _client(client_factory).post("/preview", data={"text": "Привет"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.5.1"
|
assert __version__ == "0.7.2"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue