Skip to content

Commit cd6995b

Browse files
ernadoclaude
andcommitted
feat(commands): implement Set/Get/DeleteMyCommands with scopes
Add BotCommand type, the BotCommandScope sealed union (default, all private/group chats, all chat admins, chat, chat admins, chat member) with method-dispatched MTProto resolution, and the three command methods on *Bot over Bots{Set,Get,Reset}BotCommands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 28a7b72 commit cd6995b

3 files changed

Lines changed: 276 additions & 0 deletions

File tree

commands.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// BotCommand represents a bot command shown in the menu.
10+
type BotCommand struct {
11+
// Command is the text of the command, 1-32 characters, lowercase English
12+
// letters, digits and underscores.
13+
Command string `json:"command"`
14+
// Description is the command description, 1-256 characters.
15+
Description string `json:"description"`
16+
}
17+
18+
// BotCommandScope is a sealed union describing the scope to which a list of bot
19+
// commands applies.
20+
//
21+
// Construct with BotCommandScopeDefault, BotCommandScopeAllPrivateChats,
22+
// BotCommandScopeAllGroupChats, BotCommandScopeAllChatAdministrators,
23+
// BotCommandScopeChat, BotCommandScopeChatAdministrators or
24+
// BotCommandScopeChatMember.
25+
type BotCommandScope interface {
26+
isBotCommandScope()
27+
// resolve converts the scope into the MTProto representation. The bot needs
28+
// peer resolution for the chat-targeted variants, hence the receiver.
29+
resolve(ctx context.Context, b *Bot) (tg.BotCommandScopeClass, error)
30+
}
31+
32+
type botCommandScopeDefault struct{}
33+
type botCommandScopeAllPrivateChats struct{}
34+
type botCommandScopeAllGroupChats struct{}
35+
type botCommandScopeAllChatAdministrators struct{}
36+
type botCommandScopeChat struct{ chat ChatID }
37+
type botCommandScopeChatAdministrators struct{ chat ChatID }
38+
type botCommandScopeChatMember struct {
39+
chat ChatID
40+
userID int64
41+
}
42+
43+
func (botCommandScopeDefault) isBotCommandScope() {}
44+
func (botCommandScopeAllPrivateChats) isBotCommandScope() {}
45+
func (botCommandScopeAllGroupChats) isBotCommandScope() {}
46+
func (botCommandScopeAllChatAdministrators) isBotCommandScope() {}
47+
func (botCommandScopeChat) isBotCommandScope() {}
48+
func (botCommandScopeChatAdministrators) isBotCommandScope() {}
49+
func (botCommandScopeChatMember) isBotCommandScope() {}
50+
51+
// BotCommandScopeDefault covers all chats with no narrower scope set.
52+
func BotCommandScopeDefault() BotCommandScope { return botCommandScopeDefault{} }
53+
54+
// BotCommandScopeAllPrivateChats covers all private chats.
55+
func BotCommandScopeAllPrivateChats() BotCommandScope { return botCommandScopeAllPrivateChats{} }
56+
57+
// BotCommandScopeAllGroupChats covers all group and supergroup chats.
58+
func BotCommandScopeAllGroupChats() BotCommandScope { return botCommandScopeAllGroupChats{} }
59+
60+
// BotCommandScopeAllChatAdministrators covers all group and supergroup chat
61+
// administrators.
62+
func BotCommandScopeAllChatAdministrators() BotCommandScope {
63+
return botCommandScopeAllChatAdministrators{}
64+
}
65+
66+
// BotCommandScopeChat covers a specific chat.
67+
func BotCommandScopeChat(chat ChatID) BotCommandScope { return botCommandScopeChat{chat: chat} }
68+
69+
// BotCommandScopeChatAdministrators covers the administrators of a specific
70+
// group or supergroup chat.
71+
func BotCommandScopeChatAdministrators(chat ChatID) BotCommandScope {
72+
return botCommandScopeChatAdministrators{chat: chat}
73+
}
74+
75+
// BotCommandScopeChatMember covers a specific member of a group or supergroup
76+
// chat.
77+
func BotCommandScopeChatMember(chat ChatID, userID int64) BotCommandScope {
78+
return botCommandScopeChatMember{chat: chat, userID: userID}
79+
}
80+
81+
func (botCommandScopeDefault) resolve(context.Context, *Bot) (tg.BotCommandScopeClass, error) {
82+
return &tg.BotCommandScopeDefault{}, nil
83+
}
84+
85+
func (botCommandScopeAllPrivateChats) resolve(context.Context, *Bot) (tg.BotCommandScopeClass, error) {
86+
return &tg.BotCommandScopeUsers{}, nil
87+
}
88+
89+
func (botCommandScopeAllGroupChats) resolve(context.Context, *Bot) (tg.BotCommandScopeClass, error) {
90+
return &tg.BotCommandScopeChats{}, nil
91+
}
92+
93+
func (botCommandScopeAllChatAdministrators) resolve(context.Context, *Bot) (tg.BotCommandScopeClass, error) {
94+
return &tg.BotCommandScopeChatAdmins{}, nil
95+
}
96+
97+
func (s botCommandScopeChat) resolve(ctx context.Context, b *Bot) (tg.BotCommandScopeClass, error) {
98+
peer, err := b.resolveInputPeer(ctx, s.chat)
99+
if err != nil {
100+
return nil, err
101+
}
102+
return &tg.BotCommandScopePeer{Peer: peer}, nil
103+
}
104+
105+
func (s botCommandScopeChatAdministrators) resolve(ctx context.Context, b *Bot) (tg.BotCommandScopeClass, error) {
106+
peer, err := b.resolveInputPeer(ctx, s.chat)
107+
if err != nil {
108+
return nil, err
109+
}
110+
return &tg.BotCommandScopePeerAdmins{Peer: peer}, nil
111+
}
112+
113+
func (s botCommandScopeChatMember) resolve(ctx context.Context, b *Bot) (tg.BotCommandScopeClass, error) {
114+
peer, err := b.resolveInputPeer(ctx, s.chat)
115+
if err != nil {
116+
return nil, err
117+
}
118+
user, err := b.resolveInputUser(ctx, s.userID)
119+
if err != nil {
120+
return nil, err
121+
}
122+
return &tg.BotCommandScopePeerUser{Peer: peer, UserID: user}, nil
123+
}
124+
125+
// CommandOption configures a commands call (scope and language code).
126+
type CommandOption func(*commandConfig)
127+
128+
type commandConfig struct {
129+
scope BotCommandScope
130+
langCode string
131+
}
132+
133+
func (c commandConfig) resolveScope(ctx context.Context, b *Bot) (tg.BotCommandScopeClass, error) {
134+
if c.scope == nil {
135+
return &tg.BotCommandScopeDefault{}, nil
136+
}
137+
return c.scope.resolve(ctx, b)
138+
}
139+
140+
// WithCommandScope restricts the commands call to the given scope. When unset
141+
// the default scope is used.
142+
func WithCommandScope(scope BotCommandScope) CommandOption {
143+
return func(c *commandConfig) { c.scope = scope }
144+
}
145+
146+
// WithLanguageCode restricts the commands call to a two-letter language code.
147+
func WithLanguageCode(code string) CommandOption {
148+
return func(c *commandConfig) { c.langCode = code }
149+
}
150+
151+
// SetMyCommands sets the list of the bot's commands for the given scope and
152+
// language.
153+
func (b *Bot) SetMyCommands(ctx context.Context, commands []BotCommand, opts ...CommandOption) error {
154+
var cfg commandConfig
155+
for _, o := range opts {
156+
o(&cfg)
157+
}
158+
scope, err := cfg.resolveScope(ctx, b)
159+
if err != nil {
160+
return err
161+
}
162+
163+
cmds := make([]tg.BotCommand, len(commands))
164+
for i, c := range commands {
165+
cmds[i] = tg.BotCommand{Command: c.Command, Description: c.Description}
166+
}
167+
168+
if _, err := b.raw.BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
169+
Scope: scope,
170+
LangCode: cfg.langCode,
171+
Commands: cmds,
172+
}); err != nil {
173+
return asAPIError(err)
174+
}
175+
return nil
176+
}
177+
178+
// GetMyCommands returns the current list of the bot's commands for the given
179+
// scope and language.
180+
func (b *Bot) GetMyCommands(ctx context.Context, opts ...CommandOption) ([]BotCommand, error) {
181+
var cfg commandConfig
182+
for _, o := range opts {
183+
o(&cfg)
184+
}
185+
scope, err := cfg.resolveScope(ctx, b)
186+
if err != nil {
187+
return nil, err
188+
}
189+
190+
cmds, err := b.raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{
191+
Scope: scope,
192+
LangCode: cfg.langCode,
193+
})
194+
if err != nil {
195+
return nil, asAPIError(err)
196+
}
197+
198+
out := make([]BotCommand, len(cmds))
199+
for i, c := range cmds {
200+
out[i] = BotCommand{Command: c.Command, Description: c.Description}
201+
}
202+
return out, nil
203+
}
204+
205+
// DeleteMyCommands clears the list of the bot's commands for the given scope and
206+
// language, restoring the default commands.
207+
func (b *Bot) DeleteMyCommands(ctx context.Context, opts ...CommandOption) error {
208+
var cfg commandConfig
209+
for _, o := range opts {
210+
o(&cfg)
211+
}
212+
scope, err := cfg.resolveScope(ctx, b)
213+
if err != nil {
214+
return err
215+
}
216+
217+
if _, err := b.raw.BotsResetBotCommands(ctx, &tg.BotsResetBotCommandsRequest{
218+
Scope: scope,
219+
LangCode: cfg.langCode,
220+
}); err != nil {
221+
return asAPIError(err)
222+
}
223+
return nil
224+
}

commands_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func TestBotCommandScopeResolve(t *testing.T) {
11+
ctx := context.Background()
12+
cases := []struct {
13+
scope BotCommandScope
14+
want tg.BotCommandScopeClass
15+
}{
16+
{BotCommandScopeDefault(), &tg.BotCommandScopeDefault{}},
17+
{BotCommandScopeAllPrivateChats(), &tg.BotCommandScopeUsers{}},
18+
{BotCommandScopeAllGroupChats(), &tg.BotCommandScopeChats{}},
19+
{BotCommandScopeAllChatAdministrators(), &tg.BotCommandScopeChatAdmins{}},
20+
}
21+
for _, c := range cases {
22+
// The non-targeted variants ignore the *Bot receiver, so nil is fine.
23+
got, err := c.scope.resolve(ctx, nil)
24+
if err != nil {
25+
t.Fatalf("resolve %T: %v", c.scope, err)
26+
}
27+
if got.TypeID() != c.want.TypeID() {
28+
t.Fatalf("resolve %T: got %T, want %T", c.scope, got, c.want)
29+
}
30+
}
31+
}
32+
33+
func TestCommandConfigDefaultScope(t *testing.T) {
34+
var cfg commandConfig
35+
scope, err := cfg.resolveScope(context.Background(), nil)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
if _, ok := scope.(*tg.BotCommandScopeDefault); !ok {
40+
t.Fatalf("default scope: got %T", scope)
41+
}
42+
}

resolve.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,13 @@ func (b *Bot) resolveInputPeer(ctx context.Context, chat ChatID) (tg.InputPeerCl
4545
}
4646
return p.InputPeer(), nil
4747
}
48+
49+
// resolveInputUser resolves a Bot API user id to the tg.InputUserClass the
50+
// MTProto user-targeting methods need, pulling the access hash from storage.
51+
func (b *Bot) resolveInputUser(ctx context.Context, userID int64) (tg.InputUserClass, error) {
52+
u, err := b.peers.ResolveUserID(ctx, userID)
53+
if err != nil {
54+
return nil, asAPIError(err)
55+
}
56+
return u.InputUser(), nil
57+
}

0 commit comments

Comments
 (0)