Все сообщения бота переехали в последовательности

Миграция V24 создаёт последовательность из каждого старого шаблона, а команды, магазин и рассылки теперь отправляются через общий sender: один и тот же формат с картинками и кнопками. Старые шаблоны остаются фолбэком в коде, поэтому сбой БД не оставит бота без текстов. Раздел «Сообщения» убран — редактирование только в «Последовательностях», предпросмотр вынесен в общий /preview.
This commit is contained in:
MpakobecEx 2026-10-25 22:01:24 +05:00
parent 84fc104717
commit ae6faeee1e
20 changed files with 178 additions and 779 deletions

View File

@ -55,12 +55,10 @@ APP_ENV=prod python -m bot # prod
## Админка (Блок 4)
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
Предпросмотр использует тот же `render()`, что и бот.
Все тексты бота — **последовательности** (`bot.message_sequences`): миграция `V24`
переносит каждый старый шаблон в последовательность из одного сообщения, а бот
отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
@ -92,8 +90,7 @@ ID пользователя ведёт в его карточку. ClickHouse ч
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
описание, текст и предпросмотр.
пользователь через `/ник` или админ.
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие):
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
@ -170,7 +167,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.5`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.0`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `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.middleware import AdminAuthMiddleware
from admin.routes import admins, auth, health, logs, messages, pages, schedules, send, sequences, users
from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, users
from common import __version__
from common.analytics import read as analytics_read
from common.config import AdminSettings, get_admin_settings
@ -89,7 +89,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(pages.router)
app.include_router(messages.router)
app.include_router(preview.router)
app.include_router(sequences.router)
app.include_router(schedules.router)
app.include_router(users.router)

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, пока раздел не готов).
SECTIONS: tuple[dict[str, str | None], ...] = (
{
"title": "Сообщения",
"description": "Тексты бота и предпросмотр с тегами {tag}",
"url": "/messages",
},
{
"title": "Последовательности",
"description": "Приветствие и другие наборы сообщений по порядку",

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

View File

@ -25,7 +25,6 @@
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="/messages" 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>

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="hidden px-4 py-3 font-medium sm:table-cell">Длина</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">Изменён</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="hidden px-4 py-3 text-slate-500 sm:table-cell">{{ template.text_length }}</td>
<td class="hidden px-4 py-3 text-slate-500 md:table-cell">{{ 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

@ -64,7 +64,7 @@
<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-post="/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"

View File

@ -11,11 +11,11 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions
from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings
from common.eggs import is_egg_event_active
from common.texts import system
from common.texts.render import escape, format_tops, format_user_list, render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
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_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))
@ -85,7 +85,7 @@ async def get_nick_handler(message: Message) -> None:
async def help_handler(message: Message) -> None:
if not await access.check_user(message):
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))
@ -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 ""
),
}
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))
@ -122,7 +122,7 @@ async def top_handler(message: Message) -> None:
return
tops = await stats.top_users()
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))

View File

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

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.services import access
from bot.services import shop as shop_service
from bot.services.sequences import send_template, template_text
from common.config import get_settings
from common.texts import system
from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="shop")
@ -27,16 +27,16 @@ async def shop_handler(message: Message) -> None:
if message.chat.type != "private":
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
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:
if chapter == "main":
return await get_text("shop.main"), get_keyboard_main()
return await template_text("shop.main"), get_keyboard_main()
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":
return await get_text("shop.donat"), await get_keyboard_donat()
return await template_text("shop.donat"), await get_keyboard_donat()
return None

View File

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

View File

@ -16,6 +16,7 @@ from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarku
from common.config import get_settings
from common.repositories import sequences_repo
from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
@ -46,7 +47,14 @@ def _image_path(item: dict[str, Any]) -> Path | 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 — если она не настроена/пуста."""
try:
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:
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 ""
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 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)
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

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.5.5"
__version__ = "0.6.0"

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

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():
assert __version__
assert __version__ == "0.5.5"
assert __version__ == "0.6.0"