Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ All notable changes to GoClaw are documented here. For full documentation, see [

### Improvements

- **Discord slash commands.** Discord channels now register five built-in
slash commands on startup via `ApplicationCommandsBulkOverwrite`:
`/ask <prompt> [private]`, `/status`, `/help`, `/recall <query>`,
`/summarize [count]`. Bulk-overwrite semantics mean stale commands from
any previous backend using the same Discord application are removed
automatically. `/ask`, `/recall`, and `/summarize` defer their ACK and
reply via interaction token so the response appears inline against the
slash command in Discord's UI. Fallback to a regular channel post kicks
in when the interaction token expires or the edit fails, except for
ephemeral (private:true) replies in guild channels where the fallback
is suppressed to avoid leaking the private content to the full channel.
A background sweeper drops expired interaction-token entries every
5 min so the per-invocation map doesn't grow without bound. Disable
with `discord.slash_commands: false`; register per-guild (instant,
for dev) via `discord.test_guild_id`. Requires `applications.commands`
OAuth scope. `/reset`, `/stop`, and `/thread` will land in follow-ups
once their backing dependencies are available.
- **Context pruning cleanup.** Removed redundant Pass 0 (per-result 30% guard),
deduplicated double prune call per iteration, added SanitizeHistory to
PruneStage for broken tool_use/tool_result pair cleanup.
Expand Down
1 change: 1 addition & 0 deletions docs/05-channels-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ The Discord channel uses the `discordgo` library to connect via the Discord Gate
- **Bot identity**: Fetches `@me` on startup to detect and ignore own messages
- **Typing indicator**: 9-second keepalive while agent processes
- **Group history**: Pending message buffer for context when mentioned
- **Slash commands**: Five built-in commands registered on startup via `ApplicationCommandsBulkOverwrite` — `/ask <prompt> [private]`, `/status`, `/help`, `/recall <query>`, `/summarize [count]`. Bulk-overwrite semantics remove stale commands from any previous backend using the same Discord application. `/ask`, `/recall`, `/summarize` defer their ACK and reply via interaction token (inline reply against the slash command; 15-min token lifetime, with fallback to a regular channel post for longer runs — suppressed for ephemeral replies in guilds to avoid leaking private content). `/status`, `/help` respond inline + ephemeral. Disable with `slash_commands: false`. Set `test_guild_id: "123"` to register per-guild (instant) during dev; otherwise commands are global (~1h propagation). Requires the `applications.commands` OAuth scope on the bot's installation. `/reset`, `/stop`, and `/thread` are planned follow-ups once their backing dependencies land (session-clearer, scheduler cancel hook, `create_discord_thread` tool).

---

Expand Down
201 changes: 201 additions & 0 deletions internal/channels/discord/commands_slash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package discord

import (
"context"
"fmt"
"log/slog"
"time"

"github.com/bwmarrin/discordgo"
)

// SlashCommandName is a canonical identifier for each command we register.
// Exported so handler_interaction.go can switch on them without stringly-typed
// duplication and tests can reference them directly.
type SlashCommandName string

const (
SlashCommandAsk SlashCommandName = "ask"
SlashCommandStatus SlashCommandName = "status"
SlashCommandHelp SlashCommandName = "help"
SlashCommandRecall SlashCommandName = "recall"
SlashCommandSummarize SlashCommandName = "summarize"
// SlashCommandReset, SlashCommandStop, and SlashCommandThread are
// intentionally not registered in this PR:
// - /reset needs a SessionClearer dependency injected from the gateway
// to actually clear the agent's view of the conversation. Shipping
// the command as a stub that says "not wired yet" would be a worse
// UX than not showing it at all.
// - /stop needs a scheduler-cancel hook on channels.Manager to cancel
// the in-flight run. Clearing the typing indicator alone leaves the
// agent running, so a naive stub misleads the user.
// - /thread wraps the create_discord_thread tool, which lives in a
// separate upstream PR. We keep this PR self-contained against
// upstream/dev.
// Each lands in its own follow-up once the backing dependency is
// available. Until then they stay as constants only — not registered,
// not routed.
SlashCommandReset SlashCommandName = "reset"
SlashCommandStop SlashCommandName = "stop"
SlashCommandThread SlashCommandName = "thread"
)

// DefaultSlashCommands returns the command set we register on every goclaw
// Discord bot. Edit this list when adding or removing commands — Start() calls
// ApplicationCommandsBulkOverwrite with the result, so anything not in this
// list is automatically removed from Discord on the next sidecar boot.
//
// Command categories (see handler_interaction.go for routing):
// - meta: ask (routes to agent), status, help
// - agent-backed tools: recall (memory_search), summarize (sessions_history)
//
// All fields are plain types so Discord's slash-command param UI can render
// them; commands that conceptually take richer structured input (e.g.
// send_discord_embed) are intentionally NOT surfaced as slash commands — they
// stay agent-only.
func DefaultSlashCommands() []*discordgo.ApplicationCommand {
return []*discordgo.ApplicationCommand{
{
Name: string(SlashCommandAsk),
Description: "Ask the agent anything. Routes your prompt through the full agent pipeline.",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "prompt",
Description: "Your question or request",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionBoolean,
Name: "private",
Description: "Reply only visible to you (default: false — reply visible to the channel)",
Required: false,
},
},
},
{
Name: string(SlashCommandStatus),
Description: "Show what the agent is doing right now in this channel (idle, thinking, running a tool).",
},
{
Name: string(SlashCommandHelp),
Description: "List the commands this bot supports and what each one does.",
},
{
Name: string(SlashCommandRecall),
Description: "Search the agent's memory for relevant prior context and summarize the hits.",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "query",
Description: "What to search memory for",
Required: true,
},
},
},
{
Name: string(SlashCommandSummarize),
Description: "Summarize recent messages in this channel or thread.",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "count",
Description: "How many recent messages to summarize (default 20, max 200)",
Required: false,
MinValue: pointerTo(1.0),
MaxValue: 200,
},
},
},
}
}

// pointerTo is a tiny helper so the slash-command literals above stay terse.
// discordgo's MinValue / MinLength are pointer types to distinguish "unset"
// from "zero"; without this helper every use site needs a named variable.
func pointerTo[T any](v T) *T { return &v }

// slashCommandAPI abstracts the discordgo surface SyncSlashCommands uses so
// tests can stub the overwrite call. "" guildID means a global registration
// (all servers the bot is in + DMs; ~1hr propagation delay); a non-empty
// guildID registers per-guild which is instant but only visible in that guild.
type slashCommandAPI interface {
applicationCommandsBulkOverwrite(appID, guildID string, commands []*discordgo.ApplicationCommand) ([]*discordgo.ApplicationCommand, error)
}

type sessionSlashCommandAPI struct{ s *discordgo.Session }

func (a sessionSlashCommandAPI) applicationCommandsBulkOverwrite(appID, guildID string, commands []*discordgo.ApplicationCommand) ([]*discordgo.ApplicationCommand, error) {
return a.s.ApplicationCommandBulkOverwrite(appID, guildID, commands)
}

// SyncSlashCommands registers DefaultSlashCommands() with Discord via
// ApplicationCommandBulkOverwrite. The bulk-overwrite call replaces the
// entire command list on the application atomically — stale commands from a
// previous backend are automatically removed because they aren't in the new
// list.
//
// guildID "" performs a global registration (visible everywhere, ~1hr
// propagation). A non-empty guildID registers per-guild (instant, for dev).
func (c *Channel) SyncSlashCommands(ctx context.Context) error {
if c.applicationID == "" {
return fmt.Errorf("slash commands: application ID not yet resolved (Start must run first)")
}
return syncSlashCommands(ctx, sessionSlashCommandAPI{s: c.session}, c.applicationID, c.testGuildID, DefaultSlashCommands())
}

func syncSlashCommands(_ context.Context, api slashCommandAPI, appID, guildID string, commands []*discordgo.ApplicationCommand) error {
if appID == "" {
return fmt.Errorf("slash commands: application ID is required")
}
registered, err := api.applicationCommandsBulkOverwrite(appID, guildID, commands)
if err != nil {
return fmt.Errorf("slash commands: bulk overwrite: %w", err)
}
scope := "global"
if guildID != "" {
scope = "guild=" + guildID
}
slog.Info("discord: slash commands synced",
"scope", scope,
"count", len(registered),
"wanted", len(commands),
)
return nil
}

// startSlashCommandSync launches SyncSlashCommands on a goroutine with a
// bounded retry loop. Mirrors Telegram's SyncMenuCommands retry pattern
// (internal/channels/telegram/channel.go:223-248) — 3 attempts, linear
// backoff, so a transient Discord 5xx doesn't break channel startup.
func (c *Channel) startSlashCommandSync(ctx context.Context) {
go func() {
const maxAttempts = 3
syncCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()

var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := c.SyncSlashCommands(syncCtx); err != nil {
lastErr = err
slog.Warn("discord: failed to sync slash commands",
"error", err, "attempt", attempt,
)
if attempt < maxAttempts {
select {
case <-syncCtx.Done():
return
case <-time.After(time.Duration(attempt*5) * time.Second):
}
}
continue
}
return
}
if lastErr != nil {
slog.Warn("discord: slash commands remain unsynced after retries",
"error", lastErr,
)
}
}()
}
149 changes: 149 additions & 0 deletions internal/channels/discord/commands_slash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package discord

import (
"context"
"errors"
"testing"

"github.com/bwmarrin/discordgo"
)

type fakeSlashAPI struct {
gotAppID string
gotGuildID string
gotCmds []*discordgo.ApplicationCommand
returnCmds []*discordgo.ApplicationCommand
err error
}

func (f *fakeSlashAPI) applicationCommandsBulkOverwrite(appID, guildID string, commands []*discordgo.ApplicationCommand) ([]*discordgo.ApplicationCommand, error) {
f.gotAppID = appID
f.gotGuildID = guildID
f.gotCmds = commands
if f.err != nil {
return nil, f.err
}
if f.returnCmds != nil {
return f.returnCmds, nil
}
return commands, nil
}

// TestDefaultSlashCommands is a characterization test that pins the command
// set. Deliberately checks BOTH the full list of names AND a couple of
// deep-structural expectations (required options, choice values) so schema
// drift shows up as a test failure, not a silent Discord UX regression.
func TestDefaultSlashCommands(t *testing.T) {
cmds := DefaultSlashCommands()

// The set is intentionally minimal: /reset, /stop, and /thread are
// registered only once their backing dependencies land (see comments on
// the SlashCommandName constants). Adding a command here without wiring
// the handler in handler_interaction.go is a UX regression — every user
// who clicks the slash gets "Unknown command" — so the test guards it.
want := []SlashCommandName{
SlashCommandAsk,
SlashCommandStatus,
SlashCommandHelp,
SlashCommandRecall,
SlashCommandSummarize,
}
if len(cmds) != len(want) {
t.Fatalf("command count = %d, want %d", len(cmds), len(want))
}
for i, w := range want {
if SlashCommandName(cmds[i].Name) != w {
t.Errorf("cmds[%d].Name = %q, want %q", i, cmds[i].Name, w)
}
if cmds[i].Description == "" {
t.Errorf("cmds[%d] (%s) has empty description", i, cmds[i].Name)
}
}

// /ask must require a prompt and accept an optional private flag.
ask := cmds[0]
if len(ask.Options) != 2 {
t.Fatalf("/ask option count = %d, want 2", len(ask.Options))
}
if ask.Options[0].Name != "prompt" || !ask.Options[0].Required {
t.Errorf("/ask first option must be required 'prompt', got %+v", ask.Options[0])
}
if ask.Options[1].Name != "private" || ask.Options[1].Required {
t.Errorf("/ask second option must be optional 'private', got %+v", ask.Options[1])
}

// /summarize must expose a count option with a max value so the LLM can't
// trivially blow past Discord's context budget by passing a huge number.
var summarize *discordgo.ApplicationCommand
for _, c := range cmds {
if c.Name == string(SlashCommandSummarize) {
summarize = c
break
}
}
if summarize == nil {
t.Fatal("/summarize command missing")
}
if len(summarize.Options) != 1 || summarize.Options[0].Name != "count" {
t.Fatalf("/summarize option not 'count': %+v", summarize.Options)
}
if summarize.Options[0].MaxValue != 200 {
t.Errorf("/summarize count MaxValue = %v, want 200", summarize.Options[0].MaxValue)
}
}

func TestSyncSlashCommands_BulkOverwriteCalled(t *testing.T) {
f := &fakeSlashAPI{}
cmds := DefaultSlashCommands()
if err := syncSlashCommands(context.Background(), f, "app-1", "", cmds); err != nil {
t.Fatalf("syncSlashCommands: %v", err)
}
if f.gotAppID != "app-1" {
t.Errorf("app id = %q, want app-1", f.gotAppID)
}
if f.gotGuildID != "" {
t.Errorf("guild id should be empty for global registration, got %q", f.gotGuildID)
}
if len(f.gotCmds) != len(cmds) {
t.Errorf("passed %d commands, want %d", len(f.gotCmds), len(cmds))
}
}

func TestSyncSlashCommands_GuildScoped(t *testing.T) {
f := &fakeSlashAPI{}
if err := syncSlashCommands(context.Background(), f, "app-1", "guild-xyz", DefaultSlashCommands()); err != nil {
t.Fatalf("syncSlashCommands: %v", err)
}
if f.gotGuildID != "guild-xyz" {
t.Errorf("guild id = %q, want guild-xyz", f.gotGuildID)
}
}

func TestSyncSlashCommands_MissingAppID(t *testing.T) {
err := syncSlashCommands(context.Background(), &fakeSlashAPI{}, "", "", DefaultSlashCommands())
if err == nil {
t.Fatal("expected error when app id missing")
}
}

func TestSyncSlashCommands_APIError(t *testing.T) {
f := &fakeSlashAPI{err: errors.New("HTTP 401")}
err := syncSlashCommands(context.Background(), f, "app-1", "", DefaultSlashCommands())
if err == nil {
t.Fatal("expected error propagation from API")
}
}

func TestSyncSlashCommands_BulkOverwriteClearsStale(t *testing.T) {
// The bulk-overwrite semantics are what give us "automatic cleanup of
// stale commands from the old backend." Verify the API receives the
// exact list we pass — whatever was previously registered gets replaced.
f := &fakeSlashAPI{}
cmds := []*discordgo.ApplicationCommand{{Name: "only", Description: "desc"}}
if err := syncSlashCommands(context.Background(), f, "app-1", "", cmds); err != nil {
t.Fatalf("syncSlashCommands: %v", err)
}
if len(f.gotCmds) != 1 || f.gotCmds[0].Name != "only" {
t.Errorf("commands passed to API = %+v, want exactly [only]", f.gotCmds)
}
}
Loading