Из админки можно отправить сообщение в чат от имени бота

Появился раздел «Отправить»: текст с тем же редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Отправка идёт в основной чат через Bot API; успешные и неуспешные попытки пишутся в bot.admin_messages (кто, когда, что и какая ошибка).
This commit is contained in:
MpakobecEx 2026-10-25 21:51:39 +05:00
parent cfa0e29bbf
commit 08c57f9b39
15 changed files with 609 additions and 62 deletions

View File

@ -99,6 +99,10 @@ ID пользователя ведёт в его карточку. ClickHouse ч
Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц
скрываются на узких экранах, формы складываются в одну колонку. скрываются на узких экранах, формы складываются в одну колонку.
Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же
редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка
(успешная или нет) пишется в `bot.admin_messages`.
## Аналитика (ClickHouse) ## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
@ -161,7 +165,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.3`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.4`.
- Схема — 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`.

View File

@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware
from admin.media import ensure_media 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, send, sequences, users
from common import __version__ from common import __version__
from common.analytics import read as analytics_read from common.analytics import read as analytics_read
from common.config import AdminSettings, get_admin_settings from common.config import AdminSettings, get_admin_settings
@ -95,4 +95,5 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
app.include_router(users.router) app.include_router(users.router)
app.include_router(admins.router) app.include_router(admins.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(send.router)
return app return app

33
admin/buttons.py Normal file
View File

@ -0,0 +1,33 @@
"""Разбор URL-кнопок из textarea: по одной в строке ``Текст | https://…``."""
from __future__ import annotations
MAX_BUTTONS = 8
def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]:
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

View File

@ -6,6 +6,8 @@ import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
from starlette.datastructures import UploadFile
from common.config import AdminSettings from common.config import AdminSettings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -16,6 +18,23 @@ BUILTIN_WELCOME = Path("images/welcome_image.jpg")
#: Имя файла в volume, которое ждёт сид последовательности `welcome`. #: Имя файла в volume, которое ждёт сид последовательности `welcome`.
WELCOME_IMAGE_NAME = "welcome_image.jpg" WELCOME_IMAGE_NAME = "welcome_image.jpg"
#: Допустимые типы и максимальный размер загружаемых картинок.
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_IMAGE_BYTES = 5 * 1024 * 1024
async def read_image(upload: UploadFile) -> tuple[bytes, str, str]:
"""Прочитать картинку из формы. Возвращает (данные, mime, расширение)."""
content_type = (upload.content_type or "").lower()
extension = ALLOWED_IMAGE_TYPES.get(content_type)
if extension is None:
raise ValueError("Картинка должна быть JPEG, PNG или WebP")
data = await upload.read()
if len(data) > MAX_IMAGE_BYTES:
raise ValueError("Картинка больше 5 МБ")
return data, content_type, extension
def ensure_media(settings: AdminSettings) -> Path: def ensure_media(settings: AdminSettings) -> Path:
"""Создать каталог и положить туда встроенное приветствие, если его нет.""" """Создать каталог и положить туда встроенное приветствие, если его нет."""

View File

@ -41,6 +41,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
"description": "События из ClickHouse с фильтрами", "description": "События из ClickHouse с фильтрами",
"url": "/logs", "url": "/logs",
}, },
{
"title": "Отправить",
"description": "Сообщение в чат от имени бота",
"url": "/send",
},
) )

150
admin/routes/send.py Normal file
View File

@ -0,0 +1,150 @@
"""Ручная отправка сообщения в основной чат от имени бота."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.datastructures import UploadFile
from admin import telegram
from admin.buttons import MAX_BUTTONS, parse_buttons
from admin.media import read_image
from admin.security import check_csrf
from common import __version__
from common.repositories import admin_messages_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/send", tags=["send"])
#: Лимит подписи к фото.
MAX_CAPTION = 1024
async def _recent() -> list[dict[str, Any]]:
try:
return await admin_messages_repo.recent(limit=5)
except Exception:
logger.warning("Не удалось прочитать журнал отправок", exc_info=True)
return []
async def _render(
request: Request,
*,
text: str = "",
buttons_text: str = "",
errors: list[str] | None = None,
saved: bool = False,
status_code: int = 200,
) -> HTMLResponse:
settings = request.app.state.settings
return request.app.state.templates.TemplateResponse(
request=request,
name="send/form.html",
context={
"version": __version__,
"text": text,
"buttons_text": buttons_text,
"errors": errors or [],
"saved": saved,
"chat_id": settings.chat_id,
"max_message": MAX_MESSAGE_LENGTH,
"max_caption": MAX_CAPTION,
"max_buttons": MAX_BUTTONS,
"tags": TAG_REGISTRY,
"recent": await _recent(),
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def send_form(request: Request, saved: bool = False) -> HTMLResponse:
return await _render(request, saved=saved)
@router.post("")
async def send_message(request: Request) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
text = str(form.get("text", ""))
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
photo: tuple[bytes, str, str] | None = None
upload = form.get("image")
if isinstance(upload, UploadFile) and upload.filename:
try:
data, mime, extension = await read_image(upload)
photo = (data, mime, f"upload{extension}")
except ValueError as exc:
errors.append(str(exc))
limit = MAX_CAPTION if photo else MAX_MESSAGE_LENGTH
if len(text) > limit:
errors.append(f"С фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}")
if not text.strip() and photo is None:
errors.append("Нужен текст или картинка")
if not settings.chat_id:
errors.append("CHAT_ID не задан")
if errors:
return await _render(
request,
text=text,
buttons_text=raw_buttons,
errors=errors,
status_code=400,
)
success = True
error_text: str | None = None
try:
await telegram.send_to_chat(
settings,
text=text,
buttons=buttons,
photo=photo,
)
except telegram.TelegramError as exc:
success = False
error_text = str(exc)
except Exception:
success = False
error_text = "Не удалось отправить сообщение"
logger.exception("Ошибка отправки сообщения из админки")
try:
await admin_messages_repo.log_message(
editor_id=int(request.state.admin["telegram_id"]),
chat_id=settings.chat_id,
text=text,
buttons=buttons,
has_image=photo is not None,
success=success,
error=error_text,
)
except Exception:
logger.warning("Не удалось записать журнал отправки", exc_info=True)
if not success:
return await _render(
request,
text=text,
buttons_text=raw_buttons,
errors=[f"Telegram: {error_text}"],
status_code=400,
)
return RedirectResponse(url="/send?saved=true", status_code=303)

View File

@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from starlette.datastructures import UploadFile from starlette.datastructures import UploadFile
from admin.media import media_path from admin.buttons import MAX_BUTTONS, parse_buttons
from admin.media import media_path, read_image
from admin.security import check_csrf from admin.security import check_csrf
from common import __version__ from common import __version__
from common.repositories import sequences_repo from common.repositories import sequences_repo
@ -26,12 +27,6 @@ KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
#: Лимит подписи к фото. #: Лимит подписи к фото.
MAX_CAPTION = 1024 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: def _editor_id(request: Request) -> int:
@ -44,52 +39,10 @@ def _prepare_items(items: list[dict[str, Any]]) -> None:
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"]) 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: async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
error = _image_error(upload) data, _, extension = await read_image(upload)
if error:
raise ValueError(error)
data = await upload.read() name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
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 = media_path(settings, name)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data) path.write_bytes(data)

View File

@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Any from typing import Any
@ -22,6 +23,16 @@ class TelegramError(RuntimeError):
"""Telegram отклонил запрос или недоступен.""" """Telegram отклонил запрос или недоступен."""
def _check(response: httpx.Response) -> Any:
try:
data = response.json()
except ValueError as exc:
raise TelegramError("Telegram вернул некорректный ответ") from exc
if not data.get("ok"):
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
return data.get("result")
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any: async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
if not settings.bot_token: if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан") raise TelegramError("BOT_TOKEN не задан")
@ -32,15 +43,25 @@ async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -
response = await client.post(url, json=payload) response = await client.post(url, json=payload)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc raise TelegramError(f"Telegram недоступен: {exc}") from exc
return _check(response)
async def _call_multipart(
settings: AdminSettings,
method: str,
data: dict[str, str],
files: dict[str, tuple[str, bytes, str]],
) -> Any:
if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан")
url = _API.format(token=settings.bot_token, method=method)
try: try:
data = response.json() async with httpx.AsyncClient(timeout=30.0) as client:
except ValueError as exc: response = await client.post(url, data=data, files=files)
raise TelegramError("Telegram вернул некорректный ответ") from exc except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc
if not data.get("ok"): return _check(response)
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
return data.get("result")
async def kick_member(settings: AdminSettings, user_id: int) -> None: async def kick_member(settings: AdminSettings, user_id: int) -> None:
@ -81,3 +102,57 @@ async def send_alert(settings: AdminSettings, text: str) -> None:
) )
except TelegramError: except TelegramError:
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True) logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)
async def send_to_chat(
settings: AdminSettings,
*,
text: str,
buttons: list[tuple[str, str]],
photo: tuple[bytes, str, str] | None = None,
) -> None:
"""Отправить сообщение в основной чат от имени бота.
``photo`` — (данные, mime, имя файла). При фото длинный текст уходит
отдельным сообщением (лимит подписи 1024).
"""
if not settings.chat_id:
raise TelegramError("CHAT_ID не задан")
rows = [[{"text": title, "url": url}] for title, url in buttons]
markup = {"inline_keyboard": rows} if rows else None
if photo is None:
await _call(
settings,
"sendMessage",
{
"chat_id": settings.chat_id,
"text": text,
"parse_mode": "HTML",
"reply_markup": markup,
},
)
return
data, mime, filename = photo
fields: dict[str, str] = {"chat_id": str(settings.chat_id), "parse_mode": "HTML"}
caption = text if text and len(text) <= 1024 else ""
if caption:
fields["caption"] = caption
if markup:
fields["reply_markup"] = json.dumps(markup)
await _call_multipart(settings, "sendPhoto", fields, {"photo": (filename, data, mime)})
if text and not caption:
await _call(
settings,
"sendMessage",
{
"chat_id": settings.chat_id,
"text": text,
"parse_mode": "HTML",
"reply_markup": markup,
},
)

View File

@ -31,6 +31,7 @@
<a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a> <a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a>
<a href="/admins" class="py-1 text-slate-600 hover:text-slate-900">Админы</a> <a href="/admins" class="py-1 text-slate-600 hover:text-slate-900">Админы</a>
<a href="/logs" class="py-1 text-slate-600 hover:text-slate-900">Логи</a> <a href="/logs" class="py-1 text-slate-600 hover:text-slate-900">Логи</a>
<a href="/send" class="py-1 text-slate-600 hover:text-slate-900">Отправить</a>
{% if request.session.get('admin_id') %} {% if request.session.get('admin_id') %}
<span class="py-1 text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span> <span class="py-1 text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
<form method="post" action="/logout" class="py-1"> <form method="post" action="/logout" class="py-1">

View File

@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}Отправить сообщение{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold">Отправить сообщение</h1>
<p class="mt-1 text-sm text-slate-600">
Сообщение уйдёт в основной чат <span class="font-mono">{{ chat_id }}</span> от имени бота.
</p>
{% 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="/send"
enctype="multipart/form-data"
class="mt-6 max-w-3xl space-y-4"
onsubmit="return confirm('Отправить сообщение в чат?');"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div>
<label class="text-sm font-medium text-slate-700">Текст</label>
<div class="mt-1">
{% with editor_id="send-text", editor_name="text", editor_value=text, editor_max=max_message, editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
</div>
</div>
<div class="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"
>{{ 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>
<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="flex flex-wrap items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Отправить
</button>
<button
type="button"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
hx-post="/messages/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"
>
Предпросмотр
</button>
</div>
</form>
<div class="mt-8 max-w-3xl">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
</div>
</div>
<div class="mt-10">
<h2 class="text-lg font-medium">Последние отправки</h2>
{% if recent %}
<div class="mt-3 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 item in recent %}
<tr>
<td class="px-4 py-3 text-slate-600">{{ item.sent_at.strftime("%d.%m.%Y %H:%M") }}</td>
<td class="px-4 py-3 text-slate-700">{{ item.preview }}</td>
<td class="px-4 py-3">{{ "да" if item.has_image else "—" }}</td>
<td class="px-4 py-3">
{% if item.success %}
<span class="text-emerald-700">отправлено</span>
{% else %}
<span class="text-red-600">{{ item.error or "ошибка" }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-2 text-sm text-slate-500">Отправок ещё не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,52 @@
"""Журнал ручных отправок сообщений из админки."""
from __future__ import annotations
from typing import Any
from psycopg.types.json import Jsonb
from common.db.pool import get_pool
async def log_message(
*,
editor_id: int | None,
chat_id: int,
text: str,
buttons: list[tuple[str, str]],
has_image: bool,
success: bool,
error: str | None = None,
) -> None:
async with get_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO bot.admin_messages (editor_id, chat_id, text, buttons, has_image, success, error)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
editor_id,
chat_id,
text,
Jsonb([{"text": button_text, "url": url} for button_text, url in buttons]),
has_image,
success,
error,
),
)
async def recent(*, limit: int = 10) -> list[dict[str, Any]]:
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, editor_id, chat_id, left(text, 120) AS preview, buttons,
has_image, success, error, sent_at
FROM bot.admin_messages
ORDER BY sent_at DESC
LIMIT %s
""",
(limit,),
)
return await cur.fetchall()

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.5.3" __version__ = "0.5.4"

View File

@ -0,0 +1,17 @@
-- Журнал сообщений, отправленных администратором из веб-панели в чат.
CREATE TABLE IF NOT EXISTS bot.admin_messages (
id bigserial PRIMARY KEY,
editor_id bigint,
chat_id bigint NOT NULL,
text text NOT NULL DEFAULT '',
buttons jsonb NOT NULL DEFAULT '[]'::jsonb,
has_image boolean NOT NULL DEFAULT false,
success boolean NOT NULL,
error text,
sent_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_messages_sent_at
ON bot.admin_messages (sent_at DESC);
COMMENT ON TABLE bot.admin_messages IS 'Аудит ручных отправок сообщений из админки';

118
tests/test_admin_send.py Normal file
View File

@ -0,0 +1,118 @@
"""Тесты ручной отправки сообщения из админки."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from admin import telegram
from common.repositories import admin_messages_repo
@pytest.fixture
def fake_send(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {}
async def recent(*, limit: int = 10) -> list[dict[str, Any]]:
return []
async def log_message(**kwargs: Any) -> None:
state["audit"] = kwargs
async def send_to_chat(settings: Any, *, text: str, buttons: list, photo: Any = None) -> None:
state["send"] = {"text": text, "buttons": buttons, "photo": photo}
monkeypatch.setattr(admin_messages_repo, "recent", recent)
monkeypatch.setattr(admin_messages_repo, "log_message", log_message)
monkeypatch.setattr(telegram, "send_to_chat", send_to_chat)
return state
def _client(client_factory, settings_factory) -> TestClient:
return client_factory(settings=settings_factory(chat_id=-100123), follow_redirects=False)
def test_send_form(fake_send: dict[str, Any], client_factory, settings_factory) -> None:
response = _client(client_factory, settings_factory).get("/send")
assert response.status_code == 200
assert 'data-tag="b"' in response.text
assert "-100123" in response.text
def test_send_text(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post(
"/send",
data={"text": "Привет, клан!", "buttons": "Канал | https://t.me/x", "csrf_token": token},
)
assert response.status_code == 303
assert response.headers["location"] == "/send?saved=true"
assert fake_send["send"]["text"] == "Привет, клан!"
assert fake_send["send"]["buttons"] == [("Канал", "https://t.me/x")]
assert fake_send["send"]["photo"] is None
assert fake_send["audit"]["success"] is True
def test_send_rejects_bad_buttons(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/send", data={"text": "Текст", "buttons": "без ссылки", "csrf_token": token})
assert response.status_code == 400
assert "Кнопка без разделителя" in response.text
assert "send" not in fake_send
def test_send_requires_text_or_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/send", data={"text": " ", "csrf_token": token})
assert response.status_code == 400
assert "Нужен текст или картинка" in response.text
def test_send_with_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post(
"/send",
data={"text": "С картинкой", "csrf_token": token},
files={"image": ("pic.png", b"fake-png", "image/png")},
)
assert response.status_code == 303
assert fake_send["send"]["photo"] is not None
assert fake_send["send"]["photo"][1] == "image/png"
assert fake_send["audit"]["has_image"] is True
def test_send_telegram_error(
fake_send: dict[str, Any],
client_factory,
settings_factory,
csrf_token,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def send_to_chat(*args: Any, **kwargs: Any) -> None:
raise telegram.TelegramError("chat not found")
monkeypatch.setattr(telegram, "send_to_chat", send_to_chat)
client = _client(client_factory, settings_factory)
token = csrf_token(client.get("/send").text)
response = client.post("/send", data={"text": "Текст", "csrf_token": token})
assert response.status_code == 400
assert "chat not found" in response.text
assert fake_send["audit"]["success"] is False
assert fake_send["audit"]["error"] == "chat not found"

View File

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