186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
"""
|
|
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()
|