Protect deliveries to blocked Telegram users
This commit is contained in:
185
src/core/delivery.py
Normal file
185
src/core/delivery.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user