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 сохранён). Составные части:

  1. Stateless-конверт. Payload самодостаточен для чистой функции: словарь существующего wire — слитые PublicGameState + данные DiceRolled (dfen, dice, clocks, legalMoves с тем же inline-капом; при null — ссылка на GET /games/{id}/moves). Никакого нового протокольного словаря; эволюция только аддитивная. Ответ: 200 {"moves":["e2e4",…]} (пустой список = осознанный пас).
  2. Регистрация и верификация владения. POST /bot/webhook {url} — только для зарегистрированных ботов (перк регистрации, как ротация и лэддер; anon мимо). Сервер минтит per-bot webhook-секрет (показывается один раз) и активирует URL только после handshake: сервер шлёт {type:"verification","nonce":…}, эндпоинт обязан вернуть 200 с эхом nonce. До активации игровые данные на URL не уходят. DELETE /bot/webhook снимает; GET /bot/webhook — статус.
  3. Подпись и анти-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 доставок на бота.
  4. Надёжность = существующий per-turn deadline, доставка однократная. Таймаут ответа min(конфиг ~10–30с, остаток бюджета хода); на таймаут/5xx/мусор — ничего: часы тикают, форфейт по клоку, комната не блокируется ни на миг. Ни retry, ни dead-letter в MVP: упавший webhook-бот проигрывает по времени ровно как poll-бот, переставший поллить; однократность держит диспетчер полностью stateless.
  5. Диспетчер — отдельный компонент по образцу IngestDeliverer (ограниченная конкурентность, таймауты, backoff-паттерны уже в кодовой базе); включение через env WEBHOOK_* в идиоме «absence disables».
  6. Совместимость с лэддером и честностью. Webhook-бот — обычный registered-бот: может быть on_ladder, сервер сам пейрит и толкает ходы — «спящая» функция идеальна для server-chosen пар. Клиентский сид (SubmitSeed) в MVP webhook-боты не подают — задокументированный fallback GameRoom (участнико-привязанный сид) сохраняет 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» цели.

🔗 08-Reyting-botov-i-webhook-plan-vypolneniya · 07-Revyu-bot-platformy-2026-07-blokery-i-plan · ADR-0009-Bot-API-i-turniry · ADR-0010-Anonimnye-self-service-boty · 00-Zhurnal-resheniy-ADR