Merge pull request #28 from 3252a8/feature/telegram-flood-hardening

This commit is contained in:
3252a8
2026-06-10 17:24:12 +03:00
committed by GitHub
15 changed files with 1116 additions and 3 deletions
+2 -2
View File
@@ -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' }}
@@ -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())
@@ -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",
@@ -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")
+371
View File
@@ -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
),
),
}
+12
View File
@@ -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)
+12
View File
@@ -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` | Интервал фоновой синхронизации с панелью. |
+27
View File
@@ -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 администраторов.
+1 -1
View File
@@ -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` или коде.
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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": "Реферальная программа",
+39
View File
@@ -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()
@@ -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()
+197
View File
@@ -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()