Files
remnawave-bedolaga-telegram…/app/services/broadcast_service.py
2025-11-25 02:38:17 +03:00

475 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
from app.database.database import AsyncSessionLocal
from app.database.models import BroadcastHistory
from app.handlers.admin.messages import (
create_broadcast_keyboard,
get_custom_users,
get_target_users,
)
logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {"photo", "video", "document"}
LARGE_BROADCAST_THRESHOLD = 20_000
PROGRESS_UPDATE_STEP = 5_000
@dataclass(slots=True)
class BroadcastMediaConfig:
type: str
file_id: str
caption: Optional[str] = None
@dataclass(slots=True)
class BroadcastConfig:
target: str
message_text: str
selected_buttons: list[str]
media: Optional[BroadcastMediaConfig] = None
initiator_name: Optional[str] = None
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
cancel_event: asyncio.Event
class BroadcastService:
"""Handles broadcast execution triggered from the admin web API."""
def __init__(self) -> None:
self._bot: Optional[Bot] = None
self._tasks: dict[int, _BroadcastTask] = {}
self._lock = asyncio.Lock()
def set_bot(self, bot: Bot) -> None:
self._bot = bot
def is_running(self, broadcast_id: int) -> bool:
task_entry = self._tasks.get(broadcast_id)
return bool(task_entry and not task_entry.task.done())
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
if self._bot is None:
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
await self._mark_failed(broadcast_id)
return
cancel_event = asyncio.Event()
async with self._lock:
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
logger.warning("Рассылка %s уже запущена", broadcast_id)
return
task = asyncio.create_task(
self._run_broadcast(broadcast_id, config, cancel_event),
name=f"broadcast-{broadcast_id}",
)
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
async def request_stop(self, broadcast_id: int) -> bool:
async with self._lock:
task_entry = self._tasks.get(broadcast_id)
if not task_entry:
return False
task_entry.cancel_event.set()
return True
async def _run_broadcast(
self,
broadcast_id: int,
config: BroadcastConfig,
cancel_event: asyncio.Event,
) -> None:
sent_count = 0
failed_count = 0
try:
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
return
broadcast.status = "in_progress"
broadcast.sent_count = 0
broadcast.failed_count = 0
await session.commit()
recipients = await self._fetch_recipients(config.target)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
return
broadcast.total_count = len(recipients)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
logger.info(
"Запускаем стабильный режим рассылки для %s получателей", len(recipients)
)
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_resilient_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
else:
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_standard_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
if cancelled_during_run:
logger.info(
"Рассылка %s была отменена во время выполнения, финальный статус уже установлен",
broadcast_id,
)
return
if cancel_event.is_set():
logger.info(
"Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус",
broadcast_id,
)
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=False,
)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
raise
except Exception as exc: # noqa: BLE001
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_recipients(self, target: str):
async with AsyncSessionLocal() as session:
if target.startswith("custom_"):
criteria = target[len("custom_"):]
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
async def _run_standard_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Базовый режим рассылки для небольших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent_count, failed_count, False
async def _run_resilient_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(15)
async def send_single_message(user):
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0:
await self._update_progress(broadcast_id, sent_count, failed_count)
await asyncio.sleep(0.1)
return sent_count, failed_count, False
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
if selected_buttons is None:
selected_buttons = []
return create_broadcast_keyboard(selected_buttons)
async def _deliver_message(
self,
telegram_id: int,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
) -> None:
if not self._bot:
raise RuntimeError("Телеграм-бот не инициализирован")
if config.media and config.media.type in VALID_MEDIA_TYPES:
caption = config.media.caption or config.message_text
if config.media.type == "photo":
await self._bot.send_photo(
chat_id=telegram_id,
photo=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "video":
await self._bot.send_video(
chat_id=telegram_id,
video=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "document":
await self._bot.send_document(
chat_id=telegram_id,
document=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
return
await self._bot.send_message(
chat_id=telegram_id,
text=config.message_text,
reply_markup=keyboard,
)
async def _mark_finished(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
cancelled: bool,
) -> None:
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="cancelled" if cancelled else (
"completed" if failed_count == 0 else "partial"
),
)
async def _mark_cancelled(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=True,
)
async def _mark_failed(
self,
broadcast_id: int,
sent_count: int = 0,
failed_count: int = 0,
) -> None:
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="failed",
)
async def _update_progress(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Периодически обновляет прогресс рассылки, чтобы держать соединение активным."""
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="in_progress",
update_completed_at=False,
)
async def _safe_status_update(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
status: str,
update_completed_at: bool = True,
) -> None:
attempts = 0
while attempts < 2:
try:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = status
if update_completed_at:
broadcast.completed_at = datetime.utcnow()
await session.commit()
return
except InterfaceError as exc:
attempts += 1
logger.warning(
"Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2",
broadcast_id,
exc,
attempts,
)
await asyncio.sleep(0.2)
except SQLAlchemyError:
logger.exception(
"Не удалось обновить статус рассылки %s", broadcast_id
)
return
broadcast_service = BroadcastService()