ADR-0215proposed

Telegram Callback Routing via Gateway

Status: implementing
Date: 2026-03-05
Relates: ADR-0207 (Restate), ADR-0210 (Channel Intelligence), ADR-0038 (Gateway)

Context

The gateway owns the Telegram bot connection via grammy long-polling (getUpdates). Telegram enforces a single-consumer constraint — only one process may call getUpdates at a time per bot token. Violating this returns HTTP 409.

The Restate lab worker independently polled the same bot token for callback queries (button taps on approval workflows). This caused a permanent 409 loop that took the gateway offline for hours.

As we add more Restate workflows (deploy gate, feedback pipeline, etc.), each needing interactive Telegram buttons, we need a pattern where:

  1. Only ONE process ever calls getUpdates
  2. Callback queries can be routed to arbitrary consumers

Decision

Gateway stays the sole Telegram poll owner. It exposes callback routing via Redis pub/sub:

  1. Prefix-based routing: callback_query.data is checked against registered prefixes. Matching callbacks are published to a Redis channel. Non-matching callbacks follow the existing Inngest event path.
  2. Registration: Redis hash joelclaw:telegram:callback-routes maps prefixes to Redis pub/sub channels. Services register on startup.
  3. Restate channel rewrite: TelegramChannel.startCallbackListener() subscribes to Redis instead of polling. send() stays as direct Bot API (no conflict — only getUpdates is single-consumer).
Human taps button → Telegram → Gateway (sole getUpdates owner)
  → callback_data starts with "restate:" → Redis pub/sub → Restate worker
  → callback_data starts with "mcq:" → grammy next() middleware
  → anything else → Inngest event

Consequences

  • No new services or infrastructure
  • Restate workflows get reliable callback delivery without touching getUpdates
  • Future consumers (Discord bots, Slack apps) follow the same pattern
  • Gateway callback handler gains ~30 lines for prefix routing
  • Restate TelegramChannel drops 80 lines of polling code, gains 20 lines of Redis sub