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 }}"
|
||||
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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
|
||||
@@ -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` | Интервал фоновой синхронизации с панелью. |
|
||||
|
||||
@@ -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 администраторов.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Реферальная программа",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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