Files
remnawave-bedolaga-telegram…/app/middlewares/throttling.py
T
2026-02-27 06:48:35 +03:00

133 lines
5.2 KiB
Python

import time
from collections.abc import Awaitable, Callable
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
logger = structlog.get_logger(__name__)
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__(
self,
rate_limit: float = 0.5,
start_max_calls: int = 3,
start_window: float = 60.0,
):
self.rate_limit = rate_limit
self.user_buckets: dict[int, float] = {}
# /start anti-spam: sliding window per user
self.start_max_calls = start_max_calls
self.start_window = start_window
self.start_buckets: dict[int, list[float]] = {}
self._last_cleanup: float = time.monotonic()
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]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
user_id = None
if isinstance(event, (Message, CallbackQuery)):
user_id = event.from_user.id if event.from_user else None
if not user_id:
return await handler(event, data)
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.split(maxsplit=1)[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 = max(1, int(self.start_window - (now - timestamps[0])) + 1)
logger.warning(
'Rate-limit /start burst exceeded',
user_id=user_id,
call_count=len(timestamps),
window_sec=int(self.start_window),
max_calls=self.start_max_calls,
)
try:
await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.')
except TelegramAPIError:
pass
self.start_buckets[user_id] = timestamps
return None
timestamps.append(now)
self.start_buckets[user_id] = timestamps
# --- Общий троттлинг (0.5 сек) ---
last_call = self.user_buckets.get(user_id, 0)
if now - last_call < self.rate_limit:
logger.debug('Throttling user', user_id=user_id)
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
if isinstance(event, Message):
try:
fsm: FSMContext | None = data.get('state')
current = await fsm.get_state() if fsm else None
except Exception:
current = None
if current:
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):
try:
await event.answer('⏳ Слишком быстро! Подождите немного.', show_alert=True)
except TelegramAPIError:
pass
return None
self.user_buckets[user_id] = now
return await handler(event, data)