mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-04-29 09:10:06 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user