diff --git a/README.md b/README.md index 2ee77dc..b63f431 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ client. Slash commands: `NewCommand(...).With(dctl.String(...), dctl.Sub(...))` builds a typed command; `c.Interactions().Registry()` owns `Add`, `Sync` (diff against -Discord: create/edit/delete), and `Dispatch` / `DispatchAutocomplete`. +Discord: create/edit/delete), and `Dispatch` / `DispatchAutocomplete`. They are +registered on the default guild — instant, one server — or on the application +with `WithGlobalCommands`, which reaches every server the bot is in and takes up +to an hour to propagate. Path segments are percent-escaped and queries built with `url.Values`. `Webhook.Token` and `Interaction.Token` are `Secret` — `[REDACTED]` in logs and diff --git a/dctl.go b/dctl.go index 429b233..b6ccc45 100644 --- a/dctl.go +++ b/dctl.go @@ -21,6 +21,7 @@ type ClientOption func(*clientConfig) type clientConfig struct { httpClient *http.Client guild string + global bool } // WithHTTPClient overrides the default 15s-timeout HTTP client. @@ -36,6 +37,15 @@ func WithGuild(id string) ClientOption { return func(c *clientConfig) { c.guild = id } } +// WithGlobalCommands registers slash commands on the application rather than on +// one guild, so every server the bot is in gets them — the only scope that +// works for a bot meant to be installed anywhere. Guild commands appear +// instantly and global ones take up to an hour to propagate, which is why they +// stay the default. Other guild-scoped ops are unaffected. +func WithGlobalCommands() ClientOption { + return func(c *clientConfig) { c.global = true } +} + // New builds a Client. token is the bot token (kept in memory only). defaultChannel // is the channel that message ops target when no explicit channel id is passed. func New(token, defaultChannel string, opts ...ClientOption) *Client { @@ -50,6 +60,7 @@ func New(token, defaultChannel string, opts ...ClientOption) *Client { rt := transport.NewHTTP(token, topts...) c := newWith(rt, defaultChannel) c.def.guild = cfg.guild + c.def.globalCommands = cfg.global return c } diff --git a/dctl_registry_test.go b/dctl_registry_test.go index 02a0942..dc70032 100644 --- a/dctl_registry_test.go +++ b/dctl_registry_test.go @@ -23,6 +23,45 @@ func TestClientRegistryIsStable(t *testing.T) { } } +// Commands default to the guild scope: instant propagation, one server. +func TestCommandsBaseDefaultsToGuildScope(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"app1"}`).Reply(`[{"id":"g1","name":"srv"}]`) + c := newWith(s, "chan") + + base, err := c.Interactions().commandsBase(context.Background()) + if err != nil || base != "/applications/app1/guilds/g1/commands" { + t.Fatalf("base = %q, %v", base, err) + } +} + +// A bot installable anywhere cannot pin its commands to one server. Global +// scope must also skip the guild lookup entirely — that lookup fails outright +// once the bot is in more than one server. +func TestGlobalCommandsScopeSkipsGuildLookup(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"app1"}`) + c := newWith(s, "chan") + c.def.globalCommands = true + + base, err := c.Interactions().commandsBase(context.Background()) + if err != nil || base != "/applications/app1/commands" { + t.Fatalf("base = %q, %v", base, err) + } + for _, call := range s.Calls() { + if call.Path == "/users/@me/guilds" { + t.Fatal("global commands must not resolve a guild") + } + } +} + +func TestWithGlobalCommandsConfiguresDefault(t *testing.T) { + if c := New("token", "chan", WithGlobalCommands()); !c.def.globalCommands { + t.Error("WithGlobalCommands did not reach the client") + } + if c := New("token", "chan"); c.def.globalCommands { + t.Error("commands must stay guild-scoped without the option") + } +} + // AppID is fetched once and cached, even across separate sub-client ops. func TestAppIDCached(t *testing.T) { s := transport.NewStub().Reply(`{"id":"app1"}`).Reply(`{"id":"app1"}`) diff --git a/defaults.go b/defaults.go index 6eb096d..c92c4bd 100644 --- a/defaults.go +++ b/defaults.go @@ -20,6 +20,10 @@ type defaults struct { guild string guilds *Guilds + // globalCommands scopes slash commands to the application instead of a + // guild; it deliberately touches nothing else that resolves a guild. + globalCommands bool + // appID and the sole-guild id are resolved independently via one network // call each; separate locks keep a slow guild lookup from blocking an app-id // lookup (and vice-versa). Each guards only its own value, held across the diff --git a/interactions.go b/interactions.go index 75dca59..321c43e 100644 --- a/interactions.go +++ b/interactions.go @@ -198,18 +198,24 @@ func fetchAppID(ctx context.Context, rt transport.Doer) (string, error) { return u.ID, nil } -// RegisteredCommand is the read form of a registered guild command. +// RegisteredCommand is the read form of a registered command. type RegisteredCommand struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` } +// commandsBase is the command collection every command op reads and writes: +// the application's global commands when the client asked for them +// (WithGlobalCommands), otherwise the default guild's. func (in *Interactions) commandsBase(ctx context.Context) (string, error) { appID, err := in.AppID(ctx) if err != nil { return "", err } + if in.def.globalCommands { + return "/applications/" + seg(appID) + "/commands", nil + } gid, err := in.def.resolveGuild(ctx, "") if err != nil { return "", err @@ -217,7 +223,7 @@ func (in *Interactions) commandsBase(ctx context.Context) (string, error) { return "/applications/" + seg(appID) + "/guilds/" + seg(gid) + "/commands", nil } -// RegisterCommands bulk-overwrites the sole guild's commands from raw maps. +// RegisterCommands bulk-overwrites the command scope from raw maps. func (in *Interactions) RegisterCommands(ctx context.Context, commands []map[string]any) error { base, err := in.commandsBase(ctx) if err != nil { @@ -226,7 +232,7 @@ func (in *Interactions) RegisterCommands(ctx context.Context, commands []map[str return in.rt.Do(ctx, http.MethodPut, base, commands, nil) } -// Register bulk-overwrites the sole guild's commands from builders. +// Register bulk-overwrites the command scope from builders. func (in *Interactions) Register(ctx context.Context, cmds ...*Command) error { body := make([]map[string]any, 0, len(cmds)) for _, c := range cmds { @@ -235,7 +241,7 @@ func (in *Interactions) Register(ctx context.Context, cmds ...*Command) error { return in.RegisterCommands(ctx, body) } -// List returns the sole guild's currently registered commands. +// List returns the currently registered commands in the client's scope. func (in *Interactions) List(ctx context.Context) ([]RegisteredCommand, error) { base, err := in.commandsBase(ctx) if err != nil {