Compare commits
No commits in common. "e125d4bb0e459fbbe1bc937a6ffb3576e76f61f8" and "8ae4e8bb6c846ae61c796b0c04811969a9d8b37a" have entirely different histories.
e125d4bb0e
...
8ae4e8bb6c
|
|
@ -18,8 +18,6 @@ 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).
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -90,12 +90,6 @@ ID пользователя ведёт в его карточку. ClickHouse ч
|
||||||
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
|
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
|
||||||
описание, текст и предпросмотр.
|
описание, текст и предпросмотр.
|
||||||
|
|
||||||
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие):
|
|
||||||
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
|
|
||||||
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
|
|
||||||
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
|
|
||||||
(1024 для подписи к фото, 4096 для обычного сообщения).
|
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
## Аналитика (ClickHouse)
|
||||||
|
|
||||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
||||||
|
|
@ -124,8 +118,7 @@ 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-файлом:
|
||||||
|
|
||||||
|
|
@ -158,7 +151,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.0`.
|
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.14`.
|
||||||
- Схема — 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`.
|
||||||
|
|
@ -187,13 +180,6 @@ 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 апреля по православную Пасху**
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,8 @@ 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, sequences, users
|
from admin.routes import admins, auth, health, logs, messages, pages, schedules, 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
|
||||||
|
|
@ -41,7 +40,6 @@ 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:
|
||||||
|
|
@ -90,7 +88,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""Общий каталог картинок для бота и админки (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,11 +16,6 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
"description": "Тексты бота и предпросмотр с тегами {tag}",
|
"description": "Тексты бота и предпросмотр с тегами {tag}",
|
||||||
"url": "/messages",
|
"url": "/messages",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"title": "Последовательности",
|
|
||||||
"description": "Приветствие и другие наборы сообщений по порядку",
|
|
||||||
"url": "/sequences",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"title": "Расписания",
|
"title": "Расписания",
|
||||||
"description": "Время рассылок, дни недели и шаблон",
|
"description": "Время рассылок, дни недели и шаблон",
|
||||||
|
|
@ -31,11 +26,6 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
"description": "Ники, звания, баланс и подписка",
|
"description": "Ники, звания, баланс и подписка",
|
||||||
"url": "/users",
|
"url": "/users",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"title": "Админы",
|
|
||||||
"description": "Доступ к веб-панели",
|
|
||||||
"url": "/admins",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"title": "Логи",
|
"title": "Логи",
|
||||||
"description": "События из ClickHouse с фильтрами",
|
"description": "События из ClickHouse с фильтрами",
|
||||||
|
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
"""Раздел «Последовательности»: сообщения по порядку, картинки и кнопки."""
|
|
||||||
|
|
||||||
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,7 +14,6 @@
|
||||||
<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>
|
||||||
|
|
@ -36,59 +35,5 @@
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,16 @@
|
||||||
<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 class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||||
<div class="mt-1">
|
<textarea
|
||||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %}
|
id="text"
|
||||||
{% include "partials/editor.html" %}
|
name="text"
|
||||||
{% endwith %}
|
rows="18"
|
||||||
</div>
|
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>
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||||
<div class="mt-1">
|
<textarea
|
||||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %}
|
id="text"
|
||||||
{% include "partials/editor.html" %}
|
name="text"
|
||||||
{% endwith %}
|
rows="16"
|
||||||
</div>
|
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>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
{% 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,12 +7,11 @@ 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, User
|
from aiogram.types import ChatMemberUpdated, FSInputFile
|
||||||
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
|
||||||
|
|
@ -51,48 +50,20 @@ 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()
|
|
||||||
|
|
||||||
caption, body = welcome.split_welcome(
|
|
||||||
await get_text("welcome.text"),
|
|
||||||
await get_text("welcome.text2"),
|
|
||||||
await get_text("welcome.text3"),
|
|
||||||
)
|
|
||||||
caption = render(caption, {"username": user.username})
|
|
||||||
body = render(body, {"username": user.username})
|
|
||||||
|
|
||||||
if len(caption) > 1024:
|
|
||||||
body = caption + (f"\n\n{body}" if body else "")
|
|
||||||
caption = ""
|
|
||||||
|
|
||||||
if body:
|
|
||||||
await event.bot.send_photo(
|
await event.bot.send_photo(
|
||||||
chat_id=event.chat.id,
|
chat_id=event.chat.id,
|
||||||
photo=FSInputFile("images/welcome_image.jpg"),
|
photo=FSInputFile("images/welcome_image.jpg"),
|
||||||
caption=caption or None,
|
caption=render(await get_text("welcome.text"), {"username": user.username}),
|
||||||
)
|
)
|
||||||
await event.bot.send_message(
|
await event.bot.send_message(
|
||||||
chat_id=event.chat.id,
|
chat_id=event.chat.id,
|
||||||
text=body,
|
text=await get_text("welcome.text2"),
|
||||||
reply_markup=markup,
|
reply_markup=builder.as_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,
|
|
||||||
)
|
)
|
||||||
|
await event.bot.send_message(chat_id=event.chat.id, text=await get_text("welcome.text3"))
|
||||||
|
|
||||||
|
|
||||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""Отправка последовательностей сообщений (Блок 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,9 +54,6 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
"""Репозиторий последовательностей сообщений (Блок 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()
|
|
||||||
|
|
@ -36,18 +36,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.5.0"
|
__version__ = "0.4.14"
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@ 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
|
||||||
|
|
@ -78,8 +76,6 @@ 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.
|
||||||
|
|
@ -144,6 +140,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
clickhousedata:
|
clickhousedata:
|
||||||
media:
|
|
||||||
# caddy_data:
|
# caddy_data:
|
||||||
# caddy_config:
|
# caddy_config:
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,6 @@ 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
|
||||||
|
|
@ -86,8 +84,6 @@ 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:
|
||||||
|
|
@ -131,4 +127,3 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
clickhousedata:
|
clickhousedata:
|
||||||
media:
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# Последовательности сообщений
|
|
||||||
|
|
||||||
Реализовано в 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` — фолбэк приветствия.
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
-- Последовательности сообщений (Блок 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);
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
-- Переносим текущее приветствие в последовательность `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');
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
"""Тесты раздела «Последовательности» и парсера кнопок."""
|
|
||||||
|
|
||||||
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():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.5.0"
|
assert __version__ == "0.4.14"
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"""Тесты сборки приветствия: подпись к фото + одно общее сообщение."""
|
|
||||||
|
|
||||||
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() == ("", "")
|
|
||||||
Loading…
Reference in New Issue