Приветствие стало последовательностью сообщений с картинками и кнопками
Вместо трёх жёстко зашитых шаблонов теперь есть последовательности: сообщения по порядку, у каждого свой текст, картинка из общего volume media и несколько кнопок. Приветствие миграцией перенесено в последовательность welcome (короткая подпись к фото + остальной текст), а если последовательность недоступна — бот откатывается на старые шаблоны из кода. В админке появился раздел «Последовательности» и редактор текста с тулбаром: жирный, курсив, подчёркивание, зачёркивание, код, цитата, спойлер и ссылка, со счётчиком символов и предпросмотром.
This commit is contained in:
parent
34b6574e43
commit
e125d4bb0e
|
|
@ -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).
|
||||
|
|
|
|||
11
README.md
11
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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 с фильтрами",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
<nav class="flex items-center gap-4 text-sm">
|
||||
<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="/sequences" 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="/admins" class="text-slate-600 hover:text-slate-900">Админы</a>
|
||||
|
|
@ -35,5 +36,59 @@
|
|||
</main>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -26,13 +26,12 @@
|
|||
<form method="post" action="/messages/{{ template.key }}" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<div>
|
||||
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<textarea
|
||||
id="text"
|
||||
name="text"
|
||||
rows="18"
|
||||
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"
|
||||
>{{ text }}</textarea>
|
||||
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<div class="mt-1">
|
||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -41,13 +41,12 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<textarea
|
||||
id="text"
|
||||
name="text"
|
||||
rows="16"
|
||||
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"
|
||||
>{{ text }}</textarea>
|
||||
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<div class="mt-1">
|
||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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"></></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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -7,11 +7,12 @@ import logging
|
|||
from aiogram import Router
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
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 bot.services import alerts
|
||||
from bot.services import users as users_service
|
||||
from bot.services.sequences import send_sequence
|
||||
from common.analytics import EventType, record
|
||||
from common.config import get_settings
|
||||
from common.texts import system, welcome
|
||||
|
|
@ -50,6 +51,14 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
|
|||
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.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL)
|
||||
markup = builder.as_markup()
|
||||
|
|
@ -62,7 +71,6 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
|
|||
caption = render(caption, {"username": user.username})
|
||||
body = render(body, {"username": user.username})
|
||||
|
||||
# Подпись к фото ограничена 1024 символами; если не влезла — уйдёт в тело.
|
||||
if len(caption) > 1024:
|
||||
body = caption + (f"\n\n{body}" if body else "")
|
||||
caption = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -54,6 +54,9 @@ class Settings(BaseSettings):
|
|||
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
|
||||
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
|
||||
|
||||
#: Общий каталог картинок (Docker volume), который видят бот и админка.
|
||||
media_dir: str = "/app/media"
|
||||
|
||||
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
|
||||
clickhouse_enabled: bool = True
|
||||
clickhouse_host: str = "localhost"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.4.15"
|
||||
__version__ = "0.5.0"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ services:
|
|||
DB_PORT: "5432"
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT: "8123"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
@ -76,6 +78,8 @@ services:
|
|||
CLICKHOUSE_PORT: "8123"
|
||||
ADMIN_HOST: 0.0.0.0
|
||||
ADMIN_PORT: "8000"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
ports:
|
||||
# По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1:
|
||||
# порт остаётся недоступен из интернета, но виден контейнерам на bridge.
|
||||
|
|
@ -140,5 +144,6 @@ services:
|
|||
volumes:
|
||||
pgdata:
|
||||
clickhousedata:
|
||||
media:
|
||||
# caddy_data:
|
||||
# caddy_config:
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ services:
|
|||
DB_PORT: "5432"
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT: "8123"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
@ -84,6 +86,8 @@ services:
|
|||
CLICKHOUSE_PORT: "8123"
|
||||
ADMIN_HOST: 0.0.0.0
|
||||
ADMIN_PORT: "8000"
|
||||
volumes:
|
||||
- media:/app/media
|
||||
ports:
|
||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000"
|
||||
depends_on:
|
||||
|
|
@ -127,3 +131,4 @@ services:
|
|||
volumes:
|
||||
pgdata:
|
||||
clickhousedata:
|
||||
media:
|
||||
|
|
|
|||
|
|
@ -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` — фолбэк приветствия.
|
||||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
|
@ -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
|
||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
|||
|
||||
def test_version_is_set():
|
||||
assert __version__
|
||||
assert __version__ == "0.4.15"
|
||||
assert __version__ == "0.5.0"
|
||||
|
|
|
|||
Loading…
Reference in New Issue