feat(discord): register slash commands + interaction reply path#991
Open
tarrencev wants to merge 2 commits into
Open
feat(discord): register slash commands + interaction reply path#991tarrencev wants to merge 2 commits into
tarrencev wants to merge 2 commits into
Conversation
Discord bots now register seven built-in slash commands on startup via ApplicationCommandsBulkOverwrite — `/ask <prompt> [private]`, `/reset`, `/status`, `/stop`, `/help`, `/recall <query>`, `/summarize [count]`. Bulk-overwrite semantics atomically replace the application's full command list, so any stale commands from a previous backend using the same Discord application are removed on the first sidecar boot. `/ask`, `/recall`, and `/summarize` defer their ACK and reply via the Discord interaction-token flow (edit the deferred response for the first chunk, followup messages for the rest), so replies appear inline against the slash command in Discord's UI instead of as a new channel post. Tokens expire 15 min after the interaction — the reply path auto-falls back to a regular ChannelMessageSend past that cap with a warning log, so long agent runs still deliver the response. `/reset`, `/status`, `/stop`, `/help` respond inline + ephemeral (visible only to the invoker). `/reset` and `/stop` are v1 stubs that acknowledge the request; full session-clear + run-cancel hooks will follow in a separate PR once the SessionClearer / scheduler-cancel surface is threaded into the channel layer. Config: - discord.slash_commands (*bool, default true) — opt out entirely - discord.test_guild_id (string, optional) — register per-guild for instant propagation during dev; omit for global commands (~1h propagation) Requires the `applications.commands` OAuth scope on the bot's installation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses findings from /review's security + maintainability specialists:
SECURITY (critical):
- Fix ephemeral reply leak: when the interaction token expires or the
InteractionResponseEdit fails, Send() previously fell through to
ChannelMessageSend — which posts publicly to the guild channel. For
/ask with private:true, this leaked the private reply to everyone in
the channel. Now, if echo.Ephemeral && echo.GuildID != "", fallback
is suppressed (user sees the stuck "thinking..." state; Discord clears
it after 15 min) rather than leaking the content. DMs and non-ephemeral
replies fall through as before.
- Fix interactionTokens leak: add a background sweeper goroutine started
in Channel.Start and scoped to the channel context. Every 5 min it
walks the map and drops expired entries. Without this, any slash
invocation whose agent run never fires Send() with matching metadata
leaked a ~250B *interactionEcho forever.
SCOPE (from AskUserQuestion):
- Drop /reset and /stop from DefaultSlashCommands until their backing
dependencies land (SessionClearer for /reset, scheduler-cancel hook
for /stop). Shipping them as stubs is worse UX than not showing them.
/thread stays deferred to the create_discord_thread upstream PR.
Constants retained (SlashCommandReset/Stop/Thread) with a block
comment explaining each deferral.
MAINTAINABILITY (mechanical):
- chunkForDiscord deleted; trySendViaInteraction now delegates to
channels.ChunkMarkdown so the interaction path uses the same
markdown-aware chunking as regular posts (preserves code fences).
- stringPtr removed; callers use pointerTo from commands_slash.go.
- Anonymous interface{ Stop() } cast replaced with concrete
*typing.Controller — matches the rest of the package.
- Named constants for the 15-min Discord TTL + 1-min safety margin.
- Stale comments fixed: interactionTokens map key (interaction ID, not
inbound message ID); /thread mention removed from config + command
list docs; /reset DMPermission comment reworded.
- Dead code removed: FirstChunk sync.Once on interactionEcho, unused
uuid import + sentinel, dead args on handleResetCommand (then removed
with the command itself).
- Start() gate split into slash_commands_disabled / no_application_id
branches so operators diagnosing missing commands see separate log
lines for the two reasons.
Tests added:
- TestEphemeralSuppressesFallback (matrix guarding the security fix)
- TestSweepInteractionTokens (fresh kept, expired dropped, hostile
values ignored)
Tests dropped:
- TestChunkForDiscord (function replaced by channels.ChunkMarkdown,
which has its own coverage in chunking_test.go).
Skipped (intentional): end-to-end tests for trySendViaInteraction and
handleInteraction routing. Adding them requires new httptest-based
discordgo session spies + a bus spy harness — enough scope to warrant
its own PR. Opened as a follow-up; relying on manual validation plus
the existing chunking/expiry/ephemeral-guard unit tests for now.
Co-Authored-By: Claude Opus 4.7 (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.
Summary
Discord bots now register seven built-in slash commands on startup via
ApplicationCommandsBulkOverwrite:/ask <prompt> [private],/reset,/status,/stop,/help/recall <query>(memory search),/summarize [count]Bulk-overwrite replaces the full application command list atomically, which means stale commands from any prior backend using the same Discord application are removed automatically on the first sidecar boot — no separate cleanup step required.
Reply path
/ask,/recall,/summarizedefer their ACK and reply via the Discord interaction-token flow: the first chunk edits the deferred response (so the reply surfaces inline against the slash command in Discord's UI), subsequent chunks become interaction followups. Tokens expire 15 min after the invocation — if the agent run runs past that cap, the outbound dispatcher auto-falls back to a regularChannelMessageSendwith a warning log, so the user still gets the reply./reset,/status,/stop,/helpare inline + ephemeral (visible only to the invoker)./resetand/stopare v1 stubs that acknowledge the request and point at what will land in a follow-up PR (a SessionClearer dependency + a scheduler-cancel hook onchannels.Manager).Design decisions baked in
discord.test_guild_idescape hatch re-registers per-guild (instant) for dev iteration./askpublic by default, flips to ephemeral via itsprivate:booloption. Other commands always ephemeral./threadyet — that command depends on thecreate_discord_threadtool (upstream PR feat(tools): add create_discord_thread tool #976). This PR stays self-contained againstupstream/dev;/threadlands as a small follow-up once feat(tools): add create_discord_thread tool #976 merges.Mechanism
Mirrors the existing Telegram
SyncMenuCommandsretry pattern (internal/channels/telegram/channel.go:223-248): 3 attempts, linear backoff, runs in a goroutine so a transient Discord 5xx doesn't break channel startup. The interaction token plumbing usesroutingMetaKeysso the token survives the inbound→agent→outbound hop; DiscordSend()branches onmsg.Metadata["discord_interaction_token"]to choose between the interaction path and the regular channel post.Config
Requires the
applications.commandsOAuth scope on the bot's installation. A missing scope shows up at startup asdiscord: failed to fetch application ID (slash commands disabled)— the channel continues to run, just without slash commands.Test plan
go build ./...+go build -tags sqliteonly ./...+go vet ./...cleango test ./internal/channels/discord/...— new tests passSyncSlashCommandsbulk-overwrite dispatch (global + guild-scoped + missing-app-id + API-error + stale-clearance)chunkForDiscordchunking rules (empty, fits, split-at-newline-in-second-half, hard-split fallback)interactionEcho.expired15-min lifetimeoptionString / optionBool / optionInthelpersbuildAgentPromptfor/ask//recall//summarizeincl. private flag + count clamproutingMetaKeysincludesdiscord_interaction_*keystest_guild_idset, confirm/ask,/recall,/summarize,/helpeach work end-to-end and that stale commands from the prior backend vanish on first boot.🤖 Generated with Claude Code