From 86ebff494834d171a5efb0813d645d2c3109556b Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 23 Nov 2025 04:09:04 +0300 Subject: [PATCH] Serve proxy media with detected content type --- app/webapi/app.py | 6 ++ app/webapi/routes/__init__.py | 2 + app/webapi/routes/media.py | 158 ++++++++++++++++++++++++++++++++++ app/webapi/routes/tickets.py | 21 ++++- app/webapi/schemas/media.py | 16 ++++ app/webapi/schemas/tickets.py | 1 + 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 app/webapi/routes/media.py create mode 100644 app/webapi/schemas/media.py diff --git a/app/webapi/app.py b/app/webapi/app.py index f7487b66..f922bcd7 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,6 +13,7 @@ from .routes import ( config, health, main_menu_buttons, + media, miniapp, polls, promocodes, @@ -96,6 +97,10 @@ OPENAPI_TAGS = [ "данных между ботом и панелью." ), }, + { + "name": "media", + "description": "Загрузка файлов в Telegram и получение ссылок на медиа.", + }, { "name": "miniapp", "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", @@ -158,6 +163,7 @@ def create_web_api_app() -> FastAPI: app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"]) app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) + app.include_router(media.router, tags=["media"]) app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) app.include_router(polls.router, prefix="/polls", tags=["polls"]) app.include_router(logs.router, prefix="/logs", tags=["logs"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index e4320cf9..a648a92c 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -2,6 +2,7 @@ from . import ( config, health, main_menu_buttons, + media, miniapp, polls, promo_offers, @@ -22,6 +23,7 @@ __all__ = [ "config", "health", "main_menu_buttons", + "media", "miniapp", "polls", "promo_offers", diff --git a/app/webapi/routes/media.py b/app/webapi/routes/media.py new file mode 100644 index 00000000..2fd49c3e --- /dev/null +++ b/app/webapi/routes/media.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import logging +import mimetypes +from typing import Any + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.types import BufferedInputFile +from fastapi import ( + APIRouter, + File, + Form, + HTTPException, + Request, + Response, + Security, + UploadFile, + status, +) + +from app.config import settings + +from ..dependencies import require_api_token +from ..schemas.media import MediaUploadResponse + + +router = APIRouter() +logger = logging.getLogger(__name__) + +ALLOWED_MEDIA_TYPES = {"photo", "video", "document"} + + +def _resolve_target_chat_id() -> int: + """Выбирает чат для загрузки файлов (канал уведомлений или первый админ).""" + + chat_id = settings.get_admin_notifications_chat_id() + if chat_id is not None: + return chat_id + + admin_ids = settings.get_admin_ids() + if admin_ids: + return admin_ids[0] + + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)", + ) + + +def _build_media_url(request: Request, file_id: str) -> str: + return str(request.url_for("download_media", file_id=file_id)) + + +@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED) +async def upload_media( + request: Request, + _: Any = Security(require_api_token), + file: UploadFile = File(...), + media_type: str = Form("document", description="Тип файла: photo, video или document"), + caption: str | None = Form(None, description="Необязательная подпись к файлу"), +) -> MediaUploadResponse: + media_type_normalized = (media_type or "").strip().lower() + if media_type_normalized not in ALLOWED_MEDIA_TYPES: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type") + + file_bytes = await file.read() + if not file_bytes: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty") + + target_chat_id = _resolve_target_chat_id() + upload = BufferedInputFile(file_bytes, filename=file.filename or "upload") + + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + try: + if media_type_normalized == "photo": + message = await bot.send_photo( + chat_id=target_chat_id, + photo=upload, + caption=caption, + ) + media = message.photo[-1] + elif media_type_normalized == "video": + message = await bot.send_video( + chat_id=target_chat_id, + video=upload, + caption=caption, + ) + media = message.video + else: + message = await bot.send_document( + chat_id=target_chat_id, + document=upload, + caption=caption, + ) + media = message.document + + media_url = _build_media_url(request, media.file_id) + return MediaUploadResponse( + media_type=media_type_normalized, + file_id=media.file_id, + file_unique_id=getattr(media, "file_unique_id", None), + media_url=media_url, + ) + except HTTPException: + raise + except Exception as error: + logger.error("Failed to upload media: %s", error) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error + finally: + await bot.session.close() + + +@router.get("/media/{file_id}", name="download_media", tags=["media"]) +async def download_media( + file_id: str, + _: Any = Security(require_api_token), +) -> Response: + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + try: + file = await bot.get_file(file_id) + if not file.file_path: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Media file not found") + + buffer = await bot.download_file(file.file_path) + + if hasattr(buffer, "seek"): + buffer.seek(0) + + content = buffer.read() if hasattr(buffer, "read") else bytes(buffer) + filename = file.file_path.split("/")[-1] + + media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + + return Response( + content=content, + media_type=media_type, + headers={ + "Content-Disposition": f"inline; filename={filename}", + }, + ) + except HTTPException: + raise + except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла + logger.error("Failed to download media %s: %s", file_id, error) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error + finally: + await bot.session.close() + diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index 325f77f8..f290a102 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -3,7 +3,9 @@ from __future__ import annotations from datetime import datetime from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Security, status +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status from sqlalchemy.ext.asyncio import AsyncSession from aiogram import Bot @@ -27,6 +29,7 @@ from ..schemas.tickets import ( ) router = APIRouter() +logger = logging.getLogger(__name__) def _serialize_message(message: TicketMessage) -> TicketMessageResponse: @@ -248,6 +251,7 @@ async def reply_to_ticket( async def get_ticket_message_media( ticket_id: int, message_id: int, + request: Request, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TicketMediaResponse: @@ -262,10 +266,25 @@ async def get_ticket_message_media( if not message.has_media or not message.media_file_id or not message.media_type: raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message") + media_url: Optional[str] = None + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + try: + file = await bot.get_file(message.media_file_id) + if file.file_path: + media_url = str(request.url_for("download_media", file_id=message.media_file_id)) + except Exception as error: + logger.warning("Failed to resolve media URL for ticket %s message %s: %s", ticket_id, message_id, error) + finally: + await bot.session.close() + return TicketMediaResponse( id=message.id, ticket_id=ticket.id, media_type=message.media_type, media_file_id=message.media_file_id, media_caption=message.media_caption, + media_url=media_url, ) diff --git a/app/webapi/schemas/media.py b/app/webapi/schemas/media.py new file mode 100644 index 00000000..5e7436d2 --- /dev/null +++ b/app/webapi/schemas/media.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class MediaUploadResponse(BaseModel): + media_type: str = Field(description="Тип загруженного файла (photo, video, document)") + file_id: str = Field(description="Telegram file_id загруженного файла") + file_unique_id: Optional[str] = Field( + default=None, description="Уникальный идентификатор файла" + ) + media_url: Optional[str] = Field( + default=None, description="Прямая ссылка на файл для предпросмотра" + ) diff --git a/app/webapi/schemas/tickets.py b/app/webapi/schemas/tickets.py index 129fdc52..9c99455c 100644 --- a/app/webapi/schemas/tickets.py +++ b/app/webapi/schemas/tickets.py @@ -67,3 +67,4 @@ class TicketMediaResponse(BaseModel): media_type: str media_file_id: str media_caption: Optional[str] = None + media_url: Optional[str] = None