From e125d4bb0e459fbbe1bc937a6ffb3576e76f61f8 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sun, 25 Oct 2026 02:06:59 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0=D0=BB=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо трёх жёстко зашитых шаблонов теперь есть последовательности: сообщения по порядку, у каждого свой текст, картинка из общего volume media и несколько кнопок. Приветствие миграцией перенесено в последовательность welcome (короткая подпись к фото + остальной текст), а если последовательность недоступна — бот откатывается на старые шаблоны из кода. В админке появился раздел «Последовательности» и редактор текста с тулбаром: жирный, курсив, подчёркивание, зачёркивание, код, цитата, спойлер и ссылка, со счётчиком символов и предпросмотром. --- Dockerfile | 2 + README.md | 11 +- admin/Dockerfile | 3 + admin/app.py | 5 +- admin/media.py | 37 +++ admin/routes/pages.py | 10 + admin/routes/sequences.py | 360 ++++++++++++++++++++++++++ admin/templates/base.html | 55 ++++ admin/templates/messages/edit.html | 13 +- admin/templates/messages/new.html | 13 +- admin/templates/partials/editor.html | 20 ++ admin/templates/sequences/edit.html | 165 ++++++++++++ admin/templates/sequences/list.html | 78 ++++++ bot/handlers/member_events.py | 12 +- bot/services/sequences.py | 80 ++++++ common/config.py | 3 + common/repositories/sequences_repo.py | 322 +++++++++++++++++++++++ common/version.py | 2 +- docker-compose.prod.yml | 5 + docker-compose.yml | 5 + docs/message-sequences.md | 59 +++++ sql/V20__message_sequences.sql | 48 ++++ sql/V21__seed_welcome_sequence.sql | 37 +++ tests/test_admin_sequences.py | 268 +++++++++++++++++++ tests/test_version.py | 2 +- 25 files changed, 1594 insertions(+), 21 deletions(-) create mode 100644 admin/media.py create mode 100644 admin/routes/sequences.py create mode 100644 admin/templates/partials/editor.html create mode 100644 admin/templates/sequences/edit.html create mode 100644 admin/templates/sequences/list.html create mode 100644 bot/services/sequences.py create mode 100644 common/repositories/sequences_repo.py create mode 100644 docs/message-sequences.md create mode 100644 sql/V20__message_sequences.sql create mode 100644 sql/V21__seed_welcome_sequence.sql create mode 100644 tests/test_admin_sequences.py diff --git a/Dockerfile b/Dockerfile index dd19c88..f6e4b56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,8 @@ COPY common ./common COPY bot ./bot COPY images ./images +RUN mkdir -p /app/media && chown app:app /app/media + USER app # Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py). diff --git a/README.md b/README.md index 053b213..33933bd 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ ID пользователя ведёт в его карточку. ClickHouse ч пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ, описание, текст и предпросмотр. +Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие): +у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок. +Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание, +код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов +(1024 для подписи к фото, 4096 для обычного сообщения). + ## Аналитика (ClickHouse) Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. @@ -118,7 +124,8 @@ docker compose up --build CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 / prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет -вместе с доменом и TLS (Блок 4.2). +вместе с доменом и TLS (Блок 4.2). Картинки последовательностей лежат в общем +Docker-volume `media` (`/app/media`), который монтируется и боту, и админке. Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом: @@ -151,7 +158,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.15`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.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/Dockerfile b/admin/Dockerfile index effa427..d7da341 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -17,6 +17,9 @@ RUN pip install --no-cache-dir -r requirements-admin.txt COPY common ./common COPY admin ./admin +COPY images ./images + +RUN mkdir -p /app/media && chown app:app /app/media USER app diff --git a/admin/app.py b/admin/app.py index f96db1e..f46c4f3 100644 --- a/admin/app.py +++ b/admin/app.py @@ -12,8 +12,9 @@ from fastapi import FastAPI from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware +from admin.media import ensure_media from admin.middleware import AdminAuthMiddleware -from admin.routes import admins, auth, health, logs, messages, pages, schedules, users +from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, users from common import __version__ from common.analytics import read as analytics_read from common.config import AdminSettings, get_admin_settings @@ -40,6 +41,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - async def lifespan(app: FastAPI) -> AsyncIterator[None]: if init_db: await init_pool(app.state.settings) + ensure_media(app.state.settings) try: inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids) if inserted: @@ -88,6 +90,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - app.include_router(auth.router) app.include_router(pages.router) app.include_router(messages.router) + app.include_router(sequences.router) app.include_router(schedules.router) app.include_router(users.router) app.include_router(admins.router) diff --git a/admin/media.py b/admin/media.py new file mode 100644 index 0000000..2a3826e --- /dev/null +++ b/admin/media.py @@ -0,0 +1,37 @@ +"""Общий каталог картинок для бота и админки (Docker volume).""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +from common.config import AdminSettings + +logger = logging.getLogger(__name__) + +#: Встроенная картинка приветствия (лежит в образе бота и админки). +BUILTIN_WELCOME = Path("images/welcome_image.jpg") + +#: Имя файла в volume, которое ждёт сид последовательности `welcome`. +WELCOME_IMAGE_NAME = "welcome_image.jpg" + + +def ensure_media(settings: AdminSettings) -> Path: + """Создать каталог и положить туда встроенное приветствие, если его нет.""" + directory = Path(settings.media_dir) + directory.mkdir(parents=True, exist_ok=True) + + target = directory / WELCOME_IMAGE_NAME + if not target.exists() and BUILTIN_WELCOME.is_file(): + try: + shutil.copyfile(BUILTIN_WELCOME, target) + logger.info("Приветственная картинка скопирована в %s", target) + except OSError: + logger.warning("Не удалось скопировать приветственную картинку в %s", target, exc_info=True) + return directory + + +def media_path(settings: AdminSettings, name: str) -> Path: + """Безопасный путь к файлу в media (без выхода из каталога).""" + return Path(settings.media_dir) / Path(name).name diff --git a/admin/routes/pages.py b/admin/routes/pages.py index 764e4f6..9d6331b 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -16,6 +16,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( "description": "Тексты бота и предпросмотр с тегами {tag}", "url": "/messages", }, + { + "title": "Последовательности", + "description": "Приветствие и другие наборы сообщений по порядку", + "url": "/sequences", + }, { "title": "Расписания", "description": "Время рассылок, дни недели и шаблон", @@ -26,6 +31,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( "description": "Ники, звания, баланс и подписка", "url": "/users", }, + { + "title": "Админы", + "description": "Доступ к веб-панели", + "url": "/admins", + }, { "title": "Логи", "description": "События из ClickHouse с фильтрами", diff --git a/admin/routes/sequences.py b/admin/routes/sequences.py new file mode 100644 index 0000000..72b7e05 --- /dev/null +++ b/admin/routes/sequences.py @@ -0,0 +1,360 @@ +"""Раздел «Последовательности»: сообщения по порядку, картинки и кнопки.""" + +from __future__ import annotations + +import logging +import re +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from starlette.datastructures import UploadFile + +from admin.media import media_path +from admin.security import check_csrf +from common import __version__ +from common.repositories import sequences_repo +from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/sequences", tags=["sequences"]) + +#: Формат ключа последовательности. +KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$") + +#: Лимит подписи к фото. +MAX_CAPTION = 1024 +#: Сколько кнопок можно повесить на одно сообщение. +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: + return int(request.state.admin["telegram_id"]) + + +def _prepare_items(items: list[dict[str, Any]]) -> None: + """Добавить к каждому сообщению строку кнопок для textarea.""" + for item in items: + 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: + error = _image_error(upload) + if error: + raise ValueError(error) + + data = await upload.read() + 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.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return name + + +def _delete_image(settings: Any, name: str | None) -> None: + if not name: + return + try: + media_path(settings, name).unlink(missing_ok=True) + except OSError: + logger.warning("Не удалось удалить картинку %s", name, exc_info=True) + + +async def _list_response( + request: Request, + *, + errors: list[str] | None = None, + form: dict[str, str] | None = None, + saved: str | None = None, + status_code: int = 200, +) -> HTMLResponse: + return request.app.state.templates.TemplateResponse( + request=request, + name="sequences/list.html", + context={ + "version": __version__, + "sequences": await sequences_repo.list_sequences(), + "errors": errors or [], + "form": form or {"key": "", "title": "", "description": ""}, + "saved": saved, + }, + status_code=status_code, + ) + + +async def _editor_response( + request: Request, + key: str, + *, + errors: list[str] | None = None, + saved: str | None = None, + overrides: dict[int, dict[str, Any]] | None = None, + status_code: int = 200, +) -> HTMLResponse: + sequence = await sequences_repo.get_sequence(key) + if sequence is None: + raise HTTPException(status_code=404, detail="Последовательность не найдена") + + items = await sequences_repo.get_items(key) + _prepare_items(items) + for item in items: + override = (overrides or {}).get(int(item["id"])) + if override: + item.update(override) + + return request.app.state.templates.TemplateResponse( + request=request, + name="sequences/edit.html", + context={ + "version": __version__, + "sequence": sequence, + "items": items, + "history": await sequences_repo.history(key, limit=5), + "errors": errors or [], + "saved": saved, + "max_caption": MAX_CAPTION, + "max_message": MAX_MESSAGE_LENGTH, + "max_buttons": MAX_BUTTONS, + "tags": TAG_REGISTRY, + }, + status_code=status_code, + ) + + +@router.get("", response_class=HTMLResponse) +async def list_sequences(request: Request, saved: str | None = None) -> HTMLResponse: + return await _list_response(request, saved=saved) + + +@router.post("") +async def create_sequence(request: Request) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + key = str(form.get("key", "")).strip() + title = str(form.get("title", "")).strip() + description = str(form.get("description", "")).strip() + raw_form = {"key": key, "title": title, "description": description} + + errors: list[str] = [] + if not KEY_RE.match(key): + errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–32)") + if not title: + errors.append("Название не может быть пустым") + + if errors: + return await _list_response(request, errors=errors, form=raw_form, status_code=400) + + if not await sequences_repo.create_sequence( + key, + title, + description, + editor_id=_editor_id(request), + ): + return await _list_response( + request, + errors=["Последовательность с таким ключом уже существует"], + form=raw_form, + status_code=400, + ) + return RedirectResponse(url=f"/sequences/{key}", status_code=303) + + +@router.get("/media/{filename}") +async def serve_media(request: Request, filename: str) -> FileResponse: + """Отдать картинку из media-volume (доступ только после входа).""" + path = media_path(request.app.state.settings, filename) + if not path.is_file(): + raise HTTPException(status_code=404, detail="Файл не найден") + return FileResponse(path) + + +@router.get("/{key}", response_class=HTMLResponse) +async def edit_sequence(request: Request, key: str, saved: str | None = None) -> HTMLResponse: + return await _editor_response(request, key, saved=saved) + + +@router.post("/{key}") +async def update_sequence(request: Request, key: str) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + title = str(form.get("title", "")).strip() + description = str(form.get("description", "")).strip() + if not title: + return await _editor_response(request, key, errors=["Название не может быть пустым"], status_code=400) + + if not await sequences_repo.update_sequence( + key, + title, + description, + editor_id=_editor_id(request), + ): + raise HTTPException(status_code=404, detail="Последовательность не найдена") + return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303) + + +@router.post("/{key}/items") +async def add_item(request: Request, key: str) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None: + raise HTTPException(status_code=404, detail="Последовательность не найдена") + return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303) + + +@router.post("/{key}/items/{item_id}") +async def update_item(request: Request, key: str, item_id: int) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + items = await sequences_repo.get_items(key) + item = next((row for row in items if int(row["id"]) == item_id), None) + if item is None: + raise HTTPException(status_code=404, detail="Сообщение не найдено") + + text = str(form.get("text", "")) + enabled = form.get("enabled") is not None + raw_buttons = str(form.get("buttons", "")) + buttons, errors = parse_buttons(raw_buttons) + + unknown = validate(text) + if unknown: + errors.append("Неизвестные теги: " + ", ".join(unknown)) + + settings = request.app.state.settings + new_image: str | None = None + update_image = False + saved_file: str | None = None + + upload = form.get("image") + if isinstance(upload, UploadFile) and upload.filename: + try: + saved_file = await _save_image(settings, upload, key, item_id) + new_image = saved_file + update_image = True + except ValueError as exc: + errors.append(str(exc)) + elif form.get("remove_image") is not None and item["image_name"]: + update_image = True + new_image = None + + effective_image = new_image if update_image else item["image_name"] + limit = MAX_CAPTION if effective_image else MAX_MESSAGE_LENGTH + if len(text) > limit: + errors.append(f"С подписью к фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}") + if not text.strip() and not effective_image: + errors.append("Нужен текст или картинка") + + if errors: + if saved_file: + _delete_image(settings, saved_file) + return await _editor_response( + request, + key, + errors=errors, + overrides={ + item_id: { + "text": text, + "enabled": enabled, + "buttons_text": raw_buttons, + } + }, + status_code=400, + ) + + updated = await sequences_repo.update_item( + item_id, + text, + enabled, + buttons, + image_name=new_image, + update_image=update_image, + editor_id=_editor_id(request), + ) + if updated is None: + if saved_file: + _delete_image(settings, saved_file) + raise HTTPException(status_code=404, detail="Сообщение не найдено") + + _, old_image = updated + if update_image and old_image and old_image != new_image: + _delete_image(settings, old_image) + + return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303) + + +@router.post("/{key}/items/{item_id}/move") +async def move_item(request: Request, key: str, item_id: int) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + direction = str(form.get("direction", "")) + + if direction not in {"up", "down"}: + raise HTTPException(status_code=400, detail="Неверное направление") + if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None: + raise HTTPException(status_code=404, detail="Сообщение не найдено") + return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303) + + +@router.post("/{key}/items/{item_id}/delete") +async def delete_item(request: Request, key: str, item_id: int) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + items = await sequences_repo.get_items(key) + item = next((row for row in items if int(row["id"]) == item_id), None) + if item is None: + raise HTTPException(status_code=404, detail="Сообщение не найдено") + + if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None: + raise HTTPException(status_code=404, detail="Сообщение не найдено") + + _delete_image(request.app.state.settings, item["image_name"]) + return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303) diff --git a/admin/templates/base.html b/admin/templates/base.html index 61ef119..9adda26 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -14,6 +14,7 @@