Webhook
Webhook — основной режим доставки событий MAX для production. MAX выполняет
HTTPS POST на endpoint приложения, а aiomax2 проверяет secret, парсит Update
и передаёт его в Dispatcher.
Полный пример FastAPI
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import os
from fastapi import FastAPI
from aiomax2 import Bot, Dispatcher, Router
from aiomax2.filters import Command
from aiomax2.types import Message
WEBHOOK_PATH = "/webhook"
MAX_WEBHOOK_BASE_URL = os.environ["MAX_WEBHOOK_BASE_URL"].rstrip("/")
MAX_WEBHOOK_SECRET = os.environ["MAX_WEBHOOK_SECRET"]
WEBHOOK_URL = f"{MAX_WEBHOOK_BASE_URL}{WEBHOOK_PATH}"
bot = Bot(os.environ["MAX_BOT_TOKEN"])
dp = Dispatcher()
router = Router()
@router.message(Command("start"))
async def start(message: Message) -> None:
await message.answer("Webhook работает")
dp.include_router(router)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
try:
await bot.subscribe(
WEBHOOK_URL,
secret=MAX_WEBHOOK_SECRET,
update_types=dp.resolve_used_update_types(),
)
yield
finally:
await bot.close()
await dp.close()
app = FastAPI(lifespan=lifespan)
app.include_router(
dp.webhook_router(
WEBHOOK_PATH,
bot=bot,
secret=MAX_WEBHOOK_SECRET,
)
)
Переменные окружения:
export MAX_BOT_TOKEN="..."
export MAX_WEBHOOK_BASE_URL="https://bot.example.ru"
export MAX_WEBHOOK_SECRET="replace-with-random-secret"
WEBHOOK_PATH остаётся в коде и одновременно используется для локального
route и формирования публичного URL. Это исключает рассинхронизацию между
FastAPI и подпиской MAX.
Локальный path и публичный URL
webhook_router() получает только локальный path:
subscribe() получает полный публичный HTTPS URL:
Полный маршрут запроса выглядит так:
MAX
-> https://bot.example.ru/webhook
-> reverse proxy / Caddy / nginx
-> 127.0.0.1:8000/webhook
-> FastAPI
-> aiomax2 Dispatcher
secret имеет длину 5–256 символов и состоит из A-Z, a-z, 0-9, _ и
-. MAX передаёт его в заголовке X-Max-Bot-Api-Secret. Adapter сравнивает
значение в constant time и возвращает 403 при несовпадении.
Webhook и Long Polling нельзя использовать одновременно. Управлять подписками
можно через get_subscriptions(), subscribe() и unsubscribe().
HTTPS и reverse proxy
MAX принимает только HTTPS endpoint на порту 443 с сертификатом доверенного CA или сертификатом Минцифры. Самоподписанные сертификаты не поддерживаются; домен должен совпадать с CN/SAN, сервер обязан отдавать полную цепочку.
Минимальный Caddyfile:
Локальный Uvicorn запускается так:
Caddy принимает публичный запрос к https://bot.example.ru/webhook,
обслуживает TLS на порту 443 и проксирует тот же path /webhook в Uvicorn.
Uvicorn не обязан сам слушать порт 443 или обслуживать публичный TLS. Ограничьте
прямой доступ к локальному порту 8000 на уровне firewall.
Регистрация подписки в lifespan
Автоматический subscribe() в lifespan удобен для простого single-process
deployment. При запуске:
создаются четыре процесса, и каждый выполняет свой lifespan. Это не означает, что подписка MAX обязательно сломается, но в multi-worker production лучше регистрировать Webhook отдельным setup/deploy step или гарантировать, что registration выполняется ровно один раз.
Время ответа и семантика обработки
MAX ожидает строго 200 OK в течение 30 секунд. Любой другой status или
превышение timeout считается ошибкой доставки. MAX делает до 10 повторных
попыток с растущим интервалом и может удалить подписку, если за 8 часов не
получит успешный ответ.
Текущий FastAPI adapter сначала полностью выполняет handlers и только затем
возвращает 200 OK. Эта семантика предсказуема: успешный ответ означает, что
update разобран и передан pipeline. Не выполняйте долгие CPU-bound или внешние
blocking операции прямо в webhook handler. Выносите тяжёлую работу в
собственную очередь и завершайте handler раньше 30 секунд.
Background/queue-backed webhook processing входит в roadmap.
Завершение приложения
FastAPI lifespan должен закрыть оба ресурса:
Первый вызов закрывает принадлежащие библиотеке HTTP-сессии, второй — FSM
storage. Переданная пользователем aiohttp.ClientSession остаётся его
ответственностью.