Merge pull request #28 from 3252a8/feature/telegram-flood-hardening
This commit is contained in:
@@ -96,7 +96,7 @@ jobs:
|
|||||||
github_owner="${{ github.repository_owner }}"
|
github_owner="${{ github.repository_owner }}"
|
||||||
echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT"
|
echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT"
|
||||||
dockerhub_owner="${github_owner,,}"
|
dockerhub_owner="${github_owner,,}"
|
||||||
if [ "${{ inputs.publish_dockerhub }}" = "true" ]; then
|
if [ "${{ inputs.push }}" = "true" ] && [ "${{ inputs.publish_dockerhub }}" = "true" ]; then
|
||||||
if [ -z "$DOCKERHUB_USERNAME" ]; then
|
if [ -z "$DOCKERHUB_USERNAME" ]; then
|
||||||
echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing"
|
echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
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
|
name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
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.db_session import DBSessionMiddleware
|
||||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||||
|
from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ def build_dispatcher(
|
|||||||
dp["i18n_instance"] = i18n_instance
|
dp["i18n_instance"] = i18n_instance
|
||||||
dp["async_session_factory"] = async_session_factory
|
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(DBSessionMiddleware(async_session_factory))
|
||||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||||
|
|||||||
@@ -702,6 +702,120 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||||
# ─── System ────────────────────────────────────────────────────
|
# ─── 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(
|
SettingField(
|
||||||
"TELEMETRY_ENABLED",
|
"TELEMETRY_ENABLED",
|
||||||
"bool",
|
"bool",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
result = await handler(event, data)
|
result = await handler(event, data)
|
||||||
|
|
||||||
|
if data.get("skip_action_log") or data.get("antiflood_dropped"):
|
||||||
|
return result
|
||||||
|
|
||||||
session: AsyncSession = data["session"]
|
session: AsyncSession = data["session"]
|
||||||
event_user: Optional[User] = data.get("event_from_user")
|
event_user: Optional[User] = data.get("event_from_user")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -232,6 +232,18 @@ class Settings(BaseSettings):
|
|||||||
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600)
|
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600)
|
||||||
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
|
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
|
||||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30)
|
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_NAME: str = Field(default="webhook-events")
|
||||||
WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4)
|
WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4)
|
||||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
|
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ proxy/Docker gateway и может отклонить валидный webhook.
|
|||||||
| `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES` | Дельта lifetime-трафика для более ранней записи. |
|
| `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES` | Дельта lifetime-трафика для более ранней записи. |
|
||||||
| `WEBAPP_RATE_LIMIT_TTL_SECONDS` | Окно Web App rate limit. |
|
| `WEBAPP_RATE_LIMIT_TTL_SECONDS` | Окно Web App rate limit. |
|
||||||
| `WEBAPP_RATE_LIMIT_MAX_REQUESTS` | Количество запросов в окне 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_NAME` | Redis queue для тяжелой обработки webhook. |
|
||||||
| `WEBHOOK_QUEUE_CONCURRENCY` | Количество worker consumers для webhook queue. |
|
| `WEBHOOK_QUEUE_CONCURRENCY` | Количество worker consumers для webhook queue. |
|
||||||
| `WORKER_PANEL_SYNC_INTERVAL_SECONDS` | Интервал фоновой синхронизации с панелью. |
|
| `WORKER_PANEL_SYNC_INTERVAL_SECONDS` | Интервал фоновой синхронизации с панелью. |
|
||||||
|
|||||||
@@ -15,6 +15,33 @@
|
|||||||
openssl rand -hex 32
|
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 администраторов.
|
- `ADMIN_IDS` задает Telegram ID администраторов.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
||||||
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
|
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
|
||||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
||||||
- пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
- пробный период, приветственный реферальный бонус, уведомления, логирование, Telegram антифлуд, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||||
|
|
||||||
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
||||||
|
|
||||||
|
|||||||
@@ -6050,6 +6050,243 @@
|
|||||||
"id": "system",
|
"id": "system",
|
||||||
"order": 12,
|
"order": 12,
|
||||||
"fields": [
|
"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",
|
"key": "TELEMETRY_ENABLED",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
|
|||||||
@@ -1179,7 +1179,32 @@
|
|||||||
"admin_settings_section_migrations": "Migrations",
|
"admin_settings_section_migrations": "Migrations",
|
||||||
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
"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_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_common": "Common",
|
||||||
|
"admin_settings_subsection_telegram_antiflood": "Telegram anti-flood",
|
||||||
"admin_settings_subsection_checkout": "Checkout",
|
"admin_settings_subsection_checkout": "Checkout",
|
||||||
"admin_settings_subsection_trial": "Trial",
|
"admin_settings_subsection_trial": "Trial",
|
||||||
"admin_settings_subsection_referral": "Referral program",
|
"admin_settings_subsection_referral": "Referral program",
|
||||||
|
|||||||
@@ -1179,7 +1179,32 @@
|
|||||||
"admin_settings_section_migrations": "Миграции",
|
"admin_settings_section_migrations": "Миграции",
|
||||||
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
||||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок, используемые версии и долю изменённых сборок. Отключение применяется без перезапуска.",
|
"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_common": "Общие",
|
||||||
|
"admin_settings_subsection_telegram_antiflood": "Telegram антифлуд",
|
||||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||||
"admin_settings_subsection_trial": "Пробный период",
|
"admin_settings_subsection_trial": "Пробный период",
|
||||||
"admin_settings_subsection_referral": "Реферальная программа",
|
"admin_settings_subsection_referral": "Реферальная программа",
|
||||||
|
|||||||
@@ -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",
|
"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 = (
|
REMNASHOP_MIGRATION_SETTINGS = (
|
||||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||||
"MIGRATION_REMNASHOP_PROMO_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
|
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():
|
def test_remnashop_migration_settings_i18n_keys_exist():
|
||||||
manifest = _manifest_by_key()
|
manifest = _manifest_by_key()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user