Protect deliveries to blocked Telegram users

This commit is contained in:
Андрей Цой
2026-07-02 17:49:05 +09:00
parent b0e42688b6
commit fb1830f77e
8 changed files with 508 additions and 250 deletions

View File

@@ -57,7 +57,6 @@ class ActivityService:
.where( .where(
and_( and_(
BlockedUser.telegram_id == telegram_id, BlockedUser.telegram_id == telegram_id,
BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True, BlockedUser.is_active == True,
) )
) )

View File

@@ -8,7 +8,6 @@ from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone from datetime import datetime, timezone
from aiogram import Bot from aiogram import Bot
from aiogram.types import Message from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis import redis.asyncio as redis
@@ -16,6 +15,7 @@ import redis.asyncio as redis
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
from .config import REDIS_URL, ADMIN_IDS from .config import REDIS_URL, ADMIN_IDS
from .database import async_session_maker from .database import async_session_maker
from .delivery import delivery_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -132,29 +132,9 @@ class BroadcastService:
error_type: Тип ошибки error_type: Тип ошибки
error_message: Сообщение об ошибке error_message: Сообщение об ошибке
""" """
# Проверяем, есть ли уже запись await delivery_service.mark_user_blocked(
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id) session, telegram_id, error_type, error_message
result = await session.execute(stmt)
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 = datetime.now(timezone.utc)
blocked_user.attempt_count += 1
blocked_user.is_active = True
else:
# Создаем новую запись
blocked_user = BlockedUser(
telegram_id=telegram_id,
error_type=error_type,
error_message=error_message
) )
session.add(blocked_user)
await session.commit()
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
async def unblock_user(self, session: AsyncSession, telegram_id: int): async def unblock_user(self, session: AsyncSession, telegram_id: int):
""" """
@@ -164,17 +144,7 @@ class BroadcastService:
session: Сессия БД session: Сессия БД
telegram_id: Telegram ID пользователя telegram_id: Telegram ID пользователя
""" """
stmt = select(BlockedUser).where( await delivery_service.unblock_user(session, telegram_id)
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True
)
result = await session.execute(stmt)
blocked_user = result.scalar_one_or_none()
if blocked_user:
blocked_user.is_active = False
await session.commit()
logger.info(f"Пользователь {telegram_id} разблокирован")
async def send_message_to_user( async def send_message_to_user(
self, self,
@@ -195,94 +165,43 @@ class BroadcastService:
Returns: Returns:
Tuple[bool, Optional[str]]: (успех, тип_ошибки) Tuple[bool, Optional[str]]: (успех, тип_ошибки)
""" """
try:
# Проверяем, не заблокирован ли пользователь
if not skip_block_check:
async with async_session_maker() as session:
is_blocked = await self.check_user_blocked(session, user.telegram_id)
if is_blocked:
logger.debug(f"Пропускаем заблокированного пользователя {user.telegram_id}")
return False, "blocked"
# Отправляем сообщение
if message.text: if message.text:
await bot.send_message( send_call = lambda: bot.send_message(
user.telegram_id, user.telegram_id,
message.text, message.text,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.photo: elif message.photo:
await bot.send_photo( send_call = lambda: bot.send_photo(
user.telegram_id, user.telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.video: elif message.video:
await bot.send_video( send_call = lambda: bot.send_video(
user.telegram_id, user.telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.document: elif message.document:
await bot.send_document( send_call = lambda: bot.send_document(
user.telegram_id, user.telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
else: else:
# Копируем сообщение как есть send_call = lambda: message.copy_to(user.telegram_id)
await message.copy_to(user.telegram_id)
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее) result = await delivery_service.send_with_guard(
async with async_session_maker() as session: user.telegram_id,
await self.unblock_user(session, user.telegram_id) send_call,
retry=retry,
return True, None skip_block_check=skip_block_check,
except TelegramForbiddenError as e:
# Пользователь заблокировал бота
error_type = "blocked_bot"
async with async_session_maker() as session:
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
return False, error_type
except TelegramBadRequest as e:
# Пользователь удален или деактивирован
error_str = str(e).lower()
if "user is deactivated" in error_str:
error_type = "deactivated"
elif "user not found" in error_str:
error_type = "not_found"
elif "chat not found" in error_str:
error_type = "chat_not_found"
else:
error_type = "bad_request"
async with async_session_maker() as session:
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
return False, error_type
except TelegramRetryAfter as e:
# FloodWait - слишком много запросов
if retry and e.retry_after <= self.MAX_RETRY_AFTER:
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
return await self.send_message_to_user(
bot, user, message, retry=False, skip_block_check=True
) )
return result.success, None if result.success else result.status
logger.warning(
f"Пропускаем пользователя {user.telegram_id}: FloodWait {e.retry_after} сек"
)
return False, "retry_after"
except Exception as e:
# Другие ошибки
logger.error(f"Ошибка отправки пользователю {user.telegram_id}: {e}")
return False, "unknown_error"
async def broadcast_to_users( async def broadcast_to_users(
self, self,

185
src/core/delivery.py Normal file
View 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()

View File

@@ -2,7 +2,6 @@
import logging import logging
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.exceptions import TelegramRetryAfter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter, Command from aiogram.filters import StateFilter, Command
@@ -23,10 +22,10 @@ from src.core.chat_services import (
from src.core.services import UserService from src.core.services import UserService
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.delivery import delivery_service
from src.utils.account_utils import parse_accounts_from_message from src.utils.account_utils import parse_accounts_from_message
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_RETRY_AFTER_SECONDS = 30
class ChatStates(StatesGroup): class ChatStates(StatesGroup):
@@ -488,18 +487,19 @@ async def _send_message_to_user(message: Message, user_telegram_id: int, retry:
Отправить сообщение конкретному пользователю. Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке. Возвращает message_id при успехе или None при ошибке.
""" """
try: result = await delivery_service.send_with_guard(
sent_msg = await message.copy_to(user_telegram_id) user_telegram_id,
return sent_msg.message_id lambda: message.copy_to(user_telegram_id),
except TelegramRetryAfter as e: retry=retry,
if retry and e.retry_after <= MAX_RETRY_AFTER_SECONDS: )
logger.warning(f"FloodWait при отправке {user_telegram_id}: {e.retry_after}с") if result.success:
await asyncio.sleep(e.retry_after + 1) return result.telegram_message.message_id
return await _send_message_to_user(message, user_telegram_id, retry=False)
logger.warning(f"Пропускаем отправку {user_telegram_id}: FloodWait {e.retry_after}с") logger.warning(
return None "Не удалось отправить сообщение %s: %s",
except Exception as e: user_telegram_id,
logger.warning(f"Не удалось отправить сообщение {user_telegram_id}: {e}") result.status,
)
return None return None
@@ -513,86 +513,78 @@ async def _send_message_to_user_with_sender(
Отправить сообщение обычному пользователю с информацией об отправителе. Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке. Возвращает message_id при успехе или None при ошибке.
""" """
try:
# Формируем текст с информацией об отправителе # Формируем текст с информацией об отправителе
header = f"📨 <b>{sender_info}:</b>\n\n" header = f"📨 <b>{sender_info}:</b>\n\n"
async def send_message():
if message.text: if message.text:
# Текстовое сообщение return await message.bot.send_message(
sent_msg = await message.bot.send_message(
user_telegram_id, user_telegram_id,
header + message.text, header + message.text,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.photo: elif message.photo:
# Фото
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo( return await message.bot.send_photo(
user_telegram_id, user_telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video: elif message.video:
# Видео
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_video( return await message.bot.send_video(
user_telegram_id, user_telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.document: elif message.document:
# Документ
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_document( return await message.bot.send_document(
user_telegram_id, user_telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.animation: elif message.animation:
# GIF
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation( return await message.bot.send_animation(
user_telegram_id, user_telegram_id,
animation=message.animation.file_id, animation=message.animation.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.sticker: elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id) return await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice: elif message.voice:
# Голосовое сообщение return await message.bot.send_voice(
sent_msg = await message.bot.send_voice(
user_telegram_id, user_telegram_id,
voice=message.voice.file_id, voice=message.voice.file_id,
caption=header, caption=header,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video_note: elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id) return await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
except TelegramRetryAfter as e: return await message.copy_to(user_telegram_id)
if retry and e.retry_after <= MAX_RETRY_AFTER_SECONDS:
logger.warning(f"FloodWait при отправке {user_telegram_id}: {e.retry_after}с") result = await delivery_service.send_with_guard(
await asyncio.sleep(e.retry_after + 1) user_telegram_id,
return await _send_message_to_user_with_sender( send_message,
message, user_telegram_id, sender_info, retry=False retry=retry,
)
if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение %s: %s",
user_telegram_id,
result.status,
) )
logger.warning(f"Пропускаем отправку {user_telegram_id}: FloodWait {e.retry_after}с")
return None
except Exception as e:
logger.warning(f"Не удалось отправить сообщение {user_telegram_id}: {e}")
return None return None
@@ -606,86 +598,78 @@ async def _send_message_to_admin_with_sender(
Отправить сообщение админу с информацией об отправителе. Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке. Возвращает message_id при успехе или None при ошибке.
""" """
try:
# Формируем текст с информацией об отправителе # Формируем текст с информацией об отправителе
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n" header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
async def send_message():
if message.text: if message.text:
# Текстовое сообщение return await message.bot.send_message(
sent_msg = await message.bot.send_message(
admin_telegram_id, admin_telegram_id,
header + message.text, header + message.text,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.photo: elif message.photo:
# Фото
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo( return await message.bot.send_photo(
admin_telegram_id, admin_telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video: elif message.video:
# Видео
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_video( return await message.bot.send_video(
admin_telegram_id, admin_telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.document: elif message.document:
# Документ
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_document( return await message.bot.send_document(
admin_telegram_id, admin_telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.animation: elif message.animation:
# GIF
caption = header + (message.caption or "") caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation( return await message.bot.send_animation(
admin_telegram_id, admin_telegram_id,
animation=message.animation.file_id, animation=message.animation.file_id,
caption=caption, caption=caption,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.sticker: elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id) return await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice: elif message.voice:
# Голосовое сообщение return await message.bot.send_voice(
sent_msg = await message.bot.send_voice(
admin_telegram_id, admin_telegram_id,
voice=message.voice.file_id, voice=message.voice.file_id,
caption=header, caption=header,
parse_mode="HTML" parse_mode="HTML"
) )
elif message.video_note: elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id) return await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
except TelegramRetryAfter as e: return await message.copy_to(admin_telegram_id)
if retry and e.retry_after <= MAX_RETRY_AFTER_SECONDS:
logger.warning(f"FloodWait при отправке админу {admin_telegram_id}: {e.retry_after}с") result = await delivery_service.send_with_guard(
await asyncio.sleep(e.retry_after + 1) admin_telegram_id,
return await _send_message_to_admin_with_sender( send_message,
message, admin_telegram_id, sender_info, retry=False retry=retry,
)
if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение админу %s: %s",
admin_telegram_id,
result.status,
) )
logger.warning(f"Пропускаем отправку админу {admin_telegram_id}: FloodWait {e.retry_after}с")
return None
except Exception as e:
logger.warning(f"Не удалось отправить сообщение админу {admin_telegram_id}: {e}")
return None return None

View File

@@ -15,6 +15,7 @@ from src.core.services import UserService
from src.core.models import User from src.core.models import User
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.delivery import delivery_service
router = Router(name='p2p_chat_router') router = Router(name='p2p_chat_router')
@@ -416,37 +417,49 @@ async def handle_p2p_message(message: Message, state: FSMContext):
file_id = message.document.file_id file_id = message.document.file_id
text = message.caption text = message.caption
# Отправляем сообщение получателю async def send_to_recipient():
try:
if message_type == "text": if message_type == "text":
sent = await message.bot.send_message( return await message.bot.send_message(
recipient_telegram_id, recipient_telegram_id,
f"<b>{sender_name}</b>\n\n{text}", f"<b>{sender_name}</b>\n\n{text}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "photo": elif message_type == "photo":
sent = await message.bot.send_photo( return await message.bot.send_photo(
recipient_telegram_id, recipient_telegram_id,
photo=file_id, photo=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}" , caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "video": elif message_type == "video":
sent = await message.bot.send_video( return await message.bot.send_video(
recipient_telegram_id, recipient_telegram_id,
video=file_id, video=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "document": elif message_type == "document":
sent = await message.bot.send_document( return await message.bot.send_document(
recipient_telegram_id, recipient_telegram_id,
document=file_id, document=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
# Сохраняем в БД return await message.copy_to(recipient_telegram_id)
delivery = await delivery_service.send_with_guard(
recipient_telegram_id,
send_to_recipient,
)
if not delivery.success:
if delivery.status in {"blocked_bot", "deactivated", "not_found", "chat_not_found"}:
await message.answer("❌ Получатель недоступен: он мог заблокировать бота")
else:
await message.answer("Не удалось доставить сообщение. Попробуйте позже")
return
# Сохраняем в БД только реально доставленное сообщение.
await P2PMessageService.send_message( await P2PMessageService.send_message(
session, session,
sender_id=sender.id, sender_id=sender.id,
@@ -455,10 +468,7 @@ async def handle_p2p_message(message: Message, state: FSMContext):
text=text, text=text,
file_id=file_id, file_id=file_id,
sender_message_id=message.message_id, sender_message_id=message.message_id,
recipient_message_id=sent.message_id recipient_message_id=delivery.telegram_message.message_id
) )
await message.answer("✅ Сообщение доставлено") await message.answer("✅ Сообщение доставлено")
except Exception as e:
await message.answer(f"Не удалось доставить сообщение: {e}")

View File

@@ -13,6 +13,7 @@ from src.core.services import LotteryService
from src.core.models import User, Winner from src.core.models import User, Winner
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.permissions import admin_only from src.core.permissions import admin_only
from src.core.delivery import delivery_service
router = Router() router = Router()
@@ -276,18 +277,26 @@ async def redraw_lottery(message: Message):
)] )]
]) ])
try: delivery = await delivery_service.send_with_guard(
await message.bot.send_message( owner.telegram_id,
lambda: message.bot.send_message(
owner.telegram_id, owner.telegram_id,
notification_message, notification_message,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="Markdown" parse_mode="Markdown"
),
) )
if delivery.success:
new_winner.is_notified = True new_winner.is_notified = True
await session.commit() await session.commit()
except: else:
pass import logging
logging.getLogger(__name__).warning(
"Не удалось уведомить нового победителя %s: %s",
owner.telegram_id,
delivery.status,
)
# Формируем отчет для админа # Формируем отчет для админа
text = f"🔄 **Повторный розыгрыш завершен!**\n\n" text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
@@ -416,10 +425,12 @@ async def confirm_winner_callback(callback_query):
from aiogram import Bot from aiogram import Bot
from src.core.config import BOT_TOKEN from src.core.config import BOT_TOKEN
bot = Bot(token=BOT_TOKEN) bot = Bot(token=BOT_TOKEN)
await bot.send_message(admin_id, admin_text, parse_mode="Markdown") await delivery_service.send_with_guard(
admin_id,
lambda: bot.send_message(admin_id, admin_text, parse_mode="Markdown"),
)
except Exception as e: except Exception as e:
import logging import logging
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}") logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True) await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)

View File

@@ -11,6 +11,7 @@ from ..core.models import Winner, User
from ..core.services import LotteryService from ..core.services import LotteryService
from ..core.registration_services import AccountService, WinnerNotificationService from ..core.registration_services import AccountService, WinnerNotificationService
from ..core.config import ADMIN_IDS from ..core.config import ADMIN_IDS
from ..core.delivery import delivery_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -79,19 +80,28 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
)] )]
]) ])
# Отправляем уведомление с кнопкой delivery = await delivery_service.send_with_guard(
await bot.send_message( owner.telegram_id,
lambda: bot.send_message(
owner.telegram_id, owner.telegram_id,
message, message,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="Markdown" parse_mode="Markdown"
),
) )
if delivery.success:
# Отмечаем, что уведомление отправлено # Отмечаем, что уведомление отправлено
winner.is_notified = True winner.is_notified = True
await session.commit() await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}") logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
else:
logger.warning(
"⚠️ Уведомление победителю %s не отправлено: %s",
owner.telegram_id,
delivery.status,
)
else: else:
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id") logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
@@ -118,16 +128,26 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
)] )]
]) ])
await bot.send_message( delivery = await delivery_service.send_with_guard(
user.telegram_id,
lambda: bot.send_message(
user.telegram_id, user.telegram_id,
message, message,
reply_markup=keyboard reply_markup=keyboard
),
) )
if delivery.success:
winner.is_notified = True winner.is_notified = True
await session.commit() await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})") logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
else:
logger.warning(
"⚠️ Уведомление победителю %s не отправлено: %s",
user.telegram_id,
delivery.status,
)
else: else:
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id") logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")

View File

@@ -0,0 +1,130 @@
import os
import sys
import pytest
from aiogram.exceptions import TelegramForbiddenError
from sqlalchemy import delete, select
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import src.core.models # noqa: F401 - register SQLAlchemy models before init_db
from src.core.activity_service import ActivityService
from src.core.database import async_session_maker, init_db
from src.core.delivery import DeliveryService
from src.core.models import BlockedUser
class FakeTelegramMessage:
message_id = 123
async def _cleanup_blocked_user(telegram_id: int):
async with async_session_maker() as session:
await session.execute(
delete(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
)
await session.commit()
async def _get_blocked_user(telegram_id: int):
async with async_session_maker() as session:
result = await session.execute(
select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
)
return result.scalar_one_or_none()
@pytest.mark.asyncio
async def test_delivery_guard_skips_known_blocked_user():
await init_db()
telegram_id = 880000001
await _cleanup_blocked_user(telegram_id)
async with async_session_maker() as session:
session.add(
BlockedUser(
telegram_id=telegram_id,
error_type="blocked_bot",
error_message="Forbidden: bot was blocked by the user",
is_active=True,
)
)
await session.commit()
called = False
async def send_call():
nonlocal called
called = True
return FakeTelegramMessage()
result = await DeliveryService().send_with_guard(telegram_id, send_call)
assert result.success is False
assert result.skipped is True
assert result.status == "blocked_bot"
assert called is False
await _cleanup_blocked_user(telegram_id)
@pytest.mark.asyncio
async def test_delivery_guard_marks_user_blocked_on_forbidden():
await init_db()
telegram_id = 880000002
await _cleanup_blocked_user(telegram_id)
async def send_call():
raise TelegramForbiddenError(
method=None,
message="Forbidden: bot was blocked by the user",
)
result = await DeliveryService().send_with_guard(telegram_id, send_call)
blocked_user = await _get_blocked_user(telegram_id)
assert result.success is False
assert result.status == "blocked_bot"
assert blocked_user is not None
assert blocked_user.is_active is True
assert blocked_user.error_type == "blocked_bot"
await _cleanup_blocked_user(telegram_id)
def test_delivery_guard_does_not_classify_parse_errors_as_blocked():
service = DeliveryService()
assert service.classify_bad_request(Exception("Bad Request: can't parse entities")) is None
@pytest.mark.asyncio
async def test_activity_reactivates_delivery_after_user_interaction():
await init_db()
telegram_id = 880000003
await _cleanup_blocked_user(telegram_id)
async with async_session_maker() as session:
session.add(
BlockedUser(
telegram_id=telegram_id,
error_type="blocked_bot",
error_message="Forbidden: bot was blocked by the user",
is_active=True,
)
)
await session.commit()
async with async_session_maker() as session:
reactivated = await ActivityService.update_activity_and_reactivate(
session,
telegram_id,
)
blocked_user = await _get_blocked_user(telegram_id)
assert reactivated is True
assert blocked_user is not None
assert blocked_user.is_active is False
await _cleanup_blocked_user(telegram_id)