Legan Studio
Все статьи
~ 5 мин чтения

Анти-флуд паттерны для Telegram-бота

Как защитить бота от флуда и спама: rate-limit на пользователя, token bucket, экспоненциальный backoff, теневой бан и обработка 429.

  • Telegram
  • rate-limit
  • безопасность
  • DevOps

Любой публичный бот рано или поздно сталкивается с флудом: один юзер шлёт 100 команд в секунду, скрипт-кидди дёргает API, конкурент запускает DDoS. Без защиты бот падает в 429 от Telegram, очередь забивается, легитимные юзеры не дожидаются ответа. Разберём набор anti-flood паттернов, которые внедряются за пару часов и решают проблему на годы.

Уровни защиты

Защита строится в три слоя:

  1. На уровне Telegram — лимит 30 мсг/сек суммарно и 1 мсг/сек в чат, нельзя обойти.
  2. На уровне сети — nginx rate-limit на webhook.
  3. На уровне приложения — per-user, per-action, exponential backoff.

Каждый слой ловит свой класс атак.

Лимит входящих апдейтов

В nginx — token bucket по IP:

limit_req_zone $binary_remote_addr zone=tg_in:10m rate=200r/s;

location /tg/webhook {
    limit_req zone=tg_in burst=400 nodelay;
    proxy_pass http://bot:8080;
}

Telegram присылает с фиксированных IP, реальный лимит 200 r/s — заведомо больше любых ожидаемых нагрузок. Если упёрлись — значит DDoS на webhook, добавляем CDN.

Per-user rate-limit в приложении

Самый частый случай — один юзер шлёт 50 сообщений за 5 секунд. Лимитим через token bucket в Redis:

import time
import redis.asyncio as redis

async def allow_action(user_id: int, action: str, rate: float = 1.0, burst: int = 5) -> bool:
    """Token bucket: rate токенов в секунду, burst — максимум."""
    key = f"rl:{user_id}:{action}"
    now = time.time()
    pipe = r.pipeline()
    pipe.hgetall(key)
    pipe.expire(key, 300)
    state, _ = await pipe.execute()
    last = float(state.get("last", now))
    tokens = float(state.get("tokens", burst))
    tokens = min(burst, tokens + (now - last) * rate)
    if tokens < 1:
        await r.hset(key, mapping={"last": now, "tokens": tokens})
        return False
    await r.hset(key, mapping={"last": now, "tokens": tokens - 1})
    return True

Использование:

@router.message()
async def on_message(message: Message):
    if not await allow_action(message.from_user.id, "msg", rate=1.0, burst=5):
        return  # тихо игнорируем
    await handle(message)

5 сообщений быстро (burst), потом 1 в секунду. Превышение — игнор, без ответа (чтобы не давать сигнал «бот видит»).

Per-action лимиты

Разные действия — разные лимиты:

ДействиеRateBurst
/start1/мин2
Обычное сообщение1/сек5
Команда поиска1/3 сек3
Запрос платежа1/мин1
Загрузка файла1/10 сек2

Команды, которые тратят ресурсы (LLM, генерация), лимитим жёстче.

Обработка 429 от Telegram

Если бот всё-таки превысил лимит на отправку, Telegram возвращает 429 с заголовком Retry-After. aiogram бросает TelegramRetryAfter:

from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
import asyncio

async def safe_send(bot, chat_id, text, **kwargs):
    for attempt in range(5):
        try:
            return await bot.send_message(chat_id, text, **kwargs)
        except TelegramRetryAfter as e:
            await asyncio.sleep(e.retry_after + 0.5)
        except TelegramAPIError as e:
            if "Too Many Requests" in str(e):
                await asyncio.sleep(2 ** attempt)
            else:
                raise
    raise RuntimeError("send failed after 5 attempts")

Уважайте retry_after — это сам Telegram сказал «жди столько-то секунд». Игнорировать = бан на час.

Очередь отправки

При массовых рассылках — отдельный воркер с пейсингом:

from asyncio import Queue, sleep

send_queue: Queue = Queue()

async def sender(bot):
    while True:
        chat_id, text, kwargs = await send_queue.get()
        try:
            await safe_send(bot, chat_id, text, **kwargs)
        finally:
            await sleep(1 / 25)  # 25 сообщений в секунду — запас от лимита 30

Один воркер на бота, всё через него. Гарантия: лимит Bot API не превысится никогда.

Теневой бан злостных нарушителей

Если юзер за минуту получил 50 отказов rate-limit, это либо скрипт, либо поломанный клиент. Теневой бан:

async def check_abuse(user_id: int):
    rejects = await r.incr(f"reject:{user_id}")
    await r.expire(f"reject:{user_id}", 3600)
    if rejects > 50:
        await r.set(f"shadow_ban:{user_id}", 1, ex=86400 * 7)


@router.message()
async def gate(message: Message):
    if await r.exists(f"shadow_ban:{message.from_user.id}"):
        return  # молча игнорируем сутки
    ...

Юзер видит, что бот не отвечает, но не получает явной блокировки — не понимает, что забанен, и не пытается обходить.

Защита от фишинговых атак

Боты-конкуренты могут массово кликать на inline-кнопки или callback'и:

@router.callback_query()
async def cb_gate(cb: CallbackQuery):
    if not await allow_action(cb.from_user.id, "cb", rate=2.0, burst=10):
        await cb.answer()  # пустой ack, чтобы UI не висел
        return
    ...

Особенно важно для дорогих callback'ов, которые лезут в БД или внешние API.

Honeypot для скриптов

Скрипты часто шлют команды наугад. Можно поймать их «приманкой»:

@router.message(F.text.in_({"/admin", "/shell", "/eval", "/sql"}))
async def honeypot(message: Message):
    await r.set(f"shadow_ban:{message.from_user.id}", 1, ex=86400 * 30)
    await message.answer("Команда не найдена")

Любой реальный юзер не отправит /eval — это автоматически боты, и можно банить на месяц.

Метрики

МетрикаЧто показывает
tg_429_totalсколько раз словили лимит Telegram
rl_reject_totalотказов rate-limit per user
shadow_ban_activeсколько в теневом бане
queue_depthглубина send-очереди
latency_p99задержка между received и replied

Дашборд в Grafana, алерты в админ-чат при tg_429_total > 10/мин или queue_depth > 1000.

Топ-5 ошибок

  1. Лимитят по chat_id вместо user_id — в группе один спамер блокирует чат.
  2. Возвращают «Превышен лимит» — даёт сигнал, что бот видит, и провоцирует обход.
  3. Не уважают retry_after — попадают в Telegram-бан на час.
  4. Один общий лимит на все действия — /start блокируется тем же лимитом, что callback.
  5. Не мониторят 429 — узнают о проблеме от жалоб пользователей.

Итого

Anti-flood — это набор простых паттернов: token bucket per-user-per-action, очередь отправки с пейсингом, обработка 429 с уважением retry_after, теневой бан злостных и honeypot для скриптов. Реализуется за 1–2 дня и спасает бот от 99% атак. Не делайте этого до прода — после первого вирусного поста бот ляжет, и вы будете чинить вживую.

Частые вопросы

Какой rate-limit ставить по умолчанию?

1 сообщение в секунду с burst 5 — для большинства ботов нормально. Юзер пишет вопрос, ждёт ответа, пишет следующий — комфортно. Burst 5 покрывает «быстро отвечает на серию вопросов». Для callback'ов можно либеральнее: 2/сек с burst 10.

Что делать с 429 от Bot API?

Уважать retry_after точно до секунды, повторять отправку. Если получили 429 трижды подряд — приостановите весь поток отправки на 30 секунд и алерт в мониторинг. Если 429 происходит регулярно — у вас архитектурная проблема, нужна очередь с пейсингом, не реактивная обработка.

Можно ли блокировать по IP?

Webhook принимает только от Telegram-IP, сами юзеры до webhook не доходят. Поэтому per-IP блок на уровне приложения бесполезен — всё прилетает с одних и тех же IP. Лимиты ставьте по user_id и chat_id. На webhook — по IP в nginx, но это для DDoS-защиты, не для контроля за пользователями.

Как обнаружить координированную атаку нескольких аккаунтов?

Складывайте сигналы: schwellrate-limit reject'ов растёт у 50+ юзеров одновременно, все аккаунты младше 7 дней, без аватара, у всех одинаковая последовательность команд. Скрипты палятся на повторяемости. Ставьте автоматические правила «новые аккаунты с теми же паттернами → теневой бан».

Что важнее: rate-limit или защита бизнес-логики?

Оба, но в разных слоях. Rate-limit ловит флуд, защита логики ловит абуз (например, попытка применить промокод 100 раз). Без rate-limit бот ляжет под флудом раньше, чем сработает бизнес-валидация. Без бизнес-валидации флудеру удастся в рамках лимита нанести ущерб (например, выкрутить 100 промокодов с burst).

Как тестировать anti-flood?

locust или k6 с параметризацией по user_id. Скрипт генерирует тысячу аккаунтов и шлёт каждым по 50 запросов. Метрики: % успешных, % отказов rate-limit, latency p99 у легитимных юзеров (которые шлют 1 сообщение/мин). Норма: легитимные не страдают, флудеры получают отказы или теневой бан.

Сохранять ли rate-limit между рестартами?

Да, в Redis. Если в памяти процесса — каждый рестарт обнуляет защиту, и флудер пользуется этим. Redis с TTL на ключе автоматически очищается, оверхед минимален: пара сотен байт на юзера, при 1 млн активных — ~200 МБ.