Skip to content

Commit e25a1ad

Browse files
ernadoclaude
andcommitted
feat(handler): add built-in predicates and OnCommand
Update.EffectiveMessage/Text accessors plus predicates: Command (slash- and @botusername-aware), HasPrefix, HasText, TextEquals, Regex, ChatTypeIs, CallbackData, CallbackPrefix, and the Not/Or combinators. OnCommand registers a message handler guarded by Command. Command parsing is unit tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c39b0f1 commit e25a1ad

2 files changed

Lines changed: 207 additions & 0 deletions

File tree

predicates.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package botapi
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// EffectiveMessage returns the message carried by the update, regardless of
9+
// whether it is a new/edited message or channel post. It is nil for updates
10+
// that carry no message (e.g. callback or inline queries).
11+
func (u *Update) EffectiveMessage() *Message {
12+
switch {
13+
case u.Message != nil:
14+
return u.Message
15+
case u.EditedMessage != nil:
16+
return u.EditedMessage
17+
case u.ChannelPost != nil:
18+
return u.ChannelPost
19+
case u.EditedChannelPost != nil:
20+
return u.EditedChannelPost
21+
default:
22+
return nil
23+
}
24+
}
25+
26+
// Text returns the text of the effective message, or the callback query data,
27+
// or empty when neither is present.
28+
func (u *Update) Text() string {
29+
if m := u.EffectiveMessage(); m != nil {
30+
return m.Text
31+
}
32+
if u.CallbackQuery != nil {
33+
return u.CallbackQuery.Data
34+
}
35+
return ""
36+
}
37+
38+
// commandName extracts the bot command name from message text: "/start@bot foo"
39+
// yields ("start", true). Pure.
40+
func commandName(text string) (string, bool) {
41+
if !strings.HasPrefix(text, "/") {
42+
return "", false
43+
}
44+
field := text
45+
if i := strings.IndexAny(text, " \t\n"); i >= 0 {
46+
field = text[:i]
47+
}
48+
field = field[1:] // drop leading slash
49+
if at := strings.IndexByte(field, '@'); at >= 0 {
50+
field = field[:at]
51+
}
52+
return field, field != ""
53+
}
54+
55+
// Command matches a message whose first token is the given bot command (with or
56+
// without a leading slash, and ignoring a trailing @botusername).
57+
func Command(name string) Predicate {
58+
name = strings.TrimPrefix(name, "/")
59+
return func(u *Update) bool {
60+
m := u.EffectiveMessage()
61+
if m == nil {
62+
return false
63+
}
64+
got, ok := commandName(m.Text)
65+
return ok && got == name
66+
}
67+
}
68+
69+
// HasPrefix matches a message whose text starts with prefix.
70+
func HasPrefix(prefix string) Predicate {
71+
return func(u *Update) bool {
72+
m := u.EffectiveMessage()
73+
return m != nil && strings.HasPrefix(m.Text, prefix)
74+
}
75+
}
76+
77+
// HasText matches any message that carries non-empty text.
78+
func HasText() Predicate {
79+
return func(u *Update) bool {
80+
m := u.EffectiveMessage()
81+
return m != nil && m.Text != ""
82+
}
83+
}
84+
85+
// TextEquals matches a message whose text equals s exactly.
86+
func TextEquals(s string) Predicate {
87+
return func(u *Update) bool {
88+
m := u.EffectiveMessage()
89+
return m != nil && m.Text == s
90+
}
91+
}
92+
93+
// Regex matches a message whose text matches the pattern. It panics if the
94+
// pattern does not compile (a programming error caught at registration).
95+
func Regex(pattern string) Predicate {
96+
re := regexp.MustCompile(pattern)
97+
return func(u *Update) bool {
98+
m := u.EffectiveMessage()
99+
return m != nil && re.MatchString(m.Text)
100+
}
101+
}
102+
103+
// ChatTypeIs matches a message sent in a chat of the given type.
104+
func ChatTypeIs(t ChatType) Predicate {
105+
return func(u *Update) bool {
106+
m := u.EffectiveMessage()
107+
return m != nil && m.Chat.Type == t
108+
}
109+
}
110+
111+
// CallbackData matches a callback query whose data equals s.
112+
func CallbackData(s string) Predicate {
113+
return func(u *Update) bool {
114+
return u.CallbackQuery != nil && u.CallbackQuery.Data == s
115+
}
116+
}
117+
118+
// CallbackPrefix matches a callback query whose data starts with prefix.
119+
func CallbackPrefix(prefix string) Predicate {
120+
return func(u *Update) bool {
121+
return u.CallbackQuery != nil && strings.HasPrefix(u.CallbackQuery.Data, prefix)
122+
}
123+
}
124+
125+
// Not inverts a predicate.
126+
func Not(p Predicate) Predicate {
127+
return func(u *Update) bool { return !p(u) }
128+
}
129+
130+
// Or matches when any of the given predicates matches.
131+
func Or(predicates ...Predicate) Predicate {
132+
return func(u *Update) bool {
133+
for _, p := range predicates {
134+
if p(u) {
135+
return true
136+
}
137+
}
138+
return false
139+
}
140+
}
141+
142+
// OnCommand registers a handler for the given bot command (e.g. "start").
143+
func (b *Bot) OnCommand(name string, h Handler, predicates ...Predicate) {
144+
b.OnMessage(h, prepend(Command(name), predicates)...)
145+
}

predicates_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package botapi
2+
3+
import "testing"
4+
5+
func TestCommandName(t *testing.T) {
6+
cases := map[string]struct {
7+
want string
8+
ok bool
9+
}{
10+
"/start": {"start", true},
11+
"/start@mybot": {"start", true},
12+
"/help me please": {"help", true},
13+
"/cmd@bot with args": {"cmd", true},
14+
"not a command": {"", false},
15+
"/": {"", false},
16+
}
17+
for text, want := range cases {
18+
got, ok := commandName(text)
19+
if got != want.want || ok != want.ok {
20+
t.Fatalf("commandName(%q) = (%q, %v), want (%q, %v)", text, got, ok, want.want, want.ok)
21+
}
22+
}
23+
}
24+
25+
func TestCommandPredicate(t *testing.T) {
26+
msg := &Update{Message: &Message{Text: "/start@mybot hi"}}
27+
if !Command("start")(msg) || !Command("/start")(msg) {
28+
t.Fatal("Command should match with and without slash")
29+
}
30+
if Command("help")(msg) {
31+
t.Fatal("Command should not match a different command")
32+
}
33+
if Command("start")(&Update{CallbackQuery: &CallbackQuery{}}) {
34+
t.Fatal("Command should not match a non-message update")
35+
}
36+
}
37+
38+
func TestTextAndChatPredicates(t *testing.T) {
39+
u := &Update{Message: &Message{Text: "hello world", Chat: Chat{Type: ChatTypePrivate}}}
40+
if !HasPrefix("hello")(u) || !HasText()(u) || !Regex(`^hello`)(u) {
41+
t.Fatal("text predicates should match")
42+
}
43+
if !ChatTypeIs(ChatTypePrivate)(u) || ChatTypeIs(ChatTypeChannel)(u) {
44+
t.Fatal("ChatTypeIs mismatch")
45+
}
46+
if !Not(TextEquals("nope"))(u) {
47+
t.Fatal("Not should invert")
48+
}
49+
}
50+
51+
func TestCallbackPredicates(t *testing.T) {
52+
u := &Update{CallbackQuery: &CallbackQuery{Data: "vote:42"}}
53+
if !CallbackPrefix("vote:")(u) || !CallbackData("vote:42")(u) {
54+
t.Fatal("callback predicates should match")
55+
}
56+
if !Or(CallbackData("x"), CallbackPrefix("vote:"))(u) {
57+
t.Fatal("Or should match when one matches")
58+
}
59+
if u.Text() != "vote:42" {
60+
t.Fatalf("Update.Text for callback = %q", u.Text())
61+
}
62+
}

0 commit comments

Comments
 (0)