Compare commits

...

2 Commits

Author SHA1 Message Date
MpakobecEx e125d4bb0e Приветствие стало последовательностью сообщений с картинками и кнопками
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 19s Details
Вместо трёх жёстко зашитых шаблонов теперь есть последовательности: сообщения по порядку, у каждого свой текст, картинка из общего volume media и несколько кнопок. Приветствие миграцией перенесено в последовательность welcome (короткая подпись к фото + остальной текст), а если последовательность недоступна — бот откатывается на старые шаблоны из кода. В админке появился раздел «Последовательности» и редактор текста с тулбаром: жирный, курсив, подчёркивание, зачёркивание, код, цитата, спойлер и ссылка, со счётчиком символов и предпросмотром.
2026-10-25 02:06:59 +05:00
MpakobecEx 34b6574e43 Приветствие снова читается одним блоком: фото с короткой подписью и одно сообщение
Первая строка welcome.text уходит подписью к фото, остальное вместе с text2 и text3 — одним сообщением с кнопкой. Так текст не разбит на три части и не упирается в лимит подписи к фото (1024 символа). В редакторе welcome.text появилась подсказка, что первая строка — подпись. Если понадобится больше гибкости, следующим шагом можно сделать последовательность сообщений с картинками.
2026-10-25 01:28:56 +05:00
27 changed files with 1686 additions and 30 deletions

View File

@ -18,6 +18,8 @@ COPY common ./common
COPY bot ./bot COPY bot ./bot
COPY images ./images COPY images ./images
RUN mkdir -p /app/media && chown app:app /app/media
USER app USER app
# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py). # Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py).

View File

@ -90,6 +90,12 @@ ID пользователя ведёт в его карточку. ClickHouse ч
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ, пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
описание, текст и предпросмотр. описание, текст и предпросмотр.
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие):
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
(1024 для подписи к фото, 4096 для обычного сообщения).
## Аналитика (ClickHouse) ## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
@ -118,7 +124,8 @@ docker compose up --build
CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose
по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 / по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 /
prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет 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-файлом: Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
@ -151,7 +158,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.14`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.0`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
@ -180,6 +187,13 @@ git tag -a vX.Y -m "Release vX.Y"
git push origin master --tags git push origin master --tags
``` ```
## Приветствие новых участников
Бот отправляет фото с короткой персональной подписью (первая строка шаблона `welcome.text`),
а затем одно сообщение со всем остальным текстом (`welcome.text` без первой строки +
`welcome.text2` + `welcome.text3`) и кнопкой на канал. Так приветствие остаётся одним
смысловым блоком, но не упирается в лимит подписи к фото (1024 символа).
## Пасхальное событие «Яички» ## Пасхальное событие «Яички»
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху** `/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**

View File

@ -17,6 +17,9 @@ RUN pip install --no-cache-dir -r requirements-admin.txt
COPY common ./common COPY common ./common
COPY admin ./admin COPY admin ./admin
COPY images ./images
RUN mkdir -p /app/media && chown app:app /app/media
USER app USER app

View File

@ -12,8 +12,9 @@ from fastapi import FastAPI
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from admin.media import ensure_media
from admin.middleware import AdminAuthMiddleware from admin.middleware import AdminAuthMiddleware
from admin.routes import admins, auth, health, logs, messages, pages, schedules, users from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, users
from common import __version__ from common import __version__
from common.analytics import read as analytics_read from common.analytics import read as analytics_read
from common.config import AdminSettings, get_admin_settings from common.config import AdminSettings, get_admin_settings
@ -40,6 +41,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
if init_db: if init_db:
await init_pool(app.state.settings) await init_pool(app.state.settings)
ensure_media(app.state.settings)
try: try:
inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids) inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids)
if inserted: 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(auth.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(sequences.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(admins.router) app.include_router(admins.router)

37
admin/media.py Normal file
View File

@ -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

View File

@ -16,6 +16,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
"description": "Тексты бота и предпросмотр с тегами {tag}", "description": "Тексты бота и предпросмотр с тегами {tag}",
"url": "/messages", "url": "/messages",
}, },
{
"title": "Последовательности",
"description": "Приветствие и другие наборы сообщений по порядку",
"url": "/sequences",
},
{ {
"title": "Расписания", "title": "Расписания",
"description": "Время рассылок, дни недели и шаблон", "description": "Время рассылок, дни недели и шаблон",
@ -26,6 +31,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
"description": "Ники, звания, баланс и подписка", "description": "Ники, звания, баланс и подписка",
"url": "/users", "url": "/users",
}, },
{
"title": "Админы",
"description": "Доступ к веб-панели",
"url": "/admins",
},
{ {
"title": "Логи", "title": "Логи",
"description": "События из ClickHouse с фильтрами", "description": "События из ClickHouse с фильтрами",

360
admin/routes/sequences.py Normal file
View File

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

View File

@ -14,6 +14,7 @@
<nav class="flex items-center gap-4 text-sm"> <nav class="flex items-center gap-4 text-sm">
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a> <a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a> <a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
<a href="/sequences" class="text-slate-600 hover:text-slate-900">Последовательности</a>
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a> <a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a> <a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
<a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a> <a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a>
@ -35,5 +36,59 @@
</main> </main>
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer> <footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer>
<script>
document.addEventListener("DOMContentLoaded", function () {
var tags = {
b: ["<b>", "</b>"],
i: ["<i>", "</i>"],
u: ["<u>", "</u>"],
s: ["<s>", "</s>"],
code: ["<code>", "</code>"],
pre: ["<pre>", "</pre>"],
quote: ["<blockquote>", "</blockquote>"],
spoiler: ["<tg-spoiler>", "</tg-spoiler>"],
a: ['<a href="">', "</a>"],
};
document.querySelectorAll("[data-editor]").forEach(function (root) {
var textarea = root.querySelector("textarea");
var counter = root.querySelector("[data-counter]");
var max = parseInt(root.dataset.max || "0", 10);
function updateCounter() {
if (counter) counter.textContent = textarea.value.length + (max ? " / " + max : "");
}
textarea.addEventListener("input", updateCounter);
updateCounter();
root.querySelectorAll("button[data-tag]").forEach(function (button) {
button.addEventListener("click", function () {
var pair = tags[button.dataset.tag];
if (!pair) return;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
var inserted = pair[0] + selected + pair[1];
textarea.value = textarea.value.slice(0, start) + inserted + textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + pair[0].length;
textarea.selectionEnd = start + pair[0].length + selected.length;
updateCounter();
});
});
textarea.addEventListener("keydown", function (event) {
if (!(event.ctrlKey || event.metaKey)) return;
var key = event.key.toLowerCase();
var tag = key === "b" ? "b" : key === "i" ? "i" : key === "u" ? "u" : null;
if (!tag) return;
event.preventDefault();
var button = root.querySelector('button[data-tag="' + tag + '"]');
if (button) button.click();
});
});
});
</script>
</body> </body>
</html> </html>

View File

@ -26,16 +26,21 @@
<form method="post" action="/messages/{{ template.key }}" class="mt-6 space-y-4"> <form method="post" action="/messages/{{ template.key }}" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" /> <input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div> <div>
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label> <label class="text-sm font-medium text-slate-700">Текст шаблона</label>
<textarea <div class="mt-1">
id="text" {% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %}
name="text" {% include "partials/editor.html" %}
rows="18" {% endwith %}
class="mt-1 w-full rounded-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none" </div>
>{{ text }}</textarea>
<p class="mt-1 text-xs text-slate-500"> <p class="mt-1 text-xs text-slate-500">
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка. Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
</p> </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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View File

@ -41,13 +41,12 @@
</div> </div>
<div> <div>
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label> <label class="text-sm font-medium text-slate-700">Текст шаблона</label>
<textarea <div class="mt-1">
id="text" {% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %}
name="text" {% include "partials/editor.html" %}
rows="16" {% endwith %}
class="mt-1 w-full rounded-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none" </div>
>{{ text }}</textarea>
<p class="mt-1 text-xs text-slate-500"> <p class="mt-1 text-xs text-slate-500">
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка. Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
</p> </p>

View File

@ -0,0 +1,20 @@
<div class="editor-block" data-editor data-max="{{ editor_max }}">
<div class="flex flex-wrap gap-1 rounded-t-lg border border-b-0 border-slate-300 bg-slate-50 p-2">
<button type="button" data-tag="b" title="Жирный (Ctrl+B)" class="rounded px-2 py-1 text-sm font-bold hover:bg-white">Ж</button>
<button type="button" data-tag="i" title="Курсив (Ctrl+I)" class="rounded px-2 py-1 text-sm italic hover:bg-white">К</button>
<button type="button" data-tag="u" title="Подчёркнутый (Ctrl+U)" class="rounded px-2 py-1 text-sm underline hover:bg-white">Ч</button>
<button type="button" data-tag="s" title="Зачёркнутый" class="rounded px-2 py-1 text-sm line-through hover:bg-white">З</button>
<button type="button" data-tag="code" title="Код" class="rounded px-2 py-1 font-mono text-xs hover:bg-white">&lt;/&gt;</button>
<button type="button" data-tag="pre" title="Блок кода" class="rounded px-2 py-1 font-mono text-xs hover:bg-white">[ ]</button>
<button type="button" data-tag="quote" title="Цитата" class="rounded px-2 py-1 text-sm hover:bg-white">❝</button>
<button type="button" data-tag="spoiler" title="Спойлер" class="rounded px-2 py-1 text-sm hover:bg-white">👁</button>
<button type="button" data-tag="a" title="Ссылка" class="rounded px-2 py-1 text-sm hover:bg-white">🔗</button>
</div>
<textarea
id="{{ editor_id }}"
name="{{ editor_name }}"
rows="{{ editor_rows | default(14) }}"
class="w-full rounded-b-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
>{{ editor_value }}</textarea>
<p class="mt-1 text-xs text-slate-500"><span data-counter>0</span> / {{ editor_max }} символов</p>
</div>

View File

@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}{{ sequence.title }}{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/sequences" class="hover:underline">← Последовательности</a></p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold">{{ sequence.title }}</h1>
<p class="font-mono text-xs text-slate-500">{{ sequence.key }}</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">
{% if saved == 'meta' %}Параметры последовательности сохранены.
{% elif saved == 'order' %}Порядок сообщений обновлён.
{% else %}Сообщение сохранено.{% endif %}
</div>
{% endif %}
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="/sequences/{{ sequence.key }}" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="text-xs text-slate-500">Название</label>
<input name="title" value="{{ sequence.title }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="sm:col-span-2">
<label class="text-xs text-slate-500">Описание</label>
<input name="description" value="{{ sequence.description }}"
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>
<button type="submit" class="mt-3 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить параметры
</button>
</form>
<div class="mt-8 flex items-center justify-between">
<h2 class="text-lg font-medium">Сообщения ({{ items | length }})</h2>
<form method="post" action="/sequences/{{ sequence.key }}/items">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Добавить сообщение
</button>
</form>
</div>
<div class="mt-4 space-y-5">
{% for item in items %}
<section class="rounded-lg border border-slate-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
<div class="text-sm text-slate-600">
<span class="font-medium">№ {{ loop.index }}</span>
<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>
{% if item.enabled %}
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
{% else %}
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
{% endif %}
</div>
<div class="flex items-center gap-2 text-sm">
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/move">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<input type="hidden" name="direction" value="up" />
<button type="submit" class="text-slate-600 hover:underline" {% if loop.first %}disabled{% endif %}>Выше</button>
</form>
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/move">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<input type="hidden" name="direction" value="down" />
<button type="submit" class="text-slate-600 hover:underline" {% if loop.last %}disabled{% endif %}>Ниже</button>
</form>
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/delete" onsubmit="return confirm('Удалить сообщение?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
</div>
</div>
<form
method="post"
action="/sequences/{{ sequence.key }}/items/{{ item.id }}"
enctype="multipart/form-data"
class="p-4"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
{% with editor_id="text-" ~ item.id, editor_name="text", editor_value=item.text, editor_max=(max_caption if item.image_name else max_message), editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
<div class="mt-3 grid gap-3 lg:grid-cols-2">
<div>
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
<textarea
name="buttons"
rows="3"
placeholder="Канал | https://t.me/…"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
>{{ item.buttons_text }}</textarea>
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
</div>
<div>
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
{% if item.image_name %}
<div class="mt-1 flex items-center gap-3">
<img src="/sequences/media/{{ item.image_name }}" alt="" class="h-20 w-20 rounded object-cover" />
<label class="flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" name="remove_image" /> удалить
</label>
</div>
{% endif %}
<input type="file" name="image" accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full text-xs text-slate-600" />
<p class="mt-1 text-xs text-slate-500">
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
</p>
</div>
</div>
<div class="mt-3 flex items-center justify-between">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled" {% if item.enabled %}checked{% endif %} /> включено
</label>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить сообщение
</button>
</div>
</form>
</section>
{% else %}
<p class="text-sm text-slate-500">Сообщений пока нет — добавьте первое.</p>
{% endfor %}
</div>
<div class="mt-10">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<ul class="mt-3 space-y-2">
{% for change 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>{{ change.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span>редактор: {{ change.editor_id or "—" }}</span>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-slate-600">снимок</summary>
<pre class="mt-1 max-h-64 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ change.snapshot | tojson(indent=2) }}</pre>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Последовательности{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Последовательности</h1>
<p class="mt-1 text-sm text-slate-600">Несколько сообщений по порядку: текст, картинка и кнопки.</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="/sequences" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<h2 class="text-lg font-medium">Новая последовательность</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-4">
<div>
<label for="key" class="text-xs text-slate-500">Ключ</label>
<input id="key" name="key" value="{{ form.key }}" placeholder="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" />
</div>
<div>
<label for="title" class="text-xs text-slate-500">Название</label>
<input id="title" name="title" value="{{ form.title }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div>
<label for="description" class="text-xs text-slate-500">Описание</label>
<input id="description" name="description" value="{{ form.description }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="flex items-end">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Создать
</button>
</div>
</div>
</form>
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Ключ</th>
<th class="px-4 py-3 font-medium">Название</th>
<th class="px-4 py-3 font-medium">Сообщений</th>
<th class="px-4 py-3 font-medium">Изменена</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for sequence in sequences %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a>
</td>
<td class="px-4 py-3">{{ sequence.title }}<div class="text-xs text-slate-500">{{ sequence.description }}</div></td>
<td class="px-4 py-3">{{ sequence.items }}</td>
<td class="px-4 py-3 text-slate-500">{{ sequence.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Последовательностей пока нет.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -7,11 +7,12 @@ import logging
from aiogram import Router from aiogram import Router
from aiogram.enums import ChatMemberStatus from aiogram.enums import ChatMemberStatus
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
from aiogram.types import ChatMemberUpdated, FSInputFile from aiogram.types import ChatMemberUpdated, FSInputFile, User
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.services import alerts from bot.services import alerts
from bot.services import users as users_service from bot.services import users as users_service
from bot.services.sequences import send_sequence
from common.analytics import EventType, record from common.analytics import EventType, record
from common.config import get_settings from common.config import get_settings
from common.texts import system, welcome from common.texts import system, welcome
@ -50,20 +51,48 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
chat_title=event.chat.title or "", chat_title=event.chat.title or "",
) )
if await send_sequence(event.bot, event.chat.id, "welcome", {"username": user.username}):
return
await _send_fallback_welcome(event, user)
async def _send_fallback_welcome(event: ChatMemberUpdated, user: User) -> None:
"""Приветствие по старым шаблонам, если последовательность не настроена."""
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL) builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL)
markup = builder.as_markup()
await event.bot.send_photo( caption, body = welcome.split_welcome(
chat_id=event.chat.id, await get_text("welcome.text"),
photo=FSInputFile("images/welcome_image.jpg"), await get_text("welcome.text2"),
caption=render(await get_text("welcome.text"), {"username": user.username}), await get_text("welcome.text3"),
) )
await event.bot.send_message( caption = render(caption, {"username": user.username})
chat_id=event.chat.id, body = render(body, {"username": user.username})
text=await get_text("welcome.text2"),
reply_markup=builder.as_markup(), if len(caption) > 1024:
) body = caption + (f"\n\n{body}" if body else "")
await event.bot.send_message(chat_id=event.chat.id, text=await get_text("welcome.text3")) caption = ""
if body:
await event.bot.send_photo(
chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"),
caption=caption or None,
)
await event.bot.send_message(
chat_id=event.chat.id,
text=body,
reply_markup=markup,
)
else:
await event.bot.send_photo(
chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"),
caption=caption or None,
reply_markup=markup,
)
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION)) @router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))

80
bot/services/sequences.py Normal file
View File

@ -0,0 +1,80 @@
"""Отправка последовательностей сообщений (Блок 4).
Последовательность читается из БД; каждое сообщение отправляется по порядку,
с картинкой из общего media-volume и своими inline-кнопками.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from aiogram import Bot
from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup
from common.config import get_settings
from common.repositories import sequences_repo
from common.texts.render import render
logger = logging.getLogger(__name__)
#: Лимит подписи к фото в Telegram.
CAPTION_LIMIT = 1024
def _markup(buttons: list[dict[str, Any]]) -> InlineKeyboardMarkup | None:
rows = [[InlineKeyboardButton(text=str(button["text"]), url=str(button["url"]))] for button in buttons]
return InlineKeyboardMarkup(inline_keyboard=rows) if rows else None
def _image_path(item: dict[str, Any]) -> Path | None:
name = item.get("image_name")
if not name:
return None
filename = Path(str(name)).name
candidates = [
Path(get_settings().media_dir) / filename,
Path("images") / filename,
]
for candidate in candidates:
if candidate.is_file():
return candidate
logger.warning("Картинка %s для последовательности не найдена", filename)
return None
async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any]) -> bool:
"""Отправить последовательность. False — если она не настроена/пуста."""
try:
items = await sequences_repo.get_items(key)
except Exception:
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
return False
enabled = [item for item in items if item.get("enabled")]
if not enabled:
return False
for item in enabled:
text = render(str(item.get("text") or ""), context) if item.get("text") else ""
path = _image_path(item)
markup = _markup(item.get("buttons") or [])
if path is not None:
if text and len(text) > CAPTION_LIMIT:
await bot.send_photo(chat_id=chat_id, photo=FSInputFile(path))
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
else:
await bot.send_photo(
chat_id=chat_id,
photo=FSInputFile(path),
caption=text or None,
reply_markup=markup,
)
elif text:
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
return True

View File

@ -54,6 +54,9 @@ class Settings(BaseSettings):
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически). #: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat" heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
#: Общий каталог картинок (Docker volume), который видят бот и админка.
media_dir: str = "/app/media"
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить. #: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
clickhouse_enabled: bool = True clickhouse_enabled: bool = True
clickhouse_host: str = "localhost" clickhouse_host: str = "localhost"

View File

@ -0,0 +1,322 @@
"""Репозиторий последовательностей сообщений (Блок 4).
Последовательность — несколько сообщений по порядку; у каждого может быть
картинка из общего volume и свои inline-кнопки. Бот отправляет их при входе
участника (пока используется для приветствия).
"""
from __future__ import annotations
from typing import Any
from psycopg.types.json import Jsonb
from common.db.pool import get_pool
#: Максимальный размер загружаемой картинки.
MAX_IMAGE_BYTES = 5 * 1024 * 1024
async def list_sequences() -> list[dict[str, Any]]:
"""Список последовательностей с количеством сообщений."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT s.key, s.title, s.description, s.updated_at, s.updated_by,
count(i.id) AS items
FROM bot.message_sequences AS s
LEFT JOIN bot.message_sequence_items AS i ON i.sequence_key = s.key
GROUP BY s.key, s.title, s.description, s.updated_at, s.updated_by
ORDER BY s.key
"""
)
return await cur.fetchall()
async def get_sequence(key: str) -> dict[str, Any] | None:
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT key, title, description, updated_at, updated_by FROM bot.message_sequences WHERE key = %s",
(key,),
)
return await cur.fetchone()
async def get_items(key: str) -> list[dict[str, Any]]:
"""Сообщения последовательности с кнопками, по порядку."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, position, text, enabled, image_name
FROM bot.message_sequence_items
WHERE sequence_key = %s
ORDER BY position
""",
(key,),
)
items = await cur.fetchall()
cur = await conn.execute(
"""
SELECT b.item_id, b.position, b.text, b.url
FROM bot.message_sequence_buttons AS b
JOIN bot.message_sequence_items AS i ON i.id = b.item_id
WHERE i.sequence_key = %s
ORDER BY b.item_id, b.position
""",
(key,),
)
buttons = await cur.fetchall()
by_item: dict[int, list[dict[str, Any]]] = {}
for button in buttons:
by_item.setdefault(int(button["item_id"]), []).append({"text": button["text"], "url": button["url"]})
for item in items:
item["buttons"] = by_item.get(int(item["id"]), [])
return items
async def _snapshot(conn: Any, key: str, editor_id: int | None) -> None:
"""Сохранить снимок последовательности в историю изменений."""
cur = await conn.execute(
"SELECT key, title, description FROM bot.message_sequences WHERE key = %s",
(key,),
)
sequence = await cur.fetchone()
if sequence is None:
return
cur = await conn.execute(
"""
SELECT id, position, text, enabled, image_name
FROM bot.message_sequence_items
WHERE sequence_key = %s
ORDER BY position
""",
(key,),
)
items = await cur.fetchall()
cur = await conn.execute(
"""
SELECT b.item_id, b.position, b.text, b.url
FROM bot.message_sequence_buttons AS b
JOIN bot.message_sequence_items AS i ON i.id = b.item_id
WHERE i.sequence_key = %s
ORDER BY b.item_id, b.position
""",
(key,),
)
buttons = await cur.fetchall()
by_item: dict[int, list[dict[str, Any]]] = {}
for button in buttons:
by_item.setdefault(int(button["item_id"]), []).append(
{"position": button["position"], "text": button["text"], "url": button["url"]}
)
snapshot = {
"sequence": sequence,
"items": [{**item, "buttons": by_item.get(int(item["id"]), [])} for item in items],
}
await conn.execute(
"""
INSERT INTO bot.message_sequence_history (sequence_key, snapshot, editor_id)
VALUES (%s, %s, %s)
""",
(key, Jsonb(snapshot), editor_id),
)
async def create_sequence(
key: str,
title: str,
description: str,
*,
editor_id: int | None = None,
) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.message_sequences WHERE key = %s", (key,))
if await cur.fetchone() is not None:
return False
await conn.execute(
"""
INSERT INTO bot.message_sequences (key, title, description, updated_by)
VALUES (%s, %s, %s, %s)
""",
(key, title, description, editor_id),
)
await _snapshot(conn, key, editor_id)
return True
async def update_sequence(
key: str,
title: str,
description: str,
*,
editor_id: int | None = None,
) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
UPDATE bot.message_sequences
SET title = %s, description = %s, updated_at = now(), updated_by = %s
WHERE key = %s
""",
(title, description, editor_id, key),
)
if not cur.rowcount:
return False
await _snapshot(conn, key, editor_id)
return True
async def add_item(key: str, *, editor_id: int | None = None) -> int | None:
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.message_sequences WHERE key = %s", (key,))
if await cur.fetchone() is None:
return None
cur = await conn.execute(
"SELECT coalesce(max(position), -1) + 1 AS next FROM bot.message_sequence_items WHERE sequence_key = %s",
(key,),
)
row = await cur.fetchone()
position = int(row["next"])
cur = await conn.execute(
"""
INSERT INTO bot.message_sequence_items (sequence_key, position, text, updated_by)
VALUES (%s, %s, '', %s)
RETURNING id
""",
(key, position, editor_id),
)
inserted = await cur.fetchone()
await _snapshot(conn, key, editor_id)
return int(inserted["id"])
async def update_item(
item_id: int,
text: str,
enabled: bool,
buttons: list[tuple[str, str]],
*,
image_name: str | None = None,
update_image: bool = False,
editor_id: int | None = None,
) -> tuple[str, str | None] | None:
"""Обновить сообщение и его кнопки. Возвращает (ключ, старое имя картинки)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT sequence_key, image_name FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
(item_id,),
)
item = await cur.fetchone()
if item is None:
return None
key = str(item["sequence_key"])
old_image = item["image_name"]
if update_image:
await conn.execute(
"""
UPDATE bot.message_sequence_items
SET text = %s, enabled = %s, image_name = %s, updated_at = now(), updated_by = %s
WHERE id = %s
""",
(text, enabled, image_name, editor_id, item_id),
)
else:
await conn.execute(
"""
UPDATE bot.message_sequence_items
SET text = %s, enabled = %s, updated_at = now(), updated_by = %s
WHERE id = %s
""",
(text, enabled, editor_id, item_id),
)
await conn.execute("DELETE FROM bot.message_sequence_buttons WHERE item_id = %s", (item_id,))
for position, (button_text, url) in enumerate(buttons):
await conn.execute(
"""
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
VALUES (%s, %s, %s, %s)
""",
(item_id, position, button_text, url),
)
await _snapshot(conn, key, editor_id)
return key, old_image
async def delete_item(item_id: int, *, editor_id: int | None = None) -> str | None:
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT sequence_key FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
(item_id,),
)
item = await cur.fetchone()
if item is None:
return None
key = str(item["sequence_key"])
await conn.execute("DELETE FROM bot.message_sequence_items WHERE id = %s", (item_id,))
await _snapshot(conn, key, editor_id)
return key
async def move_item(item_id: int, direction: str, *, editor_id: int | None = None) -> str | None:
"""Поменять сообщение с соседним (``up``/``down``)."""
delta = -1 if direction == "up" else 1
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT sequence_key, position FROM bot.message_sequence_items WHERE id = %s FOR UPDATE",
(item_id,),
)
item = await cur.fetchone()
if item is None:
return None
key = str(item["sequence_key"])
position = int(item["position"])
cur = await conn.execute(
"""
SELECT id, position FROM bot.message_sequence_items
WHERE sequence_key = %s AND position = %s
FOR UPDATE
""",
(key, position + delta),
)
neighbour = await cur.fetchone()
if neighbour is None:
return key
# Через -1, чтобы не нарушить UNIQUE(sequence_key, position).
await conn.execute("UPDATE bot.message_sequence_items SET position = -1 WHERE id = %s", (item_id,))
await conn.execute(
"UPDATE bot.message_sequence_items SET position = %s WHERE id = %s",
(position, neighbour["id"]),
)
await conn.execute(
"UPDATE bot.message_sequence_items SET position = %s WHERE id = %s",
(position + delta, item_id),
)
await _snapshot(conn, key, editor_id)
return key
async def history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, snapshot, editor_id, changed_at
FROM bot.message_sequence_history
WHERE sequence_key = %s
ORDER BY changed_at DESC
LIMIT %s
""",
(key, limit),
)
return await cur.fetchall()

View File

@ -36,3 +36,18 @@ MSG_WELCOME_TEXT3 = """❗️<b>По игре</b>:
MSG_WELCOME_BUTTON_CAPTION = "Канал" MSG_WELCOME_BUTTON_CAPTION = "Канал"
MSG_WELCOME_BUTTON_URL = "https://t.me/+DQk8MyN2y6QzNTM6" MSG_WELCOME_BUTTON_URL = "https://t.me/+DQk8MyN2y6QzNTM6"
def split_welcome(*parts: str) -> tuple[str, str]:
"""Разделить приветствие на подпись к фото и одно общее сообщение.
Первая строка первого шаблона становится подписью (персональное приветствие),
всё остальное (включая ``text2``/``text3``) — одним сообщением по порядку.
"""
if not parts:
return "", ""
caption, _, rest = parts[0].partition("\n")
body_parts = [rest.rstrip(), *parts[1:]]
body = "\n\n".join(part.strip() for part in body_parts if part and part.strip())
return caption.strip(), body

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.4.14" __version__ = "0.5.0"

View File

@ -48,6 +48,8 @@ services:
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT: "8123" CLICKHOUSE_PORT: "8123"
volumes:
- media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -76,6 +78,8 @@ services:
CLICKHOUSE_PORT: "8123" CLICKHOUSE_PORT: "8123"
ADMIN_HOST: 0.0.0.0 ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000" ADMIN_PORT: "8000"
volumes:
- media:/app/media
ports: ports:
# По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1: # По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1:
# порт остаётся недоступен из интернета, но виден контейнерам на bridge. # порт остаётся недоступен из интернета, но виден контейнерам на bridge.
@ -140,5 +144,6 @@ services:
volumes: volumes:
pgdata: pgdata:
clickhousedata: clickhousedata:
media:
# caddy_data: # caddy_data:
# caddy_config: # caddy_config:

View File

@ -53,6 +53,8 @@ services:
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT: "8123" CLICKHOUSE_PORT: "8123"
volumes:
- media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -84,6 +86,8 @@ services:
CLICKHOUSE_PORT: "8123" CLICKHOUSE_PORT: "8123"
ADMIN_HOST: 0.0.0.0 ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000" ADMIN_PORT: "8000"
volumes:
- media:/app/media
ports: ports:
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000" - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000"
depends_on: depends_on:
@ -127,3 +131,4 @@ services:
volumes: volumes:
pgdata: pgdata:
clickhousedata: clickhousedata:
media:

59
docs/message-sequences.md Normal file
View File

@ -0,0 +1,59 @@
# Последовательности сообщений
Реализовано в 0.5.0. Позволяет собирать приветствие (и любые другие наборы) из
нескольких сообщений по порядку, с картинками и кнопками.
## Модель
- `bot.message_sequences` — `key`, `title`, `description`, `updated_at/by`.
- `bot.message_sequence_items` — сообщения: `sequence_key`, `position`, `text`,
`enabled`, `image_name` (файл в общем volume `media/`).
- `bot.message_sequence_buttons` — кнопки конкретного сообщения: `item_id`,
`position`, `text`, `url`. Кнопок может быть несколько, каждая на своей строке.
- `bot.message_sequence_history` — JSON-снимок последовательности при каждом изменении.
Картинки не лежат в Postgres: они в Docker-volume `media`, который монтируется в
`/app/media` и боту, и админке. При старте админка копирует туда встроенное
`images/welcome_image.jpg`, если файла ещё нет.
## Отправка ботом
`bot/services/sequences.py` читает включённые сообщения по `position` и отправляет:
- с картинкой → `send_photo` (текст подписью, если ≤ 1024 символов; иначе фото без
подписи и отдельное сообщение);
- без картинки → `send_message`;
- кнопки — `InlineKeyboardMarkup`, по одной в ряд.
Для `welcome` при отсутствии/пустоте последовательности остаётся фолбэк на старые
шаблоны `welcome.text*` из кода.
## Админка
`/sequences` — список и создание; `/sequences/{key}` — редактор:
- параметры последовательности;
- сообщения по порядку: текст, картинка (загрузка/удаление), вкл/выкл, кнопки,
перемещение вверх/вниз, удаление;
- предпросмотр и история изменений.
Текст редактируется в textarea с тулбаром: жирный `<b>`, курсив `<i>`,
подчёркивание `<u>`, зачёркивание `<s>`, код `<code>`, блок `<pre>`,
цитата `<blockquote>`, спойлер `<tg-spoiler>`, ссылка `<a href>`. Есть счётчик
символов (1024 для подписи к фото, 4096 для обычного сообщения) и предпросмотр
через тот же `render()`, что использует бот.
Картинки отдаются по `/sequences/media/{filename}` (только после входа).
## Миграции
- `V20__message_sequences.sql` — таблицы и история.
- `V21__seed_welcome_sequence.sql` — перенос текущего приветствия в `welcome`
(подпись к фото + остальной текст с кнопкой «Канал») и удаление старых
шаблонов `welcome.text*` из `message_templates`.
## Проверки
`tests/test_admin_sequences.py` — список, редактор, создание, параметры, добавление,
сохранение текста/кнопок, лимиты, загрузка картинки, порядок и удаление;
`tests/test_welcome_split.py` — фолбэк приветствия.

View File

@ -0,0 +1,48 @@
-- Последовательности сообщений (Блок 4): несколько сообщений по порядку,
-- у каждого — опциональная картинка (файл в общем volume) и свои кнопки.
CREATE TABLE IF NOT EXISTS bot.message_sequences (
key text PRIMARY KEY,
title text NOT NULL,
description text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by bigint
);
COMMENT ON TABLE bot.message_sequences IS 'Именованные последовательности сообщений (приветствие и т.п.)';
CREATE TABLE IF NOT EXISTS bot.message_sequence_items (
id bigserial PRIMARY KEY,
sequence_key text NOT NULL REFERENCES bot.message_sequences (key) ON DELETE CASCADE,
position integer NOT NULL,
text text NOT NULL DEFAULT '',
enabled boolean NOT NULL DEFAULT true,
image_name text,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by bigint,
UNIQUE (sequence_key, position)
);
COMMENT ON TABLE bot.message_sequence_items IS 'Сообщения последовательности по порядку';
COMMENT ON COLUMN bot.message_sequence_items.image_name IS 'Имя файла в общем volume media/ (необязательно)';
CREATE TABLE IF NOT EXISTS bot.message_sequence_buttons (
id bigserial PRIMARY KEY,
item_id bigint NOT NULL REFERENCES bot.message_sequence_items (id) ON DELETE CASCADE,
position integer NOT NULL,
text text NOT NULL,
url text NOT NULL,
UNIQUE (item_id, position)
);
COMMENT ON TABLE bot.message_sequence_buttons IS 'Inline-кнопки конкретного сообщения последовательности';
CREATE TABLE IF NOT EXISTS bot.message_sequence_history (
id bigserial PRIMARY KEY,
sequence_key text NOT NULL,
snapshot jsonb NOT NULL,
editor_id bigint,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sequence_history_key_time
ON bot.message_sequence_history (sequence_key, changed_at DESC);

View File

@ -0,0 +1,37 @@
-- Переносим текущее приветствие в последовательность `welcome`:
-- 1) короткая подпись к фото, 2) остальной текст одним сообщением с кнопкой «Канал».
INSERT INTO bot.message_sequences (key, title, description)
VALUES ('welcome', 'Приветствие', 'Сообщения при входе участника в чат')
ON CONFLICT (key) DO NOTHING;
INSERT INTO bot.message_sequence_items (sequence_key, position, text, image_name)
SELECT 'welcome', 0, split_part(t.text, E'\n', 1), 'welcome_image.jpg'
FROM bot.message_templates AS t
WHERE t.key = 'welcome.text'
ON CONFLICT (sequence_key, position) DO NOTHING;
INSERT INTO bot.message_sequence_items (sequence_key, position, text)
SELECT
'welcome',
1,
trim(both E'\n' from (
substr(src.text, strpos(src.text, E'\n') + 1)
|| E'\n\n' || coalesce(t2.text, '')
|| E'\n\n' || coalesce(t3.text, '')
))
FROM bot.message_templates AS src
LEFT JOIN bot.message_templates AS t2 ON t2.key = 'welcome.text2'
LEFT JOIN bot.message_templates AS t3 ON t3.key = 'welcome.text3'
WHERE src.key = 'welcome.text'
ON CONFLICT (sequence_key, position) DO NOTHING;
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
SELECT i.id, 0, 'Канал', 'https://t.me/+DQk8MyN2y6QzNTM6'
FROM bot.message_sequence_items AS i
WHERE i.sequence_key = 'welcome'
AND i.position = 1
AND NOT EXISTS (SELECT 1 FROM bot.message_sequence_buttons AS b WHERE b.item_id = i.id);
-- Старые шаблоны приветствия больше не редактируются: источник правды — последовательность.
DELETE FROM bot.message_templates
WHERE key IN ('welcome.text', 'welcome.text2', 'welcome.text3');

View File

@ -0,0 +1,268 @@
"""Тесты раздела «Последовательности» и парсера кнопок."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from admin.routes.sequences import parse_buttons
from common.repositories import sequences_repo
SEQUENCE: dict[str, Any] = {
"key": "welcome",
"title": "Приветствие",
"description": "Сообщения при входе",
"updated_at": datetime(2026, 10, 25, tzinfo=UTC),
"updated_by": None,
}
ITEM_1: dict[str, Any] = {
"id": 1,
"position": 0,
"text": "Привет, {username}!",
"enabled": True,
"image_name": "welcome_image.jpg",
"buttons": [],
}
ITEM_2: dict[str, Any] = {
"id": 2,
"position": 1,
"text": "Правила клана",
"enabled": True,
"image_name": None,
"buttons": [{"text": "Канал", "url": "https://t.me/example"}],
}
@pytest.fixture
def fake_sequences(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {}
async def list_sequences() -> list[dict[str, Any]]:
return [SEQUENCE]
async def get_sequence(key: str) -> dict[str, Any] | None:
return {**SEQUENCE, "key": key}
async def get_items(key: str) -> list[dict[str, Any]]:
return [
{**ITEM_1, "buttons": [dict(button) for button in ITEM_1["buttons"]]},
{**ITEM_2, "buttons": [dict(button) for button in ITEM_2["buttons"]]},
]
async def history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
return []
async def create_sequence(key: str, title: str, description: str, *, editor_id: int | None = None) -> bool:
state["created"] = (key, title, description)
return True
async def update_sequence(key: str, title: str, description: str, *, editor_id: int | None = None) -> bool:
state["meta"] = (key, title, description)
return True
async def add_item(key: str, *, editor_id: int | None = None) -> int:
state["added"] = key
return 3
async def update_item(
item_id: int,
text: str,
enabled: bool,
buttons: list[tuple[str, str]],
*,
image_name: str | None = None,
update_image: bool = False,
editor_id: int | None = None,
) -> tuple[str, str | None]:
state["item"] = {
"id": item_id,
"text": text,
"enabled": enabled,
"buttons": buttons,
"image_name": image_name,
"update_image": update_image,
}
return "welcome", None
async def move_item(item_id: int, direction: str, *, editor_id: int | None = None) -> str:
state["move"] = (item_id, direction)
return "welcome"
async def delete_item(item_id: int, *, editor_id: int | None = None) -> str:
state["deleted"] = item_id
return "welcome"
monkeypatch.setattr(sequences_repo, "list_sequences", list_sequences)
monkeypatch.setattr(sequences_repo, "get_sequence", get_sequence)
monkeypatch.setattr(sequences_repo, "get_items", get_items)
monkeypatch.setattr(sequences_repo, "history", history)
monkeypatch.setattr(sequences_repo, "create_sequence", create_sequence)
monkeypatch.setattr(sequences_repo, "update_sequence", update_sequence)
monkeypatch.setattr(sequences_repo, "add_item", add_item)
monkeypatch.setattr(sequences_repo, "update_item", update_item)
monkeypatch.setattr(sequences_repo, "move_item", move_item)
monkeypatch.setattr(sequences_repo, "delete_item", delete_item)
return state
def _csrf(client: TestClient, csrf_token, path: str = "/sequences/welcome") -> str:
return csrf_token(client.get(path).text)
def test_parse_buttons() -> None:
buttons, errors = parse_buttons("Канал | https://t.me/x\n\nПравила | http://example.com")
assert errors == []
assert buttons == [("Канал", "https://t.me/x"), ("Правила", "http://example.com")]
def test_parse_buttons_reports_bad_lines() -> None:
buttons, errors = parse_buttons("без ссылки\nТекст | ftp://bad\n | https://x")
assert buttons == []
assert len(errors) == 3
def test_sequences_list(fake_sequences: dict[str, Any], client_factory) -> None:
response = client_factory().get("/sequences")
assert response.status_code == 200
assert "welcome" in response.text
assert "Приветствие" in response.text
def test_sequence_editor_has_toolbar(fake_sequences: dict[str, Any], client_factory) -> None:
response = client_factory().get("/sequences/welcome")
assert response.status_code == 200
assert "Правила клана" in response.text
assert 'data-tag="b"' in response.text
assert "Канал | https://t.me/example" in response.text
def test_create_sequence(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory(follow_redirects=False)
token = _csrf(client, csrf_token, "/sequences")
response = client.post(
"/sequences",
data={"key": "rules", "title": "Правила", "description": "Текст", "csrf_token": token},
)
assert response.status_code == 303
assert response.headers["location"] == "/sequences/rules"
assert fake_sequences["created"] == ("rules", "Правила", "Текст")
def test_update_sequence_meta(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory(follow_redirects=False)
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome",
data={"title": "Новое", "description": "Описание", "csrf_token": token},
)
assert response.status_code == 303
assert fake_sequences["meta"] == ("welcome", "Новое", "Описание")
def test_add_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory(follow_redirects=False)
token = _csrf(client, csrf_token)
response = client.post("/sequences/welcome/items", data={"csrf_token": token})
assert response.status_code == 303
assert fake_sequences["added"] == "welcome"
def test_update_item_text_and_buttons(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory(follow_redirects=False)
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome/items/2",
data={
"text": "Новые правила",
"buttons": "Канал | https://t.me/new\nЧат | https://t.me/chat",
"enabled": "on",
"csrf_token": token,
},
)
assert response.status_code == 303
saved = fake_sequences["item"]
assert saved["text"] == "Новые правила"
assert saved["buttons"] == [("Канал", "https://t.me/new"), ("Чат", "https://t.me/chat")]
assert saved["enabled"] is True
def test_update_item_rejects_bad_buttons(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome/items/2",
data={"text": "Текст", "buttons": "без ссылки", "csrf_token": token},
)
assert response.status_code == 400
assert "Кнопка без разделителя" in response.text
assert "item" not in fake_sequences
def test_update_item_rejects_caption_overflow(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome/items/1",
data={"text": "а" * 1500, "enabled": "on", "csrf_token": token},
)
assert response.status_code == 400
assert "не больше 1024" in response.text
assert "item" not in fake_sequences
def test_update_item_uploads_image(
fake_sequences: dict[str, Any],
client_factory,
csrf_token,
settings_factory,
tmp_path,
) -> None:
settings = settings_factory(media_dir=str(tmp_path))
client: TestClient = client_factory(settings=settings, follow_redirects=False)
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome/items/2",
data={"text": "С картинкой", "enabled": "on", "csrf_token": token},
files={"image": ("pic.png", b"fake-png-bytes", "image/png")},
)
assert response.status_code == 303
assert fake_sequences["item"]["update_image"] is True
files = list(tmp_path.glob("*.png"))
assert len(files) == 1
def test_move_and_delete_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory(follow_redirects=False)
token = _csrf(client, csrf_token)
moved = client.post(
"/sequences/welcome/items/1/move",
data={"direction": "down", "csrf_token": token},
)
deleted = client.post("/sequences/welcome/items/2/delete", data={"csrf_token": token})
assert moved.status_code == 303
assert deleted.status_code == 303
assert fake_sequences["move"] == (1, "down")
assert fake_sequences["deleted"] == 2

View File

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

View File

@ -0,0 +1,34 @@
"""Тесты сборки приветствия: подпись к фото + одно общее сообщение."""
from __future__ import annotations
from common.texts.welcome import split_welcome
def test_first_line_is_caption_rest_joins_body() -> None:
caption, body = split_welcome(
"💠{username}, добро пожаловать в Dispel Evil!\n\n❗️Правила клана\n1. …",
"Часть 2",
"Часть 3",
)
assert caption == "💠{username}, добро пожаловать в Dispel Evil!"
assert body == "❗️Правила клана\n1. …\n\nЧасть 2\n\nЧасть 3"
def test_without_newline_all_text_is_caption() -> None:
caption, body = split_welcome("Привет", "Второй", "Третий")
assert caption == "Привет"
assert body == "Второй\n\nТретий"
def test_empty_parts_are_skipped() -> None:
caption, body = split_welcome("Привет\n\nТело", "", " ")
assert caption == "Привет"
assert body == "Тело"
def test_no_parts() -> None:
assert split_welcome() == ("", "")