Из админки можно отправить сообщение в чат от имени бота
Появился раздел «Отправить»: текст с тем же редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Отправка идёт в основной чат через Bot API; успешные и неуспешные попытки пишутся в bot.admin_messages (кто, когда, что и какая ошибка).
This commit is contained in:
parent
cfa0e29bbf
commit
08c57f9b39
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
|
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
"description": "События из ClickHouse с фильтрами",
|
"description": "События из ClickHouse с фильтрами",
|
||||||
"url": "/logs",
|
"url": "/logs",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Отправить",
|
||||||
|
"description": "Сообщение в чат от имени бота",
|
||||||
|
"url": "/send",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.5.3"
|
__version__ = "0.5.4"
|
||||||
|
|
|
||||||
|
|
@ -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 'Аудит ручных отправок сообщений из админки';
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue