# Discord Messaging Bridge

## Decision

A Discord bridge is a `MessagingBridgeRecord` with `kind: "discord"` and a per-bridge encrypted bot token. The runtime talks to `discord.com/api/v10` directly over `fetch` — no SDK dependency. Outbound (`POST /api/messaging/:id/send`) calls Discord's channel-messages endpoint; inbound is a REST poll over each delivery target's channel history. Bot tokens live in the per-instance secret store under the `messaging.<bridgeId>` namespace and never appear on the bridge record.

Alongside the REST poll loop the runtime opens a **Gateway WebSocket** to `gateway.discord.gg` (v10, JSON) that serves two roles. First, presence: the connection flips the bot's "Online" badge in Discord's UI so the operator can tell at a glance whether the runtime is alive. Second, push-driven poll wake: the connection identifies with `intents: GUILD_MESSAGES | DIRECT_MESSAGES` (4608) so Discord pushes a `MESSAGE_CREATE` event the instant a message lands in any channel/DM the bot can see, and the poller's wake controller collapses the next REST-poll sleep to ~0ms. The `MESSAGE_CONTENT` privileged intent is deliberately NOT requested — event content arrives empty, the REST poll fills it in. REST polling stays the source of truth for content, watermark advancement, dedupe, and pagination; the gateway is a pure-latency optimization, so a missed wake (gateway flap, message arriving mid-poll, callback throw) just degrades to the periodic-tick baseline and no messages get lost. A Discord SDK (`discord.js`) and webhooks are still rejected — the former adds a runtime dependency tree the single ~250-line client doesn't need, the latter adds a public-internet surface the local-first Gini shape doesn't need.

## Context

This ADR follows `telegram-bridge.md`. Gini's messaging surface already modeled `MessagingBridgeRecord`, `MessagingMessageRecord`, per-bridge encrypted secrets, and the HTTP endpoints on `/api/messaging/*`. The Telegram bridge proved the per-kind dispatch pattern in `src/integrations/messaging.ts` and the per-chat persistent `ChatSessionRecord` shape. The Discord bridge mirrors that shape — adding a second messaging surface without re-litigating the trust boundary, the secret-store contract, or the chat-task routing path.

Discord differs from Telegram in two material ways. First, Discord exposes no long-poll equivalent of `getUpdates`: a runtime that doesn't want a persistent gateway WebSocket has to use REST history (`GET /channels/{id}/messages?after=<snowflake>`) on a short cadence. Second, Discord's REST history returns the channel's stored backlog regardless of when the bot joined — so a fresh bridge attaching to an active channel would dump the visible history into the agent on first start unless the watermark is seeded before the first non-empty poll.

## Required Now

- `POST /api/messaging` with `kind: "discord"` requires a `botToken` field. The token is validated as header-safe printable ASCII (no whitespace, no control characters) before storage; without that gate a control-char token would be rejected at fetch time and the underlying error string would echo the full `Authorization: Bot <token>` value into the persisted `bridge.message`. The token is then written through `writeSecret(instance, "messaging.<bridgeId>", "bot-token", value)` and immediately discarded from memory.
- Discord onboarding goes through the CLI (`gini messaging add <name> discord --bot-token <token> <channel-id>...`) or the settings page's Add Discord dialog. The in-chat `request_messaging_bridge` tool (see [telegram-bridge.md](telegram-bridge.md) and [chat-block-protocol.md](chat-block-protocol.md)) deliberately does NOT advertise `kind: "discord"`: the chat card collects only name + bot token, but a Discord bridge needs at least one `deliveryTargets` channel id, and surfacing the kind in chat without a channel-id input would always produce a server-side `ok:false` rejection. The dispatcher additionally refuses `kind: "discord"` synchronously with a points-to-settings error so a schema-violating model can't mint a dead-end approval card; the `/connect` handler retains the `deliveryTargets`-required validation as belt-and-suspenders. When the chat card grows a channel-IDs textarea, drop the dispatcher refusal, widen the tool's `kind` enum to include `"discord"`, and unhide the matching branch in `BlockApprovalRequested.tsx`.
- `POST /api/messaging/:id/health` performs a real `getMe()` round-trip when the bridge is Discord-kind. The bot's `botUsername`, `botId`, and (when present) `globalName` land on `bridge.metadata`; failures mark `bridge.status = "error"` with the API description as `bridge.message` (auth-header residue is scrubbed before persisting via `sanitizeBridgeError`). The mutate that lands the result merges into the live `metadata` instead of overwriting from a pre-await snapshot — concurrent poller writes (per-channel watermarks) must survive a health check.
- `POST /api/messaging/:id/send` dispatches the Discord channel-messages endpoint for Discord-kind bridges. The `target` field is the channel snowflake. Content is truncated client-side at Discord's 2000-character cap before the network call. Photo sends are rejected with a clear error today — multipart attachments are tracked as a follow-up that mirrors the Telegram photo path.
- `POST /api/messaging/:id/disable` deletes every encrypted file under `messaging.<bridgeId>` and clears `bridge.secretRefs`. Same shape as Telegram — the bridge flips to `"disabled"` before the secret cleanup so a partial failure cannot leave an active token on disk under a configured bridge.
- The gateway runs a Discord poller supervisor that reconciles per-bridge REST-poll loops against state every `GINI_MESSAGING_RECONCILE_MS` milliseconds (5s default; the legacy `GINI_TELEGRAM_RECONCILE_MS` env var is honored for compatibility). Each loop polls every entry in `bridge.deliveryTargets` on a short cadence and advances the watermark on `bridge.metadata.lastInboundExternalIds[channelId]` after each handled message.
- Per-tick pagination uses `after` for the first page and `before` for catch-up. Discord's `?after=X&limit=N` returns the NEWEST N messages above the cursor (sorted newest-first), so a single `after` query can't catch a burst of more than N messages between polls. The poller fires the first page with `after=watermark`, then walks backwards with `before=<oldest of previous page>` until a partial batch lands or the loop hits `MAX_PAGES_PER_TICK = 10` pages (500 messages). When the cap fires the poller emits a `pagination_cap_fired` log row and still processes the collected newest window — messages older than the oldest-collected snowflake in that tick are dropped. A sustained flood (>500 msgs/3s on one channel) is the only realistic trigger; a future change can layer a backfill secondary cursor to recover the dropped range without re-processing the collected window.
- First-contact seeding: on the first poll of each (bridge, channel) pair the poller pins the watermark to a synthetic snowflake derived from wall-clock NOW minus a 5s safety margin (`((Date.now() - DISCORD_EPOCH - 5000) << 22)`). Any historical message in the channel is older than that and stays uncoded as backfill; any real-time message that arrives during this very fetch is strictly newer than the seed and routes on the next poll. The earlier "seed to newest observed snowflake" path had a mid-fetch race where a user's just-sent message could be included in the first response, pinned as the seed, and silently dropped. If the channel is empty on first poll, the watermark is pinned to the sentinel `"0"` — strictly less than every real Discord snowflake — so a user typing between the empty poll and the next poll has their message routed instead of consumed by the seeding branch.
- Snowflake compare uses `BigInt` for both message ordering and watermark advancement. Real Discord snowflakes are uniformly 19 digits today, so lexicographic compare would happen to work; the `BigInt` path is the correct primitive and also handles the length-1 `"0"` sentinel cleanly.
- Bot-authored inbound messages advance the watermark without spawning a task — the bot's own reply must not trigger itself. Attachment-only / empty-content messages take the same path (acknowledged via watermark, not routed).
- Each Discord channel is bound to a persistent `ChatSessionRecord` tagged with `source: { kind: "discord", bridgeId, channelId, target }`. `receiveMessagingInput` looks the session up (or creates it on first contact) and submits the user turn via `submitChatMessage` — the same path the web chat uses — so the agent sees prior turns and the chat-task loop runs with full session history.
- When the chat-task settles, the poller's per-message worker calls `syncChatTaskResult` to land the assistant message in the chat session and dispatches the assistant text back via `sendMessagingOutput`. `[SILENT]` task summaries suppress the mirror.
- While an inbound-Discord task is non-terminal, the poller fires `POST /channels/:id/typing` on a ~7-second cadence (Discord chat actions auto-clear after ~10 seconds). The pulse runs concurrent with the reply-mirror's terminal-wait, on a per-call child `AbortController` derived from the supervisor signal: the mirror always calls `typingController.abort()` and `await typingDone` in a `finally` block before returning, so the pulse cannot outlive the mirror even when `awaitTerminalTask` returns a non-terminal status at its 10-minute cap. Without the child controller, a stuck task whose typing kept succeeding would pin `await typingDone` forever and the `reply_skip_non_terminal` log line would never fire. The reply mirror is decoupled from the typing pulse — a transient typing error never drops the assistant's response — and the per-call abort listener on the supervisor signal is removed on every exit path so long-lived sessions cannot accumulate subscribers.
- Outbound `sendMessage` calls pin `allowed_mentions: { parse: [] }` so an agent-emitted `@everyone` / `@here` / role mention inside a summary cannot notify the channel. A future mention-aware send path can lift this restriction explicitly when the runtime grows one.
- When the loop exits for any reason (SIGTERM, bridge disable, status flip, missing-secret error), the supervisor's `startLoop` `.finally` calls `controller.abort()` so any detached typing pulse + reply-mirror workers observe abort and unwind — not just on `stopAll`.
- A missing or unreadable secret file flips the bridge to `status: "error"` and exits the loop. Without this, the supervisor reconcile would keep restarting the loop on every tick because `shouldRun` only checks for the secret-ref entry on the record, not the file's presence.
- SIGTERM aborts every active poll via `AbortController` so shutdown does not wait out the REST-poll cadence.
- The Discord HTTP client (`src/integrations/discord.ts`) is mockable via an injected `fetch`; the messaging module exposes `setMessagingDeps` (Telegram already used it) with a Discord-specific `discordClientFactory` so tests can substitute a stub `DiscordClient`. Production callers leave both unset.
- Gateway client (`src/integrations/discord-gateway.ts`): a per-bridge WebSocket to `wss://gateway.discord.gg/?v=10&encoding=json`. The runLoop spawns it once a valid bot token is read, rotates it only when the token changes, and tears it down in a `finally` block when the loop exits for any reason (SIGTERM, bridge disable, status flip, missing-secret error). IDENTIFY requests `GUILD_MESSAGES | DIRECT_MESSAGES` (4608) so Discord pushes a `MESSAGE_CREATE` event the instant a message lands. The `MESSAGE_CONTENT` privileged intent is NOT requested — event content arrives empty, REST polling fills it in. The presence object sets `status: online` with a "gini agent" custom activity line. Heartbeats are scheduled off the HELLO interval (~41s in production). Server-initiated closes (op 7 RECONNECT, op 9 INVALID_SESSION, or a transport-level drop) trigger an exponential-backoff reconnect (1s..30s), so a flapping or rotated Gateway endpoint can't leave the bot grey-dotted for the rest of the runtime's lifetime. The poller supervisor accepts a `gatewayConnector` test seam so unit tests don't spawn live sockets.
- Gateway-as-push-notification: every `MESSAGE_CREATE` dispatch invokes the poller's `onMessageCreate` callback, which aborts a per-iteration wake controller iff the event's `channel_id` is in `bridge.deliveryTargets`. The poll-tick sleep uses `sleepUnlessAbortedOrWoken`, so a wake collapses the next sleep to ~0ms — typical inbound latency drops from 0-3s (poll cadence) to round-trip + a single REST fetch. REST polling stays the source of truth for content, watermark advancement, dedupe, and pagination, so a missed wake (gateway flap, message arriving mid-poll, etc.) just degrades to the 3s baseline — no messages get lost. Cross-bridge channels and bot-authored echoes are filtered by the same delivery-targets check so an unrelated channel's traffic never burns a wake.

## Trust Boundary

- The bot token is a write-only field on the create payload. It is encrypted at rest and never re-emitted — neither on the bridge record, nor in audit evidence, nor on `MessagingMessageRecord`. A bridge owner has no API to read it back; they re-supply it by recreating the bridge.
- The poller calls `receiveMessagingInput` which submits a chat-task. Task-level approval (per ADR `approval-execution-abort.md`) and the active-agent toolset filters (per `agents-replace-profiles.md`) apply unchanged. The bridge does not bypass them. The reply-mirror dispatches the assistant message back via `sendMessagingOutput`, which honors the messaging-target filter — an agent restricted from a target cannot use the bridge to escape that restriction.
- The Discord bot's "Message Content Intent" must be enabled in the developer portal for the REST history to include user message content (Discord otherwise returns empty `content` for non-mention user messages to unverified bots). This is a manual one-time toggle per app; the runtime documents it but cannot toggle it automatically.
- Channel-as-auth: every non-bot user who can post in a configured `deliveryTargets` channel can submit a chat-task. The bridge does not add a per-user allowlist — channel membership IS the access boundary. A per-user allowlist on the bridge metadata would be a plausible follow-up if a bridge owner needs finer-grained gating.
- Telegram's `allowedChatIds` + per-DM verification-code path (see `telegram-bridge.md`) does NOT apply to Discord. Telegram bots are addressable by anyone who finds the `@username`; Discord channel membership is gated by the server's own permissions, so the bridge owner controls who can reach the bot by adjusting the channel's role permissions on the Discord side. If a per-user allowlist becomes useful here it would live on `bridge.metadata.allowedUserIds` mirroring the Telegram shape, but the current configuration intentionally trusts the channel ACL.

## Open Questions

- Per-channel allowlist. Today every channel listed in `bridge.deliveryTargets` can submit tasks. A channel-id allowlist on the bridge would let a user restrict the bot to a subset; the data shape already supports it.
- Attachments. Photos, videos, and files are not surfaced today — empty-content messages are acknowledged via watermark but not routed. Each follows the same pattern as the Telegram photo path (download to `<instanceRoot>/inbound/<bridgeId>/`, prefix the task input with a header pointing at the saved file) but carries its own mime / size semantics worth handling in a focused follow-up.
- Embeds and rich messages. Outbound dispatch sends `content` only today; Discord supports embeds, components, allowed-mentions controls, and reply references. Each is a future option on the send payload.
- Gateway-driven inbound. A future bridge variant could use the Discord gateway WebSocket for lower-latency inbound. The supervisor + reply-mirror shape would still apply; only the transport changes. The current REST-poll path is cheaper to operate and matches the local-first runtime shape.
- Outbound message chunking. Discord caps `content` at 2000 characters; we truncate at the client today. A future change could chunk long assistant summaries on paragraph or sentence boundaries (and possibly attach the long form as a file).

## Verification

- `bun test src/integrations/discord.test.ts` exercises the HTTP client (`getMe`, `sendMessage`, `triggerTypingIndicator`, `fetchChannelMessages`, content truncation, error paths) against an injected `fetch`.
- `bun test src/integrations/messaging.test.ts` covers the Discord branches in the dispatcher (add/health/send happy + sad paths, identity-shape variant, secret cleanup on disable, target validation).
- `bun test src/integrations/discord-poller.test.ts` exercises the supervisor's start/stop reconciliation, first-contact seeding (empty channel + non-empty channel), inbound roundtrip with typing pulse + reply mirror, skip-bot-message watermark advancement, and the runLoop self-exit path.
