Compare commits
No commits in common. "master" and "v0.3.8" have entirely different histories.
|
|
@ -2,10 +2,6 @@
|
|||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# секреты локального окружения (в образ не попадают)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# venv / кеши
|
||||
.venv
|
||||
.venv11
|
||||
|
|
|
|||
31
.env.example
31
.env.example
|
|
@ -5,12 +5,6 @@ APP_ENV=dev
|
|||
|
||||
# Токен бота (получается у @BotFather)
|
||||
BOT_TOKEN=
|
||||
# Username бота без @ (для кнопки Telegram-входа в админку)
|
||||
BOT_USERNAME=
|
||||
# Публичный HTTPS-адрес админки (для Telegram Login Widget)
|
||||
ADMIN_PUBLIC_URL=http://localhost:8000
|
||||
# Секрет подписи cookie-сессии: openssl rand -hex 32 (в проде обязателен)
|
||||
ADMIN_SESSION_SECRET=
|
||||
|
||||
# Postgres
|
||||
DB_HOST=localhost
|
||||
|
|
@ -35,23 +29,16 @@ ADMIN_IDS=
|
|||
# Часовой пояс для расписаний
|
||||
TIMEZONE=Europe/Moscow
|
||||
|
||||
# Аналитика (Postgres, схема analytics). Логируются действия бота (см. docs/events.md).
|
||||
# ANALYTICS_ENABLED=false — полностью выключить аналитику.
|
||||
ANALYTICS_ENABLED=true
|
||||
# Аналитика (ClickHouse). Логируются все действия бота (см. docs/events.md).
|
||||
# CLICKHOUSE_ENABLED=false — полностью выключить аналитику.
|
||||
CLICKHOUSE_ENABLED=true
|
||||
# Для локального запуска — localhost; в compose бот ходит по имени сервиса.
|
||||
CLICKHOUSE_HOST=localhost
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_DB=analytics
|
||||
CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=
|
||||
# Батчинг записи событий: размер батча, интервал сброса (сек), предел буфера.
|
||||
ANALYTICS_BATCH_SIZE=200
|
||||
ANALYTICS_FLUSH_INTERVAL=5.0
|
||||
ANALYTICS_BUFFER_MAX=5000
|
||||
|
||||
# Шаблоны сообщений: сколько секунд бот держит тексты из БД в памяти.
|
||||
# Правка в админке применяется без перезапуска в течение этого времени.
|
||||
TEMPLATE_CACHE_TTL=45
|
||||
|
||||
# Пасхальное событие «Яички». Пусто — автоматически 1 апреля → Православная Пасха.
|
||||
# Можно задать вручную (ISO): EGG_EVENT_START=2027-04-01, EGG_EVENT_END=2027-05-02.
|
||||
EGG_EVENT_START=
|
||||
EGG_EVENT_END=
|
||||
|
||||
# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
|
||||
ADMIN_HTTP_PORT=8000
|
||||
ADMIN_BIND_IP=127.0.0.1
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ concurrency:
|
|||
|
||||
env:
|
||||
REGISTRY: git.mpakosrv.ru
|
||||
IMAGE_NAME_BOT: mpakobecex/dispelevilbot-bot
|
||||
IMAGE_NAME_ADMIN: mpakobecex/dispelevilbot-admin
|
||||
IMAGE_NAME: mpakobecex/dispelevilbot-bot
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
|
|
@ -41,22 +40,16 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- name: Логин в Gitea Registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: Сборка и публикация образов
|
||||
- name: Сборка и публикация образа
|
||||
run: |
|
||||
set -e
|
||||
SHORT_SHA="$(echo "${GITHUB_SHA:-unknown}" | cut -c1-12)"
|
||||
IMAGE="$REGISTRY/$IMAGE_NAME"
|
||||
SHA="${GITHUB_SHA:-unknown}"
|
||||
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
|
||||
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
|
||||
|
||||
build_push() {
|
||||
image="$1"
|
||||
dockerfile="$2"
|
||||
tags="$image:$SHORT_SHA $image:latest"
|
||||
if [ -n "$VERSION" ]; then tags="$tags $image:$VERSION"; fi
|
||||
args=""
|
||||
for t in $tags; do args="$args -t $t"; done
|
||||
docker build $args -f "$dockerfile" .
|
||||
for t in $tags; do docker push "$t"; done
|
||||
}
|
||||
|
||||
build_push "$REGISTRY/$IMAGE_NAME_BOT" Dockerfile
|
||||
build_push "$REGISTRY/$IMAGE_NAME_ADMIN" admin/Dockerfile
|
||||
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
|
||||
if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi
|
||||
ARGS=""
|
||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
||||
docker build $ARGS .
|
||||
for t in $TAGS; do docker push "$t"; done
|
||||
|
|
|
|||
103
DEPLOY.md
103
DEPLOY.md
|
|
@ -16,14 +16,11 @@ cd DispelEvilBot
|
|||
```bash
|
||||
cp env/test.env.example env/test.env
|
||||
# заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.),
|
||||
# ADMIN_IDS (опц.)
|
||||
# плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен),
|
||||
# ADMIN_SESSION_SECRET (openssl rand -hex 32)
|
||||
# ADMIN_IDS (опц.), CLICKHOUSE_PASSWORD (аналитика событий)
|
||||
```
|
||||
|
||||
Порт админки (`ADMIN_HTTP_PORT`) у контуров должен различаться: `prod` — `8101`,
|
||||
`test` — `8100` (8000 на VPS занят Portainer). `ADMIN_BIND_IP` по умолчанию `127.0.0.1`;
|
||||
чтобы nginx-proxy-manager (контейнер) видел админку, ставьте `172.17.0.1`.
|
||||
Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться:
|
||||
`prod` — `8123`, `test` — `8124` (иначе контейнеры конфликтуют на одной VPS).
|
||||
|
||||
## 3. Логин в registry (если пакет приватный)
|
||||
|
||||
|
|
@ -53,76 +50,70 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs -
|
|||
|
||||
1. `postgres` поднимается и проходит healthcheck;
|
||||
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
|
||||
3. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же;
|
||||
4. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста).
|
||||
3. `clickhouse` поднимается и применяет `clickhouse/init/001_events.sql` —
|
||||
**только при первой инициализации** пустого data-каталога;
|
||||
4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же.
|
||||
|
||||
Проверка контура (сервисы, миграции, Postgres, схема аналитики):
|
||||
Проверка контура (сервисы, миграции, Postgres, ClickHouse):
|
||||
|
||||
```bash
|
||||
make check E=test
|
||||
```
|
||||
|
||||
## Аналитика (Postgres)
|
||||
## ClickHouse (аналитика событий)
|
||||
|
||||
События и дневные счётчики сообщений пишутся в схему `analytics` той же БД Postgres
|
||||
(контракт — [`docs/events.md`](docs/events.md)). Отдельного сервиса нет.
|
||||
События всех действий бота пишутся в `analytics.events` (контракт — [`docs/events.md`](docs/events.md)).
|
||||
ClickHouse наружу **не публикуется**: только loopback хоста (`127.0.0.1:${CLICKHOUSE_HTTP_PORT}`).
|
||||
|
||||
```bash
|
||||
make psql E=test
|
||||
make ch E=test # clickhouse-client контура
|
||||
# пример: события за сегодня по типам
|
||||
SELECT event_type, count(*) FROM analytics.events
|
||||
WHERE event_time >= current_date GROUP BY event_type;
|
||||
|
||||
# сообщения по дням
|
||||
SELECT day, sum(count) FROM analytics.messages_daily
|
||||
WHERE day >= current_date - 7 GROUP BY day ORDER BY day;
|
||||
docker compose -p test --env-file env/test.env -f docker-compose.prod.yml exec -T clickhouse \
|
||||
sh -lc 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" \
|
||||
--query "SELECT event_type, count() FROM analytics.events WHERE toDate(event_time) = today() GROUP BY event_type"'
|
||||
```
|
||||
|
||||
Если Postgres недоступен, бот продолжает работать: батч аналитики уходит в stdout
|
||||
(WARNING) и отбрасывается. Полностью выключить можно флагом `ANALYTICS_ENABLED=false`.
|
||||
Если ClickHouse недоступен, бот продолжает работать: события уходят в stdout (WARNING),
|
||||
полностью выключить можно флагом `CLICKHOUSE_ENABLED=false`.
|
||||
|
||||
Партиции создаются приложением при старте и раз в 6 часов; старые **не удаляются**.
|
||||
Схема создаётся миграцией `V26__analytics_schema.sql`.
|
||||
Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге,
|
||||
поэтому на существующем контуре DDL нужно применить вручную (`make ch E=...`).
|
||||
|
||||
## Админка (FastAPI)
|
||||
## Бэкап событий (забирает домашний сервер)
|
||||
|
||||
Сервис `admin` — отдельный образ `mpakobecex/dispelevilbot-admin`, общий пакет `common`.
|
||||
Наружу пока **не публикуется**: по умолчанию только loopback хоста
|
||||
(`127.0.0.1:${ADMIN_HTTP_PORT}`). Для nginx-proxy-manager (он в контейнере) задайте
|
||||
`ADMIN_BIND_IP=172.17.0.1` и указывайте в NPM цель `172.17.0.1:8100` (test) /
|
||||
`172.17.0.1:8101` (prod). Домен и TLS — Блок 4.2.
|
||||
Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает**
|
||||
вчерашнюю партицию (`pull`), складывает её в `Parquet.gz`, импортирует в свой ClickHouse и
|
||||
сверяет количество строк — **только после сверки** на VPS удаляются партиции старше
|
||||
`BACKUP_GRACE_DAYS` (по умолчанию 7). На VPS остаются агрегаты (`events_daily`, `money_daily`).
|
||||
|
||||
Входящий SSH на домашний сервер не нужен: он инициирует соединение сам.
|
||||
|
||||
Разовая настройка:
|
||||
|
||||
```bash
|
||||
# проверка живости
|
||||
curl -s http://127.0.0.1:8100/healthz # test
|
||||
curl -s http://127.0.0.1:8101/healthz # prod
|
||||
# на домашнем сервере
|
||||
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_*
|
||||
```
|
||||
|
||||
Локально без Docker: `pip install -r requirements-admin.txt && python -m admin`
|
||||
(по умолчанию `0.0.0.0:8000`, переопределяется `ADMIN_HOST`/`ADMIN_PORT`).
|
||||
Требования: на VPS — пользователь с доступом к `docker` и каталог с репозиторием
|
||||
(`VPS_PROJECT_DIR`, там запускается `docker compose`); на домашнем сервере — `clickhouse-client`
|
||||
и локальный ClickHouse. Настройка SSH-доступа — `deploy/ssh-access.md`.
|
||||
|
||||
### Вход через Telegram
|
||||
Проверочный прогон (без cron): `make backup E=test` или `./scripts/clickhouse_backup.sh test`.
|
||||
|
||||
Админка закрыта Telegram Login Widget. Что нужно на контуре:
|
||||
Cron на домашнем сервере (вчерашний день уже закрыт, время московское):
|
||||
|
||||
- `BOT_TOKEN` — токен бота этого контура (им же проверяется подпись `hash`);
|
||||
- `BOT_USERNAME` — username бота без `@` (виджет рисует кнопку);
|
||||
- `ADMIN_PUBLIC_URL` — публичный HTTPS-адрес (`https://debot-test.mpakosrv.ru`);
|
||||
- `ADMIN_SESSION_SECRET` — `openssl rand -hex 32`;
|
||||
- `ADMIN_IDS` — Telegram-ID администраторов: ими засевается `bot.admin_users` при старте
|
||||
(`ON CONFLICT DO NOTHING`, существующих не трогает).
|
||||
```cron
|
||||
0 4 * * * cd /srv/DispelEvilBot && ./scripts/clickhouse_backup.sh test >> /var/log/clickhouse-backup-test.log 2>&1
|
||||
```
|
||||
|
||||
Домен должен быть привязан к боту в `@BotFather` (`/setdomain`), а TLS — настроен на
|
||||
reverse proxy. Сессия живёт 12 часов (`ADMIN_SESSION_MAX_AGE`), cookie подписана и
|
||||
`HttpOnly`; в проде ставится флаг `Secure`. Все POST-формы проверяют CSRF-токен.
|
||||
Роль администратора перечитывается из БД на каждом запросе — отключение пользователя
|
||||
(`enabled = false`) или удаление записи сразу закрывает доступ.
|
||||
Ошибка на любом шаге оставляет данные на VPS (партиции не удаляются) и пишет понятный лог.
|
||||
Импорт дня идемпотентен: повторный прогон не дублирует строки.
|
||||
|
||||
## Бэкап
|
||||
|
||||
Аналитика теперь лежит в Postgres, поэтому отдельного бэкапа событий больше нет.
|
||||
Бэкап БД — стандартными средствами Postgres (`pg_dump`/`pg_basebackup`); при росте
|
||||
истории следите за размером `analytics` и диском.
|
||||
Если контур на VPS не запущен (нет работающего `clickhouse` или не найден каталог проекта),
|
||||
скрипт пишет об этом в лог и завершается с кодом 0 — для cron это не ошибка.
|
||||
|
||||
## Обновление и откат
|
||||
|
||||
|
|
@ -143,13 +134,13 @@ docker compose -p <контур> --env-file env/<контур>.env -f docker-com
|
|||
|
||||
## Переменные окружения
|
||||
|
||||
См. `env/prod.env.example` / `env/test.env.example` (контуры).
|
||||
См. `env/prod.env.example` / `env/test.env.example` (контуры) и `env/backup.env.example` (бэкап событий).
|
||||
|
||||
## Дашборд (позже)
|
||||
|
||||
Superset поднимается отдельно и читает Postgres (схема `analytics`) с полной историей
|
||||
событий. Отдельный read-only пользователь Postgres и сам дашборд — финальный этап,
|
||||
в этот контур не входят.
|
||||
Superset поднимается на домашнем сервере и читает **локальный** ClickHouse с полной
|
||||
историей событий (сюда ежедневно приезжают архивы). Отдельный read-only пользователь
|
||||
ClickHouse и сам дашборд — финальный этап, в этот контур не входят.
|
||||
|
||||
## Перенос данных на прод (позже, короткое окно простоя)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ COPY common ./common
|
|||
COPY bot ./bot
|
||||
COPY images ./images
|
||||
|
||||
RUN mkdir -p /app/media && chown app:app /app/media
|
||||
|
||||
USER app
|
||||
|
||||
# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py).
|
||||
|
|
|
|||
10
Makefile
10
Makefile
|
|
@ -9,7 +9,7 @@ COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml
|
|||
.DEFAULT_GOAL := help
|
||||
.RECIPEPREFIX := >
|
||||
|
||||
.PHONY: help up pull down restart logs ps images check psql
|
||||
.PHONY: help up pull down restart logs ps images check psql ch backup
|
||||
|
||||
help:
|
||||
> @echo "Контур задаётся через E=test|prod (по умолчанию test):"
|
||||
|
|
@ -22,6 +22,8 @@ help:
|
|||
> @echo " make images E=test образы контура"
|
||||
> @echo " make check E=test проверка контура (скрипт)"
|
||||
> @echo " make psql E=test psql в БД контура"
|
||||
> @echo " make ch E=test clickhouse-client контура"
|
||||
> @echo " make backup E=test бэкап событий с VPS (запускать на домашнем сервере)"
|
||||
|
||||
up:
|
||||
> $(COMPOSE) up -d
|
||||
|
|
@ -49,3 +51,9 @@ check:
|
|||
|
||||
psql:
|
||||
> $(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)
|
||||
|
|
|
|||
142
README.md
142
README.md
|
|
@ -8,10 +8,10 @@ Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепо
|
|||
```
|
||||
common/ # общий пакет: config, logging, db, repositories, models, texts, analytics
|
||||
bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications
|
||||
admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile
|
||||
sql/ # миграции Flyway (V__*.sql, R__*.sql), включая схему analytics
|
||||
sql/ # миграции Flyway (V__*.sql, R__*.sql)
|
||||
clickhouse/ # init-скрипты ClickHouse (схема аналитики)
|
||||
docs/ # документация (docs/events.md — события аналитики)
|
||||
scripts/ # эксплуатационные скрипты (проверка контура)
|
||||
scripts/ # эксплуатационные скрипты (проверка контура, бэкап событий)
|
||||
images/ # статичные картинки (приветствие)
|
||||
deploy/ # материалы деплоя (Caddyfile, инструкции)
|
||||
tests/ # тесты
|
||||
|
|
@ -23,7 +23,8 @@ docker-compose.yml / docker-compose.prod.yml
|
|||
## Требования
|
||||
|
||||
- Python 3.12
|
||||
- PostgreSQL 14+ (аналитика — в схеме `analytics`)
|
||||
- PostgreSQL 14+
|
||||
- ClickHouse 25.3 (аналитика событий; можно выключить — `CLICKHOUSE_ENABLED=false`)
|
||||
|
||||
## Настройка
|
||||
|
||||
|
|
@ -42,88 +43,21 @@ APP_ENV=prod python -m bot # prod
|
|||
|
||||
или через `./start.sh` (режим берётся из `APP_ENV`).
|
||||
|
||||
Админка (FastAPI) запускается отдельным процессом: `python -m admin` (зависимости —
|
||||
`requirements-admin.txt`), по умолчанию слушает `0.0.0.0:8000`, проверка живучести — `GET /healthz`.
|
||||
|
||||
## База данных
|
||||
|
||||
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
|
||||
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
|
||||
|
||||
## Админка (Блок 4)
|
||||
## Аналитика (ClickHouse)
|
||||
|
||||
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
|
||||
Все тексты бота — **шаблоны сообщений** (`bot.message_sequences`): миграция `V24`
|
||||
переносит каждый старый шаблон в последовательность из одного сообщения, а бот
|
||||
отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
|
||||
Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
|
||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
||||
Контракт событий — [`docs/events.md`](docs/events.md) (типы, payload, примеры запросов).
|
||||
|
||||
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
|
||||
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
|
||||
Первые администраторы засеваются из `ADMIN_IDS` при старте. Сессия — подписанная
|
||||
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
|
||||
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
|
||||
|
||||
Дополнительно — **вход через установленный Telegram**: кнопка «Открыть Telegram»
|
||||
создаёт одноразовый токен (5 минут) и открывает `t.me/<bot>?start=login_<token>`;
|
||||
бот подтверждает вход, сайт забирает токен и открывает сессию. Токен в БД хранится
|
||||
как SHA-256 и используется один раз.
|
||||
|
||||
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
|
||||
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
|
||||
база недоступна — использует расписание из кода (совпадает с сидом `V16`). Каждая
|
||||
отправленная минута фиксируется в `bot.schedule_runs`, поэтому перезапуск бота в ту же
|
||||
минуту не повторяет рассылку.
|
||||
|
||||
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
|
||||
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
|
||||
подписка на рассылки, дата вступления и последние операции по балансу. В карточке можно
|
||||
сменить ник и звание, включить/выключить подписку и скорректировать баланс (запись в
|
||||
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
||||
`bot.user_changes`: кто, когда, было → стало и причина.
|
||||
|
||||
Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу
|
||||
видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого
|
||||
не стоит; используемое — скрывается (`visible = false`).
|
||||
|
||||
Раздел «Логи» — события из Postgres и отдельная вкладка «Сообщения по дням»: фильтры
|
||||
по типу (для событий), пользователю (ID или username) и датам, пагинация по 50.
|
||||
Время показывается в `TIMEZONE`, payload раскрывается как JSON, ID пользователя ведёт
|
||||
в его карточку. Если Postgres недоступен, страница показывает ошибку, а не падает.
|
||||
|
||||
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
|
||||
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
|
||||
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
|
||||
пользователь через `/ник` или админ.
|
||||
|
||||
Раздел «Шаблоны сообщений» — набор сообщений по порядку (приветствие и любые другие):
|
||||
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
|
||||
Все правки — текст, кнопки, картинки, добавление/удаление и порядок сообщений —
|
||||
хранятся на странице и применяются одной кнопкой «Сохранить всё».
|
||||
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
|
||||
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
|
||||
(1024 для подписи к фото, 4096 для обычного сообщения).
|
||||
|
||||
Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц
|
||||
скрываются на узких экранах, формы складываются в одну колонку.
|
||||
|
||||
Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же
|
||||
редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка
|
||||
(успешная или нет) пишется в `bot.admin_messages`.
|
||||
|
||||
## Аналитика (Postgres)
|
||||
|
||||
Все значимые действия бота пишутся в Postgres, схема `analytics`:
|
||||
`analytics.events` (события, партиции по дням) и `analytics.messages_daily`
|
||||
(количество сообщений пользователя по дням). Контракт событий —
|
||||
[`docs/events.md`](docs/events.md) (типы, payload, примеры запросов).
|
||||
|
||||
- схема — `sql/V26__analytics_schema.sql`;
|
||||
- клиент и батчинг — `common/analytics/` (запись неблокирующая, сбой Postgres не влияет на бота);
|
||||
- сообщения не пишутся поштучно: на каждое сообщение инкрементится дневной счётчик;
|
||||
- построчная `bot.messages` удалена (V27), старые данные перенесены в `analytics.messages_daily`;
|
||||
- партиции создаются приложением и **не удаляются** — история хранится бессрочно;
|
||||
- выключается флагом `ANALYTICS_ENABLED=false`.
|
||||
- схема — `clickhouse/init/001_events.sql` (применяется при первой инициализации контейнера);
|
||||
- клиент и батчинг — `common/analytics/` (запись неблокирующая, сбой ClickHouse не влияет на бота);
|
||||
- сырые события живут на VPS 7 дней и ежедневно уезжают архивом на домашний сервер,
|
||||
на VPS остаются агрегаты (`events_daily`, `money_daily`) — см. [DEPLOY.md](DEPLOY.md);
|
||||
- выключается флагом `CLICKHOUSE_ENABLED=false`.
|
||||
|
||||
## Уведомления в чат логов
|
||||
|
||||
|
|
@ -132,19 +66,13 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
|||
|
||||
## Docker
|
||||
|
||||
Локальный запуск (postgres + migrate + bot + admin):
|
||||
Локальный запуск (postgres + migrate + clickhouse + bot):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # заполнить
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose
|
||||
по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 /
|
||||
prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет
|
||||
вместе с доменом и TLS (Блок 4.2). Картинки шаблонов лежат в общем
|
||||
Docker-volume `media` (`/app/media`), который монтируется и боту, и админке.
|
||||
|
||||
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
|
||||
|
||||
```bash
|
||||
|
|
@ -163,17 +91,22 @@ black --check .
|
|||
pytest
|
||||
```
|
||||
|
||||
## Бэкап
|
||||
## Бэкап событий
|
||||
|
||||
Аналитика теперь в Postgres, отдельного бэкапа событий нет. Бэкап БД — стандартными
|
||||
средствами Postgres; история `analytics` не чистится, поэтому следите за размером БД
|
||||
и диском. Подробности — [`DEPLOY.md`](DEPLOY.md).
|
||||
Сырые события ClickHouse ежедневно забираются с VPS домашним сервером (`pull`), на VPS
|
||||
остаются только агрегаты и последние дни сырых данных:
|
||||
|
||||
```bash
|
||||
make backup E=test # на домашнем сервере: ./scripts/clickhouse_backup.sh test
|
||||
```
|
||||
|
||||
Настройка, cron и SSH-доступ — см. [`DEPLOY.md`](DEPLOY.md).
|
||||
|
||||
## Версионирование
|
||||
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.7.2`.
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.8`.
|
||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||
- Docker-образы тегируются версией (Блок 2), события аналитики получают `bot_version`.
|
||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||
|
||||
## Процесс работы с git
|
||||
|
|
@ -200,32 +133,13 @@ git tag -a vX.Y -m "Release vX.Y"
|
|||
git push origin master --tags
|
||||
```
|
||||
|
||||
## Приветствие новых участников
|
||||
|
||||
Бот отправляет фото с короткой персональной подписью (первая строка шаблона `welcome.text`),
|
||||
а затем одно сообщение со всем остальным текстом (`welcome.text` без первой строки +
|
||||
`welcome.text2` + `welcome.text3`) и кнопкой на канал. Так приветствие остаётся одним
|
||||
смысловым блоком, но не упирается в лимит подписи к фото (1024 символа).
|
||||
|
||||
## Пасхальное событие «Яички»
|
||||
|
||||
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**
|
||||
(в 2026 году — 12 апреля). Вне окна бот отвечает, что ещё рано, а строка про яички
|
||||
не показывается ни в `/инфо`, ни в карточке пользователя в админке.
|
||||
Даты можно зафиксировать переменными `EGG_EVENT_START`/`EGG_EVENT_END` (ISO,
|
||||
например `2027-04-01` и `2027-05-02`).
|
||||
|
||||
## Команды бота
|
||||
|
||||
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
|
||||
`/магазин`, `/рейтинг`, `/отписка`, `/подписка`.
|
||||
|
||||
В меню Telegram показываются латинские алиасы (`/help`, `/info`, `/nick`, `/list`,
|
||||
`/who`, `/purify`, `/eggs`, `/shop`, `/top`, `/sub`, `/unsub`) с русскими описаниями:
|
||||
Telegram не принимает кириллицу в именах команд. Русские команды работают при вводе
|
||||
вручную. Набор сверяется с ожидаемым при каждом старте бота (`ensure_commands`).
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- общие — `.env.example` (включая `ANALYTICS_*`);
|
||||
- контуры — `env/prod.env.example`, `env/test.env.example`.
|
||||
- общие — `.env.example` (включая `CLICKHOUSE_*` и параметры батчинга `ANALYTICS_*`);
|
||||
- контуры — `env/prod.env.example`, `env/test.env.example`;
|
||||
- бэкап событий — `env/backup.env.example`.
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Образ сервиса админки (FastAPI + Jinja2/HTMX).
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Непривилегированный пользователь
|
||||
RUN useradd --create-home --uid 1000 app
|
||||
|
||||
COPY requirements-admin.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements-admin.txt
|
||||
|
||||
COPY common ./common
|
||||
COPY admin ./admin
|
||||
COPY images ./images
|
||||
|
||||
RUN mkdir -p /app/media && chown app:app /app/media
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Живость процесса: HTTP-проверка /healthz (порт берётся из ADMIN_PORT).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('ADMIN_PORT','8000')+'/healthz', timeout=3)"
|
||||
|
||||
CMD ["python", "-m", "admin"]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""Сервис админки DispelEvilBot (FastAPI + HTMX).
|
||||
|
||||
Отдельный процесс/образ; общий код — пакет ``common``. Запуск: ``python -m admin``.
|
||||
"""
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"""Точка входа админки: python -m admin"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import uvicorn
|
||||
|
||||
from common.config import get_admin_settings
|
||||
from common.logging_config import setup_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Запустить uvicorn с приложением из ``admin.app``."""
|
||||
setup_logging(logging.INFO)
|
||||
settings = get_admin_settings()
|
||||
logger.info("Запуск админки (APP_ENV=%s) на %s:%s", settings.app_env, settings.admin_host, settings.admin_port)
|
||||
# log_config=None — чтобы uvicorn не перетирал единый формат логов.
|
||||
uvicorn.run(
|
||||
"admin.app:create_app",
|
||||
factory=True,
|
||||
host=settings.admin_host,
|
||||
port=settings.admin_port,
|
||||
log_config=None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
admin/app.py
103
admin/app.py
|
|
@ -1,103 +0,0 @@
|
|||
"""Создание FastAPI-приложения админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from admin.media import ensure_media
|
||||
from admin.middleware import AdminAuthMiddleware
|
||||
from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, titles, users
|
||||
from common import __version__
|
||||
from common.analytics.partitions import ensure_partitions
|
||||
from common.config import AdminSettings, get_admin_settings
|
||||
from common.db.pool import close_pool, init_pool
|
||||
from common.logging_config import setup_logging
|
||||
from common.repositories import admins_repo
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -> FastAPI:
|
||||
"""Собрать приложение: настройки, шаблоны, роутеры.
|
||||
|
||||
Настройки можно передать явно (тесты); по умолчанию читаются из окружения.
|
||||
``init_db=False`` — не открывать пул Postgres (юнит-тесты без БД).
|
||||
"""
|
||||
setup_logging()
|
||||
settings = settings or get_admin_settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
if init_db:
|
||||
await init_pool(app.state.settings)
|
||||
ensure_media(app.state.settings)
|
||||
try:
|
||||
await ensure_partitions(app.state.settings)
|
||||
except Exception:
|
||||
logger.warning("Не удалось создать партиции аналитики", exc_info=True)
|
||||
try:
|
||||
inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids)
|
||||
if inserted:
|
||||
logger.info("Добавлены администраторы из ADMIN_IDS: %s", inserted)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Не удалось засеять администраторов (применены ли миграции?)",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if init_db:
|
||||
await close_pool()
|
||||
|
||||
app = FastAPI(
|
||||
title="DispelEvilBot Admin",
|
||||
version=__version__,
|
||||
# Swagger/OpenAPI наружу пока не нужны: админка закрыта логином (Блок 4.2).
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = settings
|
||||
app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
# Порядок важен: SessionMiddleware добавляется последним и оборачивает auth,
|
||||
# поэтому request.session доступен в проверке администратора.
|
||||
app.add_middleware(AdminAuthMiddleware)
|
||||
session_secret = settings.admin_session_secret
|
||||
if not session_secret:
|
||||
session_secret = secrets.token_urlsafe(32)
|
||||
logger.warning("ADMIN_SESSION_SECRET не задан — сессии слетят при перезапуске")
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=session_secret,
|
||||
session_cookie="debot_admin",
|
||||
max_age=settings.admin_session_max_age,
|
||||
same_site="lax",
|
||||
https_only=settings.is_prod,
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(preview.router)
|
||||
app.include_router(sequences.router)
|
||||
app.include_router(titles.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(admins.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(send.router)
|
||||
return app
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"""Разбор 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
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""Проверка и разбор форм админки (чистые функции — без HTTP и БД)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
WEEKDAY_NAMES: dict[int, str] = {
|
||||
1: "Пн",
|
||||
2: "Вт",
|
||||
3: "Ср",
|
||||
4: "Чт",
|
||||
5: "Пт",
|
||||
6: "Сб",
|
||||
7: "Вс",
|
||||
}
|
||||
|
||||
MAX_DESCRIPTION_LENGTH = 200
|
||||
|
||||
|
||||
def format_weekdays(days: list[int] | tuple[int, ...] | None) -> str:
|
||||
"""Человеческое описание дней ISO: ``1,3,5`` -> ``Пн, Ср, Пт``."""
|
||||
if not days:
|
||||
return "каждый день"
|
||||
return ", ".join(WEEKDAY_NAMES[day] for day in sorted(set(days)) if day in WEEKDAY_NAMES)
|
||||
|
||||
|
||||
def parse_schedule_fields(
|
||||
*,
|
||||
template_key: str,
|
||||
hour: str,
|
||||
minute: str,
|
||||
weekdays: list[str],
|
||||
timezone: str,
|
||||
description: str,
|
||||
enabled: bool,
|
||||
) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
"""Собрать данные расписания из полей формы.
|
||||
|
||||
Возвращает ``(data, errors)``: при ошибках ``data is None`` и заполнен ``errors``.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
hour_value = int(hour)
|
||||
except (TypeError, ValueError):
|
||||
hour_value = -1
|
||||
errors.append("Час должен быть числом")
|
||||
try:
|
||||
minute_value = int(minute)
|
||||
except (TypeError, ValueError):
|
||||
minute_value = -1
|
||||
errors.append("Минуты должны быть числом")
|
||||
|
||||
if not 0 <= hour_value <= 23 and "Час должен быть числом" not in errors:
|
||||
errors.append("Час должен быть от 0 до 23")
|
||||
if not 0 <= minute_value <= 59 and "Минуты должны быть числом" not in errors:
|
||||
errors.append("Минуты должны быть от 0 до 59")
|
||||
|
||||
days: list[int] = []
|
||||
for raw in weekdays:
|
||||
try:
|
||||
day = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"Некорректный день недели: {raw}")
|
||||
continue
|
||||
if 1 <= day <= 7:
|
||||
days.append(day)
|
||||
else:
|
||||
errors.append("День недели должен быть от 1 (Пн) до 7 (Вс)")
|
||||
|
||||
tz_name = (timezone or "").strip() or "Europe/Moscow"
|
||||
try:
|
||||
ZoneInfo(tz_name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
errors.append(f"Неизвестная таймзона: {tz_name}")
|
||||
|
||||
if not template_key.strip():
|
||||
errors.append("Выберите шаблон сообщения")
|
||||
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
|
||||
|
||||
if errors:
|
||||
return None, errors
|
||||
|
||||
return (
|
||||
{
|
||||
"template_key": template_key,
|
||||
"hour": hour_value,
|
||||
"minute": minute_value,
|
||||
"weekdays": sorted(set(days)),
|
||||
"timezone": tz_name,
|
||||
"enabled": enabled,
|
||||
"description": description.strip(),
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"""Общий каталог картинок для бота и админки (Docker volume)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from common.config import AdminSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Встроенная картинка приветствия (лежит в образе бота и админки).
|
||||
BUILTIN_WELCOME = Path("images/welcome_image.jpg")
|
||||
|
||||
#: Имя файла в volume, которое ждёт сид последовательности `welcome`.
|
||||
WELCOME_IMAGE_NAME = "welcome_image.jpg"
|
||||
|
||||
#: Допустимые типы и максимальный размер загружаемых картинок.
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
async def read_image(upload: UploadFile) -> tuple[bytes, str, str]:
|
||||
"""Прочитать картинку из формы. Возвращает (данные, mime, расширение)."""
|
||||
content_type = (upload.content_type or "").lower()
|
||||
extension = ALLOWED_IMAGE_TYPES.get(content_type)
|
||||
if extension is None:
|
||||
raise ValueError("Картинка должна быть JPEG, PNG или WebP")
|
||||
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("Картинка больше 5 МБ")
|
||||
return data, content_type, extension
|
||||
|
||||
|
||||
def ensure_media(settings: AdminSettings) -> Path:
|
||||
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
|
||||
directory = Path(settings.media_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target = directory / WELCOME_IMAGE_NAME
|
||||
if not target.exists() and BUILTIN_WELCOME.is_file():
|
||||
try:
|
||||
shutil.copyfile(BUILTIN_WELCOME, target)
|
||||
logger.info("Приветственная картинка скопирована в %s", target)
|
||||
except OSError:
|
||||
logger.warning("Не удалось скопировать приветственную картинку в %s", target, exc_info=True)
|
||||
return directory
|
||||
|
||||
|
||||
def media_path(settings: AdminSettings, name: str) -> Path:
|
||||
"""Безопасный путь к файлу в media (без выхода из каталога)."""
|
||||
return Path(settings.media_dir) / Path(name).name
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
"""Middleware админки: пускает дальше только вошедших администраторов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from urllib.parse import quote
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
|
||||
from common.repositories import admins_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Эндпоинты без авторизации.
|
||||
PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/healthz",
|
||||
"/login",
|
||||
"/auth/telegram",
|
||||
"/auth/deeplink/start",
|
||||
"/auth/deeplink/status",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Сессия + роль из БД на каждом запросе (кроме служебных и логина)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if request.url.path in PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
admin_id = request.session.get("admin_id") if "session" in request.scope else None
|
||||
if not admin_id:
|
||||
target = quote(request.url.path)
|
||||
return RedirectResponse(url=f"/login?next={target}", status_code=303)
|
||||
|
||||
try:
|
||||
admin = await admins_repo.get_admin(int(admin_id))
|
||||
except Exception:
|
||||
logger.exception("Не удалось проверить администратора")
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
if admin is None or not admin["enabled"]:
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
request.state.admin = admin
|
||||
# CSRF-токен живёт в сессии; если её создали раньше — добираем.
|
||||
if not request.session.get("csrf"):
|
||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
||||
return await call_next(request)
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"""Предпросмотр шаблонов: тестовые значения для всех тегов.
|
||||
|
||||
Контекст общий для админки и тестов: он должен покрывать ``TAG_REGISTRY``,
|
||||
иначе в предпросмотре часть тегов останется пустой.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common import __version__
|
||||
|
||||
SAMPLE_CONTEXT: dict[str, Any] = {
|
||||
"username": "@test_user",
|
||||
"actor": "@admin_user",
|
||||
"nick": "ТестовыйНик",
|
||||
"title": "Ветеран",
|
||||
"lvl": 3,
|
||||
"exp": 120,
|
||||
"exp_max": 300,
|
||||
"money": 1500,
|
||||
"dt": "01.01.2026",
|
||||
"msg_count": 42,
|
||||
"eggs": 2,
|
||||
"eggs_line": "<b>🥚 Количество яичек:</b> 2",
|
||||
"h": "12",
|
||||
"m": "05",
|
||||
"s": "09",
|
||||
"exp_add": 10,
|
||||
"money_add": 50,
|
||||
"usernames": "@test_user @second_user",
|
||||
"users_list": "1: @test_user - ТестовыйНик\n2: @second_user - Второй",
|
||||
"users_list_count": 2,
|
||||
"tops": "1) ТестовыйНик - 42\n2) Второй - 17",
|
||||
"promo": "PROMO-2026",
|
||||
"chat_title": "Dispel Evil",
|
||||
"version": __version__,
|
||||
"uptime": "2ч 15м",
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Роутеры админки."""
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
"""Раздел «Админы»: назначение и управление доступом к веб-панели."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import admins_repo
|
||||
|
||||
router = APIRouter(prefix="/admins", tags=["admins"])
|
||||
|
||||
#: Допустимые роли (обе сейчас дают полный доступ, moderator — задел).
|
||||
ROLES = ("admin", "moderator")
|
||||
|
||||
|
||||
async def _list_response(
|
||||
request: Request,
|
||||
*,
|
||||
errors: list[str] | None = None,
|
||||
form: dict[str, str] | None = None,
|
||||
saved: str | None = None,
|
||||
status_code: int = 200,
|
||||
) -> HTMLResponse:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admins/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"admins": await admins_repo.list_admins(),
|
||||
"roles": ROLES,
|
||||
"errors": errors or [],
|
||||
"form": form or {"telegram_id": "", "username": "", "role": "admin"},
|
||||
"saved": saved,
|
||||
"current_id": int(request.state.admin["telegram_id"]),
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_admins(request: Request, saved: str | None = None) -> HTMLResponse:
|
||||
"""Список администраторов и форма добавления."""
|
||||
return await _list_response(request, saved=saved)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_admin(request: Request) -> Response:
|
||||
"""Добавить администратора по Telegram-ID."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
raw_id = str(form.get("telegram_id", "")).strip()
|
||||
username = str(form.get("username", "")).strip().lstrip("@")
|
||||
role = str(form.get("role", "admin"))
|
||||
raw_form = {"telegram_id": raw_id, "username": username, "role": role}
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
telegram_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
telegram_id = 0
|
||||
errors.append("Telegram-ID должен быть числом")
|
||||
if telegram_id <= 0 and "Telegram-ID должен быть числом" not in errors:
|
||||
errors.append("Telegram-ID должен быть положительным")
|
||||
if role not in ROLES:
|
||||
errors.append("Неизвестная роль")
|
||||
if len(username) > 64:
|
||||
errors.append("Username длиннее 64 символов")
|
||||
|
||||
if errors:
|
||||
return await _list_response(request, errors=errors, form=raw_form, status_code=400)
|
||||
|
||||
await admins_repo.upsert_admin(telegram_id, username or None, role)
|
||||
return RedirectResponse(url="/admins?saved=added", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{telegram_id}/role")
|
||||
async def change_role(request: Request, telegram_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
role = str(form.get("role", ""))
|
||||
|
||||
if role not in ROLES:
|
||||
return await _list_response(request, errors=["Неизвестная роль"], status_code=400)
|
||||
if not await admins_repo.set_admin_role(telegram_id, role):
|
||||
raise HTTPException(status_code=404, detail="Администратор не найден")
|
||||
return RedirectResponse(url="/admins?saved=role", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{telegram_id}/toggle")
|
||||
async def toggle_admin(request: Request, telegram_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
if telegram_id == int(request.state.admin["telegram_id"]):
|
||||
return await _list_response(
|
||||
request,
|
||||
errors=["Нельзя выключить самого себя"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admins = await admins_repo.list_admins()
|
||||
target = next((item for item in admins if int(item["telegram_id"]) == telegram_id), None)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Администратор не найден")
|
||||
|
||||
enabled_count = sum(1 for item in admins if item["enabled"])
|
||||
if target["enabled"] and enabled_count <= 1:
|
||||
return await _list_response(
|
||||
request,
|
||||
errors=["Нельзя выключить последнего администратора"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
await admins_repo.set_admin_enabled(telegram_id, not target["enabled"])
|
||||
return RedirectResponse(url="/admins?saved=toggle", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{telegram_id}/delete")
|
||||
async def delete_admin(request: Request, telegram_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
if telegram_id == int(request.state.admin["telegram_id"]):
|
||||
return await _list_response(
|
||||
request,
|
||||
errors=["Нельзя удалить самого себя"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admins = await admins_repo.list_admins()
|
||||
target = next((item for item in admins if int(item["telegram_id"]) == telegram_id), None)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Администратор не найден")
|
||||
if target["enabled"] and sum(1 for item in admins if item["enabled"]) <= 1:
|
||||
return await _list_response(
|
||||
request,
|
||||
errors=["Нельзя удалить последнего администратора"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
await admins_repo.delete_admin(telegram_id)
|
||||
return RedirectResponse(url="/admins?saved=deleted", status_code=303)
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
"""Вход в админку через Telegram Login Widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
|
||||
from common import __version__
|
||||
from common.repositories import admin_login_tokens_repo, admins_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
#: Поля, которые Telegram включает в подпись (остальные в query игнорируем).
|
||||
TELEGRAM_FIELDS = frozenset({"id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"})
|
||||
|
||||
|
||||
def _deny(request: Request, message: str, status_code: int = 403) -> HTMLResponse:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="auth/denied.html",
|
||||
context={"version": __version__, "message": message},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str = "/") -> Response:
|
||||
"""Страница входа с кнопкой Telegram."""
|
||||
target = safe_redirect_target(next)
|
||||
if request.session.get("admin_id"):
|
||||
return RedirectResponse(url=target, status_code=303)
|
||||
|
||||
settings = request.app.state.settings
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="auth/login.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"bot_username": settings.bot_username,
|
||||
"public_url": settings.admin_public_url.rstrip("/"),
|
||||
"configured": bool(settings.bot_token and settings.bot_username),
|
||||
"deeplink": bool(settings.bot_username and settings.admin_public_url),
|
||||
"deeplink_pending": bool(request.cookies.get("debot_login_token")),
|
||||
"deeplink_error": request.query_params.get("error") == "deeplink",
|
||||
"next": target,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/auth/deeplink/start")
|
||||
async def deeplink_start(request: Request) -> Response:
|
||||
"""Создать токен и открыть установленный Telegram с deep-link."""
|
||||
settings = request.app.state.settings
|
||||
username = settings.bot_username.lstrip("@")
|
||||
if not username:
|
||||
return RedirectResponse(url="/login?error=deeplink", status_code=303)
|
||||
|
||||
token = await admin_login_tokens_repo.create_token()
|
||||
response = RedirectResponse(
|
||||
url=f"https://t.me/{username}?start=login_{token}",
|
||||
status_code=303,
|
||||
)
|
||||
response.set_cookie(
|
||||
"debot_login_token",
|
||||
token,
|
||||
max_age=300,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.is_prod,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/deeplink/status")
|
||||
async def deeplink_status(request: Request) -> JSONResponse:
|
||||
"""Проверить, подтвердил ли пользователь вход в боте, и открыть сессию."""
|
||||
token = request.cookies.get("debot_login_token")
|
||||
if not token:
|
||||
return JSONResponse({"status": "none"})
|
||||
|
||||
try:
|
||||
telegram_id = await admin_login_tokens_repo.consume_token(token)
|
||||
except Exception:
|
||||
logger.exception("Не удалось проверить deep-link токен")
|
||||
return JSONResponse({"status": "pending"})
|
||||
|
||||
if telegram_id is None:
|
||||
return JSONResponse({"status": "pending"})
|
||||
|
||||
try:
|
||||
admin = await admins_repo.get_admin(telegram_id)
|
||||
except Exception:
|
||||
logger.exception("Ошибка проверки администратора при deep-link входе")
|
||||
return JSONResponse({"status": "pending"})
|
||||
|
||||
if admin is None or not admin["enabled"]:
|
||||
response = JSONResponse({"status": "denied"})
|
||||
response.delete_cookie("debot_login_token")
|
||||
return response
|
||||
|
||||
request.session.clear()
|
||||
request.session["admin_id"] = telegram_id
|
||||
request.session["admin_username"] = admin["username"] or ""
|
||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
||||
|
||||
try:
|
||||
await admins_repo.touch_login(telegram_id)
|
||||
except Exception:
|
||||
logger.warning("Не удалось обновить last_login_at", exc_info=True)
|
||||
|
||||
logger.info("Вход в админку по deep-link: %s", telegram_id)
|
||||
response = JSONResponse({"status": "ok"})
|
||||
response.delete_cookie("debot_login_token")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/telegram")
|
||||
async def telegram_callback(request: Request) -> Response:
|
||||
"""Проверить подпись Telegram, роль в БД и открыть сессию."""
|
||||
settings = request.app.state.settings
|
||||
data = {key: value for key, value in request.query_params.items() if key in TELEGRAM_FIELDS}
|
||||
|
||||
if not verify_telegram_auth(data, settings.bot_token):
|
||||
logger.warning("Отклонён Telegram-логин: неверная подпись или устаревший auth_date")
|
||||
return _deny(request, "Не удалось проверить вход через Telegram. Попробуйте ещё раз.")
|
||||
|
||||
try:
|
||||
telegram_id = int(str(data.get("id")))
|
||||
except (TypeError, ValueError):
|
||||
return _deny(request, "Telegram не передал идентификатор пользователя.", status_code=400)
|
||||
|
||||
try:
|
||||
admin = await admins_repo.get_admin(telegram_id)
|
||||
except Exception:
|
||||
logger.exception("Ошибка проверки администратора при входе")
|
||||
return _deny(request, "Не удалось проверить доступ. Попробуйте позже.", status_code=503)
|
||||
|
||||
if admin is None or not admin["enabled"]:
|
||||
logger.info("Отказ во входе: %s нет среди администраторов", telegram_id)
|
||||
return _deny(request, "У вас нет доступа к админке. Обратитесь к владельцу.")
|
||||
|
||||
request.session.clear()
|
||||
request.session["admin_id"] = telegram_id
|
||||
request.session["admin_username"] = admin["username"] or data.get("username") or data.get("first_name") or ""
|
||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
||||
|
||||
try:
|
||||
await admins_repo.touch_login(telegram_id)
|
||||
except Exception:
|
||||
logger.warning("Не удалось обновить last_login_at", exc_info=True)
|
||||
|
||||
logger.info("Вход в админку: %s", telegram_id)
|
||||
return RedirectResponse(url=safe_redirect_target(request.query_params.get("next")), status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> Response:
|
||||
"""Выйти: очистить сессию."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"""Служебные эндпоинты: healthcheck для Docker и мониторинга."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from common import __version__
|
||||
|
||||
router = APIRouter(tags=["service"])
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz(request: Request) -> dict[str, str]:
|
||||
"""Вернуть состояние сервиса (используется healthcheck'ом контейнера)."""
|
||||
settings = request.app.state.settings
|
||||
return {"status": "ok", "version": __version__, "app_env": settings.app_env}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
"""Раздел «Логи»: события и дневные счётчики сообщений из Postgres."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from common import __version__
|
||||
from common.analytics import ALL_EVENT_TYPES
|
||||
from common.analytics import read as analytics_read
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
#: Сколько строк показывать на странице.
|
||||
PAGE_SIZE = 50
|
||||
|
||||
|
||||
def _pretty_payload(payload: Any) -> str:
|
||||
"""Отформатировать JSON-payload для просмотра; битый JSON — как есть."""
|
||||
if isinstance(payload, dict):
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
try:
|
||||
return json.dumps(json.loads(str(payload or "")), ensure_ascii=False, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
return str(payload or "")
|
||||
|
||||
|
||||
def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]:
|
||||
"""Добавить локальное время и читаемый payload."""
|
||||
moment = row.get("event_time")
|
||||
if isinstance(moment, datetime):
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=UTC)
|
||||
time_local = moment.astimezone(settings.tzinfo).strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
time_local = "—"
|
||||
|
||||
return {
|
||||
**row,
|
||||
"time_local": time_local,
|
||||
"payload_pretty": _pretty_payload(row.get("payload")),
|
||||
}
|
||||
|
||||
|
||||
def _decorate_message(row: dict[str, Any]) -> dict[str, Any]:
|
||||
day = row.get("day")
|
||||
return {
|
||||
**row,
|
||||
"day_local": day.strftime("%d.%m.%Y") if hasattr(day, "strftime") else str(day or "—"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def logs_page(
|
||||
request: Request,
|
||||
view: str = "events",
|
||||
event_type: str = "",
|
||||
user: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
page: int = 1,
|
||||
) -> HTMLResponse:
|
||||
"""Показать события или дневные счётчики сообщений с фильтрами."""
|
||||
settings = request.app.state.settings
|
||||
view = view if view in {"events", "messages"} else "events"
|
||||
today = datetime.now(settings.tzinfo).date()
|
||||
date_from = date_from or (today - timedelta(days=6)).isoformat()
|
||||
date_to = date_to or today.isoformat()
|
||||
page = max(page, 1)
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
messages: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
error: str | None = None
|
||||
|
||||
try:
|
||||
if view == "messages":
|
||||
messages, total = await analytics_read.fetch_messages(
|
||||
settings,
|
||||
user=user,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
)
|
||||
else:
|
||||
events, total = await analytics_read.fetch_events(
|
||||
settings,
|
||||
event_type=event_type,
|
||||
user=user,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
)
|
||||
except ValueError:
|
||||
error = "Даты должны быть в формате ГГГГ-ММ-ДД."
|
||||
except Exception:
|
||||
logger.exception("Не удалось прочитать аналитику из Postgres")
|
||||
error = "Postgres недоступен — логи временно не читаются."
|
||||
|
||||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
||||
query_params = {
|
||||
"view": view if view != "events" else "",
|
||||
"event_type": event_type if view == "events" else "",
|
||||
"user": user,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
}
|
||||
query_base = urlencode({key: value for key, value in query_params.items() if value})
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="logs/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"view": view,
|
||||
"events": [_decorate(row, settings) for row in events],
|
||||
"messages": [_decorate_message(row) for row in messages],
|
||||
"event_types": sorted(ALL_EVENT_TYPES),
|
||||
"event_type": event_type,
|
||||
"user": user,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"error": error,
|
||||
"query_base": query_base,
|
||||
},
|
||||
)
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"""HTML-страницы админки (серверный рендер Jinja2 + HTMX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from common import __version__
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
|
||||
SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||
{
|
||||
"title": "Шаблоны сообщений",
|
||||
"description": "Тексты бота: сообщения, картинки и кнопки",
|
||||
"url": "/sequences",
|
||||
},
|
||||
{
|
||||
"title": "Расписания",
|
||||
"description": "Время рассылок, дни недели и шаблон",
|
||||
"url": "/schedules",
|
||||
},
|
||||
{
|
||||
"title": "Пользователи",
|
||||
"description": "Ники, звания, баланс и подписка",
|
||||
"url": "/users",
|
||||
},
|
||||
{
|
||||
"title": "Звания",
|
||||
"description": "Справочник магазина званий",
|
||||
"url": "/titles",
|
||||
},
|
||||
{
|
||||
"title": "Админы",
|
||||
"description": "Доступ к веб-панели",
|
||||
"url": "/admins",
|
||||
},
|
||||
{
|
||||
"title": "Логи",
|
||||
"description": "События и сообщения из Postgres с фильтрами",
|
||||
"url": "/logs",
|
||||
},
|
||||
{
|
||||
"title": "Отправить",
|
||||
"description": "Сообщение в чат от имени бота",
|
||||
"url": "/send",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
"""Главная страница: версия, окружение и список будущих разделов."""
|
||||
templates = request.app.state.templates
|
||||
settings = request.app.state.settings
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"app_env": settings.app_env,
|
||||
"sections": SECTIONS,
|
||||
},
|
||||
)
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"""Предпросмотр шаблонов и сообщений (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>'
|
||||
)
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
"""Раздел «Расписания»: CRUD рассылок, которые исполняет бот."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from admin.forms import format_weekdays, parse_schedule_fields
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import schedules_repo, templates_repo
|
||||
|
||||
router = APIRouter(prefix="/schedules", tags=["schedules"])
|
||||
|
||||
|
||||
def _editor_id(request: Request) -> int:
|
||||
"""Telegram-ID текущего администратора (ставит middleware)."""
|
||||
return int(request.state.admin["telegram_id"])
|
||||
|
||||
|
||||
def _raw_from_form(form: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"template_key": str(form.get("template_key", "")),
|
||||
"hour": str(form.get("hour", "")),
|
||||
"minute": str(form.get("minute", "")),
|
||||
"weekdays": [str(day) for day in form.getlist("weekdays")],
|
||||
"timezone": str(form.get("timezone", "")),
|
||||
"description": str(form.get("description", "")),
|
||||
"enabled": form.get("enabled") is not None,
|
||||
}
|
||||
|
||||
|
||||
def _view(values: Mapping[str, Any], *, is_new: bool) -> dict[str, Any]:
|
||||
"""Привести значения (из БД или формы) к виду, удобному шаблону."""
|
||||
days: list[int] = []
|
||||
for day in values.get("weekdays") or ():
|
||||
try:
|
||||
days.append(int(day))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return {
|
||||
"template_key": values.get("template_key") or "",
|
||||
"hour": values.get("hour", 16),
|
||||
"minute": values.get("minute", 0),
|
||||
"weekdays": sorted(set(days)),
|
||||
"timezone": values.get("timezone") or "Europe/Moscow",
|
||||
"description": values.get("description") or "",
|
||||
"enabled": bool(values.get("enabled", True)),
|
||||
"id": values.get("id"),
|
||||
"is_new": is_new,
|
||||
"action": "/schedules" if is_new else f"/schedules/{values.get('id')}",
|
||||
}
|
||||
|
||||
|
||||
def _form_response(
|
||||
request: Request,
|
||||
schedule: dict[str, Any],
|
||||
message_templates: list[dict[str, Any]],
|
||||
*,
|
||||
errors: list[str] | None = None,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedules/form.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"schedule": schedule,
|
||||
"message_templates": message_templates,
|
||||
"errors": errors or [],
|
||||
"history": history or [],
|
||||
"weekday_names": {1: "Пн", 2: "Вт", 3: "Ср", 4: "Чт", 5: "Пт", 6: "Сб", 7: "Вс"},
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_schedules(request: Request) -> HTMLResponse:
|
||||
"""Список расписаний: время, дни, шаблон, таймзона и статус."""
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedules/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"schedules": await schedules_repo.list_schedules(),
|
||||
"format_weekdays": format_weekdays,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_schedule(request: Request) -> HTMLResponse:
|
||||
"""Форма создания расписания."""
|
||||
message_templates = await templates_repo.list_templates()
|
||||
defaults = {
|
||||
"template_key": message_templates[0]["key"] if message_templates else "",
|
||||
"hour": 16,
|
||||
"minute": 0,
|
||||
"weekdays": [],
|
||||
"timezone": "Europe/Moscow",
|
||||
"description": "",
|
||||
"enabled": True,
|
||||
}
|
||||
return _form_response(request, _view(defaults, is_new=True), message_templates)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_schedule(request: Request) -> Response:
|
||||
"""Создать расписание (с проверкой полей и существования шаблона)."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
raw = _raw_from_form(form)
|
||||
data, errors = parse_schedule_fields(**raw)
|
||||
message_templates = await templates_repo.list_templates()
|
||||
|
||||
if data is not None and await templates_repo.get_template(data["template_key"]) is None:
|
||||
errors.append("Шаблон не найден")
|
||||
|
||||
if errors:
|
||||
return _form_response(
|
||||
request,
|
||||
_view(raw, is_new=True),
|
||||
message_templates,
|
||||
errors=errors,
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
await schedules_repo.create_schedule(data, editor_id=_editor_id(request))
|
||||
return RedirectResponse(url="/schedules", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{schedule_id}", response_class=HTMLResponse)
|
||||
async def edit_schedule(request: Request, schedule_id: int) -> HTMLResponse:
|
||||
"""Форма редактирования и история изменений."""
|
||||
schedule = await schedules_repo.get_schedule(schedule_id)
|
||||
if schedule is None:
|
||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
||||
|
||||
return _form_response(
|
||||
request,
|
||||
_view(schedule, is_new=False),
|
||||
await templates_repo.list_templates(),
|
||||
history=await schedules_repo.schedule_history(schedule_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{schedule_id}")
|
||||
async def update_schedule(request: Request, schedule_id: int) -> Response:
|
||||
"""Сохранить изменения расписания."""
|
||||
if await schedules_repo.get_schedule(schedule_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
||||
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
raw = _raw_from_form(form)
|
||||
data, errors = parse_schedule_fields(**raw)
|
||||
message_templates = await templates_repo.list_templates()
|
||||
|
||||
if data is not None and await templates_repo.get_template(data["template_key"]) is None:
|
||||
errors.append("Шаблон не найден")
|
||||
|
||||
if errors:
|
||||
view = _view({**raw, "id": schedule_id}, is_new=False)
|
||||
return _form_response(
|
||||
request,
|
||||
view,
|
||||
message_templates,
|
||||
errors=errors,
|
||||
history=await schedules_repo.schedule_history(schedule_id),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
await schedules_repo.update_schedule(schedule_id, data, editor_id=_editor_id(request))
|
||||
return RedirectResponse(url="/schedules", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{schedule_id}/toggle")
|
||||
async def toggle_schedule(request: Request, schedule_id: int) -> Response:
|
||||
"""Включить или выключить расписание."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
schedule = await schedules_repo.get_schedule(schedule_id)
|
||||
if schedule is None:
|
||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
||||
|
||||
await schedules_repo.set_enabled(schedule_id, not schedule["enabled"], editor_id=_editor_id(request))
|
||||
return RedirectResponse(url="/schedules", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{schedule_id}/delete")
|
||||
async def delete_schedule(request: Request, schedule_id: int) -> Response:
|
||||
"""Удалить расписание (история при этом сохраняется)."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
if not await schedules_repo.delete_schedule(schedule_id, editor_id=_editor_id(request)):
|
||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
||||
return RedirectResponse(url="/schedules", status_code=303)
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"""Ручная отправка сообщения в основной чат от имени бота."""
|
||||
|
||||
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)
|
||||
|
|
@ -1,441 +0,0 @@
|
|||
"""Раздел «Последовательности»: сообщения по порядку, картинки и кнопки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from admin.buttons import MAX_BUTTONS, parse_buttons
|
||||
from admin.media import media_path, read_image
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import sequences_repo
|
||||
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sequences", tags=["sequences"])
|
||||
|
||||
#: Формат ключа последовательности.
|
||||
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
|
||||
|
||||
#: Лимит подписи к фото.
|
||||
MAX_CAPTION = 1024
|
||||
|
||||
|
||||
def _editor_id(request: Request) -> int:
|
||||
return int(request.state.admin["telegram_id"])
|
||||
|
||||
|
||||
def _prepare_items(items: list[dict[str, Any]]) -> None:
|
||||
"""Добавить к каждому сообщению строку кнопок и токен для формы."""
|
||||
for item in items:
|
||||
item.setdefault("token", str(item["id"]))
|
||||
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"])
|
||||
|
||||
|
||||
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
|
||||
data, _, extension = await read_image(upload)
|
||||
|
||||
name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
|
||||
path = media_path(settings, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return name
|
||||
|
||||
|
||||
def _delete_image(settings: Any, name: str | None) -> None:
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
media_path(settings, name).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Не удалось удалить картинку %s", name, exc_info=True)
|
||||
|
||||
|
||||
async def _list_response(
|
||||
request: Request,
|
||||
*,
|
||||
errors: list[str] | None = None,
|
||||
form: dict[str, str] | None = None,
|
||||
saved: str | None = None,
|
||||
status_code: int = 200,
|
||||
) -> HTMLResponse:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="sequences/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"sequences": await sequences_repo.list_sequences(),
|
||||
"errors": errors or [],
|
||||
"form": form or {"key": "", "title": "", "description": ""},
|
||||
"saved": saved,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
async def _editor_response(
|
||||
request: Request,
|
||||
key: str,
|
||||
*,
|
||||
errors: list[str] | None = None,
|
||||
saved: str | None = None,
|
||||
overrides: dict[int, dict[str, Any]] | None = None,
|
||||
items_override: list[dict[str, Any]] | None = None,
|
||||
status_code: int = 200,
|
||||
) -> HTMLResponse:
|
||||
sequence = await sequences_repo.get_sequence(key)
|
||||
if sequence is None:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
if items_override is not None:
|
||||
items = items_override
|
||||
else:
|
||||
items = await sequences_repo.get_items(key)
|
||||
for item in items:
|
||||
override = (overrides or {}).get(int(item["id"]))
|
||||
if override:
|
||||
item.update(override)
|
||||
|
||||
_prepare_items(items)
|
||||
item_order = ",".join(str(item["token"]) for item in items)
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="sequences/edit.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"sequence": sequence,
|
||||
"items": items,
|
||||
"item_order": item_order,
|
||||
"history": await sequences_repo.history(key, limit=5),
|
||||
"errors": errors or [],
|
||||
"saved": saved,
|
||||
"max_caption": MAX_CAPTION,
|
||||
"max_message": MAX_MESSAGE_LENGTH,
|
||||
"max_buttons": MAX_BUTTONS,
|
||||
"tags": TAG_REGISTRY,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_sequences(request: Request, saved: str | None = None) -> HTMLResponse:
|
||||
return await _list_response(request, saved=saved)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_sequence(request: Request) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
key = str(form.get("key", "")).strip()
|
||||
title = str(form.get("title", "")).strip()
|
||||
description = str(form.get("description", "")).strip()
|
||||
raw_form = {"key": key, "title": title, "description": description}
|
||||
|
||||
errors: list[str] = []
|
||||
if not KEY_RE.match(key):
|
||||
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–32)")
|
||||
if not title:
|
||||
errors.append("Название не может быть пустым")
|
||||
|
||||
if errors:
|
||||
return await _list_response(request, errors=errors, form=raw_form, status_code=400)
|
||||
|
||||
if not await sequences_repo.create_sequence(
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
editor_id=_editor_id(request),
|
||||
):
|
||||
return await _list_response(
|
||||
request,
|
||||
errors=["Шаблон с таким ключом уже существует"],
|
||||
form=raw_form,
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(url=f"/sequences/{key}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/media/{filename}")
|
||||
async def serve_media(request: Request, filename: str) -> FileResponse:
|
||||
"""Отдать картинку из media-volume (доступ только после входа)."""
|
||||
path = media_path(request.app.state.settings, filename)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@router.get("/{key}", response_class=HTMLResponse)
|
||||
async def edit_sequence(request: Request, key: str, saved: str | None = None) -> HTMLResponse:
|
||||
return await _editor_response(request, key, saved=saved)
|
||||
|
||||
|
||||
@router.post("/{key}/save")
|
||||
async def save_template(request: Request, key: str) -> Response:
|
||||
"""Сохранить шаблон целиком: параметры и все сообщения одной формой."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
sequence = await sequences_repo.get_sequence(key)
|
||||
if sequence is None:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
title = str(form.get("title", "")).strip()
|
||||
description = str(form.get("description", "")).strip()
|
||||
order = [token for token in str(form.get("item_order", "")).split(",") if token]
|
||||
|
||||
existing_items = await sequences_repo.get_items(key)
|
||||
existing = {int(item["id"]): item for item in existing_items}
|
||||
|
||||
errors: list[str] = []
|
||||
if not title:
|
||||
errors.append("Название не может быть пустым")
|
||||
if not order:
|
||||
errors.append("Добавьте хотя бы одно сообщение")
|
||||
|
||||
submitted: list[dict[str, Any]] = []
|
||||
pending_images: dict[str, tuple[bytes, str]] = {}
|
||||
|
||||
for position, token in enumerate(order):
|
||||
item_id = int(token) if token.isdigit() else None
|
||||
current = existing.get(item_id) if item_id is not None else None
|
||||
|
||||
text = str(form.get(f"text_{token}", ""))
|
||||
raw_buttons = str(form.get(f"buttons_{token}", ""))
|
||||
buttons, button_errors = parse_buttons(raw_buttons)
|
||||
errors.extend(f"Сообщение {position + 1}: {error}" for error in button_errors)
|
||||
enabled = form.get(f"enabled_{token}") is not None
|
||||
|
||||
image_name = current["image_name"] if current else None
|
||||
has_image = bool(image_name)
|
||||
|
||||
upload = form.get(f"image_{token}")
|
||||
if isinstance(upload, UploadFile) and upload.filename:
|
||||
try:
|
||||
data, _, extension = await read_image(upload)
|
||||
pending_images[token] = (data, extension)
|
||||
has_image = True
|
||||
except ValueError as exc:
|
||||
errors.append(f"Сообщение {position + 1}: {exc}")
|
||||
elif form.get(f"remove_image_{token}") is not None:
|
||||
image_name = None
|
||||
has_image = False
|
||||
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
errors.append(f"Сообщение {position + 1}: неизвестные теги " + ", ".join(unknown))
|
||||
limit = MAX_CAPTION if has_image else MAX_MESSAGE_LENGTH
|
||||
if len(text) > limit:
|
||||
errors.append(
|
||||
f"Сообщение {position + 1}: с фото — не больше {MAX_CAPTION} символов, "
|
||||
f"без фото — {MAX_MESSAGE_LENGTH}"
|
||||
)
|
||||
if not text.strip() and not has_image:
|
||||
errors.append(f"Сообщение {position + 1}: нужен текст или картинка")
|
||||
|
||||
submitted.append(
|
||||
{
|
||||
"id": item_id,
|
||||
"token": token,
|
||||
"text": text,
|
||||
"enabled": enabled,
|
||||
"image_name": image_name,
|
||||
"buttons": buttons,
|
||||
"buttons_text": raw_buttons,
|
||||
}
|
||||
)
|
||||
|
||||
if errors:
|
||||
return await _editor_response(
|
||||
request,
|
||||
key,
|
||||
errors=errors,
|
||||
items_override=submitted,
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
settings = request.app.state.settings
|
||||
saved_files: list[str] = []
|
||||
for item in submitted:
|
||||
pending = pending_images.get(item["token"])
|
||||
if pending is None:
|
||||
continue
|
||||
data, extension = pending
|
||||
name = f"{key}_{item['token']}_{uuid.uuid4().hex}{extension}"
|
||||
path = media_path(settings, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
item["image_name"] = name
|
||||
saved_files.append(name)
|
||||
|
||||
removed = await sequences_repo.save_items(
|
||||
key,
|
||||
submitted,
|
||||
editor_id=_editor_id(request),
|
||||
)
|
||||
if removed is None:
|
||||
for name in saved_files:
|
||||
_delete_image(settings, name)
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
for name in removed:
|
||||
_delete_image(settings, name)
|
||||
|
||||
await sequences_repo.update_sequence(
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
editor_id=_editor_id(request),
|
||||
)
|
||||
return RedirectResponse(url=f"/sequences/{key}?saved=all", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{key}")
|
||||
async def update_sequence(request: Request, key: str) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
title = str(form.get("title", "")).strip()
|
||||
description = str(form.get("description", "")).strip()
|
||||
if not title:
|
||||
return await _editor_response(request, key, errors=["Название не может быть пустым"], status_code=400)
|
||||
|
||||
if not await sequences_repo.update_sequence(
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
editor_id=_editor_id(request),
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{key}/items")
|
||||
async def add_item(request: Request, key: str) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{key}/items/{item_id}")
|
||||
async def update_item(request: Request, key: str, item_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
items = await sequences_repo.get_items(key)
|
||||
item = next((row for row in items if int(row["id"]) == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||
|
||||
text = str(form.get("text", ""))
|
||||
enabled = form.get("enabled") is not None
|
||||
raw_buttons = str(form.get("buttons", ""))
|
||||
buttons, errors = parse_buttons(raw_buttons)
|
||||
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
errors.append("Неизвестные теги: " + ", ".join(unknown))
|
||||
|
||||
settings = request.app.state.settings
|
||||
new_image: str | None = None
|
||||
update_image = False
|
||||
saved_file: str | None = None
|
||||
|
||||
upload = form.get("image")
|
||||
if isinstance(upload, UploadFile) and upload.filename:
|
||||
try:
|
||||
saved_file = await _save_image(settings, upload, key, item_id)
|
||||
new_image = saved_file
|
||||
update_image = True
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
elif form.get("remove_image") is not None and item["image_name"]:
|
||||
update_image = True
|
||||
new_image = None
|
||||
|
||||
effective_image = new_image if update_image else item["image_name"]
|
||||
limit = MAX_CAPTION if effective_image else MAX_MESSAGE_LENGTH
|
||||
if len(text) > limit:
|
||||
errors.append(f"С подписью к фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}")
|
||||
if not text.strip() and not effective_image:
|
||||
errors.append("Нужен текст или картинка")
|
||||
|
||||
if errors:
|
||||
if saved_file:
|
||||
_delete_image(settings, saved_file)
|
||||
return await _editor_response(
|
||||
request,
|
||||
key,
|
||||
errors=errors,
|
||||
overrides={
|
||||
item_id: {
|
||||
"text": text,
|
||||
"enabled": enabled,
|
||||
"buttons_text": raw_buttons,
|
||||
}
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
updated = await sequences_repo.update_item(
|
||||
item_id,
|
||||
text,
|
||||
enabled,
|
||||
buttons,
|
||||
image_name=new_image,
|
||||
update_image=update_image,
|
||||
editor_id=_editor_id(request),
|
||||
)
|
||||
if updated is None:
|
||||
if saved_file:
|
||||
_delete_image(settings, saved_file)
|
||||
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||
|
||||
_, old_image = updated
|
||||
if update_image and old_image and old_image != new_image:
|
||||
_delete_image(settings, old_image)
|
||||
|
||||
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{key}/items/{item_id}/move")
|
||||
async def move_item(request: Request, key: str, item_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
direction = str(form.get("direction", ""))
|
||||
|
||||
if direction not in {"up", "down"}:
|
||||
raise HTTPException(status_code=400, detail="Неверное направление")
|
||||
if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None:
|
||||
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||
return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{key}/items/{item_id}/delete")
|
||||
async def delete_item(request: Request, key: str, item_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
items = await sequences_repo.get_items(key)
|
||||
item = next((row for row in items if int(row["id"]) == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||
|
||||
if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None:
|
||||
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
|
||||
|
||||
_delete_image(request.app.state.settings, item["image_name"])
|
||||
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
"""Раздел «Звания»: справочник магазина званий."""
|
||||
|
||||
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)
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from admin import telegram
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.eggs import is_egg_event_active
|
||||
from common.repositories import users_admin_repo, users_repo
|
||||
from common.texts import alerts as alerts_text
|
||||
from common.texts.render import render
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
#: Сколько пользователей показывать на странице.
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def _editor_id(request: Request) -> int:
|
||||
"""Telegram-ID текущего администратора (ставит middleware)."""
|
||||
return int(request.state.admin["telegram_id"])
|
||||
|
||||
|
||||
def _tag(username: str | None, user_id: int) -> str:
|
||||
"""``@username`` или числовой ID для уведомлений."""
|
||||
return f"@{username}" if username else str(user_id)
|
||||
|
||||
|
||||
def _victim_tag(user: dict[str, object]) -> str:
|
||||
if user.get("username"):
|
||||
return f"@{user['username']}"
|
||||
if user.get("nick"):
|
||||
return str(user["nick"])
|
||||
return str(user.get("id_user"))
|
||||
|
||||
|
||||
async def _card_response(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
*,
|
||||
errors: list[str] | None = None,
|
||||
saved: str | None = None,
|
||||
status_code: int = 200,
|
||||
) -> HTMLResponse:
|
||||
"""Отрисовать карточку (общая точка для GET и ошибок валидации)."""
|
||||
user = await users_admin_repo.get_user(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="users/card.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"user": user,
|
||||
"money": await users_admin_repo.money_history(user_id),
|
||||
"titles": await users_admin_repo.list_titles(),
|
||||
"history": await users_admin_repo.user_history(user_id),
|
||||
"errors": errors or [],
|
||||
"saved": saved,
|
||||
"eggs_visible": is_egg_event_active(request.app.state.settings),
|
||||
"can_kick": bool(request.app.state.settings.bot_token and request.app.state.settings.chat_id),
|
||||
"max_nick_length": users_admin_repo.MAX_NICK_LENGTH,
|
||||
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_users(request: Request, q: str = "", page: int = 1, saved: str | None = None) -> HTMLResponse:
|
||||
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
|
||||
total = await users_admin_repo.count_users(q)
|
||||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
||||
page = min(max(page, 1), pages)
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="users/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"users": await users_admin_repo.list_users(
|
||||
q,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
),
|
||||
"q": q,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_class=HTMLResponse)
|
||||
async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse:
|
||||
"""Карточка пользователя: сводка, управление, история и операции."""
|
||||
return await _card_response(request, user_id, saved=saved)
|
||||
|
||||
|
||||
@router.post("/{user_id}/nick")
|
||||
async def update_nick(request: Request, user_id: int) -> Response:
|
||||
"""Сменить ник игрока."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
nick = str(form.get("nick", "")).strip()
|
||||
|
||||
errors: list[str] = []
|
||||
if not nick:
|
||||
errors.append("Ник не может быть пустым")
|
||||
elif len(nick) > users_admin_repo.MAX_NICK_LENGTH:
|
||||
errors.append(f"Ник длиннее {users_admin_repo.MAX_NICK_LENGTH} символов")
|
||||
elif any(char.isspace() for char in nick):
|
||||
errors.append("Ник не должен содержать пробелы")
|
||||
|
||||
if errors:
|
||||
return await _card_response(request, user_id, errors=errors, status_code=400)
|
||||
|
||||
if not await users_admin_repo.set_nick(user_id, nick, editor_id=_editor_id(request)):
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
return RedirectResponse(url=f"/users/{user_id}?saved=nick", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{user_id}/title")
|
||||
async def update_title(request: Request, user_id: int) -> Response:
|
||||
"""Сменить звание игрока."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
try:
|
||||
title_id = int(str(form.get("title_id", "")))
|
||||
except (TypeError, ValueError):
|
||||
return await _card_response(request, user_id, errors=["Выберите звание"], status_code=400)
|
||||
|
||||
titles = await users_admin_repo.list_titles()
|
||||
if not any(int(title["id_title"]) == title_id for title in titles):
|
||||
return await _card_response(request, user_id, errors=["Звание не найдено"], status_code=400)
|
||||
|
||||
if not await users_admin_repo.set_title(user_id, title_id, editor_id=_editor_id(request)):
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
return RedirectResponse(url=f"/users/{user_id}?saved=title", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{user_id}/subscription")
|
||||
async def update_subscription(request: Request, user_id: int) -> Response:
|
||||
"""Подписать или отписать от рассылок."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
value = str(form.get("subscribed", ""))
|
||||
if value not in {"0", "1"}:
|
||||
return await _card_response(request, user_id, errors=["Некорректное значение подписки"], status_code=400)
|
||||
|
||||
if not await users_admin_repo.set_subscription(
|
||||
user_id,
|
||||
value == "1",
|
||||
editor_id=_editor_id(request),
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
return RedirectResponse(url=f"/users/{user_id}?saved=subscription", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{user_id}/balance")
|
||||
async def update_balance(request: Request, user_id: int) -> Response:
|
||||
"""Скорректировать баланс записью в журнал money (действие «Корректировка админом»)."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
raw_amount = str(form.get("amount", "")).strip()
|
||||
reason = str(form.get("reason", "")).strip()
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
amount = int(raw_amount)
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
errors.append("Сумма должна быть целым числом")
|
||||
|
||||
if amount == 0 and "Сумма должна быть целым числом" not in errors:
|
||||
errors.append("Сумма не может быть нулевой")
|
||||
if not reason:
|
||||
errors.append("Укажите причину корректировки")
|
||||
elif len(reason) > users_admin_repo.MAX_COMMENT_LENGTH:
|
||||
errors.append(f"Причина длиннее {users_admin_repo.MAX_COMMENT_LENGTH} символов")
|
||||
|
||||
if errors:
|
||||
return await _card_response(request, user_id, errors=errors, status_code=400)
|
||||
|
||||
balance = await users_admin_repo.adjust_balance(
|
||||
user_id,
|
||||
amount,
|
||||
reason,
|
||||
editor_id=_editor_id(request),
|
||||
)
|
||||
if balance is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{user_id}/kick")
|
||||
async def kick_user(request: Request, user_id: int) -> Response:
|
||||
"""Исключить пользователя из основного чата (бан + разбан)."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
user = await users_admin_repo.get_user(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
settings = request.app.state.settings
|
||||
try:
|
||||
await telegram.kick_member(settings, user_id)
|
||||
except telegram.TelegramError as exc:
|
||||
return await _card_response(
|
||||
request,
|
||||
user_id,
|
||||
errors=[f"Не удалось исключить: {exc}"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Данные удаляем сразу, не полагаясь на событие chat_member (бот может быть остановлен).
|
||||
await users_repo.del_user_by_id(user_id)
|
||||
|
||||
admin = request.state.admin
|
||||
text = render(
|
||||
alerts_text.MSG_KICK,
|
||||
{
|
||||
"actor": _tag(admin.get("username"), int(admin["telegram_id"])),
|
||||
"username": _victim_tag(user),
|
||||
"chat_title": await telegram.chat_title(settings),
|
||||
},
|
||||
)
|
||||
await telegram.send_alert(settings, text)
|
||||
return RedirectResponse(url="/users?saved=kick", status_code=303)
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
"""Безопасность админки: проверка Telegram-логина, сессия и CSRF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
#: Сколько живёт подпись Telegram-логина (секунды).
|
||||
TELEGRAM_AUTH_MAX_AGE = 24 * 60 * 60
|
||||
|
||||
#: Допустимое расхождение часов с Telegram (секунды).
|
||||
FUTURE_SKEW = 300
|
||||
|
||||
|
||||
def telegram_check_string(data: Mapping[str, object]) -> str:
|
||||
"""Строка для проверки подписи: отсортированные ``key=value`` без ``hash``."""
|
||||
pairs = sorted((key, str(value)) for key, value in data.items() if key != "hash")
|
||||
return "\n".join(f"{key}={value}" for key, value in pairs)
|
||||
|
||||
|
||||
def verify_telegram_auth(
|
||||
data: Mapping[str, object],
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age: int = TELEGRAM_AUTH_MAX_AGE,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
"""Проверить подпись Telegram Login Widget и свежесть ``auth_date``.
|
||||
|
||||
Секрет — ``SHA256(bot_token)``, подпись — HMAC-SHA256 от check-string.
|
||||
"""
|
||||
received = str(data.get("hash") or "")
|
||||
if not received or not bot_token:
|
||||
return False
|
||||
|
||||
expected = hmac.new(
|
||||
hashlib.sha256(bot_token.encode()).digest(),
|
||||
telegram_check_string(data).encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected, received):
|
||||
return False
|
||||
|
||||
try:
|
||||
auth_date = int(str(data.get("auth_date") or ""))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
moment = time.time() if now is None else now
|
||||
return -FUTURE_SKEW <= moment - auth_date <= max_age
|
||||
|
||||
|
||||
def check_csrf(request: Request, token: str) -> None:
|
||||
"""Проверить CSRF-токен формы; при несовпадении — 400."""
|
||||
expected = request.session.get("csrf")
|
||||
if not expected or not token or not secrets.compare_digest(str(expected), str(token)):
|
||||
raise HTTPException(status_code=400, detail="CSRF-токен неверен или устарел")
|
||||
|
||||
|
||||
def safe_redirect_target(value: str | None) -> str:
|
||||
"""Разрешить редирект только на внутренний путь (защита от open redirect)."""
|
||||
if value and value.startswith("/") and not value.startswith("//"):
|
||||
return value
|
||||
return "/"
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
"""Минимальный клиент Telegram Bot API для админки.
|
||||
|
||||
Нужен для действий модерации (исключение из чата) и уведомлений в чат логов.
|
||||
Специально без aiogram: админке не нужен весь фреймворк бота.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from common.config import AdminSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API = "https://api.telegram.org/bot{token}/{method}"
|
||||
|
||||
|
||||
class TelegramError(RuntimeError):
|
||||
"""Telegram отклонил запрос или недоступен."""
|
||||
|
||||
|
||||
def _check(response: httpx.Response) -> Any:
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise TelegramError("Telegram вернул некорректный ответ") from exc
|
||||
if not data.get("ok"):
|
||||
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
|
||||
return data.get("result")
|
||||
|
||||
|
||||
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
|
||||
if not settings.bot_token:
|
||||
raise TelegramError("BOT_TOKEN не задан")
|
||||
|
||||
url = _API.format(token=settings.bot_token, method=method)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TelegramError(f"Telegram недоступен: {exc}") from exc
|
||||
return _check(response)
|
||||
|
||||
|
||||
async def _call_multipart(
|
||||
settings: AdminSettings,
|
||||
method: str,
|
||||
data: dict[str, str],
|
||||
files: dict[str, tuple[str, bytes, str]],
|
||||
) -> Any:
|
||||
if not settings.bot_token:
|
||||
raise TelegramError("BOT_TOKEN не задан")
|
||||
|
||||
url = _API.format(token=settings.bot_token, method=method)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, data=data, files=files)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TelegramError(f"Telegram недоступен: {exc}") from exc
|
||||
return _check(response)
|
||||
|
||||
|
||||
async def kick_member(settings: AdminSettings, user_id: int) -> None:
|
||||
"""Исключить пользователя из основного чата (бан + разбан, чтобы мог вернуться)."""
|
||||
if not settings.chat_id:
|
||||
raise TelegramError("CHAT_ID не задан")
|
||||
|
||||
await _call(
|
||||
settings,
|
||||
"banChatMember",
|
||||
{"chat_id": settings.chat_id, "user_id": user_id},
|
||||
)
|
||||
await _call(
|
||||
settings,
|
||||
"unbanChatMember",
|
||||
{"chat_id": settings.chat_id, "user_id": user_id, "only_if_banned": True},
|
||||
)
|
||||
|
||||
|
||||
async def chat_title(settings: AdminSettings) -> str:
|
||||
"""Название основного чата (best-effort, для текста уведомления)."""
|
||||
try:
|
||||
chat = await _call(settings, "getChat", {"chat_id": settings.chat_id})
|
||||
except TelegramError:
|
||||
return "чат"
|
||||
return str(chat.get("title") or chat.get("username") or "чат")
|
||||
|
||||
|
||||
async def send_alert(settings: AdminSettings, text: str) -> None:
|
||||
"""Отправить служебное сообщение в чат логов (ошибки только в лог)."""
|
||||
if not settings.alert_chat_id:
|
||||
return
|
||||
try:
|
||||
await _call(
|
||||
settings,
|
||||
"sendMessage",
|
||||
{"chat_id": settings.alert_chat_id, "text": text},
|
||||
)
|
||||
except TelegramError:
|
||||
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)
|
||||
|
||||
|
||||
async def send_to_chat(
|
||||
settings: AdminSettings,
|
||||
*,
|
||||
text: str,
|
||||
buttons: list[tuple[str, str]],
|
||||
photo: tuple[bytes, str, str] | None = None,
|
||||
) -> None:
|
||||
"""Отправить сообщение в основной чат от имени бота.
|
||||
|
||||
``photo`` — (данные, mime, имя файла). При фото длинный текст уходит
|
||||
отдельным сообщением (лимит подписи 1024).
|
||||
"""
|
||||
if not settings.chat_id:
|
||||
raise TelegramError("CHAT_ID не задан")
|
||||
|
||||
rows = [[{"text": title, "url": url}] for title, url in buttons]
|
||||
markup = {"inline_keyboard": rows} if rows else None
|
||||
|
||||
if photo is None:
|
||||
payload = {
|
||||
"chat_id": settings.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if markup:
|
||||
payload["reply_markup"] = markup
|
||||
await _call(settings, "sendMessage", payload)
|
||||
return
|
||||
|
||||
data, mime, filename = photo
|
||||
fields: dict[str, str] = {"chat_id": str(settings.chat_id), "parse_mode": "HTML"}
|
||||
caption = text if text and len(text) <= 1024 else ""
|
||||
if caption:
|
||||
fields["caption"] = caption
|
||||
if markup:
|
||||
fields["reply_markup"] = json.dumps(markup)
|
||||
|
||||
await _call_multipart(settings, "sendPhoto", fields, {"photo": (filename, data, mime)})
|
||||
|
||||
if text and not caption:
|
||||
payload = {
|
||||
"chat_id": settings.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if markup:
|
||||
payload["reply_markup"] = markup
|
||||
await _call(settings, "sendMessage", payload)
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Админы{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Админы</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Доступ к веб-панели через Telegram.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{% if saved == 'added' %}Администратор добавлен.
|
||||
{% elif saved == 'role' %}Роль обновлена.
|
||||
{% elif saved == 'toggle' %}Доступ обновлён.
|
||||
{% elif saved == 'deleted' %}Администратор удалён.
|
||||
{% else %}Изменения сохранены.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admins" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<h2 class="text-lg font-medium">Добавить администратора</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label for="telegram_id" class="text-xs text-slate-500">Telegram-ID</label>
|
||||
<input
|
||||
id="telegram_id"
|
||||
name="telegram_id"
|
||||
value="{{ form.telegram_id }}"
|
||||
inputmode="numeric"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="text-xs text-slate-500">Username (необязательно)</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
value="{{ form.username }}"
|
||||
placeholder="без @"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="text-xs text-slate-500">Роль</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {% if role == form.role %}selected{% endif %}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
Обе роли сейчас дают полный доступ; <span class="font-mono">moderator</span> — задел на ограничения.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Telegram-ID</th>
|
||||
<th class="px-4 py-3 font-medium">Username</th>
|
||||
<th class="px-4 py-3 font-medium">Роль</th>
|
||||
<th class="px-4 py-3 font-medium">Доступ</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Последний вход</th>
|
||||
<th class="px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for admin in admins %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-mono text-xs">
|
||||
{{ admin.telegram_id }}
|
||||
{% if admin.telegram_id == current_id %}<span class="ml-1 text-slate-400">(вы)</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if admin.username %}<span class="font-mono">@{{ admin.username }}</span>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<form method="post" action="/admins/{{ admin.telegram_id }}/role" class="flex items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<select
|
||||
name="role"
|
||||
class="rounded-lg border border-slate-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {% if role == admin.role %}selected{% endif %}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="text-xs text-sky-700 hover:underline">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if admin.enabled %}
|
||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включён</span>
|
||||
{% else %}
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-slate-600 lg:table-cell">
|
||||
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
{% if admin.telegram_id != current_id %}
|
||||
<form method="post" action="/admins/{{ admin.telegram_id }}/toggle">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-slate-600 hover:underline">
|
||||
{% if admin.enabled %}Выключить{% else %}Включить{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admins/{{ admin.telegram_id }}/delete" onsubmit="return confirm('Удалить администратора?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Доступ закрыт{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-10 max-w-md rounded-lg border border-red-200 bg-white p-8 text-center">
|
||||
<h1 class="text-2xl font-semibold text-red-700">Доступ закрыт</h1>
|
||||
<p class="mt-3 text-sm text-slate-700">{{ message }}</p>
|
||||
<p class="mt-6">
|
||||
<a href="/login" class="text-sky-700 hover:underline">Вернуться к входу</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Вход{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-10 max-w-md rounded-lg border border-slate-200 bg-white p-8 text-center">
|
||||
<h1 class="text-2xl font-semibold">Вход в админку</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p>
|
||||
|
||||
{% if deeplink_error %}
|
||||
<p class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Deep-link вход не настроен: проверьте <span class="font-mono">BOT_USERNAME</span>.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if configured %}
|
||||
<div class="mt-8 flex justify-center">
|
||||
<script
|
||||
async
|
||||
src="https://telegram.org/js/telegram-widget.js?22"
|
||||
data-telegram-login="{{ bot_username }}"
|
||||
data-size="large"
|
||||
data-auth-url="{{ public_url }}/auth/telegram?next={{ next | urlencode }}"
|
||||
data-request-access="write"
|
||||
></script>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if deeplink %}
|
||||
<div class="mt-6 border-t border-slate-100 pt-5">
|
||||
<a
|
||||
href="/auth/deeplink/start"
|
||||
class="inline-block rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Открыть Telegram
|
||||
</a>
|
||||
<p class="mt-2 text-xs text-slate-500">Подтвердите вход в установленном приложении Telegram.</p>
|
||||
<p id="deeplink-status" class="mt-2 text-sm text-sky-700">
|
||||
{% if deeplink_pending %}Ожидаем подтверждение в Telegram…{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not configured and not deeplink %}
|
||||
<p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>,
|
||||
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if deeplink_pending %}
|
||||
<script>
|
||||
(function () {
|
||||
var status = document.getElementById("deeplink-status");
|
||||
var timer = setInterval(function () {
|
||||
fetch("/auth/deeplink/status", { credentials: "same-origin" })
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === "ok") {
|
||||
clearInterval(timer);
|
||||
window.location.href = "/";
|
||||
} else if (data.status === "denied") {
|
||||
clearInterval(timer);
|
||||
if (status) status.textContent = "У вас нет доступа к админке.";
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}, 2000);
|
||||
setTimeout(function () { clearInterval(timer); }, 300000);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Админка{% endblock %} — DispelEvilBot</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<header class="border-b border-slate-200 bg-white">
|
||||
<div class="mx-auto max-w-5xl px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
|
||||
<button
|
||||
id="nav-toggle"
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-600 sm:hidden"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Меню
|
||||
</button>
|
||||
<nav
|
||||
id="nav-menu"
|
||||
class="hidden w-full flex-col gap-2 border-t border-slate-100 pt-2 text-sm sm:flex sm:w-auto sm:flex-row sm:items-center sm:gap-1 sm:border-0 sm:pt-0"
|
||||
>
|
||||
<a href="/" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Главная</a>
|
||||
|
||||
<div class="relative" data-group>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-1 rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900 sm:w-auto"
|
||||
data-group-toggle
|
||||
aria-expanded="false"
|
||||
>
|
||||
Сообщения <span class="text-xs">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="hidden flex-col gap-1 rounded-lg border border-slate-200 bg-white p-2 sm:absolute sm:left-0 sm:top-full sm:z-20 sm:min-w-48 sm:shadow-lg"
|
||||
data-group-dropdown
|
||||
>
|
||||
<a href="/sequences" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Шаблоны</a>
|
||||
<a href="/schedules" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Расписания</a>
|
||||
<a href="/send" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Отправить</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" data-group>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-1 rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900 sm:w-auto"
|
||||
data-group-toggle
|
||||
aria-expanded="false"
|
||||
>
|
||||
Пользователи <span class="text-xs">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="hidden flex-col gap-1 rounded-lg border border-slate-200 bg-white p-2 sm:absolute sm:left-0 sm:top-full sm:z-20 sm:min-w-48 sm:shadow-lg"
|
||||
data-group-dropdown
|
||||
>
|
||||
<a href="/users" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Список</a>
|
||||
<a href="/admins" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Админы</a>
|
||||
<a href="/titles" class="rounded px-3 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Звания</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/logs" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">Логи</a>
|
||||
|
||||
{% if request.session.get('admin_id') %}
|
||||
<form method="post" action="/logout" class="py-1 sm:ml-1">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="rounded px-2 py-1.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900">
|
||||
Выйти
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-5xl px-4 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer>
|
||||
|
||||
<script>
|
||||
var tags = {
|
||||
b: ["<b>", "</b>"],
|
||||
i: ["<i>", "</i>"],
|
||||
u: ["<u>", "</u>"],
|
||||
s: ["<s>", "</s>"],
|
||||
code: ["<code>", "</code>"],
|
||||
pre: ["<pre>", "</pre>"],
|
||||
quote: ["<blockquote>", "</blockquote>"],
|
||||
spoiler: ["<tg-spoiler>", "</tg-spoiler>"],
|
||||
a: ['<a href="">', "</a>"],
|
||||
};
|
||||
|
||||
function initEditor(root) {
|
||||
if (!root || root.dataset.editorReady) return;
|
||||
var textarea = root.querySelector("textarea");
|
||||
if (!textarea) return;
|
||||
root.dataset.editorReady = "1";
|
||||
|
||||
var counter = root.querySelector("[data-counter]");
|
||||
var max = parseInt(root.dataset.max || "0", 10);
|
||||
|
||||
function updateCounter() {
|
||||
if (counter) counter.textContent = textarea.value.length + (max ? " / " + max : "");
|
||||
}
|
||||
textarea.addEventListener("input", updateCounter);
|
||||
updateCounter();
|
||||
|
||||
root.querySelectorAll("button[data-tag]").forEach(function (button) {
|
||||
button.addEventListener("click", function () {
|
||||
var pair = tags[button.dataset.tag];
|
||||
if (!pair) return;
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.slice(start, end);
|
||||
var inserted = pair[0] + selected + pair[1];
|
||||
textarea.value = textarea.value.slice(0, start) + inserted + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
textarea.selectionStart = start + pair[0].length;
|
||||
textarea.selectionEnd = start + pair[0].length + selected.length;
|
||||
updateCounter();
|
||||
});
|
||||
});
|
||||
|
||||
textarea.addEventListener("keydown", function (event) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
var key = event.key.toLowerCase();
|
||||
var tag = key === "b" ? "b" : key === "i" ? "i" : key === "u" ? "u" : null;
|
||||
if (!tag) return;
|
||||
event.preventDefault();
|
||||
var button = root.querySelector('button[data-tag="' + tag + '"]');
|
||||
if (button) button.click();
|
||||
});
|
||||
}
|
||||
window.initEditor = initEditor;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var navToggle = document.getElementById("nav-toggle");
|
||||
var navMenu = document.getElementById("nav-menu");
|
||||
if (navToggle && navMenu) {
|
||||
navToggle.addEventListener("click", function () {
|
||||
var hidden = navMenu.classList.toggle("hidden");
|
||||
navMenu.classList.toggle("flex", !hidden);
|
||||
navToggle.setAttribute("aria-expanded", String(!hidden));
|
||||
});
|
||||
}
|
||||
|
||||
var groups = document.querySelectorAll("[data-group]");
|
||||
|
||||
function hideGroup(group) {
|
||||
var dropdown = group.querySelector("[data-group-dropdown]");
|
||||
var toggle = group.querySelector("[data-group-toggle]");
|
||||
if (dropdown) {
|
||||
dropdown.classList.add("hidden");
|
||||
dropdown.classList.remove("flex");
|
||||
}
|
||||
if (toggle) toggle.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function hideOthers(current) {
|
||||
groups.forEach(function (group) {
|
||||
if (group !== current) hideGroup(group);
|
||||
});
|
||||
}
|
||||
|
||||
groups.forEach(function (group) {
|
||||
var toggle = group.querySelector("[data-group-toggle]");
|
||||
var dropdown = group.querySelector("[data-group-dropdown]");
|
||||
if (!toggle || !dropdown) return;
|
||||
|
||||
var closeTimer = null;
|
||||
|
||||
function setOpen(open) {
|
||||
dropdown.classList.toggle("hidden", !open);
|
||||
dropdown.classList.toggle("flex", open);
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", function (event) {
|
||||
event.stopPropagation();
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
var willOpen = dropdown.classList.contains("hidden");
|
||||
if (willOpen) hideOthers(group);
|
||||
setOpen(willOpen);
|
||||
});
|
||||
|
||||
if (window.matchMedia("(min-width: 640px)").matches) {
|
||||
group.addEventListener("mouseenter", function () {
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
hideOthers(group);
|
||||
setOpen(true);
|
||||
});
|
||||
group.addEventListener("mouseleave", function () {
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(function () { setOpen(false); }, 150);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", function () {
|
||||
groups.forEach(hideGroup);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-editor]").forEach(initEditor);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Главная{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-semibold">Панель администрирования</h1>
|
||||
<p class="mt-2 text-slate-600">
|
||||
Окружение: <span class="font-mono">{{ app_env }}</span>. Это каркас админки: разделы
|
||||
появятся по мере готовности (Блок 4.1–4.3).
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{% for section in sections %}
|
||||
<section class="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium">
|
||||
{% if section.url %}<a href="{{ section.url }}" class="hover:underline">{{ section.title }}</a>{% else %}{{ section.title }}{% endif %}
|
||||
</h2>
|
||||
{% if not section.url %}<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>{% endif %}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ section.description }}</p>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Логи{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Логи</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{% if view == "messages" %}Сообщения по дням{% else %}События из Postgres{% endif %}
|
||||
· найдено: {{ total }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-1 border-b border-slate-200">
|
||||
<a
|
||||
href="/logs?date_from={{ date_from }}&date_to={{ date_to }}"
|
||||
class="rounded-t-lg px-4 py-2 text-sm {% if view == 'events' %}border border-b-0 border-slate-200 bg-white font-medium text-slate-900{% else %}text-slate-600 hover:text-slate-900{% endif %}"
|
||||
>
|
||||
События
|
||||
</a>
|
||||
<a
|
||||
href="/logs?view=messages&date_from={{ date_from }}&date_to={{ date_to }}"
|
||||
class="rounded-t-lg px-4 py-2 text-sm {% if view == 'messages' %}border border-b-0 border-slate-200 bg-white font-medium text-slate-900{% else %}text-slate-600 hover:text-slate-900{% endif %}"
|
||||
>
|
||||
Сообщения по дням
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/logs" class="flex flex-wrap items-end gap-3 rounded-b-lg border border-t-0 border-slate-200 bg-white p-4">
|
||||
{% if view == "messages" %}
|
||||
<input type="hidden" name="view" value="messages" />
|
||||
{% endif %}
|
||||
{% if view == "events" %}
|
||||
<div>
|
||||
<label for="event_type" class="text-xs text-slate-500">Тип</label>
|
||||
<select
|
||||
id="event_type"
|
||||
name="event_type"
|
||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Все типы</option>
|
||||
{% for type in event_types %}
|
||||
<option value="{{ type }}" {% if type == event_type %}selected{% endif %}>{{ type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="user" class="text-xs text-slate-500">Пользователь</label>
|
||||
<input
|
||||
id="user"
|
||||
name="user"
|
||||
type="text"
|
||||
value="{{ user }}"
|
||||
placeholder="ID или username"
|
||||
class="mt-1 block w-48 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_from" class="text-xs text-slate-500">С</label>
|
||||
<input
|
||||
id="date_from"
|
||||
name="date_from"
|
||||
type="date"
|
||||
value="{{ date_from }}"
|
||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_to" class="text-xs text-slate-500">По</label>
|
||||
<input
|
||||
id="date_to"
|
||||
name="date_to"
|
||||
type="date"
|
||||
value="{{ date_to }}"
|
||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Показать
|
||||
</button>
|
||||
<a href="/logs{% if view == 'messages' %}?view=messages{% endif %}" class="text-sm text-slate-600 hover:underline">Сбросить</a>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if view == "messages" %}
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">День</th>
|
||||
<th class="px-4 py-3 font-medium">Пользователь</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Чат</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Сообщений</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in messages %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ row.day_local }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.username %}<div>@{{ row.username }}</div>{% endif %}
|
||||
<a href="/users/{{ row.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ row.user_id }}</a>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 font-mono text-xs text-slate-500 lg:table-cell">{{ row.chat_id or "—" }}</td>
|
||||
<td class="px-4 py-3 text-right font-mono">{{ row.count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-6 text-center text-slate-500">
|
||||
{% if error %}Данные не загрузились.{% else %}За выбранный период сообщений нет.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Время</th>
|
||||
<th class="px-4 py-3 font-medium">Событие</th>
|
||||
<th class="px-4 py-3 font-medium">Пользователь</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Чат</th>
|
||||
<th class="hidden px-4 py-3 font-medium xl:table-cell">Версия</th>
|
||||
<th class="px-4 py-3 font-medium">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for event in events %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ event.time_local }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 font-mono text-xs">{{ event.event_type }}</span>
|
||||
{% if event.prod %}<span class="ml-1 rounded bg-indigo-100 px-2 py-0.5 text-xs text-indigo-800">prod</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if event.user_id %}
|
||||
{% if event.username %}<div>@{{ event.username }}</div>{% endif %}
|
||||
<a href="/users/{{ event.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ event.user_id }}</a>
|
||||
{% else %}
|
||||
<span class="text-slate-400">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 font-mono text-xs text-slate-500 lg:table-cell">{{ event.chat_id or "—" }}</td>
|
||||
<td class="hidden px-4 py-3 text-xs text-slate-500 xl:table-cell">{{ event.bot_version }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if event.payload_pretty %}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-xs text-slate-500">JSON</summary>
|
||||
<pre class="mt-1 max-h-64 max-w-xl overflow-auto rounded bg-slate-50 p-2 text-xs">{{ event.payload_pretty }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="text-slate-400">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
||||
{% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<div>Страница {{ page }} из {{ pages }}</div>
|
||||
<div class="flex gap-3">
|
||||
{% if page > 1 %}
|
||||
<a href="/logs?{{ query_base }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
|
||||
{% endif %}
|
||||
{% if page < pages %}
|
||||
<a href="/logs?{{ query_base }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<div class="editor-block" data-editor data-max="{{ editor_max }}">
|
||||
<div class="flex flex-wrap gap-1 rounded-t-lg border border-b-0 border-slate-300 bg-slate-50 p-2">
|
||||
<button type="button" data-tag="b" title="Жирный (Ctrl+B)" class="rounded px-2 py-1 text-sm font-bold hover:bg-white">Ж</button>
|
||||
<button type="button" data-tag="i" title="Курсив (Ctrl+I)" class="rounded px-2 py-1 text-sm italic hover:bg-white">К</button>
|
||||
<button type="button" data-tag="u" title="Подчёркнутый (Ctrl+U)" class="rounded px-2 py-1 text-sm underline hover:bg-white">Ч</button>
|
||||
<button type="button" data-tag="s" title="Зачёркнутый" class="rounded px-2 py-1 text-sm line-through hover:bg-white">З</button>
|
||||
<button type="button" data-tag="code" title="Код" class="rounded px-2 py-1 font-mono text-xs hover:bg-white"></></button>
|
||||
<button type="button" data-tag="pre" title="Блок кода" class="rounded px-2 py-1 font-mono text-xs hover:bg-white">[ ]</button>
|
||||
<button type="button" data-tag="quote" title="Цитата" class="rounded px-2 py-1 text-sm hover:bg-white">❝</button>
|
||||
<button type="button" data-tag="spoiler" title="Спойлер" class="rounded px-2 py-1 text-sm hover:bg-white">👁</button>
|
||||
<button type="button" data-tag="a" title="Ссылка" class="rounded px-2 py-1 text-sm hover:bg-white">🔗</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="{{ editor_id }}"
|
||||
name="{{ editor_name }}"
|
||||
rows="{{ editor_rows | default(14) }}"
|
||||
class="w-full rounded-b-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
|
||||
>{{ editor_value }}</textarea>
|
||||
<p class="mt-1 text-xs text-slate-500"><span data-counter>0</span> / {{ editor_max }} символов</p>
|
||||
</div>
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="text-sm text-slate-500"><a href="/schedules" class="hover:underline">← Расписания</a></p>
|
||||
<h1 class="mt-1 text-2xl font-semibold">
|
||||
{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}
|
||||
</h1>
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ schedule.action }}" class="mt-6 max-w-3xl space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<div>
|
||||
<label for="template_key" class="text-sm font-medium text-slate-700">Шаблон сообщения</label>
|
||||
<select
|
||||
id="template_key"
|
||||
name="template_key"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
{% for template in message_templates %}
|
||||
<option value="{{ template.key }}" {% if template.key == schedule.template_key %}selected{% endif %}>
|
||||
{{ template.key }} — {{ template.description }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="hour" class="text-sm font-medium text-slate-700">Час (0–23)</label>
|
||||
<input
|
||||
id="hour"
|
||||
name="hour"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value="{{ schedule.hour }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="minute" class="text-sm font-medium text-slate-700">Минуты (0–59)</label>
|
||||
<input
|
||||
id="minute"
|
||||
name="minute"
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value="{{ schedule.minute }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-slate-700">Дни недели</legend>
|
||||
<p class="text-xs text-slate-500">Ничего не выбрано — каждый день.</p>
|
||||
<div class="mt-2 flex flex-wrap gap-4">
|
||||
{% for day, name in weekday_names.items() %}
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="weekdays" value="{{ day }}" {% if day in schedule.weekdays %}checked{% endif %} />
|
||||
{{ name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="timezone" class="text-sm font-medium text-slate-700">Таймзона</label>
|
||||
<input
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
type="text"
|
||||
value="{{ schedule.timezone }}"
|
||||
list="timezones"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<datalist id="timezones">
|
||||
<option value="Europe/Moscow"></option>
|
||||
<option value="Europe/Berlin"></option>
|
||||
<option value="UTC"></option>
|
||||
</datalist>
|
||||
<p class="mt-1 text-xs text-slate-500">Время указывается в этой таймзоне (IANA).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="text-sm font-medium text-slate-700">Описание (для себя)</label>
|
||||
<input
|
||||
id="description"
|
||||
name="description"
|
||||
type="text"
|
||||
value="{{ schedule.description }}"
|
||||
maxlength="200"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="enabled" {% if schedule.enabled %}checked{% endif %} />
|
||||
Включено
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить
|
||||
</button>
|
||||
<a href="/schedules" class="text-sm text-slate-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if not schedule.is_new %}
|
||||
<div class="mt-10">
|
||||
<h2 class="text-lg font-medium">История изменений</h2>
|
||||
{% if history %}
|
||||
<ul class="mt-3 space-y-2">
|
||||
{% for item in history %}
|
||||
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
||||
<div class="flex items-center justify-between text-slate-500">
|
||||
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||
<span>редактор: {{ item.editor_id or "—" }}</span>
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
|
||||
<div class="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.old_data | tojson(indent=2) if item.old_data else "—" }}</pre>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.new_data | tojson(indent=2) if item.new_data else "—" }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Расписания{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Расписания</h1>
|
||||
<a href="/schedules/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Новое расписание
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-slate-600">
|
||||
Рассылки исполняет бот: он читает активные расписания из БД раз в минуту,
|
||||
поэтому правки применяются без перезапуска.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Время</th>
|
||||
<th class="hidden px-4 py-3 font-medium sm:table-cell">Дни</th>
|
||||
<th class="px-4 py-3 font-medium">Шаблон</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Таймзона</th>
|
||||
<th class="px-4 py-3 font-medium">Статус</th>
|
||||
<th class="px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for schedule in schedules %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td>
|
||||
<td class="hidden px-4 py-3 sm:table-cell">{{ format_weekdays(schedule.weekdays) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-mono text-sky-700">{{ schedule.template_key }}</div>
|
||||
<div class="text-xs text-slate-500">{{ schedule.description or schedule.template_description }}</div>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-slate-600 lg:table-cell">{{ schedule.timezone }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if schedule.enabled %}
|
||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
||||
{% else %}
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="/schedules/{{ schedule.id }}" class="text-sky-700 hover:underline">Изменить</a>
|
||||
<form method="post" action="/schedules/{{ schedule.id }}/toggle">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-slate-600 hover:underline">
|
||||
{% if schedule.enabled %}Выключить{% else %}Включить{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/schedules/{{ schedule.id }}/delete" onsubmit="return confirm('Удалить расписание?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">Расписаний пока нет.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ sequence.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="text-sm text-slate-500"><a href="/sequences" class="hover:underline">← Шаблоны сообщений</a></p>
|
||||
<div class="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{{ sequence.title }}</h1>
|
||||
<p class="font-mono text-xs text-slate-500">{{ sequence.key }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{% if saved == 'all' %}Шаблон сохранён целиком.
|
||||
{% elif saved == 'meta' %}Параметры шаблона сохранены.
|
||||
{% elif saved == 'order' %}Порядок сообщений обновлён.
|
||||
{% else %}Сообщение сохранено.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
id="template-form"
|
||||
method="post"
|
||||
action="/sequences/{{ sequence.key }}/save"
|
||||
enctype="multipart/form-data"
|
||||
class="mt-6"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<input type="hidden" name="item_order" id="item-order" value="{{ item_order }}" />
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Название</label>
|
||||
<input name="title" value="{{ sequence.title }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="text-xs text-slate-500">Описание</label>
|
||||
<input name="description" value="{{ sequence.description }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium">Сообщения ({{ items | length }})</h2>
|
||||
<button
|
||||
type="button"
|
||||
id="add-item"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Добавить сообщение
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="items" class="mt-4 space-y-5">
|
||||
{% for item in items %}
|
||||
<section class="rounded-lg border border-slate-200 bg-white" data-token="{{ item.token }}">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
|
||||
<div class="text-sm text-slate-600">
|
||||
<span class="font-medium" data-position>№ {{ loop.index }}</span>
|
||||
{% if item.id %}<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>{% endif %}
|
||||
{% if item.enabled %}
|
||||
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
||||
{% else %}
|
||||
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
|
||||
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
|
||||
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{% with editor_id="text-" ~ item.token, editor_name="text_" ~ item.token, editor_value=item.text, editor_max=(max_caption if item.image_name else max_message), editor_rows=12 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
|
||||
<textarea
|
||||
name="buttons_{{ item.token }}"
|
||||
rows="3"
|
||||
placeholder="Канал | https://t.me/…"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
|
||||
>{{ item.buttons_text }}</textarea>
|
||||
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
|
||||
{% if item.image_name %}
|
||||
<div class="mt-1 flex items-center gap-3">
|
||||
<img src="/sequences/media/{{ item.image_name }}" alt="" class="h-20 w-20 rounded object-cover" />
|
||||
<label class="flex items-center gap-2 text-xs text-slate-600">
|
||||
<input type="checkbox" name="remove_image_{{ item.token }}" /> удалить
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<input type="file" name="image_{{ item.token }}" accept="image/jpeg,image/png,image/webp"
|
||||
class="mt-2 block w-full text-xs text-slate-600" />
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="enabled_{{ item.token }}" {% if item.enabled %}checked{% endif %} /> включено
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить всё
|
||||
</button>
|
||||
<span class="text-xs text-slate-500">Сохраняет параметры и все сообщения сразу.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="item-template">
|
||||
<section class="rounded-lg border border-slate-200 bg-white" data-token="__TOKEN__">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
|
||||
<div class="text-sm text-slate-600">
|
||||
<span class="font-medium" data-position>№</span>
|
||||
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
|
||||
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
|
||||
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{% with editor_id="text-__TOKEN__", editor_name="text___TOKEN__", editor_value="", editor_max=max_message, editor_rows=12 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
|
||||
<textarea
|
||||
name="buttons___TOKEN__"
|
||||
rows="3"
|
||||
placeholder="Канал | https://t.me/…"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
|
||||
<input type="file" name="image___TOKEN__" accept="image/jpeg,image/png,image/webp"
|
||||
class="mt-2 block w-full text-xs text-slate-600" />
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="enabled___TOKEN__" checked /> включено
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-lg font-medium">История изменений</h2>
|
||||
{% if history %}
|
||||
<ul class="mt-3 space-y-2">
|
||||
{% for change in history %}
|
||||
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
||||
<div class="flex items-center justify-between text-slate-500">
|
||||
<span>{{ change.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||
<span>редактор: {{ change.editor_id or "—" }}</span>
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-slate-600">снимок</summary>
|
||||
<pre class="mt-1 max-h-64 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ change.snapshot | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var items = document.getElementById("items");
|
||||
var orderInput = document.getElementById("item-order");
|
||||
var template = document.getElementById("item-template");
|
||||
var addButton = document.getElementById("add-item");
|
||||
var counter = 0;
|
||||
|
||||
function updateOrder() {
|
||||
var cards = items.querySelectorAll("[data-token]");
|
||||
var tokens = [];
|
||||
cards.forEach(function (card, index) {
|
||||
tokens.push(card.dataset.token);
|
||||
var position = card.querySelector("[data-position]");
|
||||
if (position) position.textContent = "№ " + (index + 1);
|
||||
});
|
||||
orderInput.value = tokens.join(",");
|
||||
}
|
||||
|
||||
addButton.addEventListener("click", function () {
|
||||
counter += 1;
|
||||
var token = "new_" + counter;
|
||||
var wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = template.innerHTML.replace(/__TOKEN__/g, token).trim();
|
||||
var card = wrapper.firstElementChild;
|
||||
items.appendChild(card);
|
||||
updateOrder();
|
||||
if (window.initEditor) window.initEditor(card);
|
||||
var textarea = card.querySelector("textarea");
|
||||
if (textarea) textarea.focus();
|
||||
});
|
||||
|
||||
items.addEventListener("click", function (event) {
|
||||
var button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
var card = button.closest("[data-token]");
|
||||
if (!card) return;
|
||||
var action = button.dataset.action;
|
||||
if (action === "delete") {
|
||||
if (confirm("Удалить сообщение?")) card.remove();
|
||||
} else if (action === "up" && card.previousElementSibling) {
|
||||
items.insertBefore(card, card.previousElementSibling);
|
||||
} else if (action === "down" && card.nextElementSibling) {
|
||||
items.insertBefore(card.nextElementSibling, card);
|
||||
}
|
||||
updateOrder();
|
||||
});
|
||||
|
||||
updateOrder();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Шаблоны сообщений{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Шаблоны сообщений</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Тексты бота: сообщения, картинки и кнопки.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
Изменения сохранены.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/sequences" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<h2 class="text-lg font-medium">Новый шаблон</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label for="key" class="text-xs text-slate-500">Ключ</label>
|
||||
<input id="key" name="key" value="{{ form.key }}" placeholder="rules"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="title" class="text-xs text-slate-500">Название</label>
|
||||
<input id="title" name="title" value="{{ form.title }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="text-xs text-slate-500">Описание</label>
|
||||
<input id="description" name="description" value="{{ form.description }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ключ</th>
|
||||
<th class="px-4 py-3 font-medium">Название</th>
|
||||
<th class="px-4 py-3 font-medium">Сообщений</th>
|
||||
<th class="px-4 py-3 font-medium">Изменён</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for sequence in sequences %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ sequence.title }}
|
||||
{% if sequence.description and sequence.description != sequence.title %}
|
||||
<div class="text-xs text-slate-500">{{ sequence.description }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ sequence.item_count }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ sequence.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Шаблонов пока нет.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ user.nick or user.id_user }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="text-sm text-slate-500"><a href="/users" class="hover:underline">← Пользователи</a></p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{{ user.nick or "Без ника" }}</h1>
|
||||
<p class="mt-1 text-slate-600">
|
||||
{% if user.username %}<span class="font-mono">@{{ user.username }}</span> · {% endif %}
|
||||
<span class="font-mono">{{ user.id_user }}</span>
|
||||
{% if user.first_name or user.last_name %}
|
||||
· {{ user.first_name or "" }} {{ user.last_name or "" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if user.title %}
|
||||
<span class="rounded bg-indigo-100 px-3 py-1 text-sm text-indigo-800">{{ user.title }}</span>
|
||||
{% endif %}
|
||||
{% if user.subscribed %}
|
||||
<span class="rounded bg-emerald-100 px-3 py-1 text-sm text-emerald-800">подписан на рассылки</span>
|
||||
{% else %}
|
||||
<span class="rounded bg-slate-100 px-3 py-1 text-sm text-slate-500">без рассылок</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{% if saved == 'nick' %}Ник обновлён.
|
||||
{% elif saved == 'title' %}Звание обновлено.
|
||||
{% elif saved == 'subscription' %}Подписка обновлена.
|
||||
{% elif saved == 'balance' %}Баланс скорректирован.
|
||||
{% else %}Изменения сохранены.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Уровень</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ user.lvl }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Опыт</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ user.expr }}</div>
|
||||
<div class="text-xs text-slate-500">серия: {{ user.expr_streak }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ user.balance }}</div>
|
||||
</div>
|
||||
{% if eggs_visible %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Яички</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ user.eggs }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Сообщения</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ user.msg_count }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
последняя активность: {{ user.last_message_at.strftime("%d.%m.%Y") if user.last_message_at else "—" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Очищения</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ user.purification_count }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
последнее: {{ user.last_purification_at.strftime("%d.%m.%Y %H:%M") if user.last_purification_at else "—" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">В клане с</div>
|
||||
<div class="mt-1 text-xl font-semibold">
|
||||
{{ user.dt_create.strftime("%d.%m.%Y") if user.dt_create else "—" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Telegram-ID</div>
|
||||
<div class="mt-1 font-mono text-sm">{{ user.id_user }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-medium">Управление</h2>
|
||||
<div class="mt-3 grid gap-4 lg:grid-cols-2">
|
||||
<form method="post" action="/users/{{ user.id_user }}/nick" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<label for="nick" class="text-sm font-medium text-slate-700">Ник</label>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input
|
||||
id="nick"
|
||||
name="nick"
|
||||
type="text"
|
||||
value="{{ user.nick or '' }}"
|
||||
maxlength="{{ max_nick_length }}"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/users/{{ user.id_user }}/title" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<label for="title_id" class="text-sm font-medium text-slate-700">Звание</label>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<select
|
||||
id="title_id"
|
||||
name="title_id"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
{% for title in titles %}
|
||||
<option value="{{ title.id_title }}" {% if title.id_title == user.id_title %}selected{% endif %}>
|
||||
{{ title.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/users/{{ user.id_user }}/subscription" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<input type="hidden" name="subscribed" value="{{ '0' if user.subscribed else '1' }}" />
|
||||
<div class="text-sm font-medium text-slate-700">Рассылки</div>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{% if user.subscribed %}Сейчас подписан.{% else %}Сейчас не подписан.{% endif %}
|
||||
</p>
|
||||
<button type="submit" class="mt-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
{% if user.subscribed %}Отписать{% else %}Подписать{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/users/{{ user.id_user }}/balance" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<div class="text-sm font-medium text-slate-700">Корректировка баланса</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="+50 или -20"
|
||||
class="w-32 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
type="text"
|
||||
maxlength="{{ max_comment_length }}"
|
||||
placeholder="Причина"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Провести
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">Запись уйдёт в журнал денег с действием «Корректировка админом».</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_kick %}
|
||||
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<h2 class="text-lg font-medium text-red-800">Исключить из чата</h2>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
Бот заблокирует пользователя и сразу разблокирует (чтобы мог вернуться),
|
||||
а данные из бота будут удалены.
|
||||
</p>
|
||||
<form
|
||||
method="post"
|
||||
action="/users/{{ user.id_user }}/kick"
|
||||
class="mt-3"
|
||||
onsubmit="return confirm('Исключить {{ user.nick or user.id_user }} из чата?');"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
Выгнать из чата
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-medium">История изменений</h2>
|
||||
{% if history %}
|
||||
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Когда</th>
|
||||
<th class="px-4 py-3 font-medium">Что</th>
|
||||
<th class="px-4 py-3 font-medium">Было → стало</th>
|
||||
<th class="px-4 py-3 font-medium">Комментарий</th>
|
||||
<th class="px-4 py-3 font-medium">Источник</th>
|
||||
<th class="px-4 py-3 font-medium">Админ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% set field_labels = {"nick": "Ник", "title": "Звание", "subscription": "Подписка", "balance": "Баланс"} %}
|
||||
{% for item in history %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-slate-600">{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
||||
<td class="px-4 py-3">{{ field_labels.get(item.field, item.field) }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ item.old_value or "—" }} → {{ item.new_value or "—" }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ item.comment or "—" }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{% if item.source == 'user' %}Пользователь{% else %}Админ{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-slate-500">Ручных изменений ещё не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
|
||||
{% if money %}
|
||||
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Когда</th>
|
||||
<th class="px-4 py-3 font-medium">Сумма</th>
|
||||
<th class="px-4 py-3 font-medium">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for item in money %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{{ item.dt_action.strftime("%d.%m.%Y %H:%M") if item.dt_action else "—" }}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium {% if item.amount < 0 %}text-red-600{% else %}text-emerald-700{% endif %}">
|
||||
{{ "%+d" | format(item.amount) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ item.action or "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-slate-500">Операций по балансу не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Пользователи{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Пользователи</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Всего: {{ total }}</p>
|
||||
</div>
|
||||
<form method="get" action="/users" class="flex items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ q }}"
|
||||
placeholder="Ник, @username, имя или Telegram-ID"
|
||||
class="w-80 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Найти
|
||||
</button>
|
||||
{% if q %}<a href="/users" class="text-sm text-slate-600 hover:underline">Сбросить</a>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if saved == 'kick' %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
Пользователь исключён из чата, данные удалены.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ник</th>
|
||||
<th class="px-4 py-3 font-medium">Telegram</th>
|
||||
<th class="hidden px-4 py-3 font-medium sm:table-cell">Уровень</th>
|
||||
<th class="px-4 py-3 font-medium">Баланс</th>
|
||||
<th class="hidden px-4 py-3 font-medium md:table-cell">Сообщения</th>
|
||||
<th class="px-4 py-3 font-medium">Подписка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for user in users %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/users/{{ user.id_user }}" class="font-medium text-sky-700 hover:underline">
|
||||
{{ user.nick or "—" }}
|
||||
</a>
|
||||
<div class="text-xs text-slate-500">{{ user.title or "" }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if user.username %}<div>@{{ user.username }}</div>{% endif %}
|
||||
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 sm:table-cell">{{ user.lvl }}</td>
|
||||
<td class="px-4 py-3">{{ user.balance }}</td>
|
||||
<td class="hidden px-4 py-3 md:table-cell">{{ user.msg_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if user.subscribed %}
|
||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>
|
||||
{% else %}
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
||||
{% if q %}Никого не нашли по запросу «{{ q }}».{% else %}Пользователей пока нет.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<div>Страница {{ page }} из {{ pages }}</div>
|
||||
<div class="flex gap-3">
|
||||
{% if page > 1 %}
|
||||
<a href="/users?q={{ q | urlencode }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
|
||||
{% endif %}
|
||||
{% if page < pages %}
|
||||
<a href="/users?q={{ q | urlencode }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -6,13 +6,11 @@ import asyncio
|
|||
import logging
|
||||
|
||||
from bot.bot_factory import create_bot, create_dispatcher
|
||||
from bot.commands_menu import ensure_commands
|
||||
from bot.services.heartbeat import run_heartbeat
|
||||
from bot.services.notifications import run_notifications
|
||||
from bot.services.runtime import uptime_seconds
|
||||
from common import __version__
|
||||
from common.analytics import EventType, close_analytics, init_analytics, record
|
||||
from common.analytics.partitions import ensure_partitions, run_partitions
|
||||
from common.config import get_settings
|
||||
from common.db.pool import close_pool, init_pool
|
||||
from common.logging_config import setup_logging
|
||||
|
|
@ -27,34 +25,20 @@ async def main() -> None:
|
|||
|
||||
await init_pool()
|
||||
await init_analytics()
|
||||
try:
|
||||
await ensure_partitions(settings)
|
||||
except Exception:
|
||||
logger.warning("Не удалось создать партиции аналитики", exc_info=True)
|
||||
record(EventType.BOT_START, payload={"app_env": settings.app_env})
|
||||
|
||||
bot = create_bot()
|
||||
dp = create_dispatcher()
|
||||
|
||||
# Сверяем меню команд при каждом старте (Telegram разрешает только ASCII-имена).
|
||||
await ensure_commands(bot)
|
||||
|
||||
notifications_task = asyncio.create_task(run_notifications(bot))
|
||||
heartbeat_task = asyncio.create_task(run_heartbeat(bot))
|
||||
partitions_task = asyncio.create_task(run_partitions(settings))
|
||||
try:
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||
finally:
|
||||
notifications_task.cancel()
|
||||
heartbeat_task.cancel()
|
||||
partitions_task.cancel()
|
||||
await asyncio.gather(
|
||||
notifications_task,
|
||||
heartbeat_task,
|
||||
partitions_task,
|
||||
return_exceptions=True,
|
||||
)
|
||||
await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True)
|
||||
record(EventType.BOT_STOP, payload={"app_env": settings.app_env, "uptime_seconds": uptime_seconds()})
|
||||
await close_analytics()
|
||||
await close_pool()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def create_dispatcher() -> Dispatcher:
|
|||
Порядок важен: сначала события участников и команды, затем магазин,
|
||||
и в конце — экономика (в ней есть catch-all обработчик сообщений).
|
||||
"""
|
||||
from bot.handlers import commands, economy, login, member_events, shop
|
||||
from bot.handlers import commands, economy, member_events, shop
|
||||
from bot.handlers.errors import register_errors
|
||||
from bot.middlewares import AnalyticsMiddleware
|
||||
|
||||
|
|
@ -33,7 +33,6 @@ def create_dispatcher() -> Dispatcher:
|
|||
# Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`).
|
||||
dp.message.outer_middleware(AnalyticsMiddleware())
|
||||
dp.include_router(member_events.router)
|
||||
dp.include_router(login.router)
|
||||
dp.include_router(commands.router)
|
||||
dp.include_router(shop.router)
|
||||
dp.include_router(economy.router)
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
"""Меню команд бота: латинские имена + русские описания.
|
||||
|
||||
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,15 @@ from aiogram.types import Message
|
|||
|
||||
from bot.services import access, stats, subscriptions
|
||||
from bot.services import users as users_service
|
||||
from bot.services.sequences import send_template
|
||||
from common.config import get_settings
|
||||
from common.eggs import is_egg_event_active
|
||||
from common.texts import system
|
||||
from common.texts.render import escape, format_tops, format_user_list, render
|
||||
from common.texts import economy, info, system
|
||||
from common.texts.render import format_tops, format_user_list, render
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router(name="commands")
|
||||
|
||||
|
||||
@router.message(Command("ник", "nick", ignore_case=True))
|
||||
@router.message(Command("ник", ignore_case=True))
|
||||
async def set_nick_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
@ -41,7 +39,7 @@ async def set_nick_handler(message: Message) -> None:
|
|||
await message.reply(system.MSG_NICK_ERROR)
|
||||
|
||||
|
||||
@router.message(Command("список", "list", ignore_case=True))
|
||||
@router.message(Command("список", ignore_case=True))
|
||||
async def list_nicks_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
@ -63,10 +61,10 @@ async def list_nicks_handler(message: Message) -> None:
|
|||
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
|
||||
"users_list_count": len(nicks),
|
||||
}
|
||||
await send_template(message.bot, message.chat.id, "economy.nick_list", context)
|
||||
await message.answer(render(economy.MSG_NICK_LIST, context))
|
||||
|
||||
|
||||
@router.message(Command("кто", "who", ignore_case=True))
|
||||
@router.message(Command("кто", ignore_case=True))
|
||||
async def get_nick_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
@ -81,14 +79,14 @@ async def get_nick_handler(message: Message) -> None:
|
|||
await message.reply(user_info.nick)
|
||||
|
||||
|
||||
@router.message(Command("помощь", "help", ignore_case=True))
|
||||
@router.message(Command("помощь", ignore_case=True))
|
||||
async def help_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
await send_template(message.bot, message.chat.id, "info.help")
|
||||
await message.answer(info.MSG_HELP)
|
||||
|
||||
|
||||
@router.message(Command("инфо", "info", ignore_case=True))
|
||||
@router.message(Command("инфо", ignore_case=True))
|
||||
async def info_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
@ -109,23 +107,20 @@ async def info_handler(message: Message) -> None:
|
|||
"dt": user_info.dt,
|
||||
"msg_count": user_info.msg_count,
|
||||
"eggs": user_info.eggs,
|
||||
"eggs_line": (
|
||||
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
|
||||
),
|
||||
}
|
||||
await send_template(message.bot, message.chat.id, "info.profile", context)
|
||||
await message.answer(render(info.MSG_INFO, context))
|
||||
|
||||
|
||||
@router.message(Command("рейтинг", "top", ignore_case=True))
|
||||
@router.message(Command("рейтинг", ignore_case=True))
|
||||
async def top_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
tops = await stats.top_users()
|
||||
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
|
||||
await send_template(message.bot, message.chat.id, "economy.top", context)
|
||||
await message.answer(render(economy.MSG_TOP, context))
|
||||
|
||||
|
||||
@router.message(Command("отписка", "unsub", ignore_case=True))
|
||||
@router.message(Command("отписка", ignore_case=True))
|
||||
async def unsubscribe_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
@ -133,7 +128,7 @@ async def unsubscribe_handler(message: Message) -> None:
|
|||
await message.reply(system.MSG_UNSUBSCRIBED)
|
||||
|
||||
|
||||
@router.message(Command("подписка", "sub", ignore_case=True))
|
||||
@router.message(Command("подписка", ignore_case=True))
|
||||
async def subscribe_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
|
|
@ -11,18 +11,21 @@ from aiogram.types import Message
|
|||
|
||||
from bot.services import access, alerts
|
||||
from bot.services import economy as economy_service
|
||||
from bot.services.sequences import send_template
|
||||
from common.config import get_settings
|
||||
from common.eggs import egg_event_period, is_egg_event_active
|
||||
from common.texts import system
|
||||
from common.texts import economy, system
|
||||
from common.texts.render import render
|
||||
|
||||
router = Router(name="economy")
|
||||
|
||||
#: Окно сезонного события «яички».
|
||||
EGG_EVENT_START = date(2025, 4, 1)
|
||||
EGG_EVENT_END = date(2025, 4, 20)
|
||||
|
||||
#: Шанс выпадения монетки на сообщение.
|
||||
COIN_CHANCE = 27 # из 100
|
||||
|
||||
|
||||
@router.message(Command("очищение", "purify", ignore_case=True))
|
||||
@router.message(Command("очищение", ignore_case=True))
|
||||
async def purification_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
|
|
@ -30,10 +33,10 @@ async def purification_handler(message: Message) -> None:
|
|||
result = await economy_service.purify(message.from_user.id)
|
||||
if not result.success:
|
||||
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
||||
await send_template(message.bot, message.chat.id, "economy.no_purification", context)
|
||||
await message.answer(render(economy.MSG_NO_PURIFICATION, context))
|
||||
else:
|
||||
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
||||
await send_template(message.bot, message.chat.id, "economy.purification", context)
|
||||
await message.answer(render(economy.MSG_PURIFICATION, context))
|
||||
await alerts.notify_purification(
|
||||
message.bot,
|
||||
username=message.from_user.username,
|
||||
|
|
@ -42,13 +45,11 @@ async def purification_handler(message: Message) -> None:
|
|||
)
|
||||
|
||||
|
||||
@router.message(Command("яички", "eggs", ignore_case=True))
|
||||
@router.message(Command("яички", ignore_case=True))
|
||||
async def eggs_handler(message: Message) -> None:
|
||||
settings = get_settings()
|
||||
if not is_egg_event_active(settings):
|
||||
today = datetime.now(settings.tzinfo).date()
|
||||
start, _ = egg_event_period(settings, today)
|
||||
await message.answer(system.MSG_EGGS_TOO_EARLY if today < start else system.MSG_EGGS_EVENT_OVER)
|
||||
today = datetime.now(get_settings().tzinfo).date()
|
||||
if not (EGG_EVENT_START <= today <= EGG_EVENT_END):
|
||||
await message.answer(system.MSG_EGGS_TOO_EARLY)
|
||||
return
|
||||
|
||||
if not await access.check_user(message):
|
||||
|
|
@ -57,9 +58,9 @@ async def eggs_handler(message: Message) -> None:
|
|||
result = await economy_service.collect_eggs(message.from_user.id)
|
||||
if not result.success:
|
||||
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
||||
await send_template(message.bot, message.chat.id, "economy.no_eggs", context)
|
||||
await message.answer(render(economy.MSG_NO_EGGS, context))
|
||||
else:
|
||||
await send_template(message.bot, message.chat.id, "economy.eggs", {"eggs": result.eggs})
|
||||
await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs}))
|
||||
|
||||
|
||||
@router.message()
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
"""Вход в админку через 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)
|
||||
|
|
@ -5,19 +5,16 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
|
||||
from aiogram.types import ChatMemberUpdated, FSInputFile, User
|
||||
from aiogram.types import ChatMemberUpdated, FSInputFile
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.services import alerts
|
||||
from bot.services import users as users_service
|
||||
from bot.services.sequences import send_sequence
|
||||
from common.analytics import EventType, record
|
||||
from common.config import get_settings
|
||||
from common.texts import system, welcome
|
||||
from common.texts.render import render
|
||||
from common.texts.store import get_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router(name="member_events")
|
||||
|
|
@ -51,96 +48,42 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
|
|||
chat_title=event.chat.title or "",
|
||||
)
|
||||
|
||||
if await send_sequence(event.bot, event.chat.id, "welcome", {"username": user.username}):
|
||||
return
|
||||
|
||||
await _send_fallback_welcome(event, user)
|
||||
|
||||
|
||||
async def _send_fallback_welcome(event: ChatMemberUpdated, user: User) -> None:
|
||||
"""Приветствие по старым шаблонам, если последовательность не настроена."""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL)
|
||||
markup = builder.as_markup()
|
||||
|
||||
caption, body = welcome.split_welcome(
|
||||
await get_text("welcome.text"),
|
||||
await get_text("welcome.text2"),
|
||||
await get_text("welcome.text3"),
|
||||
await event.bot.send_photo(
|
||||
chat_id=event.chat.id,
|
||||
photo=FSInputFile("images/welcome_image.jpg"),
|
||||
caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}),
|
||||
)
|
||||
caption = render(caption, {"username": user.username})
|
||||
body = render(body, {"username": user.username})
|
||||
|
||||
if len(caption) > 1024:
|
||||
body = caption + (f"\n\n{body}" if body else "")
|
||||
caption = ""
|
||||
|
||||
if body:
|
||||
await event.bot.send_photo(
|
||||
chat_id=event.chat.id,
|
||||
photo=FSInputFile("images/welcome_image.jpg"),
|
||||
caption=caption or None,
|
||||
)
|
||||
await event.bot.send_message(
|
||||
chat_id=event.chat.id,
|
||||
text=body,
|
||||
reply_markup=markup,
|
||||
)
|
||||
else:
|
||||
await event.bot.send_photo(
|
||||
chat_id=event.chat.id,
|
||||
photo=FSInputFile("images/welcome_image.jpg"),
|
||||
caption=caption or None,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await event.bot.send_message(
|
||||
chat_id=event.chat.id,
|
||||
text=welcome.MSG_WELCOME_TEXT2,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3)
|
||||
|
||||
|
||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||
async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
||||
"""Пользователь вышел или был исключён — удалить данные именно его."""
|
||||
if event.chat.id != get_settings().chat_id:
|
||||
"""Пользователь вышел из чата — удалить запись."""
|
||||
user = event.from_user
|
||||
if user is None or user.is_bot:
|
||||
return
|
||||
|
||||
leaver = event.old_chat_member.user
|
||||
if leaver.is_bot:
|
||||
return
|
||||
|
||||
actor = event.from_user
|
||||
kicked = event.new_chat_member.status == ChatMemberStatus.KICKED
|
||||
by_self = actor is None or actor.id == leaver.id
|
||||
|
||||
record(
|
||||
EventType.LEAVE,
|
||||
user_id=leaver.id,
|
||||
username=leaver.username,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
chat_id=event.chat.id,
|
||||
payload={
|
||||
"chat_title": event.chat.title or "",
|
||||
"kicked": kicked,
|
||||
"by_id": actor.id if actor and not by_self else 0,
|
||||
"by_username": actor.username if actor and not by_self else "",
|
||||
},
|
||||
payload={"chat_title": event.chat.title or ""},
|
||||
)
|
||||
await users_service.delete_user(leaver.id)
|
||||
logger.info("Удалён пользователь %s после выхода из чата", leaver.id)
|
||||
await users_service.delete_user(user.id)
|
||||
logger.info("Удалён пользователь %s после выхода из чата", user.id)
|
||||
|
||||
# Исключение самим ботом (кнопка в админке): уведомление уже отправила админка.
|
||||
if kicked and actor is not None and actor.id == event.bot.id:
|
||||
return
|
||||
|
||||
if kicked and not by_self:
|
||||
await alerts.notify_kick(
|
||||
event.bot,
|
||||
actor_username=actor.username if actor else None,
|
||||
actor_id=actor.id if actor else 0,
|
||||
username=leaver.username,
|
||||
user_id=leaver.id,
|
||||
chat_title=event.chat.title or "",
|
||||
)
|
||||
else:
|
||||
await alerts.notify_leave(
|
||||
event.bot,
|
||||
username=leaver.username,
|
||||
user_id=leaver.id,
|
||||
chat_title=event.chat.title or "",
|
||||
)
|
||||
await alerts.notify_leave(
|
||||
event.bot,
|
||||
username=user.username,
|
||||
user_id=user.id,
|
||||
chat_title=event.chat.title or "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ from aiogram.types import CallbackQuery, Message
|
|||
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
||||
from bot.services import access
|
||||
from bot.services import shop as shop_service
|
||||
from bot.services.sequences import send_template, template_text
|
||||
from common.config import get_settings
|
||||
from common.texts import shop as shop_texts
|
||||
from common.texts import system
|
||||
from common.texts.render import render
|
||||
|
||||
|
|
@ -20,37 +19,28 @@ logger = logging.getLogger(__name__)
|
|||
router = Router(name="shop")
|
||||
|
||||
|
||||
@router.message(Command("магазин", "shop", ignore_case=True))
|
||||
@router.message(Command("магазин", ignore_case=True))
|
||||
async def shop_handler(message: Message) -> None:
|
||||
if not await access.check_user(message):
|
||||
return
|
||||
if message.chat.type != "private":
|
||||
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
|
||||
return
|
||||
await send_template(message.bot, message.chat.id, "shop.main", reply_markup=get_keyboard_main())
|
||||
await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main())
|
||||
|
||||
|
||||
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
|
||||
if chapter == "main":
|
||||
return await template_text("shop.main"), get_keyboard_main()
|
||||
return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
|
||||
if chapter == "title":
|
||||
return await template_text("shop.title"), await get_keyboard_title()
|
||||
return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title()
|
||||
if chapter == "donat":
|
||||
return await template_text("shop.donat"), await get_keyboard_donat()
|
||||
return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat()
|
||||
return None
|
||||
|
||||
|
||||
@router.callback_query()
|
||||
async def callbacks_handler(callback: CallbackQuery) -> None:
|
||||
settings = get_settings()
|
||||
if (
|
||||
callback.message is not None
|
||||
and callback.message.chat.type != "private"
|
||||
and callback.message.chat.id != settings.chat_id
|
||||
):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
answer_text = ""
|
||||
args = (callback.data or "").split("_")
|
||||
action = args[0] if args else ""
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from typing import Any
|
|||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, TelegramObject
|
||||
|
||||
from common.analytics import EventType, record, record_message, remember_user
|
||||
from common.analytics.messages import describe_command
|
||||
from common.analytics import EventType, record, remember_user
|
||||
from common.analytics.messages import describe_command, describe_message
|
||||
from common.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -43,7 +43,13 @@ class AnalyticsMiddleware(BaseMiddleware):
|
|||
|
||||
try:
|
||||
remember_user(user.id, user.username)
|
||||
record_message(user_id=user.id, username=user.username, chat_id=message.chat.id)
|
||||
record(
|
||||
EventType.MESSAGE,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
chat_id=message.chat.id,
|
||||
payload=describe_message(message),
|
||||
)
|
||||
command = describe_command(message)
|
||||
if command is not None:
|
||||
record(
|
||||
|
|
|
|||
|
|
@ -25,12 +25,6 @@ _ALLOWED_STATUSES = {
|
|||
ChatMemberStatus.RESTRICTED,
|
||||
}
|
||||
|
||||
|
||||
def is_allowed_chat(chat_id: int, chat_type: str, main_chat_id: int) -> bool:
|
||||
"""Личные чаты и основной чат; чужие группы/каналы игнорируются полностью."""
|
||||
return chat_type == "private" or chat_id == main_chat_id
|
||||
|
||||
|
||||
#: TTL-кеш членства, чтобы не дёргать get_chat_member на каждое сообщение.
|
||||
_MEMBERSHIP_TTL = 300.0
|
||||
_membership_cache: dict[int, tuple[float, bool]] = {}
|
||||
|
|
@ -56,10 +50,6 @@ async def check_user(message: Message) -> bool:
|
|||
if user is None:
|
||||
return False
|
||||
|
||||
# Сообщения из посторонних групп не обрабатываем и не шлём по ним уведомления.
|
||||
if not is_allowed_chat(message.chat.id, message.chat.type, settings.chat_id):
|
||||
return False
|
||||
|
||||
if user.id in settings.admin_ids or await _is_member(message.bot, user.id):
|
||||
await users_service.register_from_message(message)
|
||||
remember_user(user.id, user.username)
|
||||
|
|
|
|||
|
|
@ -64,24 +64,6 @@ async def notify_leave(bot: Bot, *, username: str | None, user_id: int, chat_tit
|
|||
return await notify_alert(bot, render(alerts_text.MSG_LEAVE, context))
|
||||
|
||||
|
||||
async def notify_kick(
|
||||
bot: Bot,
|
||||
*,
|
||||
actor_username: str | None,
|
||||
actor_id: int,
|
||||
username: str | None,
|
||||
user_id: int,
|
||||
chat_title: str,
|
||||
) -> bool:
|
||||
"""Уведомление об исключении участника: кто и кого выгнал."""
|
||||
context = {
|
||||
"actor": user_tag(actor_username, actor_id),
|
||||
"username": user_tag(username, user_id),
|
||||
"chat_title": chat_title,
|
||||
}
|
||||
return await notify_alert(bot, render(alerts_text.MSG_KICK, context))
|
||||
|
||||
|
||||
async def notify_purification(bot: Bot, *, username: str | None, user_id: int, result: PurificationResult) -> bool:
|
||||
"""Уведомление об успешном очищении."""
|
||||
context = {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
|
||||
|
||||
Раз в час в чат логов (``ALERT_CHAT_ID``) уходит короткое сообщение о работе бота,
|
||||
и в аналитику пишется событие ``bot_alive``.
|
||||
и в ClickHouse пишется событие ``bot_alive``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,131 +1,70 @@
|
|||
"""Планировщик рассылок.
|
||||
|
||||
Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке).
|
||||
Если база недоступна, используется расписание по умолчанию из кода и последний
|
||||
успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через
|
||||
``common.texts.store`` (TTL-кеш, правки без перезапуска).
|
||||
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
|
||||
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
|
||||
редактироваться через админку.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from bot.services import users as users_service
|
||||
from bot.services.sequences import send_template
|
||||
from common.config import get_settings
|
||||
from common.repositories import schedules_repo
|
||||
from common.texts import format_usernames
|
||||
from common.texts import format_usernames, render
|
||||
from common.texts import notifications as notify_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Размер порции упоминаний в одном сообщении.
|
||||
BATCH_SIZE = 10
|
||||
|
||||
#: Как часто писать в лог, если БД с расписаниями недоступна (секунды).
|
||||
WARNING_INTERVAL = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScheduledMessage:
|
||||
"""Одна запланированная рассылка из БД (или из значений по умолчанию).
|
||||
"""Одна запланированная рассылка.
|
||||
|
||||
:param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно.
|
||||
:param timezone: таймзона расписания (IANA), время в ней.
|
||||
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно.
|
||||
"""
|
||||
|
||||
hour: int
|
||||
minute: int
|
||||
template_key: str
|
||||
weekdays: tuple[int, ...] = ()
|
||||
timezone: str = "Europe/Moscow"
|
||||
schedule_id: int | None = None
|
||||
template: str
|
||||
weekdays: tuple[int, ...] | None = field(default=None)
|
||||
|
||||
def matches(self, now: datetime) -> bool:
|
||||
"""Совпадает ли момент ``now`` (aware) с этим расписанием."""
|
||||
local = now.astimezone(ZoneInfo(self.timezone))
|
||||
if self.hour != local.hour or self.minute != local.minute:
|
||||
if self.hour != now.hour or self.minute != now.minute:
|
||||
return False
|
||||
return not self.weekdays or local.isoweekday() in self.weekdays
|
||||
return self.weekdays is None or now.weekday() in self.weekdays
|
||||
|
||||
|
||||
#: Расписание по умолчанию — на случай недоступной БД (совпадает с V16-сидом).
|
||||
DEFAULT_SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||||
ScheduledMessage(16, 30, "notify.blago_30"),
|
||||
ScheduledMessage(16, 45, "notify.blago_45"),
|
||||
ScheduledMessage(16, 55, "notify.blago_55"),
|
||||
ScheduledMessage(17, 0, "notify.blago"),
|
||||
ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница
|
||||
ScheduledMessage(14, 0, "notify.vklad", weekdays=(7,)), # воскресенье
|
||||
SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||||
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
|
||||
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
|
||||
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
|
||||
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
|
||||
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
|
||||
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
|
||||
)
|
||||
|
||||
#: Последний успешно прочитанный набор — чтобы пережить временный сбой БД.
|
||||
_last_good: tuple[ScheduledMessage, ...] | None = None
|
||||
_last_warning_at = 0.0
|
||||
|
||||
|
||||
def _from_row(row: dict) -> ScheduledMessage:
|
||||
return ScheduledMessage(
|
||||
hour=int(row["hour"]),
|
||||
minute=int(row["minute"]),
|
||||
template_key=str(row["template_key"]),
|
||||
weekdays=tuple(int(day) for day in (row["weekdays"] or ())),
|
||||
timezone=str(row["timezone"] or "Europe/Moscow"),
|
||||
schedule_id=int(row["id"]) if row.get("id") is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def _load_schedules() -> tuple[ScheduledMessage, ...]:
|
||||
"""Прочитать активные расписания; при сбое — последние известные или дефолт."""
|
||||
global _last_good, _last_warning_at
|
||||
|
||||
try:
|
||||
rows = await schedules_repo.list_active()
|
||||
except Exception:
|
||||
now = time.monotonic()
|
||||
if now - _last_warning_at > WARNING_INTERVAL:
|
||||
logger.warning(
|
||||
"Не удалось прочитать расписания из БД — использую последние известные",
|
||||
exc_info=True,
|
||||
)
|
||||
_last_warning_at = now
|
||||
return _last_good if _last_good is not None else DEFAULT_SCHEDULES
|
||||
|
||||
_last_good = tuple(_from_row(row) for row in rows)
|
||||
return _last_good
|
||||
|
||||
|
||||
async def _send_broadcast(bot: Bot, template_key: str, subscribers: list[dict]) -> None:
|
||||
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
|
||||
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
|
||||
for chunk in chunks:
|
||||
await send_template(
|
||||
bot,
|
||||
get_settings().chat_id,
|
||||
template_key,
|
||||
{"usernames": format_usernames(chunk)},
|
||||
)
|
||||
text = render(template, {"usernames": format_usernames(chunk)})
|
||||
await bot.send_message(chat_id=get_settings().chat_id, text=text)
|
||||
|
||||
|
||||
async def _tick(bot: Bot, now: datetime) -> None:
|
||||
for schedule in await _load_schedules():
|
||||
if not schedule.matches(now):
|
||||
continue
|
||||
|
||||
# Защита от повторной отправки после перезапуска в ту же минуту.
|
||||
if schedule.schedule_id is not None:
|
||||
run_at = now.astimezone(UTC).replace(second=0, microsecond=0)
|
||||
if not await schedules_repo.claim_run(schedule.schedule_id, run_at):
|
||||
logger.info("Рассылка %s за %s уже отправлена — пропускаю", schedule.schedule_id, run_at)
|
||||
continue
|
||||
|
||||
subscribers = await users_service.list_subscribers()
|
||||
await _send_broadcast(bot, schedule.template_key, subscribers)
|
||||
for schedule in SCHEDULES:
|
||||
if schedule.matches(now):
|
||||
subscribers = await users_service.list_subscribers()
|
||||
await _send_broadcast(bot, schedule.template, subscribers)
|
||||
|
||||
|
||||
async def run_notifications(bot: Bot) -> None:
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
"""Отправка последовательностей сообщений (Блок 4).
|
||||
|
||||
Последовательность читается из БД; каждое сообщение отправляется по порядку,
|
||||
с картинкой из общего media-volume и своими inline-кнопками.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from common.config import get_settings
|
||||
from common.repositories import sequences_repo
|
||||
from common.texts.render import render
|
||||
from common.texts.store import get_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Лимит подписи к фото в Telegram.
|
||||
CAPTION_LIMIT = 1024
|
||||
|
||||
|
||||
def _markup(buttons: list[dict[str, Any]]) -> InlineKeyboardMarkup | None:
|
||||
rows = [[InlineKeyboardButton(text=str(button["text"]), url=str(button["url"]))] for button in buttons]
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows) if rows else None
|
||||
|
||||
|
||||
def _image_path(item: dict[str, Any]) -> Path | None:
|
||||
name = item.get("image_name")
|
||||
if not name:
|
||||
return None
|
||||
|
||||
filename = Path(str(name)).name
|
||||
candidates = [
|
||||
Path(get_settings().media_dir) / filename,
|
||||
Path("images") / filename,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
logger.warning("Картинка %s для последовательности не найдена", filename)
|
||||
return None
|
||||
|
||||
|
||||
async def send_sequence(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
key: str,
|
||||
context: dict[str, Any],
|
||||
*,
|
||||
reply_markup: Any = None,
|
||||
) -> bool:
|
||||
"""Отправить последовательность. False — если она не настроена/пуста."""
|
||||
try:
|
||||
items = await sequences_repo.get_items(key)
|
||||
except Exception:
|
||||
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
|
||||
return False
|
||||
|
||||
enabled = [item for item in items if item.get("enabled")]
|
||||
if not enabled:
|
||||
return False
|
||||
|
||||
for index, item in enumerate(enabled):
|
||||
text = render(str(item.get("text") or ""), context) if item.get("text") else ""
|
||||
path = _image_path(item)
|
||||
is_last = index == len(enabled) - 1
|
||||
markup = reply_markup if is_last and reply_markup is not None else _markup(item.get("buttons") or [])
|
||||
|
||||
if path is not None:
|
||||
if text and len(text) > CAPTION_LIMIT:
|
||||
await bot.send_photo(chat_id=chat_id, photo=FSInputFile(path))
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
|
||||
else:
|
||||
await bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=FSInputFile(path),
|
||||
caption=text or None,
|
||||
reply_markup=markup,
|
||||
)
|
||||
elif text:
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def template_text(key: str, context: dict[str, Any] | None = None) -> str:
|
||||
"""Текст первого включённого сообщения последовательности (для edit_message_text)."""
|
||||
try:
|
||||
items = await sequences_repo.get_items(key)
|
||||
except Exception:
|
||||
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
|
||||
items = []
|
||||
|
||||
for item in items:
|
||||
if item.get("enabled") and item.get("text"):
|
||||
return render(str(item["text"]), context or {})
|
||||
return render(await get_text(key), context or {})
|
||||
|
||||
|
||||
async def send_template(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
key: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
*,
|
||||
reply_markup: Any = None,
|
||||
) -> bool:
|
||||
"""Отправить последовательность, а при её отсутствии — старый шаблон из кода."""
|
||||
if await send_sequence(bot, chat_id, key, context or {}, reply_markup=reply_markup):
|
||||
return True
|
||||
|
||||
text = render(await get_text(key), context or {})
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||||
return True
|
||||
|
|
@ -43,7 +43,7 @@ async def register_from_event(event: ChatMemberUpdated) -> None:
|
|||
|
||||
|
||||
async def set_nick(id_user: int, nick: str) -> None:
|
||||
await users_repo.set_nick_with_history(id_user, nick, editor_id=id_user, source="user")
|
||||
await users_repo.add_nick(id_user, nick)
|
||||
record(EventType.NICK_SET, user_id=id_user, payload={"nick": nick})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
-- Схема аналитики 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 @@
|
|||
"""Аналитика: клиент Postgres и каталог событий.
|
||||
"""Аналитика: клиент ClickHouse и каталог событий.
|
||||
|
||||
Публичный интерфейс:
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ from common.analytics.client import (
|
|||
get_analytics,
|
||||
init_analytics,
|
||||
record,
|
||||
record_message,
|
||||
remember_user,
|
||||
)
|
||||
from common.analytics.events import ALL_EVENT_TYPES, EVENT_COLUMNS, EventType
|
||||
|
|
@ -30,6 +29,5 @@ __all__ = [
|
|||
"get_analytics",
|
||||
"init_analytics",
|
||||
"record",
|
||||
"record_message",
|
||||
"remember_user",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"""Аналитика в Postgres: батч-запись событий и дневных счётчиков сообщений.
|
||||
"""Клиент ClickHouse: батч-запись событий без блокировки бота.
|
||||
|
||||
Свойства:
|
||||
- запись неблокирующая: ``record``/``record_message`` только кладут данные в буфер;
|
||||
- батчинг по размеру/таймеру;
|
||||
- сбой Postgres не роняет бота: батч уходит в лог (fallback) и отбрасывается;
|
||||
- сообщения не пишутся поштучно: инкрементится ``analytics.messages_daily``.
|
||||
- запись неблокирующая: :meth:`AnalyticsClient.record` только кладёт строку в буфер;
|
||||
- батчинг: отправка по размеру буфера и по таймеру;
|
||||
- недоступность ClickHouse **не роняет бота**: события попадают в stdout (fallback);
|
||||
- graceful-flush при остановке (:func:`close_analytics`).
|
||||
|
||||
Импорт ``clickhouse_connect`` ленивый (внутри :meth:`AnalyticsClient._ensure_client`),
|
||||
поэтому пакет не является обязательным для импорта модуля (нужно для тестов/админки).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -14,15 +17,11 @@ import contextlib
|
|||
import json
|
||||
import logging
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from typing import Any, Protocol
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from common.analytics.events import EventType
|
||||
from common.analytics.events import EVENT_COLUMNS, EventType
|
||||
from common.config import Settings, get_settings
|
||||
from common.db.pool import get_pool
|
||||
from common.version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -36,90 +35,90 @@ USERNAME_CACHE_MAX = 5000
|
|||
#: Сколько строк события выводить в лог при fallback.
|
||||
FALLBACK_SAMPLE = 5
|
||||
|
||||
#: Строка события: (time, type, user_id, username, chat_id, payload, version, prod).
|
||||
EventRow = list[Any]
|
||||
#: (day, user_id, chat_id) -> (username, count)
|
||||
MessageCounts = dict[tuple[Any, int, int], list[Any]]
|
||||
|
||||
#: Куда сбрасывать батч (подменяется в тестах).
|
||||
Writer = Callable[[list[EventRow], MessageCounts], Awaitable[None]]
|
||||
class ClickHouseClient(Protocol):
|
||||
"""Минимальный контракт клиента ClickHouse (используется и для подмены в тестах)."""
|
||||
|
||||
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> Any:
|
||||
"""Вставить строки в таблицу."""
|
||||
|
||||
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),
|
||||
)
|
||||
async def close(self) -> None:
|
||||
"""Закрыть соединения."""
|
||||
|
||||
|
||||
class AnalyticsClient:
|
||||
"""Батч-запись событий и счётчиков в Postgres с деградацией в лог."""
|
||||
"""Батч-запись событий в ClickHouse с деградацией в лог."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings | None = None,
|
||||
*,
|
||||
writer: Writer | None = None,
|
||||
) -> None:
|
||||
def __init__(self, settings: Settings | None = None, *, client: ClickHouseClient | None = None) -> None:
|
||||
self._settings = settings or get_settings()
|
||||
self._writer = writer or _write_db
|
||||
self._rows: deque[EventRow] = deque()
|
||||
self._messages: MessageCounts = {}
|
||||
self._client = client
|
||||
self._rows: deque[list[Any]] = deque()
|
||||
self._flush_now = asyncio.Event()
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._usernames: dict[int, str] = {}
|
||||
self._unavailable_logged = False
|
||||
self._overflow = 0
|
||||
self._table = f"{self._settings.clickhouse_db}.events"
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._settings.analytics_enabled
|
||||
"""Включена ли аналитика (``CLICKHOUSE_ENABLED``)."""
|
||||
return self._settings.clickhouse_enabled
|
||||
|
||||
@property
|
||||
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:
|
||||
"""Подключиться к ClickHouse (best-effort) и запустить фоновый сброс буфера."""
|
||||
if not self.enabled:
|
||||
logger.info("Аналитика выключена (ANALYTICS_ENABLED=false)")
|
||||
logger.info("Аналитика ClickHouse выключена (CLICKHOUSE_ENABLED=false)")
|
||||
return
|
||||
|
||||
await self._ensure_client()
|
||||
self._task = asyncio.create_task(self._run(), name="analytics-flush")
|
||||
logger.info(
|
||||
"Аналитика Postgres: батч %s, интервал %sс",
|
||||
"Аналитика ClickHouse: %s:%s/%s (батч %s, интервал %sс)",
|
||||
self._settings.clickhouse_host,
|
||||
self._settings.clickhouse_port,
|
||||
self._settings.clickhouse_db,
|
||||
self._settings.analytics_batch_size,
|
||||
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:
|
||||
"""Запомнить связку ``id_user -> username`` для обогащения будущих событий."""
|
||||
if not user_id or not username:
|
||||
return
|
||||
if user_id not in self._usernames and len(self._usernames) >= USERNAME_CACHE_MAX:
|
||||
|
|
@ -144,41 +143,26 @@ class AnalyticsClient:
|
|||
|
||||
self._rows.append(
|
||||
[
|
||||
datetime.now(UTC),
|
||||
datetime.now(UTC).replace(tzinfo=None),
|
||||
str(event_type),
|
||||
int(user_id),
|
||||
username or "",
|
||||
int(chat_id),
|
||||
self._payload(payload),
|
||||
self._dump_payload(payload),
|
||||
__version__,
|
||||
bool(self._settings.is_prod),
|
||||
1 if self._settings.is_prod else 0,
|
||||
]
|
||||
)
|
||||
|
||||
while len(self._rows) > self._settings.analytics_buffer_max:
|
||||
self._rows.popleft()
|
||||
self._overflow += 1
|
||||
|
||||
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:
|
||||
if len(self._rows) >= self._settings.analytics_batch_size:
|
||||
self._flush_now.set()
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Периодически (или по достижении батча) сбрасывать буфер в ClickHouse."""
|
||||
interval = self._settings.analytics_flush_interval
|
||||
while True:
|
||||
with contextlib.suppress(TimeoutError):
|
||||
|
|
@ -187,74 +171,105 @@ class AnalyticsClient:
|
|||
await self.flush()
|
||||
|
||||
async def flush(self) -> None:
|
||||
"""Отправить накопленное. При сбое — fallback в лог (без исключений)."""
|
||||
if not self._rows and not self._messages:
|
||||
"""Отправить накопленные события. При сбое — fallback в лог (без исключений)."""
|
||||
overflow, self._overflow = self._overflow, 0
|
||||
if overflow:
|
||||
logger.warning("Буфер событий переполнен, потеряно %s событий (не записаны в ClickHouse)", overflow)
|
||||
|
||||
if not self._rows:
|
||||
return
|
||||
|
||||
events = list(self._rows)
|
||||
messages = self._messages
|
||||
self._rows.clear()
|
||||
self._messages = {}
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
await self._writer(events, messages)
|
||||
await client.insert(self._table, rows, list(EVENT_COLUMNS))
|
||||
except Exception:
|
||||
self._log_fallback(events, messages)
|
||||
logger.exception("Не удалось записать %s событий в ClickHouse", len(rows))
|
||||
self._log_fallback(rows, "ошибка записи в ClickHouse")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Остановить фоновый таск, сбросить остаток и закрыть клиент."""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
await self.flush()
|
||||
|
||||
def _log_fallback(self, events: list[EventRow], messages: MessageCounts) -> None:
|
||||
sample = " | ".join(
|
||||
f"{row[0]:%Y-%m-%d %H:%M:%S} {row[1]} user={row[2]} chat={row[4]}" for row in events[:FALLBACK_SAMPLE]
|
||||
if self._client is not None:
|
||||
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:
|
||||
"""Вывести потерянные события в лог, чтобы данные не пропадали молча."""
|
||||
if not rows:
|
||||
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(
|
||||
"Fallback: не записано в Postgres событий=%s, счётчиков=%s%s",
|
||||
len(events),
|
||||
len(messages),
|
||||
f": {sample}" if sample else "",
|
||||
"Fallback (%s): %s событий не записаны в ClickHouse: %s%s",
|
||||
reason,
|
||||
len(rows),
|
||||
lines,
|
||||
" ..." if len(rows) > FALLBACK_SAMPLE else "",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _payload(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||
def _dump_payload(payload: dict[str, Any] | None) -> str:
|
||||
"""Сериализовать payload в JSON-строку с обрезкой по длине."""
|
||||
if not payload:
|
||||
return {}
|
||||
return "{}"
|
||||
try:
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return {"_error": "payload не сериализуется"}
|
||||
if len(text) > PAYLOAD_MAX_LENGTH:
|
||||
return {"_truncated": text[:PAYLOAD_MAX_LENGTH]}
|
||||
# Пересобираем из JSON: так в jsonb не попадёт объект, который не сериализуется.
|
||||
return json.loads(text)
|
||||
text = json.dumps({"_error": "payload не сериализуется"})
|
||||
return text[:PAYLOAD_MAX_LENGTH]
|
||||
|
||||
|
||||
#: Активный клиент (если аналитика не инициализирована — None).
|
||||
_client: AnalyticsClient | None = None
|
||||
|
||||
|
||||
async def init_analytics(
|
||||
settings: Settings | None = None,
|
||||
*,
|
||||
writer: Writer | None = None,
|
||||
client: ClickHouseClient | None = None,
|
||||
) -> AnalyticsClient:
|
||||
"""Создать и запустить единственный клиент аналитики. Идемпотентно."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
return _client
|
||||
_client = AnalyticsClient(settings, writer=writer)
|
||||
_client = AnalyticsClient(settings, client=client)
|
||||
await _client.start()
|
||||
return _client
|
||||
|
||||
|
||||
async def close_analytics() -> None:
|
||||
"""Остановить клиент аналитики и сбросить буфер при остановке приложения."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.close()
|
||||
|
|
@ -262,6 +277,7 @@ async def close_analytics() -> None:
|
|||
|
||||
|
||||
def get_analytics() -> AnalyticsClient | None:
|
||||
"""Вернуть активный клиент аналитики (или None)."""
|
||||
return _client
|
||||
|
||||
|
||||
|
|
@ -273,17 +289,13 @@ def record(
|
|||
chat_id: int = 0,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Записать событие. Если аналитика не инициализирована — no-op."""
|
||||
if _client is None:
|
||||
return
|
||||
_client.record(event_type, user_id=user_id, username=username, chat_id=chat_id, payload=payload)
|
||||
|
||||
|
||||
def record_message(*, user_id: int, username: str | None, chat_id: int) -> None:
|
||||
if _client is None:
|
||||
return
|
||||
_client.record_message(user_id=user_id, username=username, chat_id=chat_id)
|
||||
|
||||
|
||||
def remember_user(user_id: int, username: str | None) -> None:
|
||||
"""Запомнить username пользователя (для обогащения событий из сервисов)."""
|
||||
if _client is not None:
|
||||
_client.remember_user(user_id, username)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Каталог событий аналитики.
|
||||
|
||||
Единственный источник правды по типам событий. Список синхронизирован
|
||||
Единственный источник правды по типам событий ClickHouse. Список синхронизирован
|
||||
с документацией (``docs/events.md``) — рассинхрон ловит ``tests/test_analytics.py``.
|
||||
|
||||
Правило: добавляешь новое событие — добавь его в ``EventType`` и в ``docs/events.md``.
|
||||
|
|
@ -12,9 +12,10 @@ from enum import StrEnum
|
|||
|
||||
|
||||
class EventType(StrEnum):
|
||||
"""Типы событий (значение = колонка ``event_type`` в Postgres)."""
|
||||
"""Типы событий (значение = колонка ``event_type`` в ClickHouse)."""
|
||||
|
||||
# --- Сообщения и команды ---
|
||||
MESSAGE = "message"
|
||||
COMMAND = "command"
|
||||
|
||||
# --- Экономика ---
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
"""Партиции аналитики по дням.
|
||||
|
||||
Создаются заранее (вчера/сегодня/два дня вперёд), старые **не удаляются**:
|
||||
история событий и дневных счётчиков хранится бессрочно.
|
||||
"""
|
||||
|
||||
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,129 +0,0 @@
|
|||
"""Чтение аналитики из Postgres для админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
from common.analytics.events import ALL_EVENT_TYPES
|
||||
from common.config import Settings
|
||||
from common.db.pool import get_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Колонки, которые показываем в логах.
|
||||
EVENT_FIELDS = (
|
||||
"event_time",
|
||||
"event_type",
|
||||
"user_id",
|
||||
"username",
|
||||
"chat_id",
|
||||
"payload",
|
||||
"bot_version",
|
||||
"prod",
|
||||
)
|
||||
|
||||
|
||||
def _dates(date_from: str, date_to: str, settings: Settings) -> tuple[date, date]:
|
||||
today = datetime.now(settings.tzinfo).date()
|
||||
start = datetime.strptime(date_from, "%Y-%m-%d").date() if date_from else today - timedelta(days=6)
|
||||
end = datetime.strptime(date_to, "%Y-%m-%d").date() if date_to else today
|
||||
return start, end
|
||||
|
||||
|
||||
def _event_bounds(start: date, end: date, settings: Settings) -> tuple[datetime, datetime]:
|
||||
start_dt = datetime.combine(start, time.min, tzinfo=settings.tzinfo).astimezone(UTC)
|
||||
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=settings.tzinfo).astimezone(UTC)
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
def _user_clause(user: str, params: list[Any], column: str = "user_id") -> str:
|
||||
user = user.strip()
|
||||
if not user:
|
||||
return ""
|
||||
if user.isdigit():
|
||||
params.append(int(user))
|
||||
return f" AND {column} = %s"
|
||||
params.append(f"%{user}%")
|
||||
return " AND username ILIKE %s"
|
||||
|
||||
|
||||
async def fetch_events(
|
||||
settings: Settings,
|
||||
*,
|
||||
event_type: str = "",
|
||||
user: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Вернуть события и общее количество по фильтрам."""
|
||||
start, end = _dates(date_from, date_to, settings)
|
||||
start_dt, end_dt = _event_bounds(start, end, settings)
|
||||
|
||||
params: list[Any] = [start_dt, end_dt]
|
||||
where = " WHERE event_time >= %s AND event_time < %s"
|
||||
if event_type and event_type in ALL_EVENT_TYPES:
|
||||
where += " AND event_type = %s"
|
||||
params.append(event_type)
|
||||
where += _user_clause(user, params)
|
||||
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(f"SELECT count(*) AS total FROM analytics.events{where}", tuple(params))
|
||||
row = await cur.fetchone()
|
||||
total = int(row["total"]) if row else 0
|
||||
|
||||
cur = await conn.execute(
|
||||
f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}"
|
||||
" ORDER BY event_time DESC LIMIT %s OFFSET %s",
|
||||
(*params, limit, offset),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return rows, total
|
||||
|
||||
|
||||
async def fetch_messages(
|
||||
settings: Settings,
|
||||
*,
|
||||
user: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Вернуть дневные счётчики сообщений и общее количество строк."""
|
||||
start, end = _dates(date_from, date_to, settings)
|
||||
|
||||
params: list[Any] = [start, end]
|
||||
where = " WHERE day >= %s AND day <= %s"
|
||||
where += _user_clause(user, params)
|
||||
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
f"""
|
||||
SELECT count(*) AS total
|
||||
FROM (
|
||||
SELECT 1 FROM analytics.messages_daily{where}
|
||||
GROUP BY day, user_id
|
||||
) AS grouped
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
total = int(row["total"]) if row else 0
|
||||
|
||||
cur = await conn.execute(
|
||||
f"""
|
||||
SELECT day, user_id, max(username) AS username, max(chat_id) AS chat_id,
|
||||
sum(count)::bigint AS count
|
||||
FROM analytics.messages_daily{where}
|
||||
GROUP BY day, user_id
|
||||
ORDER BY day DESC, sum(count) DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return rows, total
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote_plus
|
||||
|
|
@ -54,28 +53,19 @@ class Settings(BaseSettings):
|
|||
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
|
||||
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
|
||||
|
||||
#: Общий каталог картинок (Docker volume), который видят бот и админка.
|
||||
media_dir: str = "/app/media"
|
||||
|
||||
#: Публичный адрес админки (deep-link вход, ссылки из бота).
|
||||
admin_public_url: str = "http://localhost:8000"
|
||||
|
||||
#: Аналитика событий в Postgres. ANALYTICS_ENABLED=false — полностью выключить.
|
||||
analytics_enabled: bool = True
|
||||
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
|
||||
clickhouse_enabled: bool = True
|
||||
clickhouse_host: str = "localhost"
|
||||
clickhouse_port: int = 8123
|
||||
clickhouse_db: str = "analytics"
|
||||
clickhouse_user: str = "default"
|
||||
clickhouse_password: str = ""
|
||||
|
||||
#: Батчинг записи событий: размер батча, интервал сброса и предел буфера.
|
||||
analytics_batch_size: int = 200
|
||||
analytics_flush_interval: float = 5.0
|
||||
analytics_buffer_max: int = 5000
|
||||
|
||||
#: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки
|
||||
#: применяются без перезапуска). 0 — читать БД при каждом обращении.
|
||||
template_cache_ttl: float = 45.0
|
||||
|
||||
#: Окно пасхального события «Яички». Пусто — автоматически 1 апреля → Православная Пасха.
|
||||
egg_event_start: date | None = None
|
||||
egg_event_end: date | None = None
|
||||
|
||||
@field_validator("chat_id", "alert_chat_id", mode="before")
|
||||
@classmethod
|
||||
def _empty_int_to_zero(cls, value: object) -> object:
|
||||
|
|
@ -84,14 +74,6 @@ class Settings(BaseSettings):
|
|||
return 0
|
||||
return value
|
||||
|
||||
@field_validator("egg_event_start", "egg_event_end", mode="before")
|
||||
@classmethod
|
||||
def _empty_date_to_none(cls, value: object) -> object:
|
||||
"""Пустое значение в .env — «даты не заданы» (работает авто-расчёт)."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("admin_ids", mode="before")
|
||||
@classmethod
|
||||
def _parse_admin_ids(cls, value: object) -> object:
|
||||
|
|
@ -123,36 +105,5 @@ class Settings(BaseSettings):
|
|||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Вернуть singleton настроек бота."""
|
||||
"""Вернуть singleton настроек."""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
class AdminSettings(Settings):
|
||||
"""Настройки сервиса админки.
|
||||
|
||||
Наследует общие поля (окружение, Postgres, таймзона), но не требует
|
||||
``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи
|
||||
Telegram-логина (Блок 4.2).
|
||||
"""
|
||||
|
||||
bot_token: str = ""
|
||||
|
||||
#: Адрес и порт, которые слушает uvicorn внутри контейнера.
|
||||
admin_host: str = "0.0.0.0"
|
||||
admin_port: int = 8000
|
||||
|
||||
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
|
||||
#: Наследуется из базовых настроек: ADMIN_PUBLIC_URL.
|
||||
#: Username бота без @ — из него виджет рисует кнопку входа.
|
||||
bot_username: str = ""
|
||||
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают
|
||||
#: при перезапуске (генерируется временный).
|
||||
admin_session_secret: str = ""
|
||||
#: Время жизни сессии, секунды (по умолчанию 12 часов).
|
||||
admin_session_max_age: int = 12 * 60 * 60
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_admin_settings() -> AdminSettings:
|
||||
"""Вернуть singleton настроек админки."""
|
||||
return AdminSettings() # type: ignore[call-arg]
|
||||
|
|
|
|||
|
|
@ -11,23 +11,20 @@ import logging
|
|||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
from common.config import Settings, get_settings
|
||||
from common.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pool: AsyncConnectionPool | None = None
|
||||
|
||||
|
||||
async def init_pool(settings: Settings | None = None) -> AsyncConnectionPool:
|
||||
"""Создать и открыть пул. Идемпотентно.
|
||||
|
||||
``settings`` передаёт админка (свои настройки); по умолчанию — настройки бота.
|
||||
"""
|
||||
async def init_pool() -> AsyncConnectionPool:
|
||||
"""Создать и открыть пул. Идемпотентно."""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
return _pool
|
||||
|
||||
settings = settings or get_settings()
|
||||
settings = get_settings()
|
||||
_pool = AsyncConnectionPool(
|
||||
conninfo=settings.dsn,
|
||||
min_size=settings.db_pool_min,
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
"""Пасхальное событие «Яички» (Блок 4: сезонность).
|
||||
|
||||
По умолчанию окно — с 1 апреля по православную Пасху включительно (как было
|
||||
в исходном боте: 01.04–20.04.2025). Даты можно переопределить переменными
|
||||
``EGG_EVENT_START``/``EGG_EVENT_END`` (например, для другого года или правила).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from common.config import Settings
|
||||
|
||||
#: Разница между юлианским и григорианским календарями для 1900–2099.
|
||||
_JULIAN_TO_GREGORIAN_DAYS = 13
|
||||
|
||||
|
||||
def orthodox_easter(year: int) -> date:
|
||||
"""Дата православной Пасхи (по юлианской пасхалии, переведённая в григорианский стиль)."""
|
||||
a = year % 4
|
||||
b = year % 7
|
||||
c = year % 19
|
||||
d = (19 * c + 15) % 30
|
||||
e = (2 * a + 4 * b - d + 34) % 7
|
||||
month = (d + e + 114) // 31
|
||||
day = ((d + e + 114) % 31) + 1
|
||||
return date(year, month, day) + timedelta(days=_JULIAN_TO_GREGORIAN_DAYS)
|
||||
|
||||
|
||||
def egg_event_period(settings: Settings, today: date) -> tuple[date, date]:
|
||||
"""Начало и конец окна «яичек» (включительно).
|
||||
|
||||
Если в настройках заданы обе даты — берём их, иначе 1 апреля → православная
|
||||
Пасха года ``today``.
|
||||
"""
|
||||
if settings.egg_event_start and settings.egg_event_end:
|
||||
return settings.egg_event_start, settings.egg_event_end
|
||||
return date(today.year, 4, 1), orthodox_easter(today.year)
|
||||
|
||||
|
||||
def is_egg_event_active(settings: Settings, today: date | None = None) -> bool:
|
||||
"""Идёт ли сейчас пасхальное событие (в таймзоне настроек)."""
|
||||
if today is None:
|
||||
today = datetime.now(settings.tzinfo).date()
|
||||
start, end = egg_event_period(settings, today)
|
||||
return start <= today <= end
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"""Одноразовые токены входа в админку через 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"])
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"""Журнал ручных отправок сообщений из админки."""
|
||||
|
||||
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()
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
"""Репозиторий администраторов веб-панели (Блок 4.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
|
||||
async def get_admin(telegram_id: int) -> dict[str, Any] | None:
|
||||
"""Вернуть администратора по Telegram-ID (для проверки доступа)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT telegram_id, username, role, enabled, created_at, last_login_at
|
||||
FROM bot.admin_users
|
||||
WHERE telegram_id = %s
|
||||
""",
|
||||
(telegram_id,),
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def ensure_admins(telegram_ids: list[int]) -> int:
|
||||
"""Засеять администраторов из ADMIN_IDS (существующих не трогаем).
|
||||
|
||||
Возвращает количество вставленных строк.
|
||||
"""
|
||||
if not telegram_ids:
|
||||
return 0
|
||||
|
||||
inserted = 0
|
||||
async with get_pool().connection() as conn:
|
||||
for telegram_id in dict.fromkeys(telegram_ids):
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.admin_users (telegram_id, role)
|
||||
VALUES (%s, 'admin')
|
||||
ON CONFLICT (telegram_id) DO NOTHING
|
||||
""",
|
||||
(telegram_id,),
|
||||
)
|
||||
inserted += cur.rowcount or 0
|
||||
return inserted
|
||||
|
||||
|
||||
async def touch_login(telegram_id: int) -> None:
|
||||
"""Отметить успешный вход (не критично, ошибки не должны ломать логин)."""
|
||||
async with get_pool().connection() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s",
|
||||
(telegram_id,),
|
||||
)
|
||||
|
||||
|
||||
async def list_admins() -> list[dict[str, Any]]:
|
||||
"""Все администраторы веб-панели (для раздела «Админы»)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT telegram_id, username, role, enabled, created_at, last_login_at
|
||||
FROM bot.admin_users
|
||||
ORDER BY role, username NULLS LAST, telegram_id
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def upsert_admin(telegram_id: int, username: str | None, role: str) -> None:
|
||||
"""Добавить администратора или обновить имя/роль существующего."""
|
||||
async with get_pool().connection() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.admin_users (telegram_id, username, role, enabled)
|
||||
VALUES (%s, %s, %s, true)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
role = EXCLUDED.role,
|
||||
enabled = true
|
||||
""",
|
||||
(telegram_id, username, role),
|
||||
)
|
||||
|
||||
|
||||
async def set_admin_role(telegram_id: int, role: str) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"UPDATE bot.admin_users SET role = %s WHERE telegram_id = %s",
|
||||
(role, telegram_id),
|
||||
)
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
async def set_admin_enabled(telegram_id: int, enabled: bool) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"UPDATE bot.admin_users SET enabled = %s WHERE telegram_id = %s",
|
||||
(enabled, telegram_id),
|
||||
)
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
async def delete_admin(telegram_id: int) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("DELETE FROM bot.admin_users WHERE telegram_id = %s", (telegram_id,))
|
||||
return bool(cur.rowcount)
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
"""Репозиторий расписаний рассылок (Блок 4.5).
|
||||
|
||||
Редактирует админка, исполняет бот: он читает активные строки раз в минуту.
|
||||
Каждое изменение пишет снимок строки в ``schedules_history`` (аудит).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
#: Поля, которые попадают в историю и форму (без служебных отметок времени).
|
||||
SNAPSHOT_FIELDS = ("template_key", "hour", "minute", "weekdays", "timezone", "enabled", "description")
|
||||
|
||||
|
||||
def _snapshot(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {field: row.get(field) for field in SNAPSHOT_FIELDS}
|
||||
|
||||
|
||||
async def list_schedules() -> list[dict[str, Any]]:
|
||||
"""Все расписания с описанием шаблона (для таблицы в админке)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT s.id, s.template_key, t.description AS template_description,
|
||||
s.hour, s.minute, s.weekdays, s.timezone, s.enabled,
|
||||
s.description, s.updated_at, s.updated_by
|
||||
FROM bot.schedules AS s
|
||||
JOIN bot.message_templates AS t ON t.key = s.template_key
|
||||
ORDER BY s.hour, s.minute, s.id
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def list_active() -> list[dict[str, Any]]:
|
||||
"""Активные расписания — для планировщика бота."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, template_key, hour, minute, weekdays, timezone
|
||||
FROM bot.schedules
|
||||
WHERE enabled
|
||||
ORDER BY hour, minute, id
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def claim_run(schedule_id: int, run_at: Any) -> bool:
|
||||
"""Атомарно отметить минуту как отработанную.
|
||||
|
||||
``True`` — эту минуту ещё никто не отправлял; ``False`` — уже была отправка
|
||||
(например, до перезапуска бота).
|
||||
"""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.schedule_runs (schedule_id, run_at)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(schedule_id, run_at),
|
||||
)
|
||||
claimed = bool(cur.rowcount)
|
||||
if claimed:
|
||||
await conn.execute("DELETE FROM bot.schedule_runs WHERE run_at < now() - interval '7 days'")
|
||||
return claimed
|
||||
|
||||
|
||||
async def get_schedule(schedule_id: int) -> dict[str, Any] | None:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, template_key, hour, minute, weekdays, timezone, enabled,
|
||||
description, updated_at, updated_by
|
||||
FROM bot.schedules
|
||||
WHERE id = %s
|
||||
""",
|
||||
(schedule_id,),
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def create_schedule(data: dict[str, Any], *, editor_id: int | None = None) -> int:
|
||||
"""Создать расписание и записать его в историю. Возвращает id."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.schedules
|
||||
(template_key, hour, minute, weekdays, timezone, enabled, description, updated_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
data["template_key"],
|
||||
data["hour"],
|
||||
data["minute"],
|
||||
data["weekdays"],
|
||||
data["timezone"],
|
||||
data["enabled"],
|
||||
data["description"],
|
||||
editor_id,
|
||||
),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
schedule_id = int(row["id"])
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
|
||||
VALUES (%s, NULL, %s, %s)
|
||||
""",
|
||||
(schedule_id, Jsonb(data), editor_id),
|
||||
)
|
||||
return schedule_id
|
||||
|
||||
|
||||
async def update_schedule(schedule_id: int, data: dict[str, Any], *, editor_id: int | None = None) -> bool:
|
||||
"""Обновить расписание и записать старое/новое значение. False — если записи нет."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE",
|
||||
(schedule_id,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
old = _snapshot(row)
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE bot.schedules
|
||||
SET template_key = %s, hour = %s, minute = %s, weekdays = %s,
|
||||
timezone = %s, enabled = %s, description = %s,
|
||||
updated_at = now(), updated_by = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
data["template_key"],
|
||||
data["hour"],
|
||||
data["minute"],
|
||||
data["weekdays"],
|
||||
data["timezone"],
|
||||
data["enabled"],
|
||||
data["description"],
|
||||
editor_id,
|
||||
schedule_id,
|
||||
),
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(schedule_id, Jsonb(old), Jsonb(data), editor_id),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def set_enabled(schedule_id: int, enabled: bool, *, editor_id: int | None = None) -> bool:
|
||||
"""Включить/выключить расписание (тоже с записью в историю)."""
|
||||
schedule = await get_schedule(schedule_id)
|
||||
if schedule is None:
|
||||
return False
|
||||
data = _snapshot(schedule)
|
||||
data["enabled"] = enabled
|
||||
return await update_schedule(schedule_id, data, editor_id=editor_id)
|
||||
|
||||
|
||||
async def delete_schedule(schedule_id: int, *, editor_id: int | None = None) -> bool:
|
||||
"""Удалить расписание, сохранив последний снимок в истории."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE", (schedule_id,))
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
|
||||
VALUES (%s, %s, NULL, %s)
|
||||
""",
|
||||
(schedule_id, Jsonb(_snapshot(row)), editor_id),
|
||||
)
|
||||
await conn.execute("DELETE FROM bot.schedules WHERE id = %s", (schedule_id,))
|
||||
return True
|
||||
|
||||
|
||||
async def schedule_history(schedule_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, old_data, new_data, editor_id, changed_at
|
||||
FROM bot.schedules_history
|
||||
WHERE schedule_id = %s
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(schedule_id, limit),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
"""Репозиторий последовательностей сообщений (Блок 4).
|
||||
|
||||
Последовательность — несколько сообщений по порядку; у каждого может быть
|
||||
картинка из общего volume и свои inline-кнопки. Бот отправляет их при входе
|
||||
участника (пока используется для приветствия).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
#: Максимальный размер загружаемой картинки.
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
async def list_sequences() -> list[dict[str, Any]]:
|
||||
"""Список последовательностей с количеством сообщений."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT s.key, s.title, s.description, s.updated_at, s.updated_by,
|
||||
count(i.id) AS item_count
|
||||
FROM bot.message_sequences AS s
|
||||
LEFT JOIN bot.message_sequence_items AS i ON i.sequence_key = s.key
|
||||
GROUP BY s.key, s.title, s.description, s.updated_at, s.updated_by
|
||||
ORDER BY s.key
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def get_sequence(key: str) -> dict[str, Any] | None:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT key, title, description, updated_at, updated_by FROM bot.message_sequences WHERE key = %s",
|
||||
(key,),
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def get_items(key: str) -> list[dict[str, Any]]:
|
||||
"""Сообщения последовательности с кнопками, по порядку."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, position, text, enabled, image_name
|
||||
FROM bot.message_sequence_items
|
||||
WHERE sequence_key = %s
|
||||
ORDER BY position
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
items = await cur.fetchall()
|
||||
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT b.item_id, b.position, b.text, b.url
|
||||
FROM bot.message_sequence_buttons AS b
|
||||
JOIN bot.message_sequence_items AS i ON i.id = b.item_id
|
||||
WHERE i.sequence_key = %s
|
||||
ORDER BY b.item_id, b.position
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
buttons = await cur.fetchall()
|
||||
|
||||
by_item: dict[int, list[dict[str, Any]]] = {}
|
||||
for button in buttons:
|
||||
by_item.setdefault(int(button["item_id"]), []).append({"text": button["text"], "url": button["url"]})
|
||||
for item in items:
|
||||
item["buttons"] = by_item.get(int(item["id"]), [])
|
||||
return items
|
||||
|
||||
|
||||
async def _snapshot(conn: Any, key: str, editor_id: int | None) -> None:
|
||||
"""Сохранить снимок последовательности в историю изменений."""
|
||||
cur = await conn.execute(
|
||||
"SELECT key, title, description FROM bot.message_sequences WHERE key = %s",
|
||||
(key,),
|
||||
)
|
||||
sequence = await cur.fetchone()
|
||||
if sequence is None:
|
||||
return
|
||||
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, position, text, enabled, image_name
|
||||
FROM bot.message_sequence_items
|
||||
WHERE sequence_key = %s
|
||||
ORDER BY position
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
items = await cur.fetchall()
|
||||
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT b.item_id, b.position, b.text, b.url
|
||||
FROM bot.message_sequence_buttons AS b
|
||||
JOIN bot.message_sequence_items AS i ON i.id = b.item_id
|
||||
WHERE i.sequence_key = %s
|
||||
ORDER BY b.item_id, b.position
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
buttons = await cur.fetchall()
|
||||
|
||||
by_item: dict[int, list[dict[str, Any]]] = {}
|
||||
for button in buttons:
|
||||
by_item.setdefault(int(button["item_id"]), []).append(
|
||||
{"position": button["position"], "text": button["text"], "url": button["url"]}
|
||||
)
|
||||
|
||||
snapshot = {
|
||||
"sequence": sequence,
|
||||
"items": [{**item, "buttons": by_item.get(int(item["id"]), [])} for item in items],
|
||||
}
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_sequence_history (sequence_key, snapshot, editor_id)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(key, Jsonb(snapshot), editor_id),
|
||||
)
|
||||
|
||||
|
||||
async def create_sequence(
|
||||
key: str,
|
||||
title: str,
|
||||
description: str,
|
||||
*,
|
||||
editor_id: int | None = None,
|
||||
) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT 1 FROM bot.message_sequences WHERE key = %s", (key,))
|
||||
if await cur.fetchone() is not None:
|
||||
return False
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_sequences (key, title, description, updated_by)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(key, title, description, editor_id),
|
||||
)
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return True
|
||||
|
||||
|
||||
async def update_sequence(
|
||||
key: str,
|
||||
title: str,
|
||||
description: str,
|
||||
*,
|
||||
editor_id: int | None = None,
|
||||
) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
UPDATE bot.message_sequences
|
||||
SET title = %s, description = %s, updated_at = now(), updated_by = %s
|
||||
WHERE key = %s
|
||||
""",
|
||||
(title, description, editor_id, key),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
return False
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return True
|
||||
|
||||
|
||||
async def add_item(key: str, *, editor_id: int | None = None) -> int | None:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT 1 FROM bot.message_sequences WHERE key = %s", (key,))
|
||||
if await cur.fetchone() is None:
|
||||
return None
|
||||
cur = await conn.execute(
|
||||
"SELECT coalesce(max(position), -1) + 1 AS next FROM bot.message_sequence_items WHERE sequence_key = %s",
|
||||
(key,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
position = int(row["next"])
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_sequence_items (sequence_key, position, text, updated_by)
|
||||
VALUES (%s, %s, '', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(key, position, editor_id),
|
||||
)
|
||||
inserted = await cur.fetchone()
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return int(inserted["id"])
|
||||
|
||||
|
||||
async def update_item(
|
||||
item_id: int,
|
||||
text: str,
|
||||
enabled: bool,
|
||||
buttons: list[tuple[str, str]],
|
||||
*,
|
||||
image_name: str | None = None,
|
||||
update_image: bool = False,
|
||||
editor_id: int | None = None,
|
||||
) -> tuple[str, str | None] | None:
|
||||
"""Обновить сообщение и его кнопки. Возвращает (ключ, старое имя картинки)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT sequence_key, image_name FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
|
||||
(item_id,),
|
||||
)
|
||||
item = await cur.fetchone()
|
||||
if item is None:
|
||||
return None
|
||||
|
||||
key = str(item["sequence_key"])
|
||||
old_image = item["image_name"]
|
||||
|
||||
if update_image:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE bot.message_sequence_items
|
||||
SET text = %s, enabled = %s, image_name = %s, updated_at = now(), updated_by = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(text, enabled, image_name, editor_id, item_id),
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE bot.message_sequence_items
|
||||
SET text = %s, enabled = %s, updated_at = now(), updated_by = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(text, enabled, editor_id, item_id),
|
||||
)
|
||||
|
||||
await conn.execute("DELETE FROM bot.message_sequence_buttons WHERE item_id = %s", (item_id,))
|
||||
for position, (button_text, url) in enumerate(buttons):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(item_id, position, button_text, url),
|
||||
)
|
||||
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return key, old_image
|
||||
|
||||
|
||||
async def save_items(
|
||||
key: str,
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
editor_id: int | None = None,
|
||||
) -> list[str] | None:
|
||||
"""Сохранить весь набор сообщений одной транзакцией.
|
||||
|
||||
``items`` — по порядку: ``id`` (None для нового), ``text``, ``enabled``,
|
||||
``image_name``, ``buttons`` (список пар текст/URL). Возвращает имена
|
||||
картинок, которые больше не используются (их можно удалить с диска).
|
||||
"""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT 1 FROM bot.message_sequences WHERE key = %s",
|
||||
(key,),
|
||||
)
|
||||
if await cur.fetchone() is None:
|
||||
return None
|
||||
|
||||
cur = await conn.execute(
|
||||
"SELECT id, image_name FROM bot.message_sequence_items WHERE sequence_key = %s",
|
||||
(key,),
|
||||
)
|
||||
existing = {int(row["id"]): row["image_name"] for row in await cur.fetchall()}
|
||||
submitted_ids = {int(item["id"]) for item in items if item.get("id")}
|
||||
|
||||
removed_images: list[str] = []
|
||||
for item_id, image_name in existing.items():
|
||||
if item_id not in submitted_ids:
|
||||
await conn.execute("DELETE FROM bot.message_sequence_items WHERE id = %s", (item_id,))
|
||||
if image_name:
|
||||
removed_images.append(str(image_name))
|
||||
|
||||
# Освобождаем позиции, чтобы не нарушить UNIQUE(sequence_key, position).
|
||||
await conn.execute(
|
||||
"UPDATE bot.message_sequence_items SET position = -id WHERE sequence_key = %s",
|
||||
(key,),
|
||||
)
|
||||
|
||||
for position, item in enumerate(items):
|
||||
item_id = int(item["id"]) if item.get("id") else None
|
||||
if item_id is not None and item_id in existing:
|
||||
old_image = existing[item_id]
|
||||
if old_image and old_image != item.get("image_name"):
|
||||
removed_images.append(str(old_image))
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE bot.message_sequence_items
|
||||
SET position = %s, text = %s, enabled = %s, image_name = %s,
|
||||
updated_at = now(), updated_by = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(position, item["text"], item["enabled"], item.get("image_name"), editor_id, item_id),
|
||||
)
|
||||
else:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_sequence_items
|
||||
(sequence_key, position, text, enabled, image_name, updated_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(key, position, item["text"], item["enabled"], item.get("image_name"), editor_id),
|
||||
)
|
||||
inserted = await cur.fetchone()
|
||||
item_id = int(inserted["id"])
|
||||
|
||||
await conn.execute("DELETE FROM bot.message_sequence_buttons WHERE item_id = %s", (item_id,))
|
||||
for button_position, (button_text, url) in enumerate(item.get("buttons") or []):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(item_id, button_position, button_text, url),
|
||||
)
|
||||
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return removed_images
|
||||
|
||||
|
||||
async def delete_item(item_id: int, *, editor_id: int | None = None) -> str | None:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT sequence_key FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
|
||||
(item_id,),
|
||||
)
|
||||
item = await cur.fetchone()
|
||||
if item is None:
|
||||
return None
|
||||
key = str(item["sequence_key"])
|
||||
await conn.execute("DELETE FROM bot.message_sequence_items WHERE id = %s", (item_id,))
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return key
|
||||
|
||||
|
||||
async def move_item(item_id: int, direction: str, *, editor_id: int | None = None) -> str | None:
|
||||
"""Поменять сообщение с соседним (``up``/``down``)."""
|
||||
delta = -1 if direction == "up" else 1
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT sequence_key, position FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
|
||||
(item_id,),
|
||||
)
|
||||
item = await cur.fetchone()
|
||||
if item is None:
|
||||
return None
|
||||
key = str(item["sequence_key"])
|
||||
position = int(item["position"])
|
||||
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, position FROM bot.message_sequence_items
|
||||
WHERE sequence_key = %s AND position = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(key, position + delta),
|
||||
)
|
||||
neighbour = await cur.fetchone()
|
||||
if neighbour is None:
|
||||
return key
|
||||
|
||||
# Через -1, чтобы не нарушить UNIQUE(sequence_key, position).
|
||||
await conn.execute("UPDATE bot.message_sequence_items SET position = -1 WHERE id = %s", (item_id,))
|
||||
await conn.execute(
|
||||
"UPDATE bot.message_sequence_items SET position = %s WHERE id = %s",
|
||||
(position, neighbour["id"]),
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE bot.message_sequence_items SET position = %s WHERE id = %s",
|
||||
(position + delta, item_id),
|
||||
)
|
||||
await _snapshot(conn, key, editor_id)
|
||||
return key
|
||||
|
||||
|
||||
async def history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, snapshot, editor_id, changed_at
|
||||
FROM bot.message_sequence_history
|
||||
WHERE sequence_key = %s
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(key, limit),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""Репозиторий шаблонов сообщений (Блок 4.1: редактирование в админке).
|
||||
|
||||
Каждое изменение текста пишет запись в ``message_template_history`` — это аудит
|
||||
и материал для отката. Бот подключится к этим данным в Блоке 4.5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
|
||||
async def load_texts() -> dict[str, str]:
|
||||
"""Актуальные тексты включённых шаблонов (для кеша бота в ``common.texts.store``)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT key, text FROM bot.message_templates WHERE enabled ORDER BY key")
|
||||
rows = await cur.fetchall()
|
||||
return {row["key"]: row["text"] for row in rows}
|
||||
|
||||
|
||||
async def list_templates() -> list[dict[str, Any]]:
|
||||
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT key, description, enabled, updated_at, updated_by,
|
||||
length(text) AS text_length
|
||||
FROM bot.message_templates
|
||||
ORDER BY key
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def get_template(key: str) -> dict[str, Any] | None:
|
||||
"""Вернуть шаблон целиком (с текстом) или ``None``."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT key, text, description, enabled, updated_at, updated_by
|
||||
FROM bot.message_templates
|
||||
WHERE key = %s
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def create_template(
|
||||
key: str,
|
||||
text: str,
|
||||
description: str,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
editor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Создать новый шаблон. False — если ключ уже занят."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT 1 FROM bot.message_templates WHERE key = %s", (key,))
|
||||
if await cur.fetchone() is not None:
|
||||
return False
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_templates (key, text, description, enabled, updated_by)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(key, text, description, enabled, editor_id),
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
|
||||
VALUES (%s, NULL, %s, %s)
|
||||
""",
|
||||
(key, text, editor_id),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def update_template(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any] | None:
|
||||
"""Обновить текст шаблона и записать историю.
|
||||
|
||||
Возвращает ``None``, если шаблона нет. Старое значение читается под
|
||||
``FOR UPDATE``, поэтому параллельные правки не потеряют историю.
|
||||
"""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT text FROM bot.message_templates WHERE key = %s FOR UPDATE", (key,))
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
old_text = row["text"]
|
||||
if old_text == text:
|
||||
return {"key": key, "old_text": old_text, "text": text, "changed": False}
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE bot.message_templates
|
||||
SET text = %s, updated_at = now(), updated_by = %s
|
||||
WHERE key = %s
|
||||
""",
|
||||
(text, editor_id, key),
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(key, old_text, text, editor_id),
|
||||
)
|
||||
return {"key": key, "old_text": old_text, "text": text, "changed": True}
|
||||
|
||||
|
||||
async def template_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Последние изменения шаблона (для блока истории в редакторе)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, old_text, new_text, editor_id, changed_at
|
||||
FROM bot.message_template_history
|
||||
WHERE key = %s
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(key, limit),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"""Справочник званий: чтение, создание, изменение, удаление."""
|
||||
|
||||
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
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
"""Чтение данных пользователей для админки (Блок 4.3).
|
||||
|
||||
Баланс — контракт ``SUM(bot.money.amount)``; яички — ``SUM(bot.eggs.amount)``.
|
||||
Подписка на рассылки — отсутствие строки в ``bot.unsubscribe``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
#: Общая часть выборки: агрегаты по сообщениям, деньгам, яичкам и очищениям.
|
||||
_FROM = """
|
||||
FROM bot.tg_users AS tg
|
||||
LEFT JOIN bot.de_users AS du ON du.id_user = tg.id_user
|
||||
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
||||
LEFT JOIN (
|
||||
SELECT user_id, sum(count) AS cnt, max(day) AS last_dt
|
||||
FROM analytics.messages_daily GROUP BY user_id
|
||||
) AS msg ON msg.user_id = tg.id_user
|
||||
LEFT JOIN (
|
||||
SELECT id_user, sum(amount) AS balance
|
||||
FROM bot.money GROUP BY id_user
|
||||
) AS money ON money.id_user = tg.id_user
|
||||
LEFT JOIN (
|
||||
SELECT id_user, sum(amount) AS eggs
|
||||
FROM bot.eggs GROUP BY id_user
|
||||
) AS eggs ON eggs.id_user = tg.id_user
|
||||
LEFT JOIN (
|
||||
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
|
||||
FROM bot.purifications GROUP BY id_user
|
||||
) AS pur ON pur.id_user = tg.id_user
|
||||
LEFT JOIN bot.unsubscribe AS unsub ON unsub.id_user = tg.id_user
|
||||
"""
|
||||
|
||||
_SELECT = """
|
||||
SELECT tg.id_user, tg.username, tg.first_name, tg.last_name,
|
||||
du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create,
|
||||
du.id_title,
|
||||
t.title,
|
||||
coalesce(msg.cnt, 0) AS msg_count,
|
||||
msg.last_dt AS last_message_at,
|
||||
coalesce(money.balance, 0) AS balance,
|
||||
coalesce(eggs.eggs, 0) AS eggs,
|
||||
coalesce(pur.cnt, 0) AS purification_count,
|
||||
pur.last_dt AS last_purification_at,
|
||||
(unsub.id_user IS NULL) AS subscribed
|
||||
"""
|
||||
|
||||
#: Колонки, по которым идёт поиск (в порядке подстановки параметров).
|
||||
_SEARCH_COLUMNS = ("tg.username", "tg.first_name", "tg.last_name", "du.nick")
|
||||
|
||||
|
||||
def _search_clause(term: str) -> tuple[str, list[Any]]:
|
||||
"""WHERE по нику, username, имени и Telegram-ID."""
|
||||
term = term.strip()
|
||||
if not term:
|
||||
return "", []
|
||||
|
||||
like = f"%{term}%"
|
||||
clauses = [f"{column} ILIKE %s" for column in _SEARCH_COLUMNS]
|
||||
params: list[Any] = [like] * len(_SEARCH_COLUMNS)
|
||||
|
||||
telegram_id = term.lstrip("@")
|
||||
if telegram_id.isdigit():
|
||||
clauses.append("tg.id_user = %s")
|
||||
params.append(int(telegram_id))
|
||||
|
||||
return " WHERE (" + " OR ".join(clauses) + ")", params
|
||||
|
||||
|
||||
async def count_users(search: str = "") -> int:
|
||||
"""Сколько пользователей подходит под поиск."""
|
||||
where, params = _search_clause(search)
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT count(*) AS total " + _FROM + where, tuple(params))
|
||||
row = await cur.fetchone()
|
||||
return int(row["total"]) if row else 0
|
||||
|
||||
|
||||
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
|
||||
"""Страница списка пользователей (с поиском)."""
|
||||
where, params = _search_clause(search)
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
_SELECT + _FROM + where + " ORDER BY du.nick NULLS LAST, tg.id_user LIMIT %s OFFSET %s",
|
||||
(*params, limit, offset),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def get_user(user_id: int) -> dict[str, Any] | None:
|
||||
"""Карточка пользователя со сводными показателями."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(_SELECT + _FROM + " WHERE tg.id_user = %s", (user_id,))
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Последние операции по балансу с названием действия."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT m.id_money, m.amount, m.dt_action, a.title AS action
|
||||
FROM bot.money AS m
|
||||
LEFT JOIN bot.actions AS a ON a.id_action = m.id_action
|
||||
WHERE m.id_user = %s
|
||||
ORDER BY m.dt_action DESC, m.id_money DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(user_id, limit),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
# --- Управление пользователем (Блок 4.3) -------------------------------------
|
||||
|
||||
#: Действие «Корректировка админом» из V18 (ручное изменение баланса).
|
||||
ADMIN_ACTION_ID = 6
|
||||
|
||||
#: Ограничение длины ника (как в команде /ник).
|
||||
MAX_NICK_LENGTH = 32
|
||||
|
||||
#: Ограничение длины комментария к корректировке баланса.
|
||||
MAX_COMMENT_LENGTH = 200
|
||||
|
||||
|
||||
async def list_titles() -> list[dict[str, Any]]:
|
||||
"""Доступные звания для выбора в карточке."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT id_title, title FROM bot.titles WHERE visible ORDER BY price DESC, id_title")
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""История ручных изменений пользователя."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, field, old_value, new_value, comment, editor_id, source, changed_at
|
||||
FROM bot.user_changes
|
||||
WHERE id_user = %s
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(user_id, limit),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def _log_change(
|
||||
conn: Any,
|
||||
user_id: int,
|
||||
field: str,
|
||||
old_value: str | None,
|
||||
new_value: str | None,
|
||||
editor_id: int | None,
|
||||
comment: str | None = None,
|
||||
source: str = "admin",
|
||||
) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(user_id, field, old_value, new_value, comment, editor_id, source),
|
||||
)
|
||||
|
||||
|
||||
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
|
||||
"""Сменить ник (создаёт запись de_users, если её ещё нет)."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (user_id,))
|
||||
row = await cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (user_id, nick))
|
||||
old_nick = None
|
||||
else:
|
||||
old_nick = row["nick"]
|
||||
if old_nick == nick:
|
||||
return True
|
||||
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, user_id))
|
||||
|
||||
await _log_change(conn, user_id, "nick", old_nick, nick, editor_id)
|
||||
return True
|
||||
|
||||
|
||||
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
|
||||
"""Сменить звание пользователя. False — если пользователя или звания нет."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT du.id_title, coalesce(t.title, '') AS title
|
||||
FROM bot.de_users AS du
|
||||
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
||||
WHERE du.id_user = %s
|
||||
FOR UPDATE OF du
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
current = await cur.fetchone()
|
||||
if current is None:
|
||||
return False
|
||||
|
||||
cur = await conn.execute("SELECT title FROM bot.titles WHERE id_title = %s", (title_id,))
|
||||
title = await cur.fetchone()
|
||||
if title is None:
|
||||
return False
|
||||
|
||||
if current["id_title"] == title_id:
|
||||
return True
|
||||
|
||||
await conn.execute("UPDATE bot.de_users SET id_title = %s WHERE id_user = %s", (title_id, user_id))
|
||||
await _log_change(conn, user_id, "title", current["title"], title["title"], editor_id)
|
||||
return True
|
||||
|
||||
|
||||
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
|
||||
"""Подписать или отписать пользователя от рассылок."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
||||
if await cur.fetchone() is None:
|
||||
return False
|
||||
|
||||
cur = await conn.execute("SELECT 1 FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
||||
was_subscribed = await cur.fetchone() is None
|
||||
if was_subscribed == subscribed:
|
||||
return True
|
||||
|
||||
if subscribed:
|
||||
await conn.execute("DELETE FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
||||
else:
|
||||
await conn.execute(
|
||||
"INSERT INTO bot.unsubscribe (id_user) VALUES (%s) ON CONFLICT DO NOTHING",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
await _log_change(
|
||||
conn,
|
||||
user_id,
|
||||
"subscription",
|
||||
"да" if was_subscribed else "нет",
|
||||
"да" if subscribed else "нет",
|
||||
editor_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def adjust_balance(
|
||||
user_id: int,
|
||||
amount: int,
|
||||
reason: str,
|
||||
*,
|
||||
editor_id: int | None = None,
|
||||
) -> int | None:
|
||||
"""Изменить баланс записью в money. Возвращает новый баланс или None."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
||||
if await cur.fetchone() is None:
|
||||
return None
|
||||
|
||||
cur = await conn.execute(
|
||||
"SELECT coalesce(sum(amount), 0) AS balance FROM bot.money WHERE id_user = %s", (user_id,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
old_balance = int(row["balance"])
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO bot.money (id_user, amount, id_action) VALUES (%s, %s, %s)",
|
||||
(user_id, amount, ADMIN_ACTION_ID),
|
||||
)
|
||||
new_balance = old_balance + amount
|
||||
await _log_change(
|
||||
conn,
|
||||
user_id,
|
||||
"balance",
|
||||
str(old_balance),
|
||||
str(new_balance),
|
||||
editor_id,
|
||||
comment=reason,
|
||||
)
|
||||
return new_balance
|
||||
|
|
@ -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 with get_pool().connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"CALL bot.add_user(%s, %s, %s, %s)",
|
||||
|
|
@ -22,41 +22,6 @@ async def add_nick(id_user: int, nick: str) -> None:
|
|||
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick))
|
||||
|
||||
|
||||
async def set_nick_with_history(
|
||||
id_user: int,
|
||||
nick: str,
|
||||
*,
|
||||
editor_id: int | None = None,
|
||||
source: str = "user",
|
||||
) -> str | None:
|
||||
"""Сменить ник и записать изменение в ``user_changes``.
|
||||
|
||||
Возвращает старый ник (``None``, если записи не было). Используется командой
|
||||
``/ник``, чтобы смена ника пользователем была видна в админке.
|
||||
"""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (id_user,))
|
||||
row = await cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (id_user, nick))
|
||||
old_nick = None
|
||||
else:
|
||||
old_nick = row["nick"]
|
||||
if old_nick == nick:
|
||||
return old_nick
|
||||
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, id_user))
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, editor_id, source)
|
||||
VALUES (%s, 'nick', %s, %s, %s, %s)
|
||||
""",
|
||||
(id_user, old_nick, nick, editor_id, source),
|
||||
)
|
||||
return old_nick
|
||||
|
||||
|
||||
async def get_nick_list() -> list[dict[str, Any]]:
|
||||
"""Список всех игроков (username, nick), отсортированный по нику."""
|
||||
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from __future__ import annotations
|
|||
|
||||
MSG_JOIN = "[вход] {username} вошёл в чат «{chat_title}»"
|
||||
MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»"
|
||||
MSG_KICK = "[кик] {actor} выгнал {username} из чата «{chat_title}»"
|
||||
MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}"
|
||||
MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}"
|
||||
MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ MSG_INFO = """<b>👤Информация о пользователе {username}
|
|||
<b>💰 Деньги:</b> {money}
|
||||
<b>📈 Начало активности в боте:</b> {dt}
|
||||
<b>📊 Количество сообщений в чате:</b> {msg_count}
|
||||
{eggs_line}
|
||||
<b>🥚 Количество яичек:</b> {eggs}
|
||||
"""
|
||||
|
||||
MSG_HELP = """<b><u>Список команд бота</u></b>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,11 @@ from typing import Any
|
|||
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
||||
|
||||
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
|
||||
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops", "eggs_line"})
|
||||
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"})
|
||||
|
||||
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
||||
TAG_REGISTRY: dict[str, str] = {
|
||||
"username": "Имя пользователя (@username или имя)",
|
||||
"actor": "Кто выполнил действие (для уведомлений)",
|
||||
"nick": "Игровой ник",
|
||||
"title": "Звание",
|
||||
"lvl": "Уровень",
|
||||
|
|
@ -33,7 +32,6 @@ TAG_REGISTRY: dict[str, str] = {
|
|||
"dt": "Дата начала активности",
|
||||
"msg_count": "Количество сообщений",
|
||||
"eggs": "Количество яичек",
|
||||
"eggs_line": "Готовая строка про яички (пусто вне пасхального события)",
|
||||
"h": "Часы до следующего действия",
|
||||
"m": "Минуты до следующего действия",
|
||||
"s": "Секунды до следующего действия",
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
"""Шаблоны сообщений из БД с TTL-кешем (Блок 4.5).
|
||||
|
||||
Бот читает тексты из ``bot.message_templates``: правка в админке применяется
|
||||
без перезапуска — в течение ``TEMPLATE_CACHE_TTL`` секунд. Если БД недоступна
|
||||
(или таблицы ещё нет), используются тексты по умолчанию из ``common.texts.TEMPLATES``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from common.config import get_settings
|
||||
from common.texts import TEMPLATES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Загрузчик «ключ -> текст» (по умолчанию — из Postgres).
|
||||
Loader = Callable[[], Awaitable[dict[str, str]]]
|
||||
|
||||
|
||||
async def _load_from_db() -> dict[str, str]:
|
||||
# Импорт внутри функции: не тянем слой БД на этапе импорта текстов.
|
||||
from common.repositories.templates_repo import load_texts
|
||||
|
||||
return await load_texts()
|
||||
|
||||
|
||||
class TemplateStore:
|
||||
"""Кеш шаблонов с временем жизни; при ошибке БД — тексты по умолчанию."""
|
||||
|
||||
def __init__(self, loader: Loader | None = None, ttl: float | None = None) -> None:
|
||||
self._loader = loader or _load_from_db
|
||||
self._ttl = ttl
|
||||
self._texts: dict[str, str] = {}
|
||||
self._loaded_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def ttl(self) -> float:
|
||||
"""TTL кеша в секундах (из настроек, если не задан явно)."""
|
||||
if self._ttl is None:
|
||||
self._ttl = get_settings().template_cache_ttl
|
||||
return max(self._ttl, 0.0)
|
||||
|
||||
async def get(self, key: str) -> str:
|
||||
"""Вернуть текст шаблона (из БД или из кода, если его там нет)."""
|
||||
await self._ensure_fresh()
|
||||
text = self._texts.get(key)
|
||||
if text is None:
|
||||
return TEMPLATES.get(key, "")
|
||||
return text
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Сбросить кеш: следующий ``get`` перечитает БД."""
|
||||
self._loaded_at = None
|
||||
|
||||
async def _ensure_fresh(self) -> None:
|
||||
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
# Пока ждали лок, кеш мог обновить другой вызов.
|
||||
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
|
||||
return
|
||||
|
||||
try:
|
||||
texts = await self._loader()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Не удалось прочитать шаблоны из БД — использую тексты по умолчанию",
|
||||
exc_info=True,
|
||||
)
|
||||
# Не долбим БД каждым сообщением: следующая попытка через TTL.
|
||||
self._loaded_at = time.monotonic()
|
||||
return
|
||||
|
||||
self._texts = texts
|
||||
self._loaded_at = time.monotonic()
|
||||
logger.debug("Шаблоны обновлены из БД: %s", len(texts))
|
||||
|
||||
|
||||
_store: TemplateStore | None = None
|
||||
|
||||
|
||||
def get_template_store() -> TemplateStore:
|
||||
"""Вернуть общий кеш шаблонов процесса."""
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = TemplateStore()
|
||||
return _store
|
||||
|
||||
|
||||
async def get_text(key: str) -> str:
|
||||
"""Короткий доступ к шаблону: ``await get_text("info.help")``."""
|
||||
return await get_template_store().get(key)
|
||||
|
|
@ -25,12 +25,6 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с"
|
|||
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
|
||||
|
||||
MSG_EGGS_TOO_EARLY = "Слишком рано..."
|
||||
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
|
||||
|
||||
MSG_START = "Привет! Это бот Dispel Evil."
|
||||
MSG_LOGIN_CONFIRMED = "Вход в админку подтверждён. Вернитесь на сайт."
|
||||
MSG_LOGIN_DENIED = "У вас нет доступа к админке."
|
||||
MSG_LOGIN_INVALID = "Ссылка для входа устарела. Начните вход заново на сайте."
|
||||
|
||||
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
|
||||
|
||||
|
|
|
|||
|
|
@ -36,18 +36,3 @@ MSG_WELCOME_TEXT3 = """❗️<b>По игре</b>:
|
|||
MSG_WELCOME_BUTTON_CAPTION = "Канал"
|
||||
|
||||
MSG_WELCOME_BUTTON_URL = "https://t.me/+DQk8MyN2y6QzNTM6"
|
||||
|
||||
|
||||
def split_welcome(*parts: str) -> tuple[str, str]:
|
||||
"""Разделить приветствие на подпись к фото и одно общее сообщение.
|
||||
|
||||
Первая строка первого шаблона становится подписью (персональное приветствие),
|
||||
всё остальное (включая ``text2``/``text3``) — одним сообщением по порядку.
|
||||
"""
|
||||
if not parts:
|
||||
return "", ""
|
||||
|
||||
caption, _, rest = parts[0].partition("\n")
|
||||
body_parts = [rest.rstrip(), *parts[1:]]
|
||||
body = "\n\n".join(part.strip() for part in body_parts if part and part.strip())
|
||||
return caption.strip(), body
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Единый источник правды. Git-тег совпадает с версией с префиксом ``v``
|
||||
(например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут
|
||||
тегироваться этой версией, а события аналитики получат поле ``bot_version``.
|
||||
тегироваться этой версией, а события в ClickHouse получат поле ``bot_version``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.7.2"
|
||||
__version__ = "0.3.8"
|
||||
|
|
|
|||
|
|
@ -46,47 +46,50 @@ services:
|
|||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT: "8123"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
# Без ожидания здоровья: аналитика некритична, бот работает и при недоступном ClickHouse.
|
||||
clickhouse:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Админка (FastAPI + Jinja2/HTMX), образ из Gitea Registry.
|
||||
# Наружу пока не публикуется: только loopback хоста; домен/TLS — Блок 2.4/4.2.
|
||||
admin:
|
||||
image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecex/dispelevilbot-admin:${IMAGE_TAG:-latest}
|
||||
# Аналитика событий (docs/events.md). Наружу не публикуется:
|
||||
# только loopback хоста для бэкапа/туннеля; бот ходит внутрь сети compose.
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.3
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${ENV_FILE:-env/prod.env}
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
ADMIN_HOST: 0.0.0.0
|
||||
ADMIN_PORT: "8000"
|
||||
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:
|
||||
- media:/app/media
|
||||
- clickhousedata:/var/lib/clickhouse
|
||||
# DDL применяется один раз при первой инициализации (пустой data-каталог).
|
||||
- ./clickhouse/init:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
# По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1:
|
||||
# порт остаётся недоступен из интернета, но виден контейнерам на bridge.
|
||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8101}:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
# Порт контура задаётся в 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) ---
|
||||
# Включается, когда появится админка/Mini App (Блоки 4–5).
|
||||
|
|
@ -105,6 +108,6 @@ services:
|
|||
|
||||
volumes:
|
||||
pgdata:
|
||||
media:
|
||||
clickhousedata:
|
||||
# caddy_data:
|
||||
# caddy_config:
|
||||
|
|
|
|||
|
|
@ -51,49 +51,47 @@ services:
|
|||
# Внутри сети compose БД доступна по имени сервиса.
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT: "8123"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
# Аналитика некритична: бот стартует, даже если ClickHouse ещё не готов.
|
||||
clickhouse:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Админка (FastAPI + HTMX). Порт — только loopback: наружу выйдет через reverse proxy
|
||||
# вместе с TLS и доменом (Блок 2.4/4.2).
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: admin/Dockerfile
|
||||
image: dispelevilbot-admin:local
|
||||
# Аналитика событий (Блок 3). DDL — в clickhouse/init/.
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.3
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
ADMIN_HOST: 0.0.0.0
|
||||
ADMIN_PORT: "8000"
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_DB:-analytics}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-de_bot}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?задайте CLICKHOUSE_PASSWORD в .env}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
- clickhousedata:/var/lib/clickhouse
|
||||
- ./clickhouse/init:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
- "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:
|
||||
pgdata:
|
||||
media:
|
||||
clickhousedata:
|
||||
|
|
|
|||
105
docs/events.md
105
docs/events.md
|
|
@ -1,45 +1,48 @@
|
|||
# События аналитики (Postgres)
|
||||
# События аналитики (ClickHouse)
|
||||
|
||||
Бот пишет значимые действия в Postgres, схема `analytics`. Дашборд (Superset) и ETL —
|
||||
отдельный финальный этап; здесь описан **контракт** событий: какие бывают `event_type`
|
||||
и что лежит в `payload`.
|
||||
Бот пишет все значимые действия в ClickHouse — единую широкую таблицу
|
||||
`analytics.events`. Дашборд (Superset) и ETL — отдельный финальный этап; здесь
|
||||
описан **контракт** событий: какие бывают `event_type` и что лежит в `payload`.
|
||||
|
||||
> Источник правды в коде — `common/analytics/events.py` (`EventType`).
|
||||
> Расхождение со списком ниже ловит `tests/test_analytics.py`.
|
||||
> Добавляешь событие — добавь строку в таблицу.
|
||||
|
||||
## Схема в Postgres
|
||||
## Схема в ClickHouse
|
||||
|
||||
DDL — `sql/V26__analytics_schema.sql`.
|
||||
DDL — `clickhouse/init/001_events.sql` (применяется при первой инициализации контейнера).
|
||||
|
||||
- `analytics.events` — события, `PARTITION BY RANGE (event_time)` по дням.
|
||||
Партиции создаёт приложение (бот и админка) на несколько дней вперёд;
|
||||
**старые партиции не удаляются** — история хранится бессрочно.
|
||||
- `analytics.messages_daily` — количество сообщений пользователя по дням:
|
||||
`day`, `user_id`, `username`, `chat_id`, `count`; PK `(day, user_id, chat_id)`.
|
||||
На каждое сообщение выполняется `INSERT ... ON CONFLICT DO UPDATE count + 1`,
|
||||
поэтому вместо строки на каждое сообщение растёт только счётчик за день.
|
||||
- `analytics.events` — сырые события, `MergeTree`, `PARTITION BY toYYYYMMDD(event_time)`,
|
||||
`ORDER BY (event_time, event_type, user_id)`. Партиционирование по дню выбрано специально:
|
||||
сырые партиции ежедневно удаляются после бэкапа (дешёвый `DROP PARTITION`, без mutation).
|
||||
- `analytics.events_daily` — агрегат (`SummingMergeTree`): `day`, `event_type`, `user_id`,
|
||||
`chat_id`, `events`. Строится материализованным представлением `events_daily_mv`.
|
||||
- `analytics.money_daily` — агрегат по деньгам: `day`, `event_type`, `user_id`,
|
||||
`amount_sum`, `events` (из `payload.amount` событий `money_accrual`/`money_spend`).
|
||||
|
||||
Сообщения **не** пишутся в `analytics.events` отдельным событием `message`.
|
||||
Построчная таблица `bot.messages` удалена миграцией `V27`: старые строки один раз
|
||||
пересчитаны в `analytics.messages_daily`, новые пишутся только счётчиком.
|
||||
Агрегаты остаются на VPS навсегда, поэтому активность, топ команд и денежные потоки
|
||||
доступны и после удаления сырых событий. Запросы к агрегатам суммируют `events`/`amount_sum`
|
||||
(фон слияний `SummingMergeTree` не гарантирует полную свёртку в любой момент).
|
||||
|
||||
## Хранение
|
||||
## Хранение и бэкап
|
||||
|
||||
| Что | Где | Сколько |
|
||||
| --- | --- | --- |
|
||||
| События | Postgres, `analytics.events` | бессрочно (партиции по дням) |
|
||||
| Счётчики сообщений | Postgres, `analytics.messages_daily` | бессрочно (партиции по дням) |
|
||||
| Сырые события | VPS, `analytics.events` | последние `BACKUP_GRACE_DAYS` дней (по умолчанию 7) |
|
||||
| Агрегаты | VPS | постоянно |
|
||||
| Полная история | домашний сервер, свой ClickHouse | архивы `events_YYYY-MM-DD.parquet.gz` (ежедневно) |
|
||||
|
||||
Бэкап — общий бэкап Postgres. Отдельного пайплайна ClickHouse/Parquet больше нет.
|
||||
При росте базы нужно следить за диском: автоматической очистки нет.
|
||||
Каждую ночь (по Москве) домашний сервер **сам забирает** вчерашнюю партицию с VPS (`pull`
|
||||
по ssh), импортирует её в свой ClickHouse и **сверяет количество строк**; только после
|
||||
успешной сверки он просит VPS удалить партиции старше грейса. Ошибка бэкапа данные не
|
||||
удаляет. Подробности — `DEPLOY.md` и `deploy/ssh-access.md`.
|
||||
|
||||
## Уведомления в чат логов
|
||||
|
||||
Помимо записи в Postgres часть событий дублируется сообщением в `ALERT_CHAT_ID`
|
||||
Помимо записи в ClickHouse часть событий дублируется сообщением в `ALERT_CHAT_ID`
|
||||
(если он задан; `0` — выключено). Отправка не критична: ошибки только логируются.
|
||||
|
||||
| Уведомление | Когда | Событие |
|
||||
| Уведомление | Когда | Событие в ClickHouse |
|
||||
| --- | --- | --- |
|
||||
| Вход участника | кто-то вошёл в основной чат | `join` |
|
||||
| Выход участника | кто-то вышел из основного чата | `leave` |
|
||||
|
|
@ -51,19 +54,20 @@ DDL — `sql/V26__analytics_schema.sql`.
|
|||
|
||||
| Поле | Тип | Описание |
|
||||
| --- | --- | --- |
|
||||
| **event_time** | `timestamptz` | Момент события (UTC) |
|
||||
| **event_type** | `text` | Тип события (см. каталог ниже) |
|
||||
| **user_id** | `bigint` | Telegram-ID пользователя (0 — системное событие) |
|
||||
| **username** | `text` | `@username` на момент события (может быть пустым) |
|
||||
| **chat_id** | `bigint` | ID чата (обычно основной чат клана) |
|
||||
| **payload** | `jsonb` | Дополнительные данные события |
|
||||
| **bot_version** | `text` | Версия бота из `common/version.py` |
|
||||
| **prod** | `boolean` | `true` — прод-контур, `false` — тестовый |
|
||||
| **event_time** | `DateTime('UTC')` | Момент события (UTC, без таймзоны) |
|
||||
| **event_type** | `LowCardinality(String)` | Тип события (см. каталог ниже) |
|
||||
| **user_id** | `Int64` | Telegram-ID пользователя (0 — системное событие) |
|
||||
| **username** | `String` | `@username` на момент события (может быть пустым) |
|
||||
| **chat_id** | `Int64` | ID чата (обычно основной чат клана) |
|
||||
| **payload** | `String` | Дополнительные данные события в виде JSON |
|
||||
| **bot_version** | `String` | Версия бота из `common/version.py` |
|
||||
| **prod** | `UInt8` | 1 — прод-контур, 0 — тестовый |
|
||||
|
||||
## Каталог событий
|
||||
|
||||
| Тип события | Когда пишется | `payload` |
|
||||
| --- | --- | --- |
|
||||
| `message` | Любое сообщение в основном чате | `message_id`, `chat_type`, `content_type`, `text_len`, `is_command` |
|
||||
| `command` | Использование команды (`/инфо`, `/очищение`, …) | `command`, `args_count` |
|
||||
| `purification` | Ежедневное очищение (`/очищение`) | `success`, `exp_add`, `money_add` |
|
||||
| `exp_accrual` | Начисление опыта за очищение | `amount`, `streak` |
|
||||
|
|
@ -74,7 +78,7 @@ DDL — `sql/V26__analytics_schema.sql`.
|
|||
| `purchase_title` | Покупка звания | `title_id`, `price`, `success` |
|
||||
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
|
||||
| `join` | Вход участника в основной чат | `chat_title` |
|
||||
| `leave` | Выход/исключение участника | `chat_title`, `kicked`, `by_id`, `by_username` |
|
||||
| `leave` | Выход участника из основного чата | `chat_title` |
|
||||
| `nick_set` | Смена ника (`/ник`) | `nick` |
|
||||
| `subscribe` | Подписка на рассылки (`/подписка`) | — |
|
||||
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — |
|
||||
|
|
@ -84,44 +88,37 @@ DDL — `sql/V26__analytics_schema.sql`.
|
|||
| `bot_alive` | Ежечасный пинг «бот живой» | `app_env`, `uptime_seconds` |
|
||||
| `error` | Необработанная ошибка при обработке апдейта | `error_type`, `error`, `update_type` |
|
||||
|
||||
## Поведение при недоступности Postgres
|
||||
## Поведение при недоступности ClickHouse
|
||||
|
||||
Аналитика — **некритичная** подсистема: её сбой не должен влиять на бота.
|
||||
|
||||
- события и счётчики копятся в буфере и записываются батчами (по размеру и по таймеру);
|
||||
- если Postgres недоступен, буфер не растёт бесконечно: при переполнении самые
|
||||
старые события отбрасываются;
|
||||
- если записать батч не удалось, он выводится в stdout (`WARNING`, первые несколько
|
||||
строк + общее количество) и отбрасывается — данные не пропадают молча;
|
||||
- события копятся в буфере и отправляются батчами (по размеру и по таймеру);
|
||||
- если ClickHouse недоступен, буфер не растёт бесконечно: при переполнении самые
|
||||
старые записи отбрасываются со счётчиком в логе;
|
||||
- если записать батч не удалось, события выводятся в stdout (`WARNING`, первые
|
||||
несколько строк + общее количество) и отбрасываются — данные не пропадают молча;
|
||||
- при остановке бота выполняется финальный сброс буфера;
|
||||
- выключение целиком — `ANALYTICS_ENABLED=false` (тогда `record()`/`record_message()` — no-op).
|
||||
- выключение целиком — `CLICKHOUSE_ENABLED=false` (тогда `record()` — no-op).
|
||||
|
||||
## Примеры запросов
|
||||
|
||||
```sql
|
||||
-- события по дням
|
||||
SELECT date_trunc('day', event_time) AS day, count(*)
|
||||
FROM analytics.events GROUP BY day ORDER BY day;
|
||||
-- активность по дням
|
||||
SELECT toDate(event_time) AS day, count() FROM analytics.events GROUP BY day ORDER BY day;
|
||||
|
||||
-- топ команд за неделю
|
||||
SELECT payload->>'command' AS cmd, count(*) AS uses
|
||||
SELECT JSONExtractString(payload, 'command') AS cmd, count() AS uses
|
||||
FROM analytics.events
|
||||
WHERE event_type = 'command' AND event_time >= now() - interval '7 days'
|
||||
WHERE event_type = 'command' AND event_time >= now() - INTERVAL 7 DAY
|
||||
GROUP BY cmd ORDER BY uses DESC;
|
||||
|
||||
-- покупки за месяц
|
||||
SELECT event_type, count(*), sum((payload->>'price')::int)
|
||||
SELECT event_type, count(), sum(JSONExtractInt(payload, 'price'))
|
||||
FROM analytics.events
|
||||
WHERE event_type IN ('purchase_title', 'purchase_donat')
|
||||
AND event_time >= now() - interval '30 days'
|
||||
WHERE event_type IN ('purchase_title', 'purchase_donat') AND event_time >= now() - INTERVAL 30 DAY
|
||||
GROUP BY event_type;
|
||||
|
||||
-- сообщения по дням
|
||||
SELECT day, sum(count) FROM analytics.messages_daily
|
||||
WHERE day >= current_date - 30 GROUP BY day ORDER BY day;
|
||||
|
||||
-- ошибки
|
||||
SELECT event_time, payload->>'error_type', payload->>'error'
|
||||
FROM analytics.events WHERE event_type = 'error'
|
||||
ORDER BY event_time DESC LIMIT 50;
|
||||
SELECT event_time, JSONExtractString(payload, 'error_type'), JSONExtractString(payload, 'error')
|
||||
FROM analytics.events WHERE event_type = 'error' ORDER BY event_time DESC LIMIT 50;
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
# Последовательности сообщений
|
||||
|
||||
Реализовано в 0.5.0. Позволяет собирать приветствие (и любые другие наборы) из
|
||||
нескольких сообщений по порядку, с картинками и кнопками.
|
||||
|
||||
## Модель
|
||||
|
||||
- `bot.message_sequences` — `key`, `title`, `description`, `updated_at/by`.
|
||||
- `bot.message_sequence_items` — сообщения: `sequence_key`, `position`, `text`,
|
||||
`enabled`, `image_name` (файл в общем volume `media/`).
|
||||
- `bot.message_sequence_buttons` — кнопки конкретного сообщения: `item_id`,
|
||||
`position`, `text`, `url`. Кнопок может быть несколько, каждая на своей строке.
|
||||
- `bot.message_sequence_history` — JSON-снимок последовательности при каждом изменении.
|
||||
|
||||
Картинки не лежат в Postgres: они в Docker-volume `media`, который монтируется в
|
||||
`/app/media` и боту, и админке. При старте админка копирует туда встроенное
|
||||
`images/welcome_image.jpg`, если файла ещё нет.
|
||||
|
||||
## Отправка ботом
|
||||
|
||||
`bot/services/sequences.py` читает включённые сообщения по `position` и отправляет:
|
||||
|
||||
- с картинкой → `send_photo` (текст подписью, если ≤ 1024 символов; иначе фото без
|
||||
подписи и отдельное сообщение);
|
||||
- без картинки → `send_message`;
|
||||
- кнопки — `InlineKeyboardMarkup`, по одной в ряд.
|
||||
|
||||
Для `welcome` при отсутствии/пустоте последовательности остаётся фолбэк на старые
|
||||
шаблоны `welcome.text*` из кода.
|
||||
|
||||
## Админка
|
||||
|
||||
`/sequences` — список и создание; `/sequences/{key}` — редактор:
|
||||
|
||||
- параметры последовательности;
|
||||
- сообщения по порядку: текст, картинка (загрузка/удаление), вкл/выкл, кнопки,
|
||||
перемещение вверх/вниз, удаление;
|
||||
- предпросмотр и история изменений.
|
||||
|
||||
Текст редактируется в textarea с тулбаром: жирный `<b>`, курсив `<i>`,
|
||||
подчёркивание `<u>`, зачёркивание `<s>`, код `<code>`, блок `<pre>`,
|
||||
цитата `<blockquote>`, спойлер `<tg-spoiler>`, ссылка `<a href>`. Есть счётчик
|
||||
символов (1024 для подписи к фото, 4096 для обычного сообщения) и предпросмотр
|
||||
через тот же `render()`, что использует бот.
|
||||
|
||||
Картинки отдаются по `/sequences/media/{filename}` (только после входа).
|
||||
|
||||
## Миграции
|
||||
|
||||
- `V20__message_sequences.sql` — таблицы и история.
|
||||
- `V21__seed_welcome_sequence.sql` — перенос текущего приветствия в `welcome`
|
||||
(подпись к фото + остальной текст с кнопкой «Канал») и удаление старых
|
||||
шаблонов `welcome.text*` из `message_templates`.
|
||||
|
||||
## Проверки
|
||||
|
||||
`tests/test_admin_sequences.py` — список, редактор, создание, параметры, добавление,
|
||||
сохранение текста/кнопок, лимиты, загрузка картинки, порядок и удаление;
|
||||
`tests/test_welcome_split.py` — фолбэк приветствия.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Параметры бэкапа событий 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=
|
||||
|
|
@ -12,12 +12,6 @@ REGISTRY=git.mpakosrv.ru
|
|||
IMAGE_TAG=latest
|
||||
|
||||
BOT_TOKEN=
|
||||
# Username бота без @ (кнопка Telegram-входа в админку)
|
||||
BOT_USERNAME=
|
||||
# Публичный HTTPS-адрес админки (Telegram Login Widget)
|
||||
ADMIN_PUBLIC_URL=https://debot.mpakosrv.ru
|
||||
# Секрет подписи cookie-сессии: openssl rand -hex 32
|
||||
ADMIN_SESSION_SECRET=
|
||||
|
||||
# Postgres (для контейнера бота host = имя сервиса compose)
|
||||
DB_HOST=postgres
|
||||
|
|
@ -37,18 +31,11 @@ ALERT_CHAT_ID=0
|
|||
ADMIN_IDS=
|
||||
TIMEZONE=Europe/Moscow
|
||||
|
||||
# Аналитика (Postgres, схема analytics; см. docs/events.md).
|
||||
ANALYTICS_ENABLED=true
|
||||
|
||||
# Админка (FastAPI). Порт на хосте; у test и prod должен различаться
|
||||
# (test 8100, prod 8101 — 8000 занят Portainer).
|
||||
# ADMIN_BIND_IP=127.0.0.1 — только хост; для nginx-proxy-manager поставьте 172.17.0.1.
|
||||
ADMIN_HTTP_PORT=8101
|
||||
ADMIN_BIND_IP=127.0.0.1
|
||||
|
||||
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
||||
TEMPLATE_CACHE_TTL=45
|
||||
|
||||
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
|
||||
EGG_EVENT_START=
|
||||
EGG_EVENT_END=
|
||||
# ClickHouse (аналитика событий, см. docs/events.md).
|
||||
# Наружу не публикуется: только loopback хоста для бэкапа/туннеля;
|
||||
# бот ходит внутрь сети compose по имени сервиса clickhouse:8123.
|
||||
CLICKHOUSE_DB=analytics
|
||||
CLICKHOUSE_USER=de_bot
|
||||
CLICKHOUSE_PASSWORD=
|
||||
# Порт на хосте (для бэкапа). У test и prod он должен различаться.
|
||||
CLICKHOUSE_HTTP_PORT=8123
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@ REGISTRY=git.mpakosrv.ru
|
|||
IMAGE_TAG=latest
|
||||
|
||||
BOT_TOKEN=
|
||||
# Username бота без @ (кнопка Telegram-входа в админку)
|
||||
BOT_USERNAME=
|
||||
# Публичный HTTPS-адрес админки (Telegram Login Widget)
|
||||
ADMIN_PUBLIC_URL=https://debot-test.mpakosrv.ru
|
||||
# Секрет подписи cookie-сессии: openssl rand -hex 32
|
||||
ADMIN_SESSION_SECRET=
|
||||
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
|
|
@ -35,18 +29,11 @@ ALERT_CHAT_ID=0
|
|||
ADMIN_IDS=
|
||||
TIMEZONE=Europe/Moscow
|
||||
|
||||
# Аналитика (Postgres, схема analytics; см. docs/events.md).
|
||||
ANALYTICS_ENABLED=true
|
||||
|
||||
# Админка (FastAPI). Порт на хосте; у test и prod должен различаться
|
||||
# (test 8100, prod 8101 — 8000 занят Portainer).
|
||||
# ADMIN_BIND_IP=127.0.0.1 — только хост; для nginx-proxy-manager поставьте 172.17.0.1.
|
||||
ADMIN_HTTP_PORT=8100
|
||||
ADMIN_BIND_IP=127.0.0.1
|
||||
|
||||
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
||||
TEMPLATE_CACHE_TTL=45
|
||||
|
||||
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
|
||||
EGG_EVENT_START=
|
||||
EGG_EVENT_END=
|
||||
# ClickHouse (аналитика событий, см. docs/events.md).
|
||||
# Наружу не публикуется: только loopback хоста для бэкапа/туннеля;
|
||||
# бот ходит внутрь сети compose по имени сервиса clickhouse:8123.
|
||||
CLICKHOUSE_DB=analytics
|
||||
CLICKHOUSE_USER=de_bot
|
||||
CLICKHOUSE_PASSWORD=
|
||||
# Порт на хосте: у test он отличается от prod (8123), иначе конфликт на одной VPS.
|
||||
CLICKHOUSE_HTTP_PORT=8124
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
# Зависимости сервиса админки (FastAPI). Бот использует requirements.txt.
|
||||
# Python 3.12
|
||||
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
jinja2>=3.1,<4.0
|
||||
|
||||
pydantic>=2.7,<3.0
|
||||
pydantic-settings>=2.3,<3.0
|
||||
|
||||
# Подписанные cookie-сессии (Starlette SessionMiddleware).
|
||||
itsdangerous>=2.2,<3.0
|
||||
|
||||
# Таймзоны для проверки расписаний (в slim-образах нет системного tzdata).
|
||||
tzdata>=2024.1
|
||||
|
||||
# Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей.
|
||||
psycopg[binary,pool]>=3.2,<4.0
|
||||
|
||||
# Telegram Bot API для модерации (исключение из чата) и уведомлений.
|
||||
httpx>=0.27,<1.0
|
||||
|
||||
# Разбор form-data (HTMX-формы).
|
||||
python-multipart>=0.0.9,<1.0
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
-r requirements.txt
|
||||
# Админка тестируется в том же прогоне: fastapi, jinja2, itsdangerous.
|
||||
-r requirements-admin.txt
|
||||
|
||||
ruff>=0.6,<1.0
|
||||
black>=24.4,<25.0
|
||||
pytest>=8.2,<9.0
|
||||
pytest-asyncio>=0.23,<1.0
|
||||
pytest-cov>=5.0,<6.0
|
||||
# TestClient для FastAPI-админки.
|
||||
httpx>=0.27,<1.0
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ aiogram>=3.7,<4.0
|
|||
pydantic>=2.7,<3.0
|
||||
pydantic-settings>=2.3,<3.0
|
||||
python-dotenv>=1.0,<2.0
|
||||
# База таймзон для zoneinfo: в slim-образах нет системного tzdata,
|
||||
# без пакета ZoneInfo("Europe/Moscow") падает (расписания и настройка TIMEZONE).
|
||||
tzdata>=2024.1
|
||||
psycopg[binary,pool]>=3.2,<4.0
|
||||
|
||||
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.
|
||||
clickhouse-connect[async]>=1.0,<2.0
|
||||
# Блок 4 (расписания из БД) — при необходимости:
|
||||
# APScheduler>=3.10,<4.0
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ echo "== проверки =="
|
|||
|
||||
running="$($COMPOSE ps --services --status running || true)"
|
||||
fail=0
|
||||
for svc in postgres bot admin; do
|
||||
for svc in postgres clickhouse bot; do
|
||||
if echo "$running" | grep -qx "$svc"; then
|
||||
echo "OK ${svc}: running"
|
||||
else
|
||||
|
|
@ -49,11 +49,17 @@ else
|
|||
fail=1
|
||||
fi
|
||||
|
||||
# Схема аналитики в Postgres (создаётся миграцией V26)
|
||||
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
|
||||
echo "OK postgres: схема analytics доступна"
|
||||
# ClickHouse отвечает и схема аналитики применена (initdb применяется один раз,
|
||||
# при первой инициализации пустого data-каталога)
|
||||
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 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
|
||||
echo "WARN clickhouse: нет доступа к analytics.events (см. clickhouse/init/001_events.sql)" >&2
|
||||
fi
|
||||
else
|
||||
echo "FAIL postgres: нет таблиц analytics.events/messages_daily (см. sql/V26__analytics_schema.sql)" >&2
|
||||
echo "FAIL clickhouse: не отвечает" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
#!/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` уже внутри контейнера.
|
||||
vps_ch() {
|
||||
"${SSH[@]}" bash -s "$VPS_PROJECT_DIR" "$E" "$VPS_COMPOSE_FILE" "$@" <<'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} дней сырых событий"
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
#!/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 $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT tu.username, du.nick, sum(m.count)::bigint AS cnt
|
||||
FROM analytics.messages_daily m
|
||||
INNER JOIN bot.tg_users tu ON m.user_id = tu.id_user
|
||||
INNER JOIN bot.de_users du ON m.user_id = du.id_user
|
||||
SELECT tu.username, du.nick, count(*) AS cnt
|
||||
FROM bot.messages m
|
||||
INNER JOIN bot.tg_users tu ON m.id_user = tu.id_user
|
||||
INNER JOIN bot.de_users du ON m.id_user = du.id_user
|
||||
GROUP BY tu.username, du.nick
|
||||
ORDER BY 3 DESC
|
||||
LIMIT 10;
|
||||
|
|
|
|||
|
|
@ -75,15 +75,15 @@ BEGIN
|
|||
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 (
|
||||
SELECT user_id, sum(count)::bigint AS msg_count
|
||||
FROM analytics.messages_daily
|
||||
GROUP BY user_id
|
||||
) m ON tg.id_user = m.user_id
|
||||
SELECT id_user, count(*) AS msg_count
|
||||
FROM bot.messages
|
||||
GROUP BY id_user
|
||||
) m ON tg.id_user = m.id_user
|
||||
WHERE tg.id_user = p_id_user;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Создать/обновить пользователя.
|
||||
-- Создать/обновить пользователя и записать сообщение.
|
||||
CREATE OR REPLACE PROCEDURE bot.add_user(
|
||||
IN p_id_user bigint,
|
||||
IN p_username character varying,
|
||||
|
|
@ -102,6 +102,8 @@ BEGIN
|
|||
INSERT INTO bot.de_users (id_user, nick)
|
||||
VALUES (p_id_user, NULL)
|
||||
ON CONFLICT (id_user) DO NOTHING;
|
||||
|
||||
INSERT INTO bot.messages (id_user) VALUES (p_id_user);
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
|
@ -125,7 +127,7 @@ DECLARE
|
|||
BEGIN
|
||||
DELETE FROM bot.tg_users WHERE id_user = p_id_user;
|
||||
DELETE FROM bot.de_users WHERE id_user = p_id_user;
|
||||
DELETE FROM analytics.messages_daily WHERE user_id = p_id_user;
|
||||
DELETE FROM bot.messages WHERE id_user = p_id_user;
|
||||
DELETE FROM bot.purifications WHERE id_user = p_id_user;
|
||||
|
||||
v_money := bot.get_money(p_id_user);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue