diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py
index 97a096f..167b817 100644
--- a/backend/app/api/v1/admin.py
+++ b/backend/app/api/v1/admin.py
@@ -1,8 +1,9 @@
from datetime import datetime
-from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
+from typing import Optional
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import (
@@ -531,9 +532,10 @@ async def get_logs(
@limiter.limit("1/minute")
async def broadcast_to_all(
request: Request,
- data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
+ message: str = Form(""),
+ media: list[UploadFile] = File(default=[]),
):
"""Send broadcast message to all users with Telegram linked. Admin only."""
require_admin_with_2fa(current_user)
@@ -547,15 +549,40 @@ async def broadcast_to_all(
total_count = len(users)
sent_count = 0
+ # Read media files if provided (up to 10 files, Telegram limit)
+ media_items = []
+ for file in media[:10]:
+ if file and file.filename:
+ file_data = await file.read()
+ content_type = file.content_type or ""
+ if content_type.startswith("image/"):
+ media_items.append({
+ "type": "photo",
+ "data": file_data,
+ "filename": file.filename,
+ "content_type": content_type
+ })
+ elif content_type.startswith("video/"):
+ media_items.append({
+ "type": "video",
+ "data": file_data,
+ "filename": file.filename,
+ "content_type": content_type
+ })
+
for user in users:
- if await telegram_notifier.send_message(user.telegram_id, data.message):
+ if await telegram_notifier.send_media_message(
+ user.telegram_id,
+ text=message if message.strip() else None,
+ media_items=media_items if media_items else None
+ ):
sent_count += 1
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
"broadcast", 0,
- {"message": data.message[:100], "sent": sent_count, "total": total_count},
+ {"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
request.client.host if request.client else None
)
@@ -567,9 +594,10 @@ async def broadcast_to_all(
async def broadcast_to_marathon(
request: Request,
marathon_id: int,
- data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
+ message: str = Form(""),
+ media: list[UploadFile] = File(default=[]),
):
"""Send broadcast message to marathon participants. Admin only."""
require_admin_with_2fa(current_user)
@@ -580,7 +608,7 @@ async def broadcast_to_marathon(
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
- # Get participants count
+ # Get participants with telegram
total_result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
@@ -592,15 +620,41 @@ async def broadcast_to_marathon(
users = total_result.scalars().all()
total_count = len(users)
- sent_count = await telegram_notifier.notify_marathon_participants(
- db, marathon_id, data.message
- )
+ # Read media files if provided (up to 10 files, Telegram limit)
+ media_items = []
+ for file in media[:10]:
+ if file and file.filename:
+ file_data = await file.read()
+ content_type = file.content_type or ""
+ if content_type.startswith("image/"):
+ media_items.append({
+ "type": "photo",
+ "data": file_data,
+ "filename": file.filename,
+ "content_type": content_type
+ })
+ elif content_type.startswith("video/"):
+ media_items.append({
+ "type": "video",
+ "data": file_data,
+ "filename": file.filename,
+ "content_type": content_type
+ })
+
+ sent_count = 0
+ for user in users:
+ if await telegram_notifier.send_media_message(
+ user.telegram_id,
+ text=message if message.strip() else None,
+ media_items=media_items if media_items else None
+ ):
+ sent_count += 1
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
"marathon", marathon_id,
- {"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
+ {"title": marathon.title, "message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
request.client.host if request.client else None
)
diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py
index 7790b05..f98de46 100644
--- a/backend/app/services/telegram_notifier.py
+++ b/backend/app/services/telegram_notifier.py
@@ -54,6 +54,209 @@ class TelegramNotifier:
logger.error(f"Error sending Telegram message: {e}")
return False
+ async def send_photo(
+ self,
+ chat_id: int,
+ photo: bytes,
+ caption: str | None = None,
+ parse_mode: str = "HTML",
+ filename: str = "photo.jpg",
+ content_type: str = "image/jpeg"
+ ) -> bool:
+ """Send a photo to a Telegram chat."""
+ if not self.bot_token:
+ logger.warning("Telegram bot token not configured")
+ return False
+
+ try:
+ timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=30.0)
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ data = {"chat_id": str(chat_id)}
+ if caption:
+ data["caption"] = caption
+ data["parse_mode"] = parse_mode
+
+ files = {"photo": (filename, photo, content_type)}
+
+ response = await client.post(
+ f"{self.api_url}/sendPhoto",
+ data=data,
+ files=files,
+ )
+ if response.status_code == 200:
+ return True
+ else:
+ logger.error(f"Failed to send photo to {chat_id}: {response.status_code} - {response.text}")
+ return False
+ except Exception as e:
+ logger.error(f"Error sending Telegram photo to {chat_id}: {type(e).__name__}: {e}")
+ return False
+
+ async def send_video(
+ self,
+ chat_id: int,
+ video: bytes,
+ caption: str | None = None,
+ parse_mode: str = "HTML",
+ filename: str = "video.mp4",
+ content_type: str = "video/mp4"
+ ) -> bool:
+ """Send a video to a Telegram chat."""
+ if not self.bot_token:
+ logger.warning("Telegram bot token not configured")
+ return False
+
+ try:
+ timeout = httpx.Timeout(connect=30.0, read=120.0, write=300.0, pool=30.0)
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ data = {"chat_id": str(chat_id)}
+ if caption:
+ data["caption"] = caption
+ data["parse_mode"] = parse_mode
+
+ files = {"video": (filename, video, content_type)}
+
+ response = await client.post(
+ f"{self.api_url}/sendVideo",
+ data=data,
+ files=files,
+ )
+ if response.status_code == 200:
+ return True
+ else:
+ logger.error(f"Failed to send video to {chat_id}: {response.status_code} - {response.text}")
+ return False
+ except Exception as e:
+ logger.error(f"Error sending Telegram video to {chat_id}: {type(e).__name__}: {e}")
+ return False
+
+ async def send_media_group(
+ self,
+ chat_id: int,
+ media_items: list[dict],
+ caption: str | None = None,
+ parse_mode: str = "HTML"
+ ) -> bool:
+ """
+ Send a media group (multiple photos/videos) to a Telegram chat.
+
+ media_items: list of dicts with keys:
+ - type: "photo" or "video"
+ - data: bytes
+ - filename: str
+ - content_type: str
+ """
+ if not self.bot_token:
+ logger.warning("Telegram bot token not configured")
+ return False
+
+ if not media_items:
+ return False
+
+ try:
+ import json
+ # Use longer timeouts for file uploads
+ timeout = httpx.Timeout(
+ connect=30.0,
+ read=120.0,
+ write=300.0, # 5 minutes for uploading files
+ pool=30.0
+ )
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ # Build media array and files dict
+ media_array = []
+ files_dict = {}
+
+ for i, item in enumerate(media_items):
+ attach_name = f"media{i}"
+ media_obj = {
+ "type": item["type"],
+ "media": f"attach://{attach_name}"
+ }
+ # Only first item gets the caption
+ if i == 0 and caption:
+ media_obj["caption"] = caption
+ media_obj["parse_mode"] = parse_mode
+
+ media_array.append(media_obj)
+ files_dict[attach_name] = (
+ item.get("filename", f"file{i}"),
+ item["data"],
+ item.get("content_type", "application/octet-stream")
+ )
+
+ data = {
+ "chat_id": str(chat_id),
+ "media": json.dumps(media_array)
+ }
+
+ logger.info(f"Sending media group to {chat_id}: {len(media_items)} files")
+ response = await client.post(
+ f"{self.api_url}/sendMediaGroup",
+ data=data,
+ files=files_dict,
+ )
+ if response.status_code == 200:
+ logger.info(f"Successfully sent media group to {chat_id}")
+ return True
+ else:
+ logger.error(f"Failed to send media group to {chat_id}: {response.status_code} - {response.text}")
+ return False
+ except Exception as e:
+ logger.error(f"Error sending Telegram media group to {chat_id}: {type(e).__name__}: {e}")
+ return False
+
+ async def send_media_message(
+ self,
+ chat_id: int,
+ text: str | None = None,
+ media_type: str | None = None,
+ media_data: bytes | None = None,
+ media_items: list[dict] | None = None,
+ parse_mode: str = "HTML"
+ ) -> bool:
+ """
+ Send a message with optional media.
+
+ For single media: use media_type and media_data
+ For multiple media: use media_items list with dicts containing:
+ - type: "photo" or "video"
+ - data: bytes
+ - filename: str (optional)
+ - content_type: str (optional)
+ """
+ # Multiple media - use media group
+ if media_items and len(media_items) > 1:
+ return await self.send_media_group(chat_id, media_items, text, parse_mode)
+
+ # Single media from media_items
+ if media_items and len(media_items) == 1:
+ item = media_items[0]
+ if item["type"] == "photo":
+ return await self.send_photo(
+ chat_id, item["data"], text, parse_mode,
+ item.get("filename", "photo.jpg"),
+ item.get("content_type", "image/jpeg")
+ )
+ elif item["type"] == "video":
+ return await self.send_video(
+ chat_id, item["data"], text, parse_mode,
+ item.get("filename", "video.mp4"),
+ item.get("content_type", "video/mp4")
+ )
+
+ # Legacy single media support
+ if media_data and media_type:
+ if media_type == "photo":
+ return await self.send_photo(chat_id, media_data, text, parse_mode)
+ elif media_type == "video":
+ return await self.send_video(chat_id, media_data, text, parse_mode)
+
+ if text:
+ return await self.send_message(chat_id, text, parse_mode)
+
+ return False
+
async def notify_user(
self,
db: AsyncSession,
diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts
index 962a2b7..8e938a2 100644
--- a/frontend/src/api/admin.ts
+++ b/frontend/src/api/admin.ts
@@ -92,13 +92,31 @@ export const adminApi = {
},
// Broadcast
- broadcastToAll: async (message: string): Promise
Введите сообщение...
+- {new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} -
+Введите сообщение...
+ ) : null} ++ {new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +
+