У Telegram‑бота может быть только один активный webhook. В одном из проектов при этом появились три независимых сценария: поддержка пользователей, управление небольшим магазином и внутренние административные команды. Склеивать их в один процесс не хотелось, заводить отдельного бота под каждый сервис — тоже.

Во время выбора архитектуры переезд панели с одного домена на другой неожиданно провёл бесплатный chaos test: Telegram продолжил отправлять updates на старый адрес, получил 301 Moved Permanently и перестал доставлять сообщения. В очереди зависло шесть updates, а со стороны пользователей бот просто замолчал.

В статье разберу диагностику этого инцидента и устройство небольшого router на FastAPI и Redis: с декларативными правилами, дедупликацией, надёжной очередью, retry и HMAC‑подписью внутренних запросов.

С чего всё началось

Один Telegram‑бот постепенно стал общей точкой входа для нескольких задач. Панель использовала его для поддержки, магазин — для управления товарами и заказами, внутренний сервис — для административных команд.

Первое очевидное решение — добавить все handlers в одно приложение. Оно работает, пока бот и бизнес‑логика действительно являются одним продуктом. Здесь сервисы имели разные зависимости, базы данных, жизненные циклы и права доступа. Изменение магазина не должно перезапускать поддержку, а обработчику обращений не нужен доступ к административным функциям.

Завести по боту на каждый сервис технически ещё проще. Но тогда пользователю нужно понимать, куда писать, а разработчику — управлять несколькими токенами, webhook и наборами команд. Для изолированных публичных продуктов это нормально, для одной системы с общей точкой входа — не всегда удобно.

Long polling проблему также не решал. Методы getUpdates и webhook взаимоисключающие, а несколько независимых polling‑процессов начали бы конкурировать за общую последовательность updates.

Оставался router: единственный публичный webhook принимает update, определяет получателей и пересылает данные во внутренние сервисы. Но «небольшой» не должен означать один httpx.post() прямо внутри endpoint. Такой proxy связывает время ответа Telegram со скоростью внутренних обработчиков. Если один сервис завис, наружный webhook тоже зависает. Если процесс умер между ответом Telegram и пересылкой, update потерян. Если Telegram повторил запрос, можно получить два заказа или два одинаковых обращения.

Поэтому появились следующие требования:

  • публичный endpoint должен быстро подтверждать приём;

  • один update может предназначаться нескольким сервисам;

  • повторная доставка одного update_id не должна создавать новые задания;

  • внутренние сервисы не должны знать token бота;

  • временные ошибки должны повторяться, постоянные — уходить в dead‑letter queue;

  • после рестарта незавершённая доставка должна восстанавливаться;

  • конфигурация маршрутов должна читаться без изучения Python‑кода.

Инцидент: почему бот молчал

Параллельно панель переезжала с panel-old.example на panel-new.example. Названия доменов здесь и далее заменены, но поведение сохранено. Старый адрес корректно отвечал браузеру постоянным редиректом на новый, поэтому визуально всё выглядело исправным. Бот при этом не отвечал.

В приложении не было входящих запросов и исключений: запросы до него вообще не доходили. Состояние прояснил метод getWebhookInfo:

{
  "url": "https://panel-old.example/integrations/telegram/webhook",
  "pending_update_count": 6,
  "last_error_message": "Wrong response from the webhook: 301 Moved Permanently",
  "allowed_updates": ["message", "callback_query"]
}

Браузерный переезд через 301 ещё не означает переезд webhook. Telegram повторяет доставку при неуспешном HTTP‑ответе, поэтому pending_update_count начал расти.

Исправление состояло не в дополнительной настройке reverse proxy, а в явной повторной регистрации webhook на каноническом URL:

curl -X POST "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://panel-new.example/integrations/telegram/webhook",
    "secret_token": "'"${TELEGRAM_WEBHOOK_SECRET}"'",
    "allowed_updates": ["message", "callback_query"],
    "drop_pending_updates": false
  }'

Здесь принципиально drop_pending_updates: false. Значение true удобно при тестовой настройке нового бота, но во время обычного переезда удалило бы уже накопленные updates.

После обновления URL очередь из шести событий дошла до приложения, pending_update_count стал равен нулю, а last_error_message исчез. После этого проверка getWebhookInfo стала частью процедуры переезда. HTTP‑проверок старого и нового сайта недостаточно: нужно проверять адрес, который хранится на стороне поставщика webhook.

Итоговая архитектура

Архитектура Telegram Webhook Router
Архитектура Telegram Webhook Router

Система состоит из трёх контейнеров: API, worker и Redis. Снаружи доступен только API. Redis находится во внутренней Docker‑сети, а worker сам инициирует запросы к целевым сервисам.

Telegram ──HTTPS──▶ API ──▶ очередь Redis ──▶ Worker ──HMAC──▶ сервисы
                      │          │               │
                   быстрый    dedup +         retry +
                     202      processing         DLQ

Путь update выглядит так:

  1. Telegram отправляет POST на /webhooks/telegram.

  2. API проверяет X-Telegram-Bot-Api-Secret-Token, размер тела и целочисленный update_id.

  3. Router выбирает все совпавшие маршруты.

  4. Redis одной транзакцией сохраняет ключ дедупликации и задания доставки.

  5. API сразу отвечает 202 Accepted.

  6. Worker забирает задание, подписывает исходное тело и отправляет его внутреннему сервису.

  7. Успешная доставка подтверждается, временная ошибка повторяется, исчерпавшая попытки попадает в DLQ.

Согласно Bot API, при ответе не из диапазона 2xx доставка считается неуспешной. Поэтому 202 подходит для быстрого подтверждения и точнее описывает состояние: router принял update в обработку, но внутренняя доставка ещё не завершилась.

Декларативная маршрутизация

Маршруты описываются в YAML. Например, административные команды конкретного пользователя направляются в магазин, сообщения и callbacks — в поддержку, а часть callback дополнительно копируется в аудит:

routes:
  - name: shop-admin
    target_url: http://shop-service:8080/telegram/updates
    signing_secret_env: SHOP_SIGNING_SECRET
    match:
      user_ids: [123456789]
      commands: [/admin, /products, /orders]

  - name: support
    target_url: http://support-service:8080/telegram/updates
    signing_secret_env: SUPPORT_SIGNING_SECRET
    match:
      update_types: [message, callback_query]

  - name: audit-callbacks
    target_url: http://audit-service:8080/telegram/callbacks
    signing_secret_env: AUDIT_SIGNING_SECRET
    match:
      update_types: [callback_query]
      callback_prefixes: ["order:", "ticket:"]

Поля внутри одного match объединяются по И, значения внутри поля — по ИЛИ. В первом маршруте одновременно должны совпасть user ID и команда. Один update может соответствовать нескольким маршрутам: callback order:42 уйдёт и в поддержку, и в аудит. Пустой match является catch‑all и получает все типы updates, разрешённые в setWebhook.

Сам matching оставлен обычными функциями, а не языком выражений:

def matches(rule: MatchRule, update: dict[str, Any]) -> bool:
    checks: list[bool] = []

    if rule.update_types:
        checks.append(update_type(update) in rule.update_types)
    if rule.user_ids:
        checks.append(actor_id(update) in rule.user_ids)
    if rule.commands:
        checks.append(command(update) in rule.commands)
    if rule.callback_prefixes:
        value = callback_data(update)
        checks.append(value is not None and value.startswith(rule.callback_prefixes))

    return all(checks) if checks else True

На текущем объёме это проще тестировать и труднее неправильно сконфигурировать, чем универсальный DSL с eval, приоритетами и вложенными булевыми выражениями.

Входной endpoint: сделать минимум и ответить

Telegram умеет передавать заданный при setWebhook секрет в заголовке X-Telegram-Bot-Api-Secret-Token. Сравнивать его лучше через secrets.compare_digest, а не обычным ==.

После проверки секрета endpoint ограничивает размер тела, разбирает JSON и извлекает update_id. Telegram описывает его как идентификатор, полезный для игнорирования повторов и восстановления порядка updates. Это естественный ключ идемпотентности на границе router.

Упрощённая основная часть endpoint выглядит так:

routes = select_routes(router_config.routes, update)
if not routes:
    return WebhookResult(status="ignored")

jobs = [
    DeliveryJob(
        route_name=route.name,
        target_url=str(route.target_url),
        signing_secret_env=route.signing_secret_env,
        update_id=update_id,
        update=update,
    )
    for route in routes
]

inserted = await queue.enqueue_once(update_id, jobs, dedup_ttl_seconds)
if not inserted:
    return WebhookResult(status="duplicate")

return WebhookResult(status="queued", deliveries=len(jobs))

Endpoint не вызывает внутренние сервисы и не выполняет бизнес‑логику. Его задача заканчивается после надёжной постановки заданий в очередь.

Дедупликация и очередь Redis

Простой SET NX, а затем отдельный LPUSH оставляет окно отказа. Процесс может записать ключ дедупликации и умереть до добавления задания. Повторный запрос Telegram уже будет считаться дублем, хотя доставлять нечего.

Поэтому проверка ключа, его создание и постановка всех jobs выполняются одной optimistic transaction через WATCH/MULTI/EXEC:

async with redis.pipeline(transaction=True) as pipe:
    try:
        await pipe.watch(dedup_key)
        if await pipe.exists(dedup_key):
            await pipe.reset()
            return False

        pipe.multi()
        pipe.set(dedup_key, "1", ex=dedup_ttl_seconds)
        pipe.lpush(queue_name, *payloads)
        await pipe.execute()
    except WatchError:
        return False

TTL ключа равен 24 часам. Telegram хранит недоставленные updates не дольше суток, а Redis не приходится бесконечно помнить старые идентификаторы.

Для получения задания используется не BLPOP, а BRPOPLPUSH. Операция атомарно переносит payload из основной очереди в processing:

item = await redis.brpoplpush(
    queue_name,
    processing_queue_name,
    timeout=5,
)

После успешного HTTP‑запроса worker удаляет конкретное задание из processing через LREM. При ошибке оно возвращается в основную очередь с увеличенным attempt. Если worker завершился между получением и подтверждением, payload останется в processing и будет возвращён в очередь при следующем запуске.

В Docker Compose для Redis включён AOF и запрещено вытеснение ключей при достижении лимита памяти:

command: >-
  redis-server
  --appendonly yes
  --maxmemory 128mb
  --maxmemory-policy noeviction

Один Redis‑контейнер не становится от этого высокодоступным брокером, но обычный рестарт процесса не обнуляет очередь.

Почему внутренним сервисам не нужен bot token

Для каждой внутренней доставки маршрут использует отдельный signing_secret. Worker сериализует update без пробелов и подписывает байты вместе с timestamp:

def sign_payload(secret: str, timestamp: str, body: bytes) -> str:
    message = timestamp.encode() + b"." + body
    digest = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return "v1=" + digest

В запрос добавляются заголовки:

X-Webhook-Timestamp: 1788088524
X-Webhook-Signature: v1=<hex digest>
X-Telegram-Update-Id: 4242
X-Webhook-Route: shop-admin

Получатель проверяет подпись по исходному телу до разбора JSON и отклоняет слишком старый timestamp. Пятиминутное ограничение не исключает replay полностью, но ограничивает его окно. Сравнение digest снова выполняется constant‑time функцией.

Даже при правильной подписи consumer должен быть идемпотентным по X-Telegram-Update-Id. Возможен сценарий: сервис успел создать заказ и ответил 204, но соединение оборвалось до того, как worker получил ответ. Повторная доставка корректна, повторное создание заказа — нет.

Retry, DLQ и ещё раз редиректы

Worker не следует редиректам:

httpx.AsyncClient(
    timeout=request_timeout,
    follow_redirects=False,
)

Для внутренних запросов автоматический redirect скрывает неправильный URL, а подписанный запрос может уйти не тому получателю, который указан в конфигурации.

Сетевые ошибки и ответы не из диапазона 2xx повторяются с exponential backoff и небольшим jitter. После исчерпания max_attempts задание вместе с последней ошибкой попадает в Redis‑список twr:dead-letter.

DLQ здесь намеренно простая: просмотреть её можно через redis-cli, вернуть исправленное задание — вручную. Автоматический replay и интерфейс администратора появились бы раньше реальной потребности и увеличили бы объём проекта.

Что проверяют тесты

В первой версии 18 тестов. Они проверяют:

  • извлечение команды с суффиксом бота;

  • AND‑семантику matcher;

  • fan‑out в несколько маршрутов;

  • callback prefixes;

  • входной секрет и дедупликацию API;

  • HMAC‑подпись;

  • запрет редиректов;

  • жизненный цикл задания в Redis‑compatible runtime.

В Docker smoke‑test API, worker и Redis запускались как три контейнера. Первый update получил:

{"status":"queued","deliveries":1}

Повтор с тем же update_id:

{"status":"duplicate","deliveries":0}

Mock‑сервис получил ровно один POST с ожидаемым route, update ID и проверяемой HMAC‑подписью. Ruff, Pytest и сборка Docker image дополнительно запускаются в GitHub Actions.

Честные ограничения v0.1.0

Версия рассчитана на одного бота и один worker. Механизм восстановления переносит все задания из processing обратно при старте, поэтому запуск нескольких workers потребовал бы leases либо Redis Streams с consumer groups. Делать вид, что текущая list‑based очередь уже поддерживает горизонтальное масштабирование, было бы неправильно.

Также здесь нет веб‑панели, горячей перезагрузки YAML и произвольного языка условий. Конфигурация читается при старте, изменения применяются перезапуском контейнера. Для нескольких маршрутов это предсказуемое поведение и приемлемая цена простоты.

Если нагрузка и число consumers вырастут, логичный следующий шаг — Redis Streams, RabbitMQ или другой брокер с явными acknowledgements и отдельным retry scheduling. Начинать с них для трёх обработчиков означало бы проектировать систему под воображаемую нагрузку.

Что я вынес из этой истории

При переезде webhook нельзя полагаться на HTTP‑редирект, даже если он подходит браузерам и поисковикам. Нужно изменить callback URL у внешнего сервиса и проверить его собственный статусный endpoint. Для Telegram источником правды является getWebhookInfo, а pending_update_count и last_error_message имеет смысл мониторить.

Router при этом не обязан становиться очередным фреймворком. Небольшой API, понятные правила и надёжная граница доставки уже позволяют сохранить сервисы независимыми. Сложность стоит добавлять в местах реальных отказов: атомарная дедупликация, processing queue, подпись, retry и DLQ. Универсальный DSL и dashboard могут подождать.

Репозиторий с полной реализацией и тестами.