Compare commits

..

5 Commits

Author SHA1 Message Date
MpakobecEx 3347dacbb5 Справочник званий редактируется из админки
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 18s Details
Появился раздел «Звания»: название, цена и видимость; изменения сразу видит бот, потому что читает bot.titles напрямую. Удалить можно только звание, которое ни у кого не стоит, иначе предлагается скрыть — данные игроков не ломаются.
2026-10-25 22:03:07 +05:00
MpakobecEx ae6faeee1e Все сообщения бота переехали в последовательности
Миграция V24 создаёт последовательность из каждого старого шаблона, а команды, магазин и рассылки теперь отправляются через общий sender: один и тот же формат с картинками и кнопками. Старые шаблоны остаются фолбэком в коде, поэтому сбой БД не оставит бота без текстов. Раздел «Сообщения» убран — редактирование только в «Последовательностях», предпросмотр вынесен в общий /preview.
2026-10-25 22:01:24 +05:00
MpakobecEx 84fc104717 В админку можно войти через установленный Telegram
К виджету добавился deep-link: сайт создаёт одноразовый токен на 5 минут и открывает t.me/<bot>?start=login_<token>, бот проверяет администратора и подтверждает вход, а сайт забирает токен и открывает сессию. В БД хранится только SHA-256 токена; ссылка одноразовая. Если deep-link не настроен, остаётся обычный виджет.
2026-10-25 21:54:50 +05:00
MpakobecEx 08c57f9b39 Из админки можно отправить сообщение в чат от имени бота
Появился раздел «Отправить»: текст с тем же редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Отправка идёт в основной чат через Bot API; успешные и неуспешные попытки пишутся в bot.admin_messages (кто, когда, что и какая ошибка).
2026-10-25 21:52:00 +05:00
MpakobecEx cfa0e29bbf Админка стала удобной с телефона
Навигация сворачивается в бургер-меню, второстепенные колонки таблиц скрываются на узких экранах, формы и тулбар редактора нормально складываются в одну колонку. Основные действия и ссылки остаются доступны на ширине 360–390 px.
2026-10-25 21:47:06 +05:00
47 changed files with 1603 additions and 874 deletions

View File

@ -55,12 +55,10 @@ APP_ENV=prod python -m bot # prod
## Админка (Блок 4) ## Админка (Блок 4)
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`. Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение Все тексты бота — **последовательности** (`bot.message_sequences`): миграция `V24`
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты — переносит каждый старый шаблон в последовательность из одного сообщения, а бот
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их). отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
Предпросмотр использует тот же `render()`, что и бот.
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ **Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе). есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
@ -68,6 +66,11 @@ APP_ENV=prod python -m bot # prod
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`. `BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
Дополнительно — **вход через установленный Telegram**: кнопка «Открыть Telegram»
создаёт одноразовый токен (5 минут) и открывает `t.me/<bot>?start=login_<token>`;
бот подтверждает вход, сайт забирает токен и открывает сессию. Токен в БД хранится
как SHA-256 и используется один раз.
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели, Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`). база недоступна — использует расписание из кода (совпадает с сидом `V16`).
@ -79,6 +82,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
`bot.user_changes`: кто, когда, было → стало и причина. `bot.user_changes`: кто, когда, было → стало и причина.
Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу
видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого
не стоит; используемое — скрывается (`visible = false`).
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username) Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON, и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом; ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
@ -87,8 +94,7 @@ ID пользователя ведёт в его карточку. ClickHouse ч
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл. Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ, пользователь через `/ник` или админ.
описание, текст и предпросмотр.
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие): Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие):
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок. у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
@ -96,6 +102,13 @@ ID пользователя ведёт в его карточку. ClickHouse ч
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
(1024 для подписи к фото, 4096 для обычного сообщения). (1024 для подписи к фото, 4096 для обычного сообщения).
Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц
скрываются на узких экранах, формы складываются в одну колонку.
Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же
редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка
(успешная или нет) пишется в `bot.admin_messages`.
## Аналитика (ClickHouse) ## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
@ -158,7 +171,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.2`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.1`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware
from admin.media import ensure_media from admin.media import ensure_media
from admin.middleware import AdminAuthMiddleware from admin.middleware import AdminAuthMiddleware
from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, users from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, titles, users
from common import __version__ from common import __version__
from common.analytics import read as analytics_read from common.analytics import read as analytics_read
from common.config import AdminSettings, get_admin_settings from common.config import AdminSettings, get_admin_settings
@ -89,10 +89,12 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(messages.router) app.include_router(preview.router)
app.include_router(sequences.router) app.include_router(sequences.router)
app.include_router(titles.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(admins.router) app.include_router(admins.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(send.router)
return app return app

33
admin/buttons.py Normal file
View File

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

View File

@ -6,6 +6,8 @@ import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
from starlette.datastructures import UploadFile
from common.config import AdminSettings from common.config import AdminSettings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -16,6 +18,23 @@ BUILTIN_WELCOME = Path("images/welcome_image.jpg")
#: Имя файла в volume, которое ждёт сид последовательности `welcome`. #: Имя файла в volume, которое ждёт сид последовательности `welcome`.
WELCOME_IMAGE_NAME = "welcome_image.jpg" WELCOME_IMAGE_NAME = "welcome_image.jpg"
#: Допустимые типы и максимальный размер загружаемых картинок.
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_IMAGE_BYTES = 5 * 1024 * 1024
async def read_image(upload: UploadFile) -> tuple[bytes, str, str]:
"""Прочитать картинку из формы. Возвращает (данные, mime, расширение)."""
content_type = (upload.content_type or "").lower()
extension = ALLOWED_IMAGE_TYPES.get(content_type)
if extension is None:
raise ValueError("Картинка должна быть JPEG, PNG или WebP")
data = await upload.read()
if len(data) > MAX_IMAGE_BYTES:
raise ValueError("Картинка больше 5 МБ")
return data, content_type, extension
def ensure_media(settings: AdminSettings) -> Path: def ensure_media(settings: AdminSettings) -> Path:
"""Создать каталог и положить туда встроенное приветствие, если его нет.""" """Создать каталог и положить туда встроенное приветствие, если его нет."""

View File

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

View File

@ -6,11 +6,11 @@ import logging
import secrets import secrets
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
from common import __version__ from common import __version__
from common.repositories import admins_repo from common.repositories import admin_login_tokens_repo, admins_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,11 +45,81 @@ async def login_page(request: Request, next: str = "/") -> Response:
"bot_username": settings.bot_username, "bot_username": settings.bot_username,
"public_url": settings.admin_public_url.rstrip("/"), "public_url": settings.admin_public_url.rstrip("/"),
"configured": bool(settings.bot_token and settings.bot_username), "configured": bool(settings.bot_token and settings.bot_username),
"deeplink": bool(settings.bot_username and settings.admin_public_url),
"deeplink_pending": bool(request.cookies.get("debot_login_token")),
"deeplink_error": request.query_params.get("error") == "deeplink",
"next": target, "next": target,
}, },
) )
@router.get("/auth/deeplink/start")
async def deeplink_start(request: Request) -> Response:
"""Создать токен и открыть установленный Telegram с deep-link."""
settings = request.app.state.settings
username = settings.bot_username.lstrip("@")
if not username:
return RedirectResponse(url="/login?error=deeplink", status_code=303)
token = await admin_login_tokens_repo.create_token()
response = RedirectResponse(
url=f"https://t.me/{username}?start=login_{token}",
status_code=303,
)
response.set_cookie(
"debot_login_token",
token,
max_age=300,
httponly=True,
samesite="lax",
secure=settings.is_prod,
)
return response
@router.get("/auth/deeplink/status")
async def deeplink_status(request: Request) -> JSONResponse:
"""Проверить, подтвердил ли пользователь вход в боте, и открыть сессию."""
token = request.cookies.get("debot_login_token")
if not token:
return JSONResponse({"status": "none"})
try:
telegram_id = await admin_login_tokens_repo.consume_token(token)
except Exception:
logger.exception("Не удалось проверить deep-link токен")
return JSONResponse({"status": "pending"})
if telegram_id is None:
return JSONResponse({"status": "pending"})
try:
admin = await admins_repo.get_admin(telegram_id)
except Exception:
logger.exception("Ошибка проверки администратора при deep-link входе")
return JSONResponse({"status": "pending"})
if admin is None or not admin["enabled"]:
response = JSONResponse({"status": "denied"})
response.delete_cookie("debot_login_token")
return response
request.session.clear()
request.session["admin_id"] = telegram_id
request.session["admin_username"] = admin["username"] or ""
request.session["csrf"] = secrets.token_urlsafe(32)
try:
await admins_repo.touch_login(telegram_id)
except Exception:
logger.warning("Не удалось обновить last_login_at", exc_info=True)
logger.info("Вход в админку по deep-link: %s", telegram_id)
response = JSONResponse({"status": "ok"})
response.delete_cookie("debot_login_token")
return response
@router.get("/auth/telegram") @router.get("/auth/telegram")
async def telegram_callback(request: Request) -> Response: async def telegram_callback(request: Request) -> Response:
"""Проверить подпись Telegram, роль в БД и открыть сессию.""" """Проверить подпись Telegram, роль в БД и открыть сессию."""

View File

@ -1,228 +0,0 @@
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (HTMX)."""
from __future__ import annotations
import html
import re
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.preview import SAMPLE_CONTEXT
from admin.security import check_csrf
from common import __version__
from common.repositories import templates_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError, render, validate
router = APIRouter(prefix="/messages", tags=["messages"])
#: Формат ключа нового шаблона (латиница, цифры, точка, подчёркивание, дефис).
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,63}$")
#: Максимальная длина описания шаблона.
MAX_DESCRIPTION_LENGTH = 200
def _editor_context(
request: Request,
template: dict[str, object],
*,
text: str | None = None,
errors: list[str] | None = None,
saved: bool = False,
history: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""Контекст страницы редактора (общий для GET и повторного показа после ошибки)."""
return {
"version": __version__,
"template": template,
"text": text if text is not None else template["text"],
"errors": errors or [],
"saved": saved,
"history": history or [],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
}
@router.get("", response_class=HTMLResponse)
async def list_messages(request: Request) -> HTMLResponse:
"""Список шаблонов: ключ, описание, длина и время последней правки."""
templates = request.app.state.templates
return templates.TemplateResponse(
request=request,
name="messages/list.html",
context={"version": __version__, "templates": await templates_repo.list_templates()},
)
@router.get("/new", response_class=HTMLResponse)
async def new_template(request: Request) -> HTMLResponse:
"""Форма создания нового шаблона."""
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/new.html",
context={
"version": __version__,
"key": "",
"description": "",
"text": "",
"enabled": True,
"errors": [],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
"max_description_length": MAX_DESCRIPTION_LENGTH,
},
)
@router.post("")
async def create_template(request: Request) -> Response:
"""Создать шаблон (ключ проверяется на формат и занятость)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
key = str(form.get("key", "")).strip()
description = str(form.get("description", "")).strip()
text = str(form.get("text", ""))
enabled = form.get("enabled") is not None
errors: list[str] = []
if not KEY_RE.match(key):
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–64 символа)")
if not text.strip():
errors.append("Текст не может быть пустым")
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
if len(text) > MAX_MESSAGE_LENGTH:
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
if len(description) > MAX_DESCRIPTION_LENGTH:
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
if errors:
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/new.html",
context={
"version": __version__,
"key": key,
"description": description,
"text": text,
"enabled": enabled,
"errors": errors,
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
"max_description_length": MAX_DESCRIPTION_LENGTH,
},
status_code=400,
)
created = await templates_repo.create_template(
key,
text,
description,
enabled=enabled,
editor_id=int(request.state.admin["telegram_id"]),
)
if not created:
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/new.html",
context={
"version": __version__,
"key": key,
"description": description,
"text": text,
"enabled": enabled,
"errors": ["Шаблон с таким ключом уже существует"],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
"max_description_length": MAX_DESCRIPTION_LENGTH,
},
status_code=400,
)
return RedirectResponse(url=f"/messages/{key}", status_code=303)
@router.post("/preview", response_class=HTMLResponse)
async def preview_message(request: Request) -> HTMLResponse:
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
Результат кладётся в ``iframe sandbox``: разметка Telegram видна «как в чате»,
но скрипты из введённого текста не выполняются.
"""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
text = str(form.get("text", ""))
unknown = validate(text)
if unknown:
return HTMLResponse(
'<p class="text-sm text-red-600">Неизвестные теги: ' + html.escape(", ".join(unknown)) + "</p>"
)
try:
rendered = render(text, SAMPLE_CONTEXT)
except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
return HTMLResponse(f'<p class="text-sm text-red-600">{html.escape(str(exc))}</p>')
if len(rendered) > MAX_MESSAGE_LENGTH:
return HTMLResponse(
f'<p class="text-sm text-red-600">Текст длиннее лимита Telegram: '
f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.</p>"
)
return HTMLResponse(
'<iframe sandbox class="h-64 w-full rounded border border-slate-200 bg-white" '
f'srcdoc="{html.escape(rendered, quote=True)}"></iframe>'
)
@router.post("/{key}")
async def save_message(request: Request, key: str) -> Response:
"""Сохранить текст шаблона; при ошибках вернуть редактор с подсветкой."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
text = str(form.get("text", ""))
template = await templates_repo.get_template(key)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
errors: list[str] = []
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
if not text.strip():
errors.append("Текст не может быть пустым")
if len(text) > MAX_MESSAGE_LENGTH:
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
if errors:
history = await templates_repo.template_history(key)
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/edit.html",
context=_editor_context(request, template, text=text, errors=errors, history=history),
status_code=400,
)
editor_id = int(request.state.admin["telegram_id"])
await templates_repo.update_template(key, text, editor_id=editor_id)
return RedirectResponse(url=f"/messages/{key}?saved=1", status_code=303)
@router.get("/{key}", response_class=HTMLResponse)
async def edit_message(request: Request, key: str, saved: bool = False) -> HTMLResponse:
"""Редактор шаблона: текст, подсказка по тегам, предпросмотр и история."""
template = await templates_repo.get_template(key)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
history = await templates_repo.template_history(key)
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/edit.html",
context=_editor_context(request, template, saved=saved, history=history),
)

View File

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

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

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

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

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

View File

@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from starlette.datastructures import UploadFile from starlette.datastructures import UploadFile
from admin.media import media_path from admin.buttons import MAX_BUTTONS, parse_buttons
from admin.media import media_path, read_image
from admin.security import check_csrf from admin.security import check_csrf
from common import __version__ from common import __version__
from common.repositories import sequences_repo from common.repositories import sequences_repo
@ -26,12 +27,6 @@ KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
#: Лимит подписи к фото. #: Лимит подписи к фото.
MAX_CAPTION = 1024 MAX_CAPTION = 1024
#: Сколько кнопок можно повесить на одно сообщение.
MAX_BUTTONS = 8
#: Допустимые типы картинок.
ALLOWED_IMAGES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
#: Максимальный размер картинки.
MAX_IMAGE_BYTES = 5 * 1024 * 1024
def _editor_id(request: Request) -> int: def _editor_id(request: Request) -> int:
@ -44,52 +39,10 @@ def _prepare_items(items: list[dict[str, Any]]) -> None:
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"]) item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"])
def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]:
"""Разобрать кнопки: по одной в строке, формат ``Текст | https://…``."""
buttons: list[tuple[str, str]] = []
errors: list[str] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if "|" not in line:
errors.append(f"Кнопка без разделителя «|»: {line}")
continue
text, url = (part.strip() for part in line.split("|", 1))
if not text:
errors.append("У кнопки пустой текст")
continue
if not url.startswith(("https://", "http://")):
errors.append(f"Ссылка кнопки должна начинаться с http(s): {url}")
continue
if len(text) > 64:
errors.append("Текст кнопки длиннее 64 символов")
continue
buttons.append((text, url))
if len(buttons) > MAX_BUTTONS:
errors.append(f"Не больше {MAX_BUTTONS} кнопок на сообщение")
return buttons, errors
def _image_error(upload: UploadFile) -> str | None:
content_type = (upload.content_type or "").lower()
if content_type not in ALLOWED_IMAGES:
return "Картинка должна быть JPEG, PNG или WebP"
return None
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str: async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
error = _image_error(upload) data, _, extension = await read_image(upload)
if error:
raise ValueError(error)
data = await upload.read() name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
if len(data) > MAX_IMAGE_BYTES:
raise ValueError("Картинка больше 5 МБ")
name = f"{key}_{item_id}_{uuid.uuid4().hex}{ALLOWED_IMAGES[(upload.content_type or '').lower()]}"
path = media_path(settings, name) path = media_path(settings, name)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data) path.write_bytes(data)

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

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

View File

@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Any from typing import Any
@ -22,6 +23,16 @@ class TelegramError(RuntimeError):
"""Telegram отклонил запрос или недоступен.""" """Telegram отклонил запрос или недоступен."""
def _check(response: httpx.Response) -> Any:
try:
data = response.json()
except ValueError as exc:
raise TelegramError("Telegram вернул некорректный ответ") from exc
if not data.get("ok"):
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
return data.get("result")
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any: async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
if not settings.bot_token: if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан") raise TelegramError("BOT_TOKEN не задан")
@ -32,15 +43,25 @@ async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -
response = await client.post(url, json=payload) response = await client.post(url, json=payload)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc raise TelegramError(f"Telegram недоступен: {exc}") from exc
return _check(response)
async def _call_multipart(
settings: AdminSettings,
method: str,
data: dict[str, str],
files: dict[str, tuple[str, bytes, str]],
) -> Any:
if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан")
url = _API.format(token=settings.bot_token, method=method)
try: try:
data = response.json() async with httpx.AsyncClient(timeout=30.0) as client:
except ValueError as exc: response = await client.post(url, data=data, files=files)
raise TelegramError("Telegram вернул некорректный ответ") from exc except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc
if not data.get("ok"): return _check(response)
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
return data.get("result")
async def kick_member(settings: AdminSettings, user_id: int) -> None: async def kick_member(settings: AdminSettings, user_id: int) -> None:
@ -81,3 +102,57 @@ async def send_alert(settings: AdminSettings, text: str) -> None:
) )
except TelegramError: except TelegramError:
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True) logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)
async def send_to_chat(
settings: AdminSettings,
*,
text: str,
buttons: list[tuple[str, str]],
photo: tuple[bytes, str, str] | None = None,
) -> None:
"""Отправить сообщение в основной чат от имени бота.
``photo`` — (данные, mime, имя файла). При фото длинный текст уходит
отдельным сообщением (лимит подписи 1024).
"""
if not settings.chat_id:
raise TelegramError("CHAT_ID не задан")
rows = [[{"text": title, "url": url}] for title, url in buttons]
markup = {"inline_keyboard": rows} if rows else None
if photo is None:
await _call(
settings,
"sendMessage",
{
"chat_id": settings.chat_id,
"text": text,
"parse_mode": "HTML",
"reply_markup": markup,
},
)
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:
await _call(
settings,
"sendMessage",
{
"chat_id": settings.chat_id,
"text": text,
"parse_mode": "HTML",
"reply_markup": markup,
},
)

View File

@ -81,7 +81,7 @@
<th class="px-4 py-3 font-medium">Username</th> <th class="px-4 py-3 font-medium">Username</th>
<th class="px-4 py-3 font-medium">Роль</th> <th class="px-4 py-3 font-medium">Роль</th>
<th class="px-4 py-3 font-medium">Доступ</th> <th class="px-4 py-3 font-medium">Доступ</th>
<th class="px-4 py-3 font-medium">Последний вход</th> <th class="hidden px-4 py-3 font-medium lg:table-cell">Последний вход</th>
<th class="px-4 py-3 font-medium"></th> <th class="px-4 py-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
@ -116,7 +116,7 @@
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span> <span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3 text-slate-600"> <td class="hidden px-4 py-3 text-slate-600 lg:table-cell">
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }} {{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">

View File

@ -7,6 +7,12 @@
<h1 class="text-2xl font-semibold">Вход в админку</h1> <h1 class="text-2xl font-semibold">Вход в админку</h1>
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p> <p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p>
{% if deeplink_error %}
<p class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Deep-link вход не настроен: проверьте <span class="font-mono">BOT_USERNAME</span>.
</p>
{% endif %}
{% if configured %} {% if configured %}
<div class="mt-8 flex justify-center"> <div class="mt-8 flex justify-center">
<script <script
@ -18,11 +24,51 @@
data-request-access="write" data-request-access="write"
></script> ></script>
</div> </div>
{% else %} {% endif %}
{% if deeplink %}
<div class="mt-6 border-t border-slate-100 pt-5">
<a
href="/auth/deeplink/start"
class="inline-block rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Открыть Telegram
</a>
<p class="mt-2 text-xs text-slate-500">Подтвердите вход в установленном приложении Telegram.</p>
<p id="deeplink-status" class="mt-2 text-sm text-sky-700">
{% if deeplink_pending %}Ожидаем подтверждение в Telegram…{% endif %}
</p>
</div>
{% endif %}
{% if not configured and not deeplink %}
<p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"> <p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>, Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>,
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>. <span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
</p> </p>
{% endif %} {% endif %}
</div> </div>
{% if deeplink_pending %}
<script>
(function () {
var status = document.getElementById("deeplink-status");
var timer = setInterval(function () {
fetch("/auth/deeplink/status", { credentials: "same-origin" })
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.status === "ok") {
clearInterval(timer);
window.location.href = "/";
} else if (data.status === "denied") {
clearInterval(timer);
if (status) status.textContent = "У вас нет доступа к админке.";
}
})
.catch(function () {});
}, 2000);
setTimeout(function () { clearInterval(timer); }, 300000);
})();
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -9,26 +9,40 @@
</head> </head>
<body class="min-h-screen bg-slate-100 text-slate-900"> <body class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white"> <header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4"> <div class="mx-auto max-w-5xl px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<a href="/" class="text-lg font-semibold">DispelEvilBot</a> <a href="/" class="text-lg font-semibold">DispelEvilBot</a>
<nav class="flex items-center gap-4 text-sm"> <button
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a> id="nav-toggle"
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a> type="button"
<a href="/sequences" class="text-slate-600 hover:text-slate-900">Последовательности</a> class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-600 sm:hidden"
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a> aria-expanded="false"
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a> >
<a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a> Меню
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a> </button>
<nav
id="nav-menu"
class="hidden w-full flex-col gap-2 border-t border-slate-100 pt-2 text-sm sm:flex sm:w-auto sm:flex-row sm:items-center sm:gap-4 sm:border-0 sm:pt-0"
>
<a href="/" class="py-1 text-slate-600 hover:text-slate-900">Главная</a>
<a href="/sequences" class="py-1 text-slate-600 hover:text-slate-900">Последовательности</a>
<a href="/schedules" class="py-1 text-slate-600 hover:text-slate-900">Расписания</a>
<a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a>
<a href="/titles" class="py-1 text-slate-600 hover:text-slate-900">Звания</a>
<a href="/admins" class="py-1 text-slate-600 hover:text-slate-900">Админы</a>
<a href="/logs" class="py-1 text-slate-600 hover:text-slate-900">Логи</a>
<a href="/send" class="py-1 text-slate-600 hover:text-slate-900">Отправить</a>
{% if request.session.get('admin_id') %} {% if request.session.get('admin_id') %}
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span> <span class="py-1 text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
<form method="post" action="/logout"> <form method="post" action="/logout" class="py-1">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" /> <input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-slate-600 hover:text-slate-900">Выйти</button> <button type="submit" class="text-slate-600 hover:text-slate-900">Выйти</button>
</form> </form>
{% endif %} {% endif %}
<span class="text-slate-500">админка · v{{ version }}</span> <span class="py-1 text-slate-500">админка · v{{ version }}</span>
</nav> </nav>
</div> </div>
</div>
</header> </header>
<main class="mx-auto max-w-5xl px-4 py-8"> <main class="mx-auto max-w-5xl px-4 py-8">
@ -39,6 +53,16 @@
<script> <script>
document.addEventListener("DOMContentLoaded", function () { 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 tags = { var tags = {
b: ["<b>", "</b>"], b: ["<b>", "</b>"],
i: ["<i>", "</i>"], i: ["<i>", "</i>"],

View File

@ -72,8 +72,8 @@
<th class="px-4 py-3 font-medium">Время</th> <th class="px-4 py-3 font-medium">Время</th>
<th class="px-4 py-3 font-medium">Событие</th> <th class="px-4 py-3 font-medium">Событие</th>
<th class="px-4 py-3 font-medium">Пользователь</th> <th class="px-4 py-3 font-medium">Пользователь</th>
<th class="px-4 py-3 font-medium">Чат</th> <th class="hidden px-4 py-3 font-medium lg:table-cell">Чат</th>
<th class="px-4 py-3 font-medium">Версия</th> <th class="hidden px-4 py-3 font-medium xl:table-cell">Версия</th>
<th class="px-4 py-3 font-medium">Payload</th> <th class="px-4 py-3 font-medium">Payload</th>
</tr> </tr>
</thead> </thead>
@ -93,8 +93,8 @@
<span class="text-slate-400">—</span> <span class="text-slate-400">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ event.chat_id or "—" }}</td> <td class="hidden px-4 py-3 font-mono text-xs text-slate-500 lg:table-cell">{{ event.chat_id or "—" }}</td>
<td class="px-4 py-3 text-xs text-slate-500">{{ event.bot_version }}</td> <td class="hidden px-4 py-3 text-xs text-slate-500 xl:table-cell">{{ event.bot_version }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{% if event.payload_pretty %} {% if event.payload_pretty %}
<details> <details>

View File

@ -1,107 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ template.key }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
<h1 class="mt-1 font-mono text-xl font-semibold">{{ template.key }}</h1>
<p class="text-sm text-slate-600">{{ template.description }}</p>
</div>
</div>
{% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
Текст сохранён.
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="/messages/{{ template.key }}" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div>
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
<div class="mt-1">
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %}
{% include "partials/editor.html" %}
{% endwith %}
</div>
<p class="mt-1 text-xs text-slate-500">
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
</p>
{% if template.key == 'welcome.text' %}
<p class="mt-2 rounded border border-sky-200 bg-sky-50 p-2 text-xs text-sky-800">
Первая строка этого шаблона уходит подписью к фото, остальное — одним сообщением
вместе с <span class="font-mono">welcome.text2</span> и <span class="font-mono">welcome.text3</span>.
</p>
{% endif %}
</div>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить
</button>
<button
type="button"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
hx-post="/messages/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"
>
Предпросмотр
</button>
</div>
</form>
<div class="mt-8">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<p class="text-sm text-slate-600">Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.</p>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.</p>
</div>
</div>
<details class="mt-8 rounded-lg border border-slate-200 bg-white p-4">
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
{% for name, description in tags.items() %}
<div class="flex gap-2">
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
<dd class="text-slate-600">{{ description }}</dd>
</div>
{% endfor %}
</dl>
</details>
<div class="mt-8">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<ul class="mt-3 space-y-2">
{% for item in history %}
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
<div class="flex items-center justify-between text-slate-500">
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span>редактор: {{ item.editor_id or "—" }}</span>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
<div class="mt-2 grid gap-3 sm:grid-cols-2">
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.old_text or "" }}</pre>
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.new_text }}</pre>
</div>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
{% endblock %}

View File

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

View File

@ -1,96 +0,0 @@
{% extends "base.html" %}
{% block title %}Новый шаблон{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
<h1 class="mt-1 text-2xl font-semibold">Новый шаблон</h1>
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="/messages" class="mt-6 max-w-3xl space-y-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="key" class="text-sm font-medium text-slate-700">Ключ</label>
<input
id="key"
name="key"
value="{{ key }}"
placeholder="например, shop.rules"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
/>
<p class="mt-1 text-xs text-slate-500">Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.</p>
</div>
<div>
<label for="description" class="text-sm font-medium text-slate-700">Описание</label>
<input
id="description"
name="description"
value="{{ description }}"
maxlength="{{ max_description_length }}"
placeholder="для себя и админов"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
</div>
<div>
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
<div class="mt-1">
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %}
{% include "partials/editor.html" %}
{% endwith %}
</div>
<p class="mt-1 text-xs text-slate-500">
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled" {% if enabled %}checked{% endif %} />
Включён (бот читает текст)
</label>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Создать
</button>
<button
type="button"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
hx-post="/messages/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"
>
Предпросмотр
</button>
<a href="/messages" class="text-sm text-slate-600 hover:underline">Отмена</a>
</div>
</form>
<div class="mt-8 max-w-3xl">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
</div>
</div>
<details class="mt-8 max-w-3xl rounded-lg border border-slate-200 bg-white p-4">
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
{% for name, description in tags.items() %}
<div class="flex gap-2">
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
<dd class="text-slate-600">{{ description }}</dd>
</div>
{% endfor %}
</dl>
</details>
{% endblock %}

View File

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

View File

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

View File

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

View File

@ -35,9 +35,9 @@
<tr> <tr>
<th class="px-4 py-3 font-medium">Ник</th> <th class="px-4 py-3 font-medium">Ник</th>
<th class="px-4 py-3 font-medium">Telegram</th> <th class="px-4 py-3 font-medium">Telegram</th>
<th class="px-4 py-3 font-medium">Уровень</th> <th class="hidden px-4 py-3 font-medium sm:table-cell">Уровень</th>
<th class="px-4 py-3 font-medium">Баланс</th> <th class="px-4 py-3 font-medium">Баланс</th>
<th class="px-4 py-3 font-medium">Сообщения</th> <th class="hidden px-4 py-3 font-medium md:table-cell">Сообщения</th>
<th class="px-4 py-3 font-medium">Подписка</th> <th class="px-4 py-3 font-medium">Подписка</th>
</tr> </tr>
</thead> </thead>
@ -54,9 +54,9 @@
{% if user.username %}<div>@{{ user.username }}</div>{% endif %} {% if user.username %}<div>@{{ user.username }}</div>{% endif %}
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div> <div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
</td> </td>
<td class="px-4 py-3">{{ user.lvl }}</td> <td class="hidden px-4 py-3 sm:table-cell">{{ user.lvl }}</td>
<td class="px-4 py-3">{{ user.balance }}</td> <td class="px-4 py-3">{{ user.balance }}</td>
<td class="px-4 py-3">{{ user.msg_count }}</td> <td class="hidden px-4 py-3 md:table-cell">{{ user.msg_count }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{% if user.subscribed %} {% if user.subscribed %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span> <span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>

View File

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

View File

@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
#: Ожидаемый набор команд в меню (порядок = порядок в Telegram). #: Ожидаемый набор команд в меню (порядок = порядок в Telegram).
BOT_COMMANDS: tuple[BotCommand, ...] = ( BOT_COMMANDS: tuple[BotCommand, ...] = (
BotCommand(command="start", description="Начать"),
BotCommand(command="help", description="Помощь"), BotCommand(command="help", description="Помощь"),
BotCommand(command="info", description="Инфо"), BotCommand(command="info", description="Инфо"),
BotCommand(command="nick", description="Сменить ник"), BotCommand(command="nick", description="Сменить ник"),

View File

@ -11,11 +11,11 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions from bot.services import access, stats, subscriptions
from bot.services import users as users_service from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings from common.config import get_settings
from common.eggs import is_egg_event_active from common.eggs import is_egg_event_active
from common.texts import system from common.texts import system
from common.texts.render import escape, format_tops, format_user_list, render from common.texts.render import escape, format_tops, format_user_list, render
from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router(name="commands") router = Router(name="commands")
@ -63,7 +63,7 @@ async def list_nicks_handler(message: Message) -> None:
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]), "users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
"users_list_count": len(nicks), "users_list_count": len(nicks),
} }
await message.answer(render(await get_text("economy.nick_list"), context)) await send_template(message.bot, message.chat.id, "economy.nick_list", context)
@router.message(Command("кто", "who", ignore_case=True)) @router.message(Command("кто", "who", ignore_case=True))
@ -85,7 +85,7 @@ async def get_nick_handler(message: Message) -> None:
async def help_handler(message: Message) -> None: async def help_handler(message: Message) -> None:
if not await access.check_user(message): if not await access.check_user(message):
return return
await message.answer(await get_text("info.help")) await send_template(message.bot, message.chat.id, "info.help")
@router.message(Command("инфо", "info", ignore_case=True)) @router.message(Command("инфо", "info", ignore_case=True))
@ -113,7 +113,7 @@ async def info_handler(message: Message) -> None:
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else "" f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
), ),
} }
await message.answer(render(await get_text("info.profile"), context)) await send_template(message.bot, message.chat.id, "info.profile", context)
@router.message(Command("рейтинг", "top", ignore_case=True)) @router.message(Command("рейтинг", "top", ignore_case=True))
@ -122,7 +122,7 @@ async def top_handler(message: Message) -> None:
return return
tops = await stats.top_users() tops = await stats.top_users()
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])} context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
await message.answer(render(await get_text("economy.top"), context)) await send_template(message.bot, message.chat.id, "economy.top", context)
@router.message(Command("отписка", "unsub", ignore_case=True)) @router.message(Command("отписка", "unsub", ignore_case=True))

View File

@ -11,11 +11,10 @@ from aiogram.types import Message
from bot.services import access, alerts from bot.services import access, alerts
from bot.services import economy as economy_service from bot.services import economy as economy_service
from bot.services.sequences import send_template
from common.config import get_settings from common.config import get_settings
from common.eggs import egg_event_period, is_egg_event_active from common.eggs import egg_event_period, is_egg_event_active
from common.texts import system from common.texts import system
from common.texts.render import render
from common.texts.store import get_text
router = Router(name="economy") router = Router(name="economy")
@ -31,10 +30,10 @@ async def purification_handler(message: Message) -> None:
result = await economy_service.purify(message.from_user.id) result = await economy_service.purify(message.from_user.id)
if not result.success: if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds} context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(await get_text("economy.no_purification"), context)) await send_template(message.bot, message.chat.id, "economy.no_purification", context)
else: else:
context = {"money_add": result.money_add, "exp_add": result.exp_add} context = {"money_add": result.money_add, "exp_add": result.exp_add}
await message.answer(render(await get_text("economy.purification"), context)) await send_template(message.bot, message.chat.id, "economy.purification", context)
await alerts.notify_purification( await alerts.notify_purification(
message.bot, message.bot,
username=message.from_user.username, username=message.from_user.username,
@ -58,9 +57,9 @@ async def eggs_handler(message: Message) -> None:
result = await economy_service.collect_eggs(message.from_user.id) result = await economy_service.collect_eggs(message.from_user.id)
if not result.success: if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds} context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(await get_text("economy.no_eggs"), context)) await send_template(message.bot, message.chat.id, "economy.no_eggs", context)
else: else:
await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs})) await send_template(message.bot, message.chat.id, "economy.eggs", {"eggs": result.eggs})
@router.message() @router.message()

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

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

View File

@ -11,10 +11,10 @@ from aiogram.types import CallbackQuery, Message
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
from bot.services import access from bot.services import access
from bot.services import shop as shop_service from bot.services import shop as shop_service
from bot.services.sequences import send_template, template_text
from common.config import get_settings from common.config import get_settings
from common.texts import system from common.texts import system
from common.texts.render import render from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router(name="shop") router = Router(name="shop")
@ -27,16 +27,16 @@ async def shop_handler(message: Message) -> None:
if message.chat.type != "private": if message.chat.type != "private":
await message.answer(system.MSG_SHOP_PRIVATE_ONLY) await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
return return
await message.answer(await get_text("shop.main"), reply_markup=get_keyboard_main()) await send_template(message.bot, message.chat.id, "shop.main", reply_markup=get_keyboard_main())
async def _render_chapter(chapter: str) -> tuple[str, object] | None: async def _render_chapter(chapter: str) -> tuple[str, object] | None:
if chapter == "main": if chapter == "main":
return await get_text("shop.main"), get_keyboard_main() return await template_text("shop.main"), get_keyboard_main()
if chapter == "title": if chapter == "title":
return await get_text("shop.title"), await get_keyboard_title() return await template_text("shop.title"), await get_keyboard_title()
if chapter == "donat": if chapter == "donat":
return await get_text("shop.donat"), await get_keyboard_donat() return await template_text("shop.donat"), await get_keyboard_donat()
return None return None

View File

@ -18,10 +18,10 @@ from zoneinfo import ZoneInfo
from aiogram import Bot from aiogram import Bot
from bot.services import users as users_service from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings from common.config import get_settings
from common.repositories import schedules_repo from common.repositories import schedules_repo
from common.texts import format_usernames, render from common.texts import format_usernames
from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -99,23 +99,23 @@ async def _load_schedules() -> tuple[ScheduledMessage, ...]:
return _last_good return _last_good
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None: async def _send_broadcast(bot: Bot, template_key: str, subscribers: list[dict]) -> None:
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]] chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
for chunk in chunks: for chunk in chunks:
text = render(template, {"usernames": format_usernames(chunk)}) await send_template(
await bot.send_message(chat_id=get_settings().chat_id, text=text) bot,
get_settings().chat_id,
template_key,
{"usernames": format_usernames(chunk)},
)
async def _tick(bot: Bot, now: datetime) -> None: async def _tick(bot: Bot, now: datetime) -> None:
for schedule in await _load_schedules(): for schedule in await _load_schedules():
if not schedule.matches(now): if not schedule.matches(now):
continue continue
template = await get_text(schedule.template_key)
if not template:
logger.warning("Шаблон %s пуст или не найден — рассылка пропущена", schedule.template_key)
continue
subscribers = await users_service.list_subscribers() subscribers = await users_service.list_subscribers()
await _send_broadcast(bot, template, subscribers) await _send_broadcast(bot, schedule.template_key, subscribers)
async def run_notifications(bot: Bot) -> None: async def run_notifications(bot: Bot) -> None:

View File

@ -16,6 +16,7 @@ from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarku
from common.config import get_settings from common.config import get_settings
from common.repositories import sequences_repo from common.repositories import sequences_repo
from common.texts.render import render from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -46,7 +47,14 @@ def _image_path(item: dict[str, Any]) -> Path | None:
return None return None
async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any]) -> bool: async def send_sequence(
bot: Bot,
chat_id: int,
key: str,
context: dict[str, Any],
*,
reply_markup: Any = None,
) -> bool:
"""Отправить последовательность. False — если она не настроена/пуста.""" """Отправить последовательность. False — если она не настроена/пуста."""
try: try:
items = await sequences_repo.get_items(key) items = await sequences_repo.get_items(key)
@ -58,10 +66,11 @@ async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any
if not enabled: if not enabled:
return False return False
for item in enabled: for index, item in enumerate(enabled):
text = render(str(item.get("text") or ""), context) if item.get("text") else "" text = render(str(item.get("text") or ""), context) if item.get("text") else ""
path = _image_path(item) path = _image_path(item)
markup = _markup(item.get("buttons") or []) is_last = index == len(enabled) - 1
markup = reply_markup if is_last and reply_markup is not None else _markup(item.get("buttons") or [])
if path is not None: if path is not None:
if text and len(text) > CAPTION_LIMIT: if text and len(text) > CAPTION_LIMIT:
@ -78,3 +87,34 @@ async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup) await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
return True return True
async def template_text(key: str, context: dict[str, Any] | None = None) -> str:
"""Текст первого включённого сообщения последовательности (для edit_message_text)."""
try:
items = await sequences_repo.get_items(key)
except Exception:
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
items = []
for item in items:
if item.get("enabled") and item.get("text"):
return render(str(item["text"]), context or {})
return render(await get_text(key), context or {})
async def send_template(
bot: Bot,
chat_id: int,
key: str,
context: dict[str, Any] | None = None,
*,
reply_markup: Any = None,
) -> bool:
"""Отправить последовательность, а при её отсутствии — старый шаблон из кода."""
if await send_sequence(bot, chat_id, key, context or {}, reply_markup=reply_markup):
return True
text = render(await get_text(key), context or {})
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
return True

View File

@ -57,6 +57,9 @@ class Settings(BaseSettings):
#: Общий каталог картинок (Docker volume), который видят бот и админка. #: Общий каталог картинок (Docker volume), который видят бот и админка.
media_dir: str = "/app/media" media_dir: str = "/app/media"
#: Публичный адрес админки (deep-link вход, ссылки из бота).
admin_public_url: str = "http://localhost:8000"
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить. #: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
clickhouse_enabled: bool = True clickhouse_enabled: bool = True
clickhouse_host: str = "localhost" clickhouse_host: str = "localhost"
@ -144,7 +147,7 @@ class AdminSettings(Settings):
admin_port: int = 8000 admin_port: int = 8000
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS). #: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
admin_public_url: str = "http://localhost:8000" #: Наследуется из базовых настроек: ADMIN_PUBLIC_URL.
#: Username бота без @ — из него виджет рисует кнопку входа. #: Username бота без @ — из него виджет рисует кнопку входа.
bot_username: str = "" bot_username: str = ""
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают #: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают

View File

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

View File

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

View File

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

View File

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

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.5.2" __version__ = "0.6.1"

View File

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

View File

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

View File

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

View File

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

118
tests/test_admin_send.py Normal file
View File

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

105
tests/test_admin_titles.py Normal file
View File

@ -0,0 +1,105 @@
"""Тесты справочника званий."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.repositories import titles_repo
TITLE: dict[str, Any] = {
"id_title": 1,
"title": "Ветеран",
"price": 500,
"visible": True,
"used": 2,
}
@pytest.fixture
def fake_titles(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"used": 2}
async def list_titles() -> list[dict[str, Any]]:
return [TITLE]
async def create_title(title: str, price: int, visible: bool) -> int:
state["created"] = (title, price, visible)
return 2
async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool:
state["updated"] = (title_id, title, price, visible)
return True
async def delete_title(title_id: int) -> bool:
state["deleted"] = title_id
return True
async def usage_count(title_id: int) -> int:
return state["used"]
monkeypatch.setattr(titles_repo, "list_titles", list_titles)
monkeypatch.setattr(titles_repo, "create_title", create_title)
monkeypatch.setattr(titles_repo, "update_title", update_title)
monkeypatch.setattr(titles_repo, "delete_title", delete_title)
monkeypatch.setattr(titles_repo, "usage_count", usage_count)
return state
def _client(client_factory) -> TestClient:
return client_factory(follow_redirects=False)
def test_titles_list(fake_titles: dict[str, Any], client_factory) -> None:
response = _client(client_factory).get("/titles")
assert response.status_code == 200
assert "Ветеран" in response.text
assert "используется: 2" in response.text
def test_create_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
client = _client(client_factory)
token = csrf_token(client.get("/titles").text)
response = client.post("/titles", data={"title": "Новичок", "price": "100", "csrf_token": token})
assert response.status_code == 303
assert fake_titles["created"] == ("Новичок", 100, True)
def test_update_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
client = _client(client_factory)
token = csrf_token(client.get("/titles").text)
response = client.post(
"/titles/1",
data={"title": "Ветеран+", "price": "700", "visible": "on", "csrf_token": token},
)
assert response.status_code == 303
assert fake_titles["updated"] == (1, "Ветеран+", 700, True)
def test_delete_used_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
client = _client(client_factory)
token = csrf_token(client.get("/titles").text)
response = client.post("/titles/1/delete", data={"csrf_token": token})
assert response.status_code == 400
assert "используется 2 игроками" in response.text
assert "deleted" not in fake_titles
def test_delete_unused_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
fake_titles["used"] = 0
client = _client(client_factory)
token = csrf_token(client.get("/titles").text)
response = client.post("/titles/1/delete", data={"csrf_token": token})
assert response.status_code == 303
assert fake_titles["deleted"] == 1

View File

@ -0,0 +1,102 @@
"""Тесты deep-link входа в админку через Telegram."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.repositories import admin_login_tokens_repo
@pytest.fixture
def fake_tokens(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"confirmed": None}
async def create_token() -> str:
return "test-token"
async def consume_token(token: str) -> int | None:
return state["confirmed"]
monkeypatch.setattr(admin_login_tokens_repo, "create_token", create_token)
monkeypatch.setattr(admin_login_tokens_repo, "consume_token", consume_token)
return state
def _client(client_factory, settings_factory) -> TestClient:
return client_factory(
authenticated=False,
follow_redirects=False,
settings=settings_factory(
bot_username="debot_test_bot",
admin_public_url="https://debot-test.mpakosrv.ru",
),
)
def test_login_page_has_deeplink(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
response = _client(client_factory, settings_factory).get("/login")
assert response.status_code == 200
assert "Открыть Telegram" in response.text
assert "/auth/deeplink/start" in response.text
def test_deeplink_start_redirects_to_telegram(
fake_tokens: dict[str, Any],
client_factory,
settings_factory,
) -> None:
client = _client(client_factory, settings_factory)
response = client.get("/auth/deeplink/start")
assert response.status_code == 303
assert response.headers["location"] == "https://t.me/debot_test_bot?start=login_test-token"
assert "debot_login_token" in response.headers["set-cookie"]
def test_deeplink_status_without_cookie(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
response = _client(client_factory, settings_factory).get("/auth/deeplink/status")
assert response.json() == {"status": "none"}
def test_deeplink_status_pending(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
client = _client(client_factory, settings_factory)
client.get("/auth/deeplink/start")
response = client.get("/auth/deeplink/status")
assert response.json() == {"status": "pending"}
def test_deeplink_status_confirms_session(
fake_tokens: dict[str, Any],
client_factory,
settings_factory,
) -> None:
fake_tokens["confirmed"] = 1
client = _client(client_factory, settings_factory)
client.get("/auth/deeplink/start")
response = client.get("/auth/deeplink/status")
assert response.json() == {"status": "ok"}
assert client.get("/").status_code == 200
def test_deeplink_status_denied_for_unknown_admin(
fake_tokens: dict[str, Any],
client_factory,
settings_factory,
) -> None:
fake_tokens["confirmed"] = 2
client = _client(client_factory, settings_factory)
client.get("/auth/deeplink/start")
response = client.get("/auth/deeplink/status")
assert response.json() == {"status": "denied"}

36
tests/test_preview.py Normal file
View File

@ -0,0 +1,36 @@
"""Тесты общего предпросмотра сообщений."""
from __future__ import annotations
from fastapi.testclient import TestClient
def _client(client_factory) -> TestClient:
return client_factory()
def test_preview_renders_tags(client_factory, csrf_token) -> None:
client = _client(client_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/preview", data={"text": "Привет, {nick}!", "csrf_token": token})
assert response.status_code == 200
assert "ТестовыйНик" in response.text
assert "<iframe" in response.text
def test_preview_reports_unknown_tag(client_factory, csrf_token) -> None:
client = _client(client_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/preview", data={"text": "Привет, {unknown_tag}!", "csrf_token": token})
assert response.status_code == 200
assert "Неизвестные теги" in response.text
def test_preview_requires_csrf(client_factory) -> None:
response = _client(client_factory).post("/preview", data={"text": "Привет"})
assert response.status_code == 400

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set(): def test_version_is_set():
assert __version__ assert __version__
assert __version__ == "0.5.2" assert __version__ == "0.6.1"