ADR-0031: Direct messenger integration (no middleware); Discord Gateway needs a single-owner lease

Status

Accepted (pending Phase 1 implementation)

Tags

digital-worker, integration, telegram, discord, whatsapp, multi-replica, lease, chat

Decision

The Digital Worker integrates directly with each messenger’s own bot API — WhatsApp (Meta Cloud API), Telegram (Bot API), Discord (Bot Gateway) — with no OpenClaw/Hermes middleware and no Bun bridge. Each provider adapter is a thin Go-owned boundary that normalizes inbound events into one ChatEvent contract and performs outbound sends via a ChatSender port. Ingest is not uniform, and that affects multi-replica safety:
  • WhatsApp & Telegram ingest via stateless webhooks — any replica can receive; the (provider, external_message_id) idempotency key dedups retries.
  • Discord requires a persistent Gateway WebSocket for channel-message events (its webhooks cover only slash-command interactions). Exactly one replica owns the Discord Gateway connection via a discord-gateway-owner lease (the lease/advisory-lock pattern of ADR-0023); on holder death another replica takes over using Discord RESUME (session_id + seq).
Outbound send is stateless REST for all three providers (any replica). MVP providers are WhatsApp, Telegram, Discord; Slack is deferred.

Why

The original spec hedged on routing chat through OpenClaw/Hermes middleware. Going direct removes an external dependency, an extra network hop, duplicated identity/audit concerns, and the uncertainty of “which transport will the middleware expose first.” It is simpler and fully under our control — and the CRM module’s existing Meta WhatsApp client (inbound webhook + outbound Graph send) already proves direct provider integration works here. Recording the rejection matters so nobody re-proposes a middleware layer in six months. The surprising part worth recording is Discord. In an otherwise stateless, horizontally-scaled core, Discord alone needs a singleton connection with leader-election — a future reader will wonder why one provider gets special treatment. The reason: Discord has no webhook path for observing channel messages (only the Gateway delivers MESSAGE_CREATE / MESSAGE_UPDATE), and it actively rejects multiple Gateway sessions per bot token. So a per-replica connection would produce N× duplicate ingest and hit session limits. The lease confines the statefulness to Discord ingest ownership; everything downstream (triage, extraction, send) stays stateless and multi-replica. Rejected alternatives:
  • OpenClaw/Hermes middleware layer. External dependency, extra hop, duplicated identity/audit, uncertain transport. Rejected — direct is simpler and owned.
  • Bun/ClawUI-owned worker. Duplicates org identity, permissions, audit, and AI billing concerns. Rejected for the product core.
  • Per-replica Discord Gateway connections. N× duplicate ingest; Discord session limits / forced disconnects. Rejected.
  • Pinned single-instance Discord ingest (deployment scaled to 1). Simpler than leader-election but a single point of failure until restarted. Rejected in favor of the lease.

Consequences

  • A Discord failover gap that exceeds the RESUME window loses those Gateway messages — Discord offers no full backfill (logged; the human is still in the channel).
  • The lease adds leader-election complexity, but scoped narrowly to Discord ingest; WhatsApp/Telegram ingest and all outbound send remain stateless.
  • Each provider needs its own bot credentials, stored encrypted in dw_worker_credentials (AES-256-GCM, per ADR’s reuse of the BYOK KeyCrypto primitive).

Rules for agents

  • Do not introduce an OpenClaw/Hermes/Bun middleware hop for chat transport; adapters are Go-owned and direct.
  • Discord ingest MUST run under a single-owner lease with RESUME-based failover; never open the Gateway from every replica.
  • WhatsApp/Telegram ingest MUST be stateless webhooks deduped by the idempotency key; outbound send is stateless REST for all providers.