Skip to content

feat(discord): register slash commands + interaction reply path#991

Open
tarrencev wants to merge 2 commits into
nextlevelbuilder:devfrom
cartridge-gg:feat/discord-slash-commands
Open

feat(discord): register slash commands + interaction reply path#991
tarrencev wants to merge 2 commits into
nextlevelbuilder:devfrom
cartridge-gg:feat/discord-slash-commands

Conversation

@tarrencev
Copy link
Copy Markdown

Summary

Discord bots now register seven built-in slash commands on startup via ApplicationCommandsBulkOverwrite:

  • Meta/ask <prompt> [private], /reset, /status, /stop, /help
  • Agent-backed tool shortcuts/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, /summarize defer 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 regular ChannelMessageSend with a warning log, so the user still gets the reply.

/reset, /status, /stop, /help are inline + ephemeral (visible only to the invoker). /reset and /stop are v1 stubs that acknowledge the request and point at what will land in a follow-up PR (a SessionClearer dependency + a scheduler-cancel hook on channels.Manager).

Design decisions baked in

  • Global by default — commands register application-wide (~1h propagation). discord.test_guild_id escape hatch re-registers per-guild (instant) for dev iteration.
  • /ask public by default, flips to ephemeral via its private:bool option. Other commands always ephemeral.
  • No /thread yet — that command depends on the create_discord_thread tool (upstream PR feat(tools): add create_discord_thread tool #976). This PR stays self-contained against upstream/dev; /thread lands as a small follow-up once feat(tools): add create_discord_thread tool #976 merges.

Mechanism

Mirrors the existing Telegram SyncMenuCommands retry 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 uses routingMetaKeys so the token survives the inbound→agent→outbound hop; Discord Send() branches on msg.Metadata["discord_interaction_token"] to choose between the interaction path and the regular channel post.

Config

discord: {
  // default true — opt out:
  slash_commands: false,

  // optional — dev escape hatch:
  test_guild_id: "1234567890",
}

Requires the applications.commands OAuth scope on the bot's installation. A missing scope shows up at startup as discord: 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 ./... clean
  • go test ./internal/channels/discord/... — new tests pass
    • Command schema characterization (pins command set + required-option shape)
    • SyncSlashCommands bulk-overwrite dispatch (global + guild-scoped + missing-app-id + API-error + stale-clearance)
    • chunkForDiscord chunking rules (empty, fits, split-at-newline-in-second-half, hard-split fallback)
    • interactionEcho.expired 15-min lifetime
    • optionString / optionBool / optionInt helpers
    • buildAgentPrompt for /ask / /recall / /summarize incl. private flag + count clamp
    • routingMetaKeys includes discord_interaction_* keys
  • Manual: invite bot to test guild with test_guild_id set, confirm /ask, /recall, /summarize, /help each work end-to-end and that stale commands from the prior backend vanish on first boot.

🤖 Generated with Claude Code

tarrencev and others added 2 commits April 21, 2026 17:13
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>
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