Skip to content

feat(discord): distributed Discord app — Phase 1 (Stages 1–4) - #492

Draft
lbangalosbt wants to merge 14 commits into
work/multi-tenancy-phase4from
laasya/discord-distributed/app
Draft

lbangalosbt wants to merge 14 commits into
work/multi-tenancy-phase4from
laasya/discord-distributed/app

Conversation

@lbangalosbt

@lbangalosbt lbangalosbt commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Distributed Discord app — Phase 1

One Discord application we register and distribute: a customer clicks Add to
Server
and never sees a token. The counterpart to the distributed Slack app.
Full design: docs/old/bridges/DISCORD_DISTRIBUTED_APP.md.

Targets work/multi-tenancy-phase4 (the messaging-install layer it builds on),
not main. Draft until 435 merges, then retarget + rebase onto main.

Key decisions — and why

  • One shared Gateway connection, not one per tenant. Discord issues one bot
    token per app and has no API to create an app per customer, so a single
    connection multiplexes every tenant's guilds and each event is routed to a
    tenant by its guild_id. It's owned by the single switch-core pod (already a
    forced singleton), so there's no leader election to arrange.

  • Installs carry no token. Adding the bot to a guild grants no per-guild
    credential — the bot authenticates with the one deployment-level token. So
    messaging_installs.encrypted_bot_token is nullable, a Discord install stores
    nothing, and disconnect revokes nothing. Slack stays strict (its connection
    config still requires a token).

  • Switch enforces all isolation, because a shared, long-lived, multi-tenant
    socket is the design's central hazard. Every event resolves guild_id → tenant
    fresh (RLS-scoped re-read), dispatches under no_tenant(), and binds
    per-room — no tenant is ever cached on the connection. Four guards, each with a
    test: G1 per-event scoping · G2 identity keyed by (tenant, Discord user
    id) · G3 unknown guild dropped (fails closed) · G4 DMs dropped, no DM
    intent.

  • Mention-only by default. message_content and members are privileged
    intents that close the connection past ~100 guilds unless verified, so both are
    gated behind flags, default off.

  • The per-guild bridge is a normal, inert CollaborationAdapter. Under
    event_delivery: shared it opens no socket of its own and routes through the
    shared connection — reusing all existing per-guild logic (posting, roles,
    slash, room map) unchanged, so get_adapter, moderation and the operator list
    keep working.

Shape

Ordered, individually reviewable commits: refactor (extract DiscordConnection)
1 tokenless install → 2 config + installer → 3 inert bridge → 4
shared connection + per-event routing + global slash + removal + guards.

Testing

Full suite green (3472 unit + 11 integration). Validated live against real
Discord
: install → tokenless record + inert bridge; @mention → agent reply;
two guilds in two tenants over the one connection with zero cross-talk (same
Discord user → two tenant-scoped puppets); DMs not delivered; Slack untouched.

Follow-ups (out of scope)

  • Eager attach: an inert bridge provisions agent roles at start before its
    first message injects the shared connection — fails loud and self-heals; worth
    attaching eagerly.
  • Phase 2 (mostly shared code): self-serve install (installing is
    admin-gated today), callback → redirect, and the operator list showing a
    shared-connection install's status/revoke.

🤖 Generated with Claude Code

lbangalosbt and others added 14 commits September 16, 2026 08:44
…the adapter

Separate the Gateway socket — the discord.Client, the command tree, readiness,
slash-command sync, reconnection and the bot's own user id — from the adapter's
per-guild message handling, into a new DiscordConnection the adapter composes.
The socket is per bot token where the adapter is per guild, so the two have
genuinely different lifetimes; this is the first step toward a single shared,
multi-tenant connection. Behavior-preserving: the self-registered single-guild
bridge connects, syncs guild-scoped commands and handles messages exactly as
before. Intents are now built by the adapter and handed to the connection as a
constructor argument — the seam a shared connection will use to request a
different set. Tests that injected a fake client or bot id now do so on
adapter._connection; no behavioral assertion changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ction

The connection now dispatches each inbound message to a handler registered
for its guild id (via register_message_handler), and a guild-less direct
message to an optional DM handler (set_dm_handler) — instead of the adapter
filtering every event against its one guild. Handlers are looked up live per
message, so a handler registered after connect() still receives events. This
is behavior-preserving for the self-registered bridge, which registers one
guild handler plus the DM handler so its guild messages and DMs both flow as
before; and it is the seam the shared multi-tenant connection uses to register
a handler per installed guild (and to leave the DM slot empty, dropping DMs
that carry no guild to attribute to a tenant). The former guild filter in
_handle_message is removed, and its foreign-guild/DM behavior is now covered
by connection-level routing tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A long line left unformatted by the DiscordConnection extraction
(aa616ed) that ruff format now wraps; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scord)

The distributed Discord app authenticates to every guild with one
deployment-level application bot token, so a Discord install has no
per-install credential to capture or store — its grant is a guild id and
a name. This cuts the tokenless-grant seam through the shared messaging-
install layer so such an install can be recorded and completed, while a
token-based platform (Slack) still carries and requires its token.

Three places assumed a per-install token:

- `InstallGrant.bot_token` becomes `str | None` — `None` for a platform
  whose credential is deployment-level.
- `MessagingInstallStore.record_install` accepts a nullable
  `encrypted_bot_token` and inserts it as-is (the column is already
  nullable from a7f2c3e9b481).
- `MessagingInstallService.complete` encrypts only when the grant
  carries a token, else stores `None`.

Slack stays strict: its requirement lives on `SlackConnectionConfig.
bot_token`, a required field, so a token-based bridge still fails config
validation without one — the column's nullability is not the guard.

The disconnect path already skips the platform `revoke` call when the
stored token is `None` (added with the "install can end" work), so a
tokenless install revokes nothing on disconnect and just ends its local
record; a regression test locks that in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d app

Stage 2 of the distributed Discord app: the config the app exists for and
the installer that turns an Add-to-Server click into a tokenless install.
The completing flow, the inert bridge and the shared Gateway connection
are later stages; nothing here starts a bridge.

- config: DISCORD_APP_CLIENT_ID / DISCORD_APP_CLIENT_SECRET /
  DISCORD_APP_BOT_TOKEN / DISCORD_APP_APPLICATION_ID, with an all-or-nothing
  _validate_discord_app mirroring the Slack one and the same requirement
  that MESSAGING_PUBLIC_URL be set with them. The bot token lives here, in
  deployment config, not in a per-install row — the one shape difference
  from Slack that the rest follows from.

- DiscordAppInstaller: authorize_url with the pinned `bot
  applications.commands` scopes and a least-privilege permission integer
  (275683314768, summed from named bits the adapter actually uses); redeem
  exchanges the code and reads the guild id and name back, returning a
  tokenless InstallGrant; connection_config renders {guild_id,
  event_delivery: "shared"} and no token. Discord has no webhook, so the
  inbound half of the ABC (verify_webhook / parse_webhook /
  workspace_of_event / revocation_of_event) and revoke are stubbed to raise
  — never reached, and split from the ABC later.

- DiscordConnectionConfig: a hidden event_delivery discriminator
  (own_connection | shared) mirroring Slack's, bot_token now optional, and a
  validator refusing the half-states (own_connection without a token; shared
  with one). A shared bridge is not startable yet, so the adapter fails loud
  on a tokenless config rather than constructing one that receives nothing.

- main.py registers DiscordAppInstaller when its credentials are configured,
  beside the Slack one; the operator dashboard lists it automatically.

- docs/old/bridges/DISCORD_DISTRIBUTED_APP.md: the operator walkthrough,
  carrying a pinned contract block (scopes, permission integer, redirect)
  that a doc-vs-code test parses and compares to the code, so the two cannot
  drift. Slack code untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 3 (first half of the split): make the distributed app's per-guild
bridge exist without a connection of its own, so completing a Discord
install produces a tokenless install pointed at a real — if inert — bridge
instead of a 500. Attaching it to the shared Gateway connection, and the
removal / out-of-band-join / DM handling that ride Gateway events, land
with that connection in the next stage.

Under `event_delivery == "shared"` the adapter builds no DiscordConnection
(`_connection` is None), `start()` returns without dialling Discord (it
keeps its callbacks for when the shared connection attaches), and `stop()`
has nothing to close. Every outbound path goes through a new
`_require_connection()` that raises rather than no-ops, so an inert bridge
fails loud if something drives it before it is attached — never fakes a
send. The self-registered (`own_connection`) path is unchanged: it still
builds and owns its connection, and all existing Discord adapter tests pass
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First reviewable piece of Stage 4: the one Gateway connection the
distributed Discord app multiplexes every tenant over. DiscordGatewayClient
owns a single DiscordConnection built with the application bot token, binds
no tenant, registers commands globally (command_guild_id=None, decision #7),
and is started at boot before the bridges and stopped on shutdown. It lives
in the one switch-core pod (a forced singleton), so there is never a second
owner and no leader election.

Its intents are the least a shared, multi-tenant connection needs: no
dm_messages (a DM carries no guild to attribute to a tenant, and the DM
handler is left unset — guard G4 begins here) and no members (a privileged
intent that would close the connection past Discord's ~100-guild
verification threshold; member lookups fall back to API fetches).
message_content is privileged and gated behind DISCORD_APP_MESSAGE_CONTENT,
default off (mention-only) — decision #5.

A configured-but-unreachable Discord app must not take down a pod serving
every other platform, so a failed start is logged and Discord installs stay
inert until it reconnects, rather than fatal.

Attaching each guild's inert bridge to this connection, slash routing,
removal handling and the remaining guards land in the following commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server-members intent is privileged the same way message content is:
requesting it unapproved closes the shared connection past Discord's
~100-guild threshold. So the shared Gateway connection requests it only when
DISCORD_APP_MEMBERS is set (default off), on its own flag rather than riding
message content's, because the two are approved independently. Off, member
lookups fall back to API fetches; on (once verified), the bot fills its
member cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge (Stage 4b)

The shared connection now delivers. It carries one catch-all guild handler
(not a per-guild registry that would cache which tenant a guild belongs to on
the connection), and every message is resolved fresh:

- read the guild id off the Gateway event and resolve it through the new
  MessagingInstallService.resolve_by_workspace — the same tenant lookup and
  RLS-scoped install re-read the webhook path uses, kept inside the module
  already allowlisted for the exemption, just reached without a webhook;
- a guild with no active install resolves to nothing and is dropped, never
  routed to a default or first tenant (G3);
- the resolved inert bridge is handed the shared connection the first time it
  is used (lazily, since rooms — and therefore any outbound — only exist after
  a first inbound message), and the event is dispatched with no tenant bound,
  each handler binding the tenant of the room it acts on (G1).

resolve_by_workspace is split out of resolve() (behaviour-preserving); the
connection gains a set_guild_message_handler slot that takes precedence over
the per-guild registry (a connection is only ever one shape); the adapter
gains ensure_shared_connection (idempotent inject) and a thin dispatch_inbound
entry so the shared client does not reach into it. main.py hands the gateway
the install service.

Guards G1 and G3 land here with tests. Global slash routing, removal/DM
handling and G2/G4 tests follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared connection now registers the in-room command set globally, once
for the application (decision #7), and routes each invocation to the bridge
its interaction.guild_id resolves to — the same fresh, no-tenant-bound
resolution the message path uses. Global commands appear in every guild the
bot is in, so an invocation from a guild with no active install is answered
with an ephemeral refusal rather than dropped: an unacknowledged interaction
shows the user "interaction failed", where an install-less message can just
be dropped. The adapter gains a thin dispatch_slash entry mirroring
dispatch_inbound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Stage 4d)

The shared connection now handles the two guild-lifecycle Gateway events.
Removal (the bot kicked from a guild, or the guild deleted) routes through
the platform-initiated end path — the same one a Slack app_uninstalled takes
— which marks the install inactive and detaches its bridge but revokes no
token (decision #8; a Discord install has none). A removal for a guild no
tenant installed resolves to nobody and is a harmless no-op.

A guild join provisions nothing: only a recorded install (via the OAuth flow)
makes a guild's events route anywhere, and a guild with none is ignored — its
messages resolve to nobody and are dropped (G3). The join is logged so an
out-of-band add is visible rather than silent.

DiscordConnection gains guild-remove/join handler slots the shared connection
wires and the self-registered adapter leaves unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…age 4e)

Pins the two guard faces not already covered where their feature landed:
- G4 (no guild-less routing): a DM reaches no handler when the DM slot is
  empty — how the shared connection is wired — while a guild message still
  routes. Its intent half (no dm_messages requested) is in test_discord_gateway.
- G2 (tenant-scoped identity): the same Discord user in two tenants' guilds
  gets two independent records, because each guild is served by its own adapter
  (one install = one tenant, D2) with its own id-keyed caches; the database
  side is scoped the same way through row-level security.

G1 (per-event scoping, resolved fresh, no tenant cached) and G3 (no default
tenant, unmapped guild dropped) are pinned in test_discord_gateway_routing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The distributed Discord app's credentials mirror the Slack block: all four
or none, MESSAGING_PUBLIC_URL required with them, plus the two privileged-
intent flags. Config docs only — no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The default discord-client-id rule flags the 17-19 digit placeholder in
test_config_discord_app.py. A Discord client/application id is a public
identifier, not a secret, and the value is an obvious dummy — allowlisted
following the existing false-positive convention in this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant