feat(discord): distributed Discord app — Phase 1 (Stages 1–4) - #492
Draft
lbangalosbt wants to merge 14 commits into
Draft
lbangalosbt wants to merge 14 commits into
lbangalosbt wants to merge 14 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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 aforced 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_tokenis nullable, a Discord install storesnothing, 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 → tenantfresh (RLS-scoped re-read), dispatches under
no_tenant(), and bindsper-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_contentandmembersare privilegedintents 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. Underevent_delivery: sharedit opens no socket of its own and routes through theshared connection — reusing all existing per-guild logic (posting, roles,
slash, room map) unchanged, so
get_adapter, moderation and the operator listkeep 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)
first message injects the shared connection — fails loud and self-heals; worth
attaching eagerly.
admin-gated today), callback → redirect, and the operator list showing a
shared-connection install's status/revoke.
🤖 Generated with Claude Code