ADR-0013 · Синхронный request-reply webhook для сторонних ботов
Статус: принято · 2026-07-17 (F.1 плана 08; гейтит play-api#104 (F.2)). Расширяет ADR-0009/ADR-0010.
Контекст. Poll-only serverless работает уже сегодня (cron-функция + GET /bot/games), но это второй сорт: на коротких контролях поллинг не успевает, впустую жжёт вызовы и держит латентность хода на уровне интервала таймера. Цель фазы F — первоклассный zero-infra бот: одна HTTP-функция без состояния, просыпающаяся только на свой ход. Экономика хостингов и AGPL-стартеры разобраны в 07 §5/§4. Ключевые ограничения со стороны play-api: single-writer GameRoom (никакого второго писателя), enforced per-turn deadline и часы как существующий механизм надёжности, единый Bot-API-словарь на wire.
Решение. Синхронный request-reply: когда наступает ход бота, play-api сам делает POST на зарегистрированный callback-URL; тело HTTP-ответа — это ход, который внутренний подписчик комнаты подаёт как обычную команду SubmitTurn (single-writer сохранён). Составные части:
- Stateless-конверт. Payload самодостаточен для чистой функции: словарь существующего wire — слитые
PublicGameState+ данныеDiceRolled(dfen, dice, clocks,legalMovesс тем же inline-капом; приnull— ссылка наGET /games/{id}/moves). Никакого нового протокольного словаря; эволюция только аддитивная. Ответ:200 {"moves":["e2e4",…]}(пустой список = осознанный пас). - Регистрация и верификация владения.
POST /bot/webhook {url}— только для зарегистрированных ботов (перк регистрации, как ротация и лэддер; anon мимо). Сервер минтит per-bot webhook-секрет (показывается один раз) и активирует URL только после handshake: сервер шлёт{type:"verification","nonce":…}, эндпоинт обязан вернуть200с эхом nonce. До активации игровые данные на URL не уходят.DELETE /bot/webhookснимает;GET /bot/webhook— статус. - Подпись и анти-SSRF (всё обязательно, не опции). Каждая доставка подписана
HMAC-SHA256(secret, timestamp + "." + body)в заголовках (X-DiceChess-Signature,X-DiceChess-Timestamp; окно replay ±5 мин). Только HTTPS. SSRF-guard: запрет непубличных адресов (RFC1918, loopback, link-local/169.254.169.254, ULA), резолв и пиннинг IP в момент отправки (анти-DNS-rebinding), редиректы не следуются (0), кап на размер ответа, rate-limit доставок на бота. - Надёжность = существующий per-turn deadline, доставка однократная. Таймаут ответа
min(конфиг ~10–30с, остаток бюджета хода); на таймаут/5xx/мусор — ничего: часы тикают, форфейт по клоку, комната не блокируется ни на миг. Ни retry, ни dead-letter в MVP: упавший webhook-бот проигрывает по времени ровно как poll-бот, переставший поллить; однократность держит диспетчер полностью stateless. - Диспетчер — отдельный компонент по образцу
IngestDeliverer(ограниченная конкурентность, таймауты, backoff-паттерны уже в кодовой базе); включение через envWEBHOOK_*в идиоме «absence disables». - Совместимость с лэддером и честностью. Webhook-бот — обычный registered-бот: может быть
on_ladder, сервер сам пейрит и толкает ходы — «спящая» функция идеальна для server-chosen пар. Клиентский сид (SubmitSeed) в MVP webhook-боты не подают — задокументированный fallbackGameRoom(участнико-привязанный сид) сохраняет provably-fair-схему без их участия.
sequenceDiagram participant Op as Владелец бота participant PA as play-api participant Fn as Бот (HTTP-функция) Note over Op,Fn: Регистрация (один раз) Op->>PA: POST /bot/webhook {url} (Bearer registered-бота) PA->>Fn: POST {type:"verification", nonce} Fn-->>PA: 200 {nonce} PA-->>Op: 201 {secret} — показан один раз Note over PA,Fn: Игровой цикл (каждый ход) PA->>Fn: POST {gameId, state+dice+legalMoves} + HMAC(ts.body) Fn->>Fn: проверить подпись · посчитать ход (cold start ок) Fn-->>PA: 200 {moves:[...]} PA->>PA: SubmitTurn через подписчика комнаты (single-writer) Note over PA,Fn: Нет ответа за min(бюджет, ~10–30с) → часы тикают, форфейт по клоку
Последствия.
- Подключение бота = один HTTPS-хендлер: считает ход из самодостаточного конверта и отвечает JSON’ом. Любые контролы времени (cold start 1–3 c против бюджета Fischer 300+3 — незаметен). Темплейты F.3 получают форму «HTTP-функция за 5 минут».
- Новая исходящая поверхность у play-api — потому SSRF-guard/подпись/верификация обязательны в самом MVP, а не «потом» (см. play-api#104, где это уже в Done-критериях).
- Однократная доставка — осознанная чувствительность к сетевым глюкам: цена — форфейт по клоку, выгода — нулевое состояние диспетчера и невозможность дублей
SubmitTurn. - Async-вариант (событие + отдельный
POST /moveс retry/health) — отдельная будущая задача для долгодумающих ботов и высокой конкурентности; конверт и подпись переиспользуются as-is.
Альтернативы (отклонены). Async событие + callback POST /move — два HTTP-звена, идемпотентность и состояние на стороне функции: ровно то трение, которое webhook должен убрать; остаётся как расширение. Long-poll GET — держит функцию горячей всё ожидание, т.е. платный простой, ломающий смысл serverless. Push по WebSocket/стриму — несовместим с FaaS-моделью исполнения. Очередь (MQTT/SQS…) — третья инфраструктура и учётные данные у обеих сторон против «zero-infra» цели.