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 %} - -{% endif %} - -
- -
- -
- {% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %} - {% include "partials/editor.html" %} - {% endwith %} -
-

- Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка. -

- {% if template.key == 'welcome.text' %} -

- Первая строка этого шаблона уходит подписью к фото, остальное — одним сообщением - вместе с welcome.text2 и welcome.text3. -

- {% 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 %} -
-

Сообщения

-
- {{ templates | length }} шаблонов - - Новый шаблон - -
-
- -

- Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов - {tag} на тестовых данных. -

- -
- Изменения применяются ботом без перезапуска — в течение TEMPLATE_CACHE_TTL - секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода. -
- -
- - - - - - - - - - - {% for template in templates %} - - - - - - - {% endfor %} - -
КлючОписание
- {{ template.key }} - {{ template.description }}
-
-{% 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 %} - -{% endif %} - -
- - -
-
- - -

Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.

-
-
- - -
-
- -
- -
- {% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %} - {% include "partials/editor.html" %} - {% endwith %} -
-

- Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка. -

-
- - - -
- - - Отмена -
-
- -
-

Предпросмотр

-
-

Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.

-
-
- -
- Доступные теги ({{ 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 @@