diff --git a/README.md b/README.md index 5685019..c2b4828 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ ID пользователя ведёт в его карточку. ClickHouse ч Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц скрываются на узких экранах, формы складываются в одну колонку. +Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же +редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка +(успешная или нет) пишется в `bot.admin_messages`. + ## Аналитика (ClickHouse) Все значимые действия бота пишутся в 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`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/app.py b/admin/app.py index f46c4f3..cfc5ba4 100644 --- a/admin/app.py +++ b/admin/app.py @@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware from admin.media import ensure_media from admin.middleware import AdminAuthMiddleware -from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, users +from admin.routes import admins, auth, health, logs, messages, pages, schedules, send, sequences, users from common import __version__ from common.analytics import read as analytics_read 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(admins.router) app.include_router(logs.router) + app.include_router(send.router) return app diff --git a/admin/buttons.py b/admin/buttons.py new file mode 100644 index 0000000..3bc7207 --- /dev/null +++ b/admin/buttons.py @@ -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 diff --git a/admin/media.py b/admin/media.py index 2a3826e..e07c68f 100644 --- a/admin/media.py +++ b/admin/media.py @@ -6,6 +6,8 @@ import logging import shutil from pathlib import Path +from starlette.datastructures import UploadFile + from common.config import AdminSettings logger = logging.getLogger(__name__) @@ -16,6 +18,23 @@ BUILTIN_WELCOME = Path("images/welcome_image.jpg") #: Имя файла в volume, которое ждёт сид последовательности `welcome`. 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: """Создать каталог и положить туда встроенное приветствие, если его нет.""" diff --git a/admin/routes/pages.py b/admin/routes/pages.py index 9d6331b..e5f1364 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -41,6 +41,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( "description": "События из ClickHouse с фильтрами", "url": "/logs", }, + { + "title": "Отправить", + "description": "Сообщение в чат от имени бота", + "url": "/send", + }, ) diff --git a/admin/routes/send.py b/admin/routes/send.py new file mode 100644 index 0000000..7632cd6 --- /dev/null +++ b/admin/routes/send.py @@ -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) diff --git a/admin/routes/sequences.py b/admin/routes/sequences.py index 72b7e05..4834bbd 100644 --- a/admin/routes/sequences.py +++ b/admin/routes/sequences.py @@ -11,7 +11,8 @@ 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.buttons import MAX_BUTTONS, parse_buttons +from admin.media import media_path, read_image from admin.security import check_csrf from common import __version__ 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_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: @@ -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"]) -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, _, extension = await read_image(upload) - 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()]}" + name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}" path = media_path(settings, name) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) diff --git a/admin/telegram.py b/admin/telegram.py index b3a711f..98cfc4c 100644 --- a/admin/telegram.py +++ b/admin/telegram.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import logging from typing import Any @@ -22,6 +23,16 @@ class TelegramError(RuntimeError): """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: if not settings.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) except httpx.HTTPError as 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: - 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 with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, data=data, files=files) + except httpx.HTTPError as exc: + raise TelegramError(f"Telegram недоступен: {exc}") from exc + return _check(response) 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: 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, + }, + ) diff --git a/admin/templates/base.html b/admin/templates/base.html index 0fe05db..cab36bb 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -31,6 +31,7 @@ Пользователи Админы Логи + Отправить {% if request.session.get('admin_id') %} {{ request.session.get('admin_username') or 'админ' }}
+ +Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.
+| Когда | +Текст | +Фото | +Статус | +
|---|---|---|---|
| {{ item.sent_at.strftime("%d.%m.%Y %H:%M") }} | +{{ item.preview }} | +{{ "да" if item.has_image else "—" }} | ++ {% if item.success %} + отправлено + {% else %} + {{ item.error or "ошибка" }} + {% endif %} + | +
Отправок ещё не было.
+ {% endif %} +