Skip to content
Merged
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions dctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
39 changes: 39 additions & 0 deletions dctl_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`)
Expand Down
4 changes: 4 additions & 0 deletions defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions interactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,26 +198,32 @@ 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
}
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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading