fix: handle expired callback queries and harden middleware error handling

- Throttling: catch TelegramAPIError instead of bare Exception on .answer()
- Throttling: share single instance across message/callback dispatchers
- Throttling: fix from_user None crash, memory leak (cleanup on timer now)
- Throttling: use time.monotonic(), fix /start matching, fix log messages
- ChannelChecker: wrap .answer() in try/except for expired queries
- ChannelChecker: guard from_user None access
- DisplayNameRestriction: wrap .answer() in try/except TelegramAPIError
This commit is contained in:
Fringg
2026-02-27 05:21:26 +03:00
parent 256cbfcadf
commit f52e6aedac
4 changed files with 76 additions and 53 deletions

View File

@@ -132,8 +132,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
throttling_middleware = ThrottlingMiddleware()
dp.message.middleware(throttling_middleware)
dp.callback_query.middleware(throttling_middleware)
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:

View File

@@ -93,7 +93,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Fast-path bypasses
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
telegram_id = event.from_user.id if event.from_user else None
elif isinstance(event, Update):
if event.message:
telegram_id = event.message.from_user.id
@@ -145,7 +145,10 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Rate limit: max 1 check per 5 seconds per user
rate_key = f'sub_check_rate:{telegram_id}'
if await cache.exists(rate_key):
await event.answer()
try:
await event.answer()
except TelegramBadRequest:
pass
return None
await cache.set(rate_key, 1, expire=5)
@@ -184,13 +187,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if 'message is not modified' not in str(e).lower():
raise
await event.answer(
texts.t(
'CHANNEL_CHECK_NOT_SUBSCRIBED',
'You are not subscribed to all required channels. Please subscribe and try again.',
),
show_alert=True,
)
try:
await event.answer(
texts.t(
'CHANNEL_CHECK_NOT_SUBSCRIBED',
'You are not subscribed to all required channels. Please subscribe and try again.',
),
show_alert=True,
)
except TelegramBadRequest:
pass
return None
return await self._deny_message(event, bot, all_channels)

View File

@@ -4,6 +4,7 @@ from typing import Any
import structlog
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramAPIError
from aiogram.types import (
CallbackQuery,
Message,
@@ -106,12 +107,15 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware):
suspicious_value=suspicious_value,
)
if isinstance(event, Message):
await event.answer(warning)
elif isinstance(event, CallbackQuery):
await event.answer(warning, show_alert=True)
elif isinstance(event, PreCheckoutQuery):
await event.answer(ok=False, error_message=warning)
try:
if isinstance(event, Message):
await event.answer(warning)
elif isinstance(event, CallbackQuery):
await event.answer(warning, show_alert=True)
elif isinstance(event, PreCheckoutQuery):
await event.answer(ok=False, error_message=warning)
except TelegramAPIError:
pass
return None
return await handler(event, data)

View File

@@ -4,6 +4,7 @@ from typing import Any
import structlog
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramAPIError
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject
@@ -16,6 +17,9 @@ class ThrottlingMiddleware(BaseMiddleware):
Двухуровневый rate-limiter:
1. Общий троттлинг — 0.5 сек между любыми сообщениями (UX)
2. /start burst-лимит — макс N вызовов за окно (anti-spam)
NOTE: Assumes single-process, single-event-loop execution.
For multi-worker deployments, replace with Redis-based rate limiting.
"""
def __init__(
@@ -32,6 +36,22 @@ class ThrottlingMiddleware(BaseMiddleware):
self.start_window = start_window
self.start_buckets: dict[int, list[float]] = {}
self._last_cleanup: float = 0.0
self._cleanup_interval: float = 30.0
def _maybe_cleanup(self, now: float) -> None:
"""Periodic cleanup of stale entries. Runs at most once per _cleanup_interval."""
if now - self._last_cleanup < self._cleanup_interval:
return
self._last_cleanup = now
cleanup_threshold = now - 60
self.user_buckets = {uid: ts for uid, ts in self.user_buckets.items() if ts > cleanup_threshold}
self.start_buckets = {
uid: [ts for ts in tss if now - ts < self.start_window]
for uid, tss in self.start_buckets.items()
if any(now - ts < self.start_window for ts in tss)
}
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
@@ -40,31 +60,33 @@ class ThrottlingMiddleware(BaseMiddleware):
) -> Any:
user_id = None
if isinstance(event, (Message, CallbackQuery)):
user_id = event.from_user.id
user_id = event.from_user.id if event.from_user else None
if not user_id:
return await handler(event, data)
now = time.time()
now = time.monotonic()
# Always run cleanup (independent of throttle path)
self._maybe_cleanup(now)
# --- /start burst rate-limit ---
if isinstance(event, Message) and event.text and event.text.startswith('/start'):
if isinstance(event, Message) and event.text and event.text.split()[0] == '/start':
timestamps = self.start_buckets.get(user_id, [])
# Оставляем только вызовы внутри окна
timestamps = [ts for ts in timestamps if now - ts < self.start_window]
if len(timestamps) >= self.start_max_calls:
cooldown = int(self.start_window - (now - timestamps[0])) + 1
cooldown = max(1, int(self.start_window - (now - timestamps[0])) + 1)
logger.warning(
'Rate-limit /start для : вызовов за s (лимит)',
'Rate-limit /start burst exceeded',
user_id=user_id,
timestamps_count=len(timestamps),
start_window=int(self.start_window),
start_max_calls=self.start_max_calls,
call_count=len(timestamps),
window_sec=int(self.start_window),
max_calls=self.start_max_calls,
)
try:
await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.')
except Exception:
except TelegramAPIError:
pass
self.start_buckets[user_id] = timestamps
return None
@@ -76,45 +98,35 @@ class ThrottlingMiddleware(BaseMiddleware):
last_call = self.user_buckets.get(user_id, 0)
if now - last_call < self.rate_limit:
logger.warning('Throttling для пользователя', user_id=user_id)
logger.warning('Throttling user', user_id=user_id)
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
if isinstance(event, Message):
try:
fsm: FSMContext = data.get('state') # может отсутствовать
fsm: FSMContext | None = data.get('state')
current = await fsm.get_state() if fsm else None
except Exception:
current = None
is_ticket_state = False
if current:
# Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply
lowered = str(current)
is_ticket_state = (':waiting_for_message' in lowered or ':waiting_for_reply' in lowered) and (
'TicketStates' in lowered or 'AdminTicketStates' in lowered
)
if is_ticket_state:
return None
# В остальных случаях — явный блок
await event.answer('⏳ Пожалуйста, не отправляйте сообщения так часто!')
state_str = str(current)
is_ticket_state = (
':waiting_for_message' in state_str or ':waiting_for_reply' in state_str
) and ('TicketStates' in state_str or 'AdminTicketStates' in state_str)
if is_ticket_state:
return None
try:
await event.answer('⏳ Пожалуйста, не отправляйте сообщения так часто!')
except TelegramAPIError:
pass
return None
# Для callback допустим краткое уведомление
if isinstance(event, CallbackQuery):
await event.answer('⏳ Слишком быстро! Подождите немного.', show_alert=True)
try:
await event.answer('⏳ Слишком быстро! Подождите немного.', show_alert=True)
except TelegramAPIError:
pass
return None
self.user_buckets[user_id] = now
# Периодическая очистка старых записей
cleanup_threshold = now - 60
self.user_buckets = {
uid: timestamp for uid, timestamp in self.user_buckets.items() if timestamp > cleanup_threshold
}
# Очистка /start бакетов (раз в ~60 сек, лениво)
if len(self.start_buckets) > 500:
self.start_buckets = {
uid: [ts for ts in tss if now - ts < self.start_window]
for uid, tss in self.start_buckets.items()
if any(now - ts < self.start_window for ts in tss)
}
return await handler(event, data)