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:
- Only ONE process ever calls
getUpdates - Callback queries can be routed to arbitrary consumers
Decision
Gateway stays the sole Telegram poll owner. It exposes callback routing via Redis pub/sub:
- Prefix-based routing:
callback_query.datais checked against registered prefixes. Matching callbacks are published to a Redis channel. Non-matching callbacks follow the existing Inngest event path. - Registration: Redis hash
joelclaw:telegram:callback-routesmaps prefixes to Redis pub/sub channels. Services register on startup. - Restate channel rewrite:
TelegramChannel.startCallbackListener()subscribes to Redis instead of polling.send()stays as direct Bot API (no conflict — onlygetUpdatesis 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 eventConsequences
- 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