diff --git a/README.md b/README.md
index 972dea5..532a352 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/admin/app.py b/admin/app.py
index cfc5ba4..4cae463 100644
--- a/admin/app.py
+++ b/admin/app.py
@@ -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)
diff --git a/admin/routes/messages.py b/admin/routes/messages.py
deleted file mode 100644
index d9a94e0..0000000
--- a/admin/routes/messages.py
+++ /dev/null
@@ -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(
- '
Неизвестные теги: ' + html.escape(", ".join(unknown)) + "
"
- )
-
- try:
- rendered = render(text, SAMPLE_CONTEXT)
- except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
- return HTMLResponse(f'{html.escape(str(exc))}
')
-
- if len(rendered) > MAX_MESSAGE_LENGTH:
- return HTMLResponse(
- f'Текст длиннее лимита Telegram: '
- f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.
"
- )
-
- return HTMLResponse(
- ''
- )
-
-
-@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),
- )
diff --git a/admin/routes/pages.py b/admin/routes/pages.py
index e5f1364..df69fe1 100644
--- a/admin/routes/pages.py
+++ b/admin/routes/pages.py
@@ -11,11 +11,6 @@ router = APIRouter(tags=["pages"])
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
SECTIONS: tuple[dict[str, str | None], ...] = (
- {
- "title": "Сообщения",
- "description": "Тексты бота и предпросмотр с тегами {tag}",
- "url": "/messages",
- },
{
"title": "Последовательности",
"description": "Приветствие и другие наборы сообщений по порядку",
diff --git a/admin/routes/preview.py b/admin/routes/preview.py
new file mode 100644
index 0000000..8a04a70
--- /dev/null
+++ b/admin/routes/preview.py
@@ -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(
+ 'Неизвестные теги: ' + html.escape(", ".join(unknown)) + "
"
+ )
+
+ try:
+ rendered = render(text, SAMPLE_CONTEXT)
+ except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
+ return HTMLResponse(f'{html.escape(str(exc))}
')
+
+ if len(rendered) > MAX_MESSAGE_LENGTH:
+ return HTMLResponse(
+ f'Текст длиннее лимита Telegram: '
+ f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.
"
+ )
+
+ return HTMLResponse(
+ ''
+ )
diff --git a/admin/templates/base.html b/admin/templates/base.html
index cab36bb..101fddd 100644
--- a/admin/templates/base.html
+++ b/admin/templates/base.html
@@ -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"
>
Главная
- Сообщения
Последовательности
Расписания
Пользователи
diff --git a/admin/templates/messages/edit.html b/admin/templates/messages/edit.html
deleted file mode 100644
index f57c032..0000000
--- a/admin/templates/messages/edit.html
+++ /dev/null
@@ -1,107 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ template.key }}{% endblock %}
-
-{% block content %}
-
-
-
← Сообщения
-
{{ template.key }}
-
{{ template.description }}
-
-
-
-{% if saved %}
-
- Текст сохранён.
-
-{% endif %}
-
-{% if errors %}
-
- {% for error in errors %}{{ error }} {% endfor %}
-
-{% endif %}
-
-
-
-
-
Предпросмотр
-
Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.
-
-
Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.
-
-
-
-
- Доступные теги ({{ tags | length }})
-
- {% for name, description in tags.items() %}
-
-
{ {{ name }} }
- {{ description }}
-
- {% endfor %}
-
-
-
-
-
История изменений
- {% if history %}
-
- {% else %}
-
Изменений ещё не было.
- {% endif %}
-
-{% endblock %}
diff --git a/admin/templates/messages/list.html b/admin/templates/messages/list.html
deleted file mode 100644
index 390e874..0000000
--- a/admin/templates/messages/list.html
+++ /dev/null
@@ -1,50 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Сообщения{% endblock %}
-
-{% block content %}
-
-
-
- Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов
- {tag} на тестовых данных.
-
-
-
- Изменения применяются ботом без перезапуска — в течение TEMPLATE_CACHE_TTL
- секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
-
-
-
-
-
-
- Ключ
- Описание
- Длина
- Изменён
-
-
-
- {% for template in templates %}
-
-
- {{ template.key }}
-
- {{ template.description }}
- {{ template.text_length }}
- {{ template.updated_at.strftime("%d.%m.%Y %H:%M") }}
-
- {% endfor %}
-
-
-
-{% endblock %}
diff --git a/admin/templates/messages/new.html b/admin/templates/messages/new.html
deleted file mode 100644
index 0ebd53a..0000000
--- a/admin/templates/messages/new.html
+++ /dev/null
@@ -1,96 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Новый шаблон{% endblock %}
-
-{% block content %}
-← Сообщения
-Новый шаблон
-
-{% if errors %}
-
- {% for error in errors %}{{ error }} {% endfor %}
-
-{% endif %}
-
-
-
-
-
Предпросмотр
-
-
Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.
-
-
-
-
- Доступные теги ({{ tags | length }})
-
- {% for name, description in tags.items() %}
-
-
{ {{ name }} }
- {{ description }}
-
- {% endfor %}
-
-
-{% endblock %}
diff --git a/admin/templates/send/form.html b/admin/templates/send/form.html
index c76a46b..34e80e6 100644
--- a/admin/templates/send/form.html
+++ b/admin/templates/send/form.html
@@ -64,7 +64,7 @@
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"🥚 Количество яичек: {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))
diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py
index 9304c06..cd22ba9 100644
--- a/bot/handlers/economy.py
+++ b/bot/handlers/economy.py
@@ -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()
diff --git a/bot/handlers/shop.py b/bot/handlers/shop.py
index 088e752..2090f0f 100644
--- a/bot/handlers/shop.py
+++ b/bot/handlers/shop.py
@@ -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
diff --git a/bot/services/notifications.py b/bot/services/notifications.py
index bf29986..ab03509 100644
--- a/bot/services/notifications.py
+++ b/bot/services/notifications.py
@@ -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:
diff --git a/bot/services/sequences.py b/bot/services/sequences.py
index b7c15c9..dd0c914 100644
--- a/bot/services/sequences.py
+++ b/bot/services/sequences.py
@@ -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
diff --git a/common/version.py b/common/version.py
index d0f7d2d..8fe7efe 100644
--- a/common/version.py
+++ b/common/version.py
@@ -7,4 +7,4 @@
from __future__ import annotations
-__version__ = "0.5.5"
+__version__ = "0.6.0"
diff --git a/sql/V24__templates_to_sequences.sql b/sql/V24__templates_to_sequences.sql
new file mode 100644
index 0000000..673ce39
--- /dev/null
+++ b/sql/V24__templates_to_sequences.sql
@@ -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;
diff --git a/tests/test_admin.py b/tests/test_admin.py
deleted file mode 100644
index 75136ba..0000000
--- a/tests/test_admin.py
+++ /dev/null
@@ -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 "