diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml index d9137a8..029b0a2 100644 --- a/.github/workflows/_docker-build-push.yml +++ b/.github/workflows/_docker-build-push.yml @@ -96,7 +96,7 @@ jobs: github_owner="${{ github.repository_owner }}" echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT" dockerhub_owner="${github_owner,,}" - if [ "${{ inputs.publish_dockerhub }}" = "true" ]; then + if [ "${{ inputs.push }}" = "true" ] && [ "${{ inputs.publish_dockerhub }}" = "true" ]; then if [ -z "$DOCKERHUB_USERNAME" ]; then echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing" exit 1 @@ -110,7 +110,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.publish_dockerhub }} + name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }} name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true tags: | type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} diff --git a/backend/bot/app/controllers/dispatcher_controller.py b/backend/bot/app/controllers/dispatcher_controller.py index 14deecf..97c112c 100644 --- a/backend/bot/app/controllers/dispatcher_controller.py +++ b/backend/bot/app/controllers/dispatcher_controller.py @@ -17,6 +17,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware from bot.middlewares.db_session import DBSessionMiddleware from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance from bot.middlewares.profile_sync import ProfileSyncMiddleware +from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware from config.settings import Settings @@ -38,6 +39,7 @@ def build_dispatcher( dp["i18n_instance"] = i18n_instance dp["async_session_factory"] = async_session_factory + dp.update.outer_middleware(UpdateAntiFloodMiddleware(settings=settings)) dp.update.outer_middleware(DBSessionMiddleware(async_session_factory)) dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings)) dp.update.outer_middleware(ProfileSyncMiddleware()) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 3fee918..4d3c93e 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -702,6 +702,120 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"), SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"), # ─── System ──────────────────────────────────────────────────── + SettingField( + "TELEGRAM_DROP_NON_PRIVATE_UPDATES", + "bool", + "system", + "Drop non-private Telegram updates", + "Drops group/channel messages and callbacks before DB-backed middleware runs.", + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_ENABLED", + "bool", + "system", + "Telegram anti-flood enabled", + "Enables soft per-user limits for extreme Telegram update floods.", + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", + "int", + "system", + "Anti-flood window", + "Rolling window, in seconds, used by all Telegram anti-flood buckets.", + min=1, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + "int", + "system", + "All updates limit", + "Maximum total Telegram updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + "int", + "system", + "Messages limit", + "Maximum message updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + "int", + "system", + "Button callbacks limit", + "Maximum callback-query updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + "int", + "system", + "Inline queries limit", + "Maximum inline-query updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + "int", + "system", + "/start limit", + "Maximum /start messages from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + "int", + "system", + "Expensive callbacks limit", + ( + "Maximum payment, trial, promo and account-changing callbacks from one actor " + "during the window. 0 disables this bucket." + ), + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ACTION_COOLDOWN_ENABLED", + "bool", + "system", + "Action cooldowns enabled", + "Deduplicates repeated payment and trial button presses from the same user.", + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + "int", + "system", + "Payment callback cooldown", + ( + "Seconds to suppress an exact repeated payment callback from the same user. " + "0 disables this cooldown." + ), + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", + "int", + "system", + "Trial callback cooldown", + ( + "Seconds to suppress an exact repeated trial activation callback from the same user. " + "0 disables this cooldown." + ), + min=0, + subsection="telegram_antiflood", + ), SettingField( "TELEMETRY_ENABLED", "bool", diff --git a/backend/bot/middlewares/action_logger_middleware.py b/backend/bot/middlewares/action_logger_middleware.py index a104944..6317fe2 100644 --- a/backend/bot/middlewares/action_logger_middleware.py +++ b/backend/bot/middlewares/action_logger_middleware.py @@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware): result = await handler(event, data) + if data.get("skip_action_log") or data.get("antiflood_dropped"): + return result + session: AsyncSession = data["session"] event_user: Optional[User] = data.get("event_from_user") diff --git a/backend/bot/middlewares/update_antiflood.py b/backend/bot/middlewares/update_antiflood.py new file mode 100644 index 0000000..8df6371 --- /dev/null +++ b/backend/bot/middlewares/update_antiflood.py @@ -0,0 +1,371 @@ +import asyncio +import hashlib +import logging +import time +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Deque, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.types import Update + +from bot.infra.redis import get_redis, redis_key +from config.settings import Settings + +logger = logging.getLogger(__name__) + +DEFAULT_WINDOW_SECONDS = 60 +DEFAULT_MAX_UPDATES_PER_WINDOW = 180 +DEFAULT_MESSAGE_MAX_PER_WINDOW = 120 +DEFAULT_CALLBACK_MAX_PER_WINDOW = 240 +DEFAULT_INLINE_MAX_PER_WINDOW = 60 +DEFAULT_START_MAX_PER_WINDOW = 30 +DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW = 60 +DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS = 20 +DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS = 30 + +EXPENSIVE_CALLBACK_PREFIXES = ( + "pay_", + "trial_action:confirm_activate", + "main_action:request_trial", + "main_action:apply_promo", + "main_action:bot_apply_promo", + "tariff_change:apply:", + "tariff_change:confirm_pay:", + "tariff_change:pay:", + "autorenew:confirm:", + "disconnect_device:", +) + +TRIAL_CALLBACK_PREFIXES = ( + "trial_action:confirm_activate", + "main_action:request_trial", +) + + +@dataclass(frozen=True) +class RateLimitRule: + window_seconds: int + max_events: int + + +class UpdateAntiFloodMiddleware(BaseMiddleware): + """Drop extreme update floods before DB-backed middleware runs.""" + + def __init__( + self, + settings: Settings, + *, + default_rule: Optional[RateLimitRule] = None, + action_rules: Optional[Dict[str, RateLimitRule]] = None, + ) -> None: + super().__init__() + self.settings = settings + self.default_rule = default_rule or RateLimitRule( + window_seconds=int( + getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS) + or DEFAULT_WINDOW_SECONDS + ), + max_events=int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + DEFAULT_MAX_UPDATES_PER_WINDOW, + ) + or DEFAULT_MAX_UPDATES_PER_WINDOW + ), + ) + self.action_rules = action_rules or _default_action_rules(settings) + self._local_buckets: Dict[str, Deque[float]] = defaultdict(deque) + self._local_cooldowns: Dict[str, float] = {} + self._local_lock = asyncio.Lock() + + async def __call__( + self, + handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], + event: Update, + data: Dict[str, Any], + ) -> Any: + if bool(getattr(self.settings, "TELEGRAM_DROP_NON_PRIVATE_UPDATES", True)): + chat_type = _message_or_callback_chat_type(event) + if chat_type is not None and chat_type != "private": + logger.info( + "Telegram update dropped outside private chat: chat_type=%s update_type=%s", + chat_type, + getattr(event, "event_type", "unknown"), + ) + _mark_dropped(data) + return None + + if not bool(getattr(self.settings, "TELEGRAM_ANTIFLOOD_ENABLED", True)): + return await handler(event, data) + + actor_key = _update_actor_key(event) + if not actor_key: + return await handler(event, data) + + action_key = _update_action_key(event) + cooldown = _update_action_cooldown(event, self.settings) + if cooldown and await self._is_cooldown_active(cooldown[0], cooldown[1]): + logger.info( + "Telegram callback dropped by action cooldown: actor=%s cooldown=%s", + actor_key, + cooldown[0], + ) + _mark_dropped(data) + await _quietly_answer_callback(event) + return None + + if await self._is_limited("updates", actor_key, self.default_rule) or ( + action_key + and action_key in self.action_rules + and await self._is_limited(action_key, actor_key, self.action_rules[action_key]) + ): + logger.warning( + "Telegram update dropped by anti-flood: actor=%s update_type=%s", + actor_key, + action_key or getattr(event, "event_type", "unknown"), + ) + _mark_dropped(data) + return None + + return await handler(event, data) + + async def _is_limited(self, bucket_name: str, actor_key: str, rule: RateLimitRule) -> bool: + if rule.window_seconds <= 0 or rule.max_events <= 0: + return False + + try: + redis = await get_redis(self.settings) + if redis is not None: + key = redis_key( + self.settings, + "rate-limit", + "telegram", + bucket_name, + actor_key, + ) + current = int(await redis.incr(key)) + if current == 1: + await redis.expire(key, rule.window_seconds) + return current > rule.max_events + except Exception as exc: + logger.warning("Redis telegram anti-flood unavailable; using local fallback: %s", exc) + + return await self._is_limited_local(f"{bucket_name}:{actor_key}", rule) + + async def _is_cooldown_active(self, cooldown_key: str, ttl_seconds: int) -> bool: + if ttl_seconds <= 0: + return False + + try: + redis = await get_redis(self.settings) + if redis is not None: + key = redis_key( + self.settings, + "cooldown", + "telegram", + cooldown_key, + ) + acquired = await redis.set(key, "1", nx=True, ex=ttl_seconds) + return not bool(acquired) + except Exception as exc: + logger.warning("Redis telegram cooldown unavailable; using local fallback: %s", exc) + + return await self._is_cooldown_active_local(cooldown_key, ttl_seconds) + + async def _is_cooldown_active_local(self, cooldown_key: str, ttl_seconds: int) -> bool: + now = time.monotonic() + async with self._local_lock: + expired = [ + key for key, expires_at in self._local_cooldowns.items() if expires_at <= now + ] + for key in expired: + self._local_cooldowns.pop(key, None) + + expires_at = self._local_cooldowns.get(cooldown_key) + if expires_at and expires_at > now: + return True + + self._local_cooldowns[cooldown_key] = now + ttl_seconds + return False + + async def _is_limited_local(self, actor_key: str, rule: RateLimitRule) -> bool: + now = time.monotonic() + cutoff = now - rule.window_seconds + async with self._local_lock: + bucket = self._local_buckets[actor_key] + while bucket and bucket[0] <= cutoff: + bucket.popleft() + bucket.append(now) + if len(bucket) > rule.max_events: + return True + if not bucket: + self._local_buckets.pop(actor_key, None) + return False + + +def _update_actor_key(update: Update) -> Optional[str]: + user_id = None + chat_id = None + + if update.message: + user_id = update.message.from_user.id if update.message.from_user else None + chat_id = update.message.chat.id if update.message.chat else None + elif update.callback_query: + user_id = update.callback_query.from_user.id if update.callback_query.from_user else None + if update.callback_query.message and update.callback_query.message.chat: + chat_id = update.callback_query.message.chat.id + elif update.inline_query: + user_id = update.inline_query.from_user.id if update.inline_query.from_user else None + + if user_id is not None: + return f"user:{int(user_id)}" + if chat_id is not None: + return f"chat:{int(chat_id)}" + return None + + +def _message_or_callback_chat_type(update: Update) -> Optional[str]: + if update.message and update.message.chat: + return str(update.message.chat.type) + if ( + update.callback_query + and update.callback_query.message + and update.callback_query.message.chat + ): + return str(update.callback_query.message.chat.type) + return None + + +def _update_action_key(update: Update) -> str: + if update.message: + text = update.message.text or "" + if text.startswith("/start"): + return "start" + return "message" + if update.callback_query: + data = update.callback_query.data or "" + if data.startswith(EXPENSIVE_CALLBACK_PREFIXES): + return "expensive_callback" + return "callback" + if update.inline_query: + return "inline" + return "updates" + + +def _update_action_cooldown(update: Update, settings: Settings) -> Optional[tuple[str, int]]: + if not bool(getattr(settings, "TELEGRAM_ACTION_COOLDOWN_ENABLED", True)): + return None + if not update.callback_query or not update.callback_query.from_user: + return None + + callback_data = update.callback_query.data or "" + if not callback_data: + return None + + user_id = int(update.callback_query.from_user.id) + data_digest = hashlib.sha256(callback_data.encode("utf-8")).hexdigest()[:24] + + if callback_data.startswith("pay_"): + ttl = int( + getattr( + settings, + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS, + ) + or DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS + ) + return f"payment:user:{user_id}:data:{data_digest}", ttl + + if callback_data.startswith(TRIAL_CALLBACK_PREFIXES): + ttl = int( + getattr( + settings, + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", + DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS, + ) + or DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS + ) + return f"trial:user:{user_id}:data:{data_digest}", ttl + + return None + + +async def _quietly_answer_callback(update: Update) -> None: + callback = update.callback_query + if not callback: + return + try: + await callback.answer() + except Exception: + pass + + +def _mark_dropped(data: Dict[str, Any]) -> None: + data["antiflood_dropped"] = True + data["skip_action_log"] = True + + +def _default_action_rules(settings: Settings) -> Dict[str, RateLimitRule]: + window_seconds = int( + getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS) + or DEFAULT_WINDOW_SECONDS + ) + return { + "message": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + DEFAULT_MESSAGE_MAX_PER_WINDOW, + ) + or DEFAULT_MESSAGE_MAX_PER_WINDOW + ), + ), + "callback": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + DEFAULT_CALLBACK_MAX_PER_WINDOW, + ) + or DEFAULT_CALLBACK_MAX_PER_WINDOW + ), + ), + "inline": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + DEFAULT_INLINE_MAX_PER_WINDOW, + ) + or DEFAULT_INLINE_MAX_PER_WINDOW + ), + ), + "start": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + DEFAULT_START_MAX_PER_WINDOW, + ) + or DEFAULT_START_MAX_PER_WINDOW + ), + ), + "expensive_callback": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW, + ) + or DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW + ), + ), + } diff --git a/backend/config/settings.py b/backend/config/settings.py index 9225def..c848bdf 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -232,6 +232,18 @@ class Settings(BaseSettings): PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600) WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60) WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30) + TELEGRAM_DROP_NON_PRIVATE_UPDATES: bool = Field(default=True) + TELEGRAM_ANTIFLOOD_ENABLED: bool = Field(default=True) + TELEGRAM_ANTIFLOOD_WINDOW_SECONDS: int = Field(default=60) + TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW: int = Field(default=180) + TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW: int = Field(default=120) + TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW: int = Field(default=240) + TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW: int = Field(default=60) + TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW: int = Field(default=30) + TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW: int = Field(default=60) + TELEGRAM_ACTION_COOLDOWN_ENABLED: bool = Field(default=True) + TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS: int = Field(default=20) + TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS: int = Field(default=30) WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events") WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4) WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 09aed29..61b9b32 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -81,6 +81,18 @@ proxy/Docker gateway и может отклонить валидный webhook. | `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES` | Дельта lifetime-трафика для более ранней записи. | | `WEBAPP_RATE_LIMIT_TTL_SECONDS` | Окно Web App rate limit. | | `WEBAPP_RATE_LIMIT_MAX_REQUESTS` | Количество запросов в окне rate limit. | +| `TELEGRAM_DROP_NON_PRIVATE_UPDATES` | Отбрасывать group/channel Telegram-апдейты до DB-backed middleware. По умолчанию `True`. | +| `TELEGRAM_ANTIFLOOD_ENABLED` | Включает мягкие per-user/per-chat лимиты для экстремального Telegram-флуда. По умолчанию `True`. | +| `TELEGRAM_ANTIFLOOD_WINDOW_SECONDS` | Окно лимитов Telegram антифлуда. По умолчанию `60`. | +| `TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW` | Глобальный лимит Telegram-апдейтов на источник за окно. По умолчанию `180`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW` | Лимит Telegram messages на источник за окно. По умолчанию `120`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW` | Лимит callback query на источник за окно. По умолчанию `240`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW` | Лимит inline query на источник за окно. По умолчанию `60`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW` | Лимит `/start` на источник за окно. По умолчанию `30`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW` | Лимит платежных, trial, promo и account-changing callback за окно. По умолчанию `60`; `0` отключает лимит. | +| `TELEGRAM_ACTION_COOLDOWN_ENABLED` | Дедуплицирует точные повторы платежных и trial callback от того же пользователя. По умолчанию `True`. | +| `TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS` | Cooldown точного повтора платежного callback. По умолчанию `20`; `0` отключает cooldown. | +| `TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS` | Cooldown точного повтора trial callback. По умолчанию `30`; `0` отключает cooldown. | | `WEBHOOK_QUEUE_NAME` | Redis queue для тяжелой обработки webhook. | | `WEBHOOK_QUEUE_CONCURRENCY` | Количество worker consumers для webhook queue. | | `WORKER_PANEL_SYNC_INTERVAL_SECONDS` | Интервал фоновой синхронизации с панелью. | diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 19c368c..45cd5c3 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -15,6 +15,33 @@ openssl rand -hex 32 ``` +## Telegram антифлуд + +Апдейты Telegram-бота проходят через ранний anti-flood middleware до sync +профиля, проверки каналов и логирования действий, которые обращаются к базе. +Профиль по умолчанию специально мягкий: он не должен мешать быстрым обычным +нажатиям кнопок, но отбрасывает экстремальные всплески сообщений и callback от +одного источника до того, как они разгонят записи в БД, платежные обработчики +или Telegram FloodWait. + +Настройки доступны в **Админка -> Система -> Настройки -> Система -> Telegram +антифлуд**. Для обычного приватного продающего бота дефолтов достаточно, поэтому +они не добавлены в `.env.example`. Меняйте их только если реальный трафик +показывает, что пороги нужно расширить или сузить. + +- `TELEGRAM_DROP_NON_PRIVATE_UPDATES=True` отбрасывает апдейты из групп и + каналов до дорогих middleware. Оставляйте включенным, если бот не должен + работать вне приватных чатов. +- `TELEGRAM_ANTIFLOOD_ENABLED=True` включает per-user/per-chat лимиты. Значение + `0` у отдельного числового лимита отключает только этот лимит. +- `TELEGRAM_ANTIFLOOD_WINDOW_SECONDS=60` задает общее скользящее окно. Дефолты + рассчитаны на нормальные button-heavy флоу: `180` всех апдейтов, `120` + сообщений, `240` callback, `60` inline-запросов, `30` команд `/start` и `60` + тяжелых callback за окно. +- `TELEGRAM_ACTION_COOLDOWN_ENABLED=True` дедуплицирует точные повторные + платежные и trial callback от того же пользователя. Разные платежные payload + не объединяются, поэтому обычный checkout остается независимым. + ## Доступ администраторов - `ADMIN_IDS` задает Telegram ID администраторов. diff --git a/docs/features/admin-panel.md b/docs/features/admin-panel.md index 1bc7f36..8ceb670 100644 --- a/docs/features/admin-panel.md +++ b/docs/features/admin-panel.md @@ -53,7 +53,7 @@ - инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу; - legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика; - платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты; -- пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика. +- пробный период, приветственный реферальный бонус, уведомления, логирование, Telegram антифлуд, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика. Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json index 9f1751c..b4ae184 100644 --- a/frontend/src/lib/webapp/settingsManifest.generated.json +++ b/frontend/src/lib/webapp/settingsManifest.generated.json @@ -6050,6 +6050,243 @@ "id": "system", "order": 12, "fields": [ + { + "key": "TELEGRAM_DROP_NON_PRIVATE_UPDATES", + "type": "bool", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Drop non-private Telegram updates", + "description": "Drops group/channel messages and callbacks before DB-backed middleware runs.", + "i18n_label_key": "admin_settings_field_telegram_drop_non_private_updates_label", + "i18n_description_key": "admin_settings_field_telegram_drop_non_private_updates_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_ENABLED", + "type": "bool", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Telegram anti-flood enabled", + "description": "Enables soft per-user limits for extreme Telegram update floods.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_enabled_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_enabled_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Anti-flood window", + "description": "Rolling window, in seconds, used by all Telegram anti-flood buckets.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_window_seconds_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_window_seconds_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 1, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "All updates limit", + "description": "Maximum total Telegram updates from one actor during the window. 0 disables this bucket.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_max_updates_per_window_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_max_updates_per_window_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Messages limit", + "description": "Maximum message updates from one actor during the window. 0 disables this bucket.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_message_max_per_window_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_message_max_per_window_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Button callbacks limit", + "description": "Maximum callback-query updates from one actor during the window. 0 disables this bucket.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_callback_max_per_window_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_callback_max_per_window_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Inline queries limit", + "description": "Maximum inline-query updates from one actor during the window. 0 disables this bucket.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_inline_max_per_window_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_inline_max_per_window_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "/start limit", + "description": "Maximum /start messages from one actor during the window. 0 disables this bucket.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_start_max_per_window_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_start_max_per_window_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Expensive callbacks limit", + "description": "Maximum payment, trial, promo and account-changing callbacks from one actor during the window. 0 disables this bucket.", + "i18n_label_key": "admin_settings_field_telegram_antiflood_expensive_callback_max_per_window_label", + "i18n_description_key": "admin_settings_field_telegram_antiflood_expensive_callback_max_per_window_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_ACTION_COOLDOWN_ENABLED", + "type": "bool", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Action cooldowns enabled", + "description": "Deduplicates repeated payment and trial button presses from the same user.", + "i18n_label_key": "admin_settings_field_telegram_action_cooldown_enabled_label", + "i18n_description_key": "admin_settings_field_telegram_action_cooldown_enabled_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Payment callback cooldown", + "description": "Seconds to suppress an exact repeated payment callback from the same user. 0 disables this cooldown.", + "i18n_label_key": "admin_settings_field_telegram_payment_callback_cooldown_seconds_label", + "i18n_description_key": "admin_settings_field_telegram_payment_callback_cooldown_seconds_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, + { + "key": "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", + "type": "int", + "section": "system", + "section_order": 12, + "subsection": "telegram_antiflood", + "label": "Trial callback cooldown", + "description": "Seconds to suppress an exact repeated trial activation callback from the same user. 0 disables this cooldown.", + "i18n_label_key": "admin_settings_field_telegram_trial_callback_cooldown_seconds_label", + "i18n_description_key": "admin_settings_field_telegram_trial_callback_cooldown_seconds_description", + "i18n_subsection_key": "admin_settings_subsection_telegram_antiflood", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 0, + "value": "", + "overridden": false, + "updated_at": null + }, { "key": "TELEMETRY_ENABLED", "type": "bool", diff --git a/locales/en.json b/locales/en.json index 1c60826..7dd3b8e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1179,7 +1179,32 @@ "admin_settings_section_migrations": "Migrations", "admin_settings_field_telemetry_enabled_label": "Anonymous install analytics", "admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, official/custom image marker, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge active installs, versions in use and the share of modified builds. Toggling this off takes effect without a restart.", + "admin_settings_field_telegram_drop_non_private_updates_label": "Drop non-private Telegram updates", + "admin_settings_field_telegram_drop_non_private_updates_description": "Drops group and channel messages/callbacks before database-backed middleware runs. Keep enabled for a private sales bot.", + "admin_settings_field_telegram_antiflood_enabled_label": "Telegram anti-flood enabled", + "admin_settings_field_telegram_antiflood_enabled_description": "Enables soft per-user limits that cut off only extreme Telegram update floods.", + "admin_settings_field_telegram_antiflood_window_seconds_label": "Anti-flood window", + "admin_settings_field_telegram_antiflood_window_seconds_description": "Rolling window in seconds used by all Telegram anti-flood buckets.", + "admin_settings_field_telegram_antiflood_max_updates_per_window_label": "All updates limit", + "admin_settings_field_telegram_antiflood_max_updates_per_window_description": "Maximum total Telegram updates from one user or chat during the window. 0 disables this bucket.", + "admin_settings_field_telegram_antiflood_message_max_per_window_label": "Messages limit", + "admin_settings_field_telegram_antiflood_message_max_per_window_description": "Maximum message updates from one user or chat during the window. 0 disables this bucket.", + "admin_settings_field_telegram_antiflood_callback_max_per_window_label": "Button callbacks limit", + "admin_settings_field_telegram_antiflood_callback_max_per_window_description": "Maximum callback-query updates from one user during the window. 0 disables this bucket.", + "admin_settings_field_telegram_antiflood_inline_max_per_window_label": "Inline queries limit", + "admin_settings_field_telegram_antiflood_inline_max_per_window_description": "Maximum inline-query updates from one user during the window. 0 disables this bucket.", + "admin_settings_field_telegram_antiflood_start_max_per_window_label": "/start limit", + "admin_settings_field_telegram_antiflood_start_max_per_window_description": "Maximum /start messages from one user during the window. 0 disables this bucket.", + "admin_settings_field_telegram_antiflood_expensive_callback_max_per_window_label": "Expensive callbacks limit", + "admin_settings_field_telegram_antiflood_expensive_callback_max_per_window_description": "Maximum payment, trial, promo and account-changing callbacks from one user during the window. 0 disables this bucket.", + "admin_settings_field_telegram_action_cooldown_enabled_label": "Action cooldowns enabled", + "admin_settings_field_telegram_action_cooldown_enabled_description": "Deduplicates exact repeated payment and trial button presses from the same user.", + "admin_settings_field_telegram_payment_callback_cooldown_seconds_label": "Payment callback cooldown", + "admin_settings_field_telegram_payment_callback_cooldown_seconds_description": "Seconds to suppress an exact repeated payment callback from the same user. 0 disables this cooldown.", + "admin_settings_field_telegram_trial_callback_cooldown_seconds_label": "Trial callback cooldown", + "admin_settings_field_telegram_trial_callback_cooldown_seconds_description": "Seconds to suppress an exact repeated trial activation callback from the same user. 0 disables this cooldown.", "admin_settings_subsection_common": "Common", + "admin_settings_subsection_telegram_antiflood": "Telegram anti-flood", "admin_settings_subsection_checkout": "Checkout", "admin_settings_subsection_trial": "Trial", "admin_settings_subsection_referral": "Referral program", diff --git a/locales/ru.json b/locales/ru.json index a643216..76900a9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1179,7 +1179,32 @@ "admin_settings_section_migrations": "Миграции", "admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки", "admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок, используемые версии и долю изменённых сборок. Отключение применяется без перезапуска.", + "admin_settings_field_telegram_drop_non_private_updates_label": "Отбрасывать неприватные чаты", + "admin_settings_field_telegram_drop_non_private_updates_description": "Отбрасывает сообщения и нажатия кнопок из групп и каналов до middleware, которые ходят в базу. Для приватного продающего бота лучше держать включенным.", + "admin_settings_field_telegram_antiflood_enabled_label": "Антифлуд Telegram включен", + "admin_settings_field_telegram_antiflood_enabled_description": "Включает мягкие лимиты на пользователя, которые отсекают только экстремальный поток Telegram-апдейтов.", + "admin_settings_field_telegram_antiflood_window_seconds_label": "Окно антифлуда", + "admin_settings_field_telegram_antiflood_window_seconds_description": "Скользящее окно в секундах, по которому считаются все лимиты Telegram антифлуда.", + "admin_settings_field_telegram_antiflood_max_updates_per_window_label": "Лимит всех апдейтов", + "admin_settings_field_telegram_antiflood_max_updates_per_window_description": "Максимум всех Telegram-апдейтов от одного пользователя или чата за окно. 0 отключает этот лимит.", + "admin_settings_field_telegram_antiflood_message_max_per_window_label": "Лимит сообщений", + "admin_settings_field_telegram_antiflood_message_max_per_window_description": "Максимум сообщений от одного пользователя или чата за окно. 0 отключает этот лимит.", + "admin_settings_field_telegram_antiflood_callback_max_per_window_label": "Лимит нажатий кнопок", + "admin_settings_field_telegram_antiflood_callback_max_per_window_description": "Максимум callback query от одного пользователя за окно. 0 отключает этот лимит.", + "admin_settings_field_telegram_antiflood_inline_max_per_window_label": "Лимит inline-запросов", + "admin_settings_field_telegram_antiflood_inline_max_per_window_description": "Максимум inline query от одного пользователя за окно. 0 отключает этот лимит.", + "admin_settings_field_telegram_antiflood_start_max_per_window_label": "Лимит /start", + "admin_settings_field_telegram_antiflood_start_max_per_window_description": "Максимум сообщений /start от одного пользователя за окно. 0 отключает этот лимит.", + "admin_settings_field_telegram_antiflood_expensive_callback_max_per_window_label": "Лимит тяжелых кнопок", + "admin_settings_field_telegram_antiflood_expensive_callback_max_per_window_description": "Максимум платежных, trial, промокодных и меняющих аккаунт callback от одного пользователя за окно. 0 отключает этот лимит.", + "admin_settings_field_telegram_action_cooldown_enabled_label": "Cooldown действий включен", + "admin_settings_field_telegram_action_cooldown_enabled_description": "Дедуплицирует точные повторные нажатия платежных и trial-кнопок от одного пользователя.", + "admin_settings_field_telegram_payment_callback_cooldown_seconds_label": "Cooldown платежной кнопки", + "admin_settings_field_telegram_payment_callback_cooldown_seconds_description": "Сколько секунд подавлять точный повтор платежного callback от того же пользователя. 0 отключает этот cooldown.", + "admin_settings_field_telegram_trial_callback_cooldown_seconds_label": "Cooldown trial-кнопки", + "admin_settings_field_telegram_trial_callback_cooldown_seconds_description": "Сколько секунд подавлять точный повтор callback активации trial от того же пользователя. 0 отключает этот cooldown.", "admin_settings_subsection_common": "Общие", + "admin_settings_subsection_telegram_antiflood": "Telegram антифлуд", "admin_settings_subsection_checkout": "Оформление оплаты", "admin_settings_subsection_trial": "Пробный период", "admin_settings_subsection_referral": "Реферальная программа", diff --git a/tests/test_action_logger_middleware.py b/tests/test_action_logger_middleware.py new file mode 100644 index 0000000..c837c34 --- /dev/null +++ b/tests/test_action_logger_middleware.py @@ -0,0 +1,39 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware + + +class ActionLoggerMiddlewareTests(unittest.IsolatedAsyncioTestCase): + async def test_skip_action_log_flag_suppresses_database_logging(self): + middleware = ActionLoggerMiddleware(SimpleNamespace(ADMIN_IDS=[], LOG_ADMIN_ACTIONS=True)) + event = SimpleNamespace(event_type="message") + data = { + "session": object(), + "event_from_user": SimpleNamespace(id=42, username="user", first_name="User"), + } + + async def handler(_event, handler_data): + handler_data["skip_action_log"] = True + return "ok" + + with ( + patch( + "bot.middlewares.action_logger_middleware.user_dal.get_user_by_id", + AsyncMock(), + ) as get_user, + patch( + "bot.middlewares.action_logger_middleware.message_log_dal.create_message_log_no_commit", + AsyncMock(), + ) as create_log, + ): + result = await middleware(handler, event, data) + + self.assertEqual(result, "ok") + get_user.assert_not_awaited() + create_log.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index fed24a5..c1cc1ef 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -45,6 +45,21 @@ BACKUP_SETTINGS = ( "BACKUP_COMPOSE_ENABLED", ) +TELEGRAM_ANTIFLOOD_SETTINGS = ( + "TELEGRAM_DROP_NON_PRIVATE_UPDATES", + "TELEGRAM_ANTIFLOOD_ENABLED", + "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + "TELEGRAM_ACTION_COOLDOWN_ENABLED", + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", +) + REMNASHOP_MIGRATION_SETTINGS = ( "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", @@ -230,6 +245,40 @@ def test_backup_settings_i18n_keys_exist(): assert field["i18n_description_key"] in messages +def test_telegram_antiflood_settings_i18n_keys_exist(): + manifest = _manifest_by_key() + + for setting_key in TELEGRAM_ANTIFLOOD_SETTINGS: + field = manifest[setting_key] + assert field["section"] == "system" + assert field["section_order"] == 12 + assert field["subsection"] == "telegram_antiflood" + assert field["i18n_subsection_key"] == "admin_settings_subsection_telegram_antiflood" + + assert manifest["TELEGRAM_ANTIFLOOD_WINDOW_SECONDS"]["min"] == 1 + for setting_key in ( + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", + ): + assert manifest[setting_key]["min"] == 0 + + for language in ("ru", "en"): + messages = _locale(language) + + assert "admin_settings_section_system" in messages + assert "admin_settings_subsection_telegram_antiflood" in messages + for setting_key in TELEGRAM_ANTIFLOOD_SETTINGS: + field = manifest[setting_key] + assert field["i18n_label_key"] in messages + assert field["i18n_description_key"] in messages + + def test_remnashop_migration_settings_i18n_keys_exist(): manifest = _manifest_by_key() diff --git a/tests/test_update_antiflood_middleware.py b/tests/test_update_antiflood_middleware.py new file mode 100644 index 0000000..66fbb95 --- /dev/null +++ b/tests/test_update_antiflood_middleware.py @@ -0,0 +1,197 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.middlewares.update_antiflood import RateLimitRule, UpdateAntiFloodMiddleware + + +def _settings(**overrides): + base = { + "REDIS_URL": None, + "REDIS_KEY_PREFIX": "test-shop", + "TELEGRAM_DROP_NON_PRIVATE_UPDATES": True, + "TELEGRAM_ANTIFLOOD_ENABLED": True, + "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS": 60, + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW": 180, + "TELEGRAM_ACTION_COOLDOWN_ENABLED": True, + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS": 20, + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS": 30, + } + base.update(overrides) + return SimpleNamespace(**base) + + +def _message_update(*, user_id=42, chat_id=42, chat_type="private", text="hello"): + return SimpleNamespace( + event_type="message", + message=SimpleNamespace( + from_user=SimpleNamespace(id=user_id), + chat=SimpleNamespace(id=chat_id, type=chat_type), + text=text, + ), + callback_query=None, + inline_query=None, + ) + + +def _callback_update( + *, + user_id=42, + chat_id=42, + chat_type="private", + data="main_action:back_to_main", +): + return SimpleNamespace( + event_type="callback_query", + message=None, + callback_query=SimpleNamespace( + from_user=SimpleNamespace(id=user_id), + message=SimpleNamespace(chat=SimpleNamespace(id=chat_id, type=chat_type)), + data=data, + answer=AsyncMock(), + ), + inline_query=None, + ) + + +def _inline_update(*, user_id=42, query="ref"): + return SimpleNamespace( + event_type="inline_query", + message=None, + callback_query=None, + inline_query=SimpleNamespace(from_user=SimpleNamespace(id=user_id), query=query), + ) + + +class UpdateAntiFloodMiddlewareTests(unittest.IsolatedAsyncioTestCase): + async def test_extreme_update_flood_is_dropped_before_handler(self): + middleware = UpdateAntiFloodMiddleware( + _settings(), + default_rule=RateLimitRule(window_seconds=60, max_events=2), + ) + handler = AsyncMock(return_value="ok") + event = _message_update() + + with patch("bot.middlewares.update_antiflood.get_redis", AsyncMock(return_value=None)): + self.assertEqual(await middleware(handler, event, {}), "ok") + self.assertEqual(await middleware(handler, event, {}), "ok") + dropped_data = {} + self.assertIsNone(await middleware(handler, event, dropped_data)) + + self.assertEqual(handler.await_count, 2) + self.assertTrue(dropped_data["antiflood_dropped"]) + self.assertTrue(dropped_data["skip_action_log"]) + + async def test_antiflood_can_be_disabled(self): + middleware = UpdateAntiFloodMiddleware( + _settings(TELEGRAM_ANTIFLOOD_ENABLED=False), + default_rule=RateLimitRule(window_seconds=60, max_events=0), + ) + handler = AsyncMock(return_value="ok") + + with patch("bot.middlewares.update_antiflood.get_redis", AsyncMock(return_value=None)): + self.assertEqual(await middleware(handler, _message_update(), {}), "ok") + + handler.assert_awaited_once() + + async def test_action_specific_limits_are_counted_separately(self): + middleware = UpdateAntiFloodMiddleware( + _settings(), + default_rule=RateLimitRule(window_seconds=60, max_events=100), + action_rules={ + "start": RateLimitRule(window_seconds=60, max_events=1), + "callback": RateLimitRule(window_seconds=60, max_events=2), + "expensive_callback": RateLimitRule(window_seconds=60, max_events=1), + "inline": RateLimitRule(window_seconds=60, max_events=1), + }, + ) + handler = AsyncMock(return_value="ok") + + with patch("bot.middlewares.update_antiflood.get_redis", AsyncMock(return_value=None)): + self.assertEqual( + await middleware(handler, _message_update(text="/start"), {}), + "ok", + ) + self.assertIsNone(await middleware(handler, _message_update(text="/start abc"), {})) + + self.assertEqual(await middleware(handler, _callback_update(), {}), "ok") + self.assertEqual(await middleware(handler, _callback_update(), {}), "ok") + self.assertIsNone(await middleware(handler, _callback_update(), {})) + + self.assertEqual( + await middleware(handler, _callback_update(data="pay_fk:1:100:subscription"), {}), + "ok", + ) + self.assertIsNone( + await middleware(handler, _callback_update(data="pay_fk:1:100:subscription"), {}) + ) + + self.assertEqual(await middleware(handler, _inline_update(), {}), "ok") + self.assertIsNone(await middleware(handler, _inline_update(), {})) + + self.assertEqual(handler.await_count, 5) + + async def test_non_private_message_is_dropped_before_handler(self): + middleware = UpdateAntiFloodMiddleware(_settings()) + handler = AsyncMock(return_value="ok") + + result = await middleware( + handler, + _message_update(chat_id=-100123, chat_type="supergroup"), + {}, + ) + + self.assertIsNone(result) + handler.assert_not_awaited() + + async def test_non_private_drop_can_be_disabled(self): + middleware = UpdateAntiFloodMiddleware(_settings(TELEGRAM_DROP_NON_PRIVATE_UPDATES=False)) + handler = AsyncMock(return_value="ok") + + with patch("bot.middlewares.update_antiflood.get_redis", AsyncMock(return_value=None)): + result = await middleware( + handler, + _callback_update(chat_id=-100123, chat_type="group"), + {}, + ) + + self.assertEqual(result, "ok") + handler.assert_awaited_once() + + async def test_duplicate_payment_callback_is_cooled_down_by_exact_payload(self): + middleware = UpdateAntiFloodMiddleware( + _settings(), + default_rule=RateLimitRule(window_seconds=60, max_events=100), + ) + handler = AsyncMock(return_value="ok") + first = _callback_update(data="pay_fk:1:100:subscription") + duplicate = _callback_update(data="pay_fk:1:100:subscription") + different_payment = _callback_update(data="pay_fk:3:250:subscription") + + with patch("bot.middlewares.update_antiflood.get_redis", AsyncMock(return_value=None)): + self.assertEqual(await middleware(handler, first, {}), "ok") + self.assertIsNone(await middleware(handler, duplicate, {})) + self.assertEqual(await middleware(handler, different_payment, {}), "ok") + + self.assertEqual(handler.await_count, 2) + duplicate.callback_query.answer.assert_awaited_once() + + async def test_duplicate_trial_callback_is_cooled_down(self): + middleware = UpdateAntiFloodMiddleware( + _settings(), + default_rule=RateLimitRule(window_seconds=60, max_events=100), + ) + handler = AsyncMock(return_value="ok") + first = _callback_update(data="main_action:request_trial") + duplicate = _callback_update(data="main_action:request_trial") + + with patch("bot.middlewares.update_antiflood.get_redis", AsyncMock(return_value=None)): + self.assertEqual(await middleware(handler, first, {}), "ok") + self.assertIsNone(await middleware(handler, duplicate, {})) + + handler.assert_awaited_once() + duplicate.callback_query.answer.assert_awaited_once() + + +if __name__ == "__main__": + unittest.main()