""" Safe Telegram delivery helpers. The bot sends messages from several places: broadcasts, winner notifications, P2P chat, and chat forwarding. This module keeps the "user blocked the bot" handling in one place so every outbound path behaves consistently. """ import asyncio import logging from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Awaitable, Callable, Optional from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from .database import async_session_maker from .models import BlockedUser logger = logging.getLogger(__name__) @dataclass(slots=True) class DeliveryResult: success: bool status: Optional[str] = None telegram_message: Any = None skipped: bool = False class DeliveryService: """Guarded Telegram delivery with persistent blocked-user tracking.""" RETRY_AFTER_DELAY = 5.0 MAX_RETRY_AFTER = 30 _BAD_REQUEST_ERROR_TYPES = { "user is deactivated": "deactivated", "user not found": "not_found", "chat not found": "chat_not_found", "bot was blocked by the user": "blocked_bot", "forbidden": "blocked_bot", } async def check_user_blocked( self, session: AsyncSession, telegram_id: int, ) -> Optional[BlockedUser]: result = await session.execute( select(BlockedUser).where( BlockedUser.telegram_id == telegram_id, BlockedUser.is_active == True, ) ) return result.scalar_one_or_none() async def mark_user_blocked( self, session: AsyncSession, telegram_id: int, error_type: str, error_message: str, ) -> None: now = datetime.now(timezone.utc) result = await session.execute( select(BlockedUser).where(BlockedUser.telegram_id == telegram_id) ) blocked_user = result.scalar_one_or_none() if blocked_user: blocked_user.error_type = error_type blocked_user.error_message = error_message blocked_user.last_attempt_at = now blocked_user.attempt_count = (blocked_user.attempt_count or 0) + 1 blocked_user.is_active = True else: session.add( BlockedUser( telegram_id=telegram_id, error_type=error_type, error_message=error_message, first_blocked_at=now, last_attempt_at=now, attempt_count=1, is_active=True, ) ) await session.commit() logger.info("User %s marked as unavailable: %s", telegram_id, error_type) async def unblock_user(self, session: AsyncSession, telegram_id: int) -> bool: blocked_user = await self.check_user_blocked(session, telegram_id) if not blocked_user: return False blocked_user.is_active = False blocked_user.last_attempt_at = datetime.now(timezone.utc) await session.commit() logger.info("User %s delivery reactivated", telegram_id) return True async def send_with_guard( self, telegram_id: int, send_call: Callable[[], Awaitable[Any]], *, retry: bool = True, skip_block_check: bool = False, ) -> DeliveryResult: """ Execute a Telegram send call unless the recipient is known unavailable. Args: telegram_id: recipient Telegram ID. send_call: zero-argument async callable that performs the actual aiogram send/copy operation and returns Telegram Message. retry: retry once after TelegramRetryAfter when the delay is small. skip_block_check: caller already filtered blocked users. """ if not skip_block_check: async with async_session_maker() as session: blocked_user = await self.check_user_blocked(session, telegram_id) if blocked_user: return DeliveryResult( success=False, status=blocked_user.error_type, skipped=True, ) try: telegram_message = await send_call() except TelegramForbiddenError as exc: await self._mark_unavailable(telegram_id, "blocked_bot", str(exc)) return DeliveryResult(success=False, status="blocked_bot") except TelegramBadRequest as exc: error_type = self.classify_bad_request(exc) if error_type: await self._mark_unavailable(telegram_id, error_type, str(exc)) return DeliveryResult(success=False, status=error_type) logger.warning("Telegram bad request for %s: %s", telegram_id, exc) return DeliveryResult(success=False, status="bad_request") except TelegramRetryAfter as exc: if retry and exc.retry_after <= self.MAX_RETRY_AFTER: logger.warning("FloodWait for %s: %s sec", telegram_id, exc.retry_after) await asyncio.sleep(exc.retry_after + self.RETRY_AFTER_DELAY) return await self.send_with_guard( telegram_id, send_call, retry=False, skip_block_check=True, ) logger.warning("Skipping %s after FloodWait %s sec", telegram_id, exc.retry_after) return DeliveryResult(success=False, status="retry_after") except Exception as exc: logger.error("Telegram delivery error for %s: %s", telegram_id, exc) return DeliveryResult(success=False, status="unknown_error") async with async_session_maker() as session: await self.unblock_user(session, telegram_id) return DeliveryResult(success=True, telegram_message=telegram_message) def classify_bad_request(self, exc: TelegramBadRequest) -> Optional[str]: error_text = str(exc).lower() for marker, error_type in self._BAD_REQUEST_ERROR_TYPES.items(): if marker in error_text: return error_type return None async def _mark_unavailable( self, telegram_id: int, error_type: str, error_message: str, ) -> None: async with async_session_maker() as session: await self.mark_user_blocked(session, telegram_id, error_type, error_message) delivery_service = DeliveryService()