Skip to content

Commit a90a3fa

Browse files
ernadoclaude
andcommitted
feat(commands): auto-register OnCommand handlers with Telegram
OnCommand now takes a description; the registered (command, description) pairs are collected on the Bot (deduped by name) and published to Telegram via SetMyCommands when the bot starts, so the client command menu stays in sync with the handlers. Opt out with Options.DisableCommandRegistration. Registration failure is logged, not fatal. Update examples and docs for the new signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f47051f commit a90a3fa

9 files changed

Lines changed: 122 additions & 18 deletions

File tree

bot.go

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package botapi
22

33
import (
44
"context"
5+
"strings"
6+
"sync"
57

68
"github.com/go-faster/errors"
79
"github.com/gotd/log"
@@ -33,6 +35,13 @@ type Bot struct {
3335

3436
onStart func(ctx context.Context)
3537

38+
// commands collects the (command, description) pairs registered through
39+
// OnCommand so Run can publish them to Telegram. registerCommands gates the
40+
// publish.
41+
commandsMu sync.Mutex
42+
commands []BotCommand
43+
registerCommands bool
44+
3645
self *tg.User
3746
}
3847

@@ -86,20 +95,58 @@ func New(token string, opt Options) (*Bot, error) {
8695
*rawPlaceholder = *client.API()
8796

8897
b := &Bot{
89-
token: token,
90-
log: opt.Logger,
91-
client: client,
92-
raw: client.API(),
93-
sender: message.NewSender(client.API()),
94-
peers: pm,
95-
gaps: gaps,
96-
disp: disp,
97-
onStart: opt.OnStart,
98+
token: token,
99+
log: opt.Logger,
100+
client: client,
101+
raw: client.API(),
102+
sender: message.NewSender(client.API()),
103+
peers: pm,
104+
gaps: gaps,
105+
disp: disp,
106+
onStart: opt.OnStart,
107+
registerCommands: !opt.DisableCommandRegistration,
98108
}
99109
b.installHandlers()
100110
return b, nil
101111
}
102112

113+
// registerCommand records a command registered through OnCommand so Run can
114+
// publish the set to Telegram. Duplicate names keep their first description.
115+
func (b *Bot) registerCommand(name, description string) {
116+
name = strings.TrimPrefix(name, "/")
117+
if name == "" {
118+
return
119+
}
120+
b.commandsMu.Lock()
121+
defer b.commandsMu.Unlock()
122+
for _, c := range b.commands {
123+
if c.Command == name {
124+
return
125+
}
126+
}
127+
b.commands = append(b.commands, BotCommand{Command: name, Description: description})
128+
}
129+
130+
// publishCommands reports the collected OnCommand set to Telegram (default
131+
// scope). Failures are logged, not fatal, since a bad description should not
132+
// stop the bot from serving.
133+
func (b *Bot) publishCommands(ctx context.Context) {
134+
if !b.registerCommands {
135+
return
136+
}
137+
b.commandsMu.Lock()
138+
cmds := append([]BotCommand(nil), b.commands...)
139+
b.commandsMu.Unlock()
140+
if len(cmds) == 0 {
141+
return
142+
}
143+
if err := b.SetMyCommands(ctx, cmds); err != nil {
144+
b.log.Warn("Register bot commands", zap.Error(err))
145+
return
146+
}
147+
b.log.Debug("Registered bot commands", zap.Int("count", len(cmds)))
148+
}
149+
103150
// Run connects, authorizes as a bot, and blocks serving updates until ctx is
104151
// canceled or a fatal error occurs. Register handlers before calling Run.
105152
func (b *Bot) Run(ctx context.Context) error {
@@ -134,6 +181,7 @@ func (b *Bot) Run(ctx context.Context) error {
134181
zap.Int64("id", me.ID),
135182
zap.String("username", me.Username),
136183
)
184+
b.publishCommands(ctx)
137185
if b.onStart != nil {
138186
b.onStart(ctx)
139187
}

commands_register_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package botapi
2+
3+
import "testing"
4+
5+
func TestOnCommandCollectsCommands(t *testing.T) {
6+
b := newTestBot(t)
7+
noop := func(*Context) error { return nil }
8+
9+
b.OnCommand("start", "Start the bot", noop)
10+
b.OnCommand("/help", "Show help", noop) // leading slash is stripped
11+
b.Group().OnCommand("ban", "Ban a user", noop)
12+
b.OnCommand("start", "duplicate", noop) // dedup by name, keep first description
13+
14+
if len(b.commands) != 3 {
15+
t.Fatalf("want 3 commands, got %d: %#v", len(b.commands), b.commands)
16+
}
17+
want := map[string]string{
18+
"start": "Start the bot",
19+
"help": "Show help",
20+
"ban": "Ban a user",
21+
}
22+
for _, c := range b.commands {
23+
if want[c.Command] != c.Description {
24+
t.Fatalf("command %q: got %q, want %q", c.Command, c.Description, want[c.Command])
25+
}
26+
}
27+
}
28+
29+
func TestRegisterCommandIgnoresEmpty(t *testing.T) {
30+
b := newTestBot(t)
31+
b.registerCommand("", "no name")
32+
b.registerCommand("/", "slash only")
33+
if len(b.commands) != 0 {
34+
t.Fatalf("empty command names should be ignored, got %#v", b.commands)
35+
}
36+
}
37+
38+
func TestDisableCommandRegistration(t *testing.T) {
39+
b, err := New("123:abc", Options{AppID: 1, AppHash: "h", DisableCommandRegistration: true})
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
if b.registerCommands {
44+
t.Fatal("registerCommands should be false when disabled")
45+
}
46+
}

doc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
// if err != nil {
1919
// return err
2020
// }
21-
// bot.OnCommand("start", func(c *botapi.Context) error {
21+
// bot.OnCommand("start", "Start the bot", func(c *botapi.Context) error {
2222
// _, err := c.Reply("hello")
2323
// return err
2424
// })

examples/buttons/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func main() {
5454

5555
bot.Use(botapi.Recover(), botapi.Timeout(30*time.Second))
5656

57-
bot.OnCommand("menu", func(c *botapi.Context) error {
57+
bot.OnCommand("menu", "Show the voting menu", func(c *botapi.Context) error {
5858
_, err := c.Reply("How do you like this bot?", botapi.WithReplyMarkup(menu()))
5959
return err
6060
})

examples/echo/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func main() {
4040
// Middleware applies to every handler.
4141
bot.Use(botapi.Recover(), botapi.Timeout(30*time.Second))
4242

43-
bot.OnCommand("start", func(c *botapi.Context) error {
43+
bot.OnCommand("start", "Show the welcome message", func(c *botapi.Context) error {
4444
_, err := c.Reply("Hi! Send me any text and I'll echo it back.")
4545
return err
4646
})

examples/media/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ func main() {
4848

4949
bot.Use(botapi.Recover(), botapi.Timeout(time.Minute), botapi.Logging())
5050

51-
bot.OnCommand("start", func(c *botapi.Context) error {
51+
bot.OnCommand("start", "Show the welcome message", func(c *botapi.Context) error {
5252
_, err := c.Reply("Send me a photo or a file and I'll send it back. Or try /photo.")
5353
return err
5454
})
5555

5656
// Send a photo by URL — Telegram fetches it server-side.
57-
bot.OnCommand("photo", func(c *botapi.Context) error {
57+
bot.OnCommand("photo", "Send a sample photo", func(c *botapi.Context) error {
5858
chat, ok := c.Chat()
5959
if !ok {
6060
return nil

group.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ package botapi
88
//
99
// admin := bot.Group(ChatTypeIs(ChatTypeSupergroup))
1010
// admin.Use(Recover())
11-
// admin.OnCommand("ban", banHandler)
11+
// admin.OnCommand("ban", "Ban a user", banHandler)
1212
type Group struct {
1313
bot *Bot
1414
preds []Predicate
@@ -46,6 +46,7 @@ func (g *Group) OnCallbackQuery(h Handler, predicates ...Predicate) {
4646
}
4747

4848
// OnCommand registers a command handler in the group.
49-
func (g *Group) OnCommand(name string, h Handler, predicates ...Predicate) {
49+
func (g *Group) OnCommand(name, description string, h Handler, predicates ...Predicate) {
50+
g.bot.registerCommand(name, description)
5051
g.OnMessage(h, prepend(Command(name), predicates)...)
5152
}

options.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ type Options struct {
6060
// RequestBurst is the token-bucket burst size for RequestsPerSecond. Defaults
6161
// to 1 when RequestsPerSecond is set.
6262
RequestBurst int
63+
64+
// DisableCommandRegistration stops Run from publishing the commands
65+
// registered via OnCommand to Telegram (SetMyCommands, default scope). By
66+
// default the bot's command menu is kept in sync with its OnCommand handlers.
67+
DisableCommandRegistration bool
6368
}
6469

6570
func (o *Options) setDefaults() {

predicates.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ func Or(predicates ...Predicate) Predicate {
139139
}
140140
}
141141

142-
// OnCommand registers a handler for the given bot command (e.g. "start").
143-
func (b *Bot) OnCommand(name string, h Handler, predicates ...Predicate) {
142+
// OnCommand registers a handler for the given bot command (e.g. "start"). The
143+
// description is shown in the client's command menu; unless command
144+
// registration is disabled, Run publishes the registered commands to Telegram
145+
// via SetMyCommands.
146+
func (b *Bot) OnCommand(name, description string, h Handler, predicates ...Predicate) {
147+
b.registerCommand(name, description)
144148
b.OnMessage(h, prepend(Command(name), predicates)...)
145149
}

0 commit comments

Comments
 (0)