From cbdd2b85aa539cc1aa96bc3d8c0caf83778fd1cc Mon Sep 17 00:00:00 2001 From: Akayashuu Date: Tue, 4 Aug 2026 15:38:11 +0200 Subject: [PATCH] feat: add WithGuild to pin the default guild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guild-scoped ops fell back to Guilds().Sole, which errors as soon as the bot is in more than one server — command registration then fails outright with no way to say which server was meant. WithGuild pins it without a network call. --- README.md | 8 +++++--- dctl.go | 13 ++++++++++++- defaults.go | 6 ++++++ defaults_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7f87a3d..2ee77dc 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,11 @@ msgs, _ := c.Messages().Read(ctx, "", 20, "") // oldest-first ## Notes Channel-scoped ops accept `""` for the configured default channel; guild-scoped -ops accept `""` for the bot's sole guild. Nothing is read from the environment — -pass the token explicitly. Without one, `Enabled()` is false and every call -returns `ErrDisabled`. `WithHTTPClient` overrides the default 15s client. +ops accept `""` for the bot's sole guild — or for the guild pinned by +`WithGuild`, which a bot in several servers needs. Nothing is read from the +environment — pass the token explicitly. Without one, `Enabled()` is false and +every call returns `ErrDisabled`. `WithHTTPClient` overrides the default 15s +client. Slash commands: `NewCommand(...).With(dctl.String(...), dctl.Sub(...))` builds a typed command; `c.Interactions().Registry()` owns `Add`, `Sync` (diff against diff --git a/dctl.go b/dctl.go index e57d537..429b233 100644 --- a/dctl.go +++ b/dctl.go @@ -20,6 +20,7 @@ type ClientOption func(*clientConfig) type clientConfig struct { httpClient *http.Client + guild string } // WithHTTPClient overrides the default 15s-timeout HTTP client. @@ -27,6 +28,14 @@ func WithHTTPClient(h *http.Client) ClientOption { return func(c *clientConfig) { c.httpClient = h } } +// WithGuild pins the guild that guild-scoped ops target when no explicit id is +// passed. Without it they fall back to Guilds().Sole, which errors when the bot +// is in more than one server — a bot invited to a second guild would otherwise +// lose command registration entirely. +func WithGuild(id string) ClientOption { + return func(c *clientConfig) { c.guild = id } +} + // 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 { @@ -39,7 +48,9 @@ func New(token, defaultChannel string, opts ...ClientOption) *Client { topts = append(topts, transport.WithHTTPClient(cfg.httpClient)) } rt := transport.NewHTTP(token, topts...) - return newWith(rt, defaultChannel) + c := newWith(rt, defaultChannel) + c.def.guild = cfg.guild + return c } // newWith wires a Client around an arbitrary Doer (used by tests with a stub). diff --git a/defaults.go b/defaults.go index 54f8e54..6eb096d 100644 --- a/defaults.go +++ b/defaults.go @@ -17,6 +17,7 @@ var ErrNoChannel = errors.New("dctl: no channel (DISCORD_CHANNEL_ID or --channel type defaults struct { rt transport.Doer channel string + guild string guilds *Guilds // appID and the sole-guild id are resolved independently via one network @@ -43,6 +44,11 @@ func (d *defaults) resolveGuild(ctx context.Context, guildID string) (string, er if guildID != "" { return guildID, nil } + // A configured guild (WithGuild) settles it without a network call and + // without the mono-server assumption Sole makes. + if d.guild != "" { + return d.guild, nil + } d.muGuild.Lock() defer d.muGuild.Unlock() if d.soleGuid != "" { diff --git a/defaults_test.go b/defaults_test.go index c9d0841..67d7b66 100644 --- a/defaults_test.go +++ b/defaults_test.go @@ -30,6 +30,30 @@ func TestDefaultsResolveChannelErrorsWhenNone(t *testing.T) { } } +// A configured guild must win over the sole-guild lookup: a bot in several +// servers has no sole guild, and the lookup would fail the call outright. +func TestResolveGuildPrefersConfiguredGuild(t *testing.T) { + s := transport.NewStub().Reply(`[{"id":"g1","name":"a"},{"id":"g2","name":"b"}]`) + d := &defaults{guild: "g2", guilds: &Guilds{rt: s}} + got, err := d.resolveGuild(context.Background(), "") + if err != nil || got != "g2" { + t.Fatalf("got %q, %v", got, err) + } + if _, err := (&defaults{guilds: &Guilds{rt: s}}).resolveGuild(context.Background(), ""); err == nil { + t.Fatal("without a configured guild a multi-server bot should still fail") + } +} + +func TestWithGuildConfiguresDefault(t *testing.T) { + c := New("token", "chan", WithGuild("g7")) + if c.def.guild != "g7" { + t.Fatalf("def.guild = %q, want g7", c.def.guild) + } + if plain := New("token", "chan"); plain.def.guild != "" { + t.Fatalf("def.guild = %q, want empty without WithGuild", plain.def.guild) + } +} + func TestResolveGuildUsesSoleGuild(t *testing.T) { s := transport.NewStub().Reply(`[{"id":"g1","name":"srv"}]`) d := &defaults{guilds: &Guilds{rt: s}}