ScuttleBot

feat: shared bot command framework (cmdparse) Add internal/bots/cmdparse package — reusable command router for system bots. Handles DM, !fantasy, and botname: addressed input forms with case-insensitive matching. Auto-generates HELP output from registered commands. Unknown commands return helpful error with available command list. Not wired into any bots yet. Closes #85

lmata 2026-04-04 19:43 trunk
Commit 699b1090c0c49b7f66db53472ccf46a541bd2ee18e806345a7e0edd319117185
--- a/internal/bots/cmdparse/cmdparse.go
+++ b/internal/bots/cmdparse/cmdparse.go
@@ -0,0 +1,240 @@
1
+// Package cmdparse provides a shared command framework for system bots.
2
+//
3
+// It handles three IRC input forms:
4
+// - DM: /msg botname COMMAND args
5
+// - Fantasy: !command args (in a channel)
6
+// - Addressed: botname: command args (in a channel)
7
+//
8
+// Bots register commands into a CommandRouter, which dispatches incoming
9
+// messages and auto-generates HELP output.
10
+package cmdparse
11
+
12
+import (
13
+ "fmt"
14
+ "sort"
15
+ "strings"
16
+)
17
+
18
+// HandlerFunc is called when a command is matched.
19
+// args is everything after the command name, already trimmed.
20
+// Returns the text to send back (may be multi-line).
21
+type HandlerFunc func(ctx *Context, args string) string
22
+
23
+// Command describes a single bot command.
24
+type Command struct {
25
+ // Name is the canonical command name (case-insensitive matching).
26
+ Name string
27
+ // Usage is a one-line usage string shown in help, e.g. "replay #channel [last=N]".
28
+ Usage string
29
+ // Description is a short description shown in help.
30
+ Description string
31
+ // Handler is called when the command is matched.
32
+ Handler HandlerFunc
33
+}
34
+
35
+// Context carries information about the parsed incoming message.
36
+type Context struct {
37
+ // Nick is the sender's IRC nick.
38
+ Nick string
39
+ // Channel is the channel the message was sent in, or "" for a DM.
40
+ Channel string
41
+ // IsDM is true when the message was sent as a private message.
42
+ IsDM bool
43
+}
44
+
45
+// Reply is a response produced by the router after dispatching a message.
46
+type Reply struct {
47
+ // Target is where the reply should be sent (nick for DM, channel for channel).
48
+ Target string
49
+ // Text is the reply text (may be multi-line).
50
+ Text string
51
+}
52
+
53
+// CommandRouter dispatches IRC messages to registered command handlers.
54
+type CommandRouter struct {
55
+ botNick string
56
+ commands map[string]*Command // lowercase name → command
57
+}
58
+
59
+// NewRouter creates a CommandRouter for a bot with the given IRC nick.
60
+func NewRouter(botNick string) *CommandRouter {
61
+ return &CommandRouter{
62
+ botNick: strings.ToLower(botNick),
63
+ commands: make(map[string]*Command),
64
+ }
65
+}
66
+
67
+// Register adds a command to the router. Panics if name is empty or duplicate.
68
+func (r *CommandRouter) Register(cmd Command) {
69
+ key := strings.ToLower(cmd.Name)
70
+ if key == "" {
71
+ panic("cmdparse: command name must not be empty")
72
+ }
73
+ if _, ok := r.commands[key]; ok {
74
+ panic(fmt.Sprintf("cmdparse: duplicate command %q", cmd.Name))
75
+ }
76
+ r.commands[key] = &cmd
77
+}
78
+
79
+// Dispatch parses an IRC message and dispatches it to the appropriate handler.
80
+// Returns nil if the message is not a command addressed to this bot.
81
+//
82
+// Parameters:
83
+// - nick: sender's IRC nick
84
+// - target: IRC target param (channel name for channel messages, bot nick for DMs)
85
+// - text: the message body
86
+func (r *CommandRouter) Dispatch(nick, target, text string) *Reply {
87
+ text = strings.TrimSpace(text)
88
+ if text == "" {
89
+ return nil
90
+ }
91
+
92
+ var ctx Context
93
+ ctx.Nick = nick
94
+
95
+ isChannel := strings.HasPrefix(target, "#")
96
+
97
+ var cmdLine string
98
+
99
+ if !isChannel {
100
+ // DM — entire text is the command line.
101
+ ctx.IsDM = true
102
+ cmdLine = text
103
+ } else {
104
+ ctx.Channel = target
105
+ if strings.HasPrefix(text, "!") {
106
+ // Fantasy: !command args
107
+ cmdLine = text[1:]
108
+ } else if r.isAddressed(text) {
109
+ // Addressed: botname: command args
110
+ cmdLine = r.stripAddress(text)
111
+ } else {
112
+ return nil
113
+ }
114
+ }
115
+
116
+ cmdLine = strings.TrimSpace(cmdLine)
117
+ if cmdLine == "" {
118
+ return nil
119
+ }
120
+
121
+ cmdName, args := splitFirst(cmdLine)
122
+ cmdKey := strings.ToLower(cmdName)
123
+
124
+ // Built-in HELP.
125
+ if cmdKey == "help" {
126
+ return r.helpReply(&ctx, args)
127
+ }
128
+
129
+ cmd, ok := r.commands[cmdKey]
130
+ if !ok {
131
+ return r.unknownReply(&ctx, cmdName)
132
+ }
133
+
134
+ response := cmd.Handler(&ctx, args)
135
+ if response == "" {
136
+ return nil
137
+ }
138
+
139
+ return &Reply{
140
+ Target: r.replyTarget(&ctx),
141
+ Text: response,
142
+ }
143
+}
144
+
145
+func splitFirst(s string) (first, rest string) {
146
+ s = strings.TrimSpace(s)
147
+ idx := strings.IndexAny(s, " \t")
148
+ if idx < 0 {
149
+ return s, ""
150
+ }
151
+ return s[:idx], strings.TrimSpace(s[idx+1:])
152
+}
153
+
154
+func (r *CommandRouter) isAddressed(text string) bool {
155
+ lower := strings.ToLower(text)
156
+ for _, sep := range []string{": ", ","} {
157
+ prefix := r.botNick + sep
158
+ if strings.HasPrefix(lower, prefix) {
159
+ return true
160
+ }
161
+ }
162
+ // Also handle "botname:" with no space after colon.
163
+ if strings.HasPrefix(lower, r.botNick+":") {
164
+ return true
165
+ }
166
+ return false
167
+}
168
+
169
+func (r *CommandRouter) stripAddress(text string) string {
170
+ lower := strings.ToLower(text)
171
+ for _, sep := range []string{": ", " _, sep := range []string{": ", ","} {
172
+ prefix := r.botNick + sep
173
+ if strings.HasPrefix(lower, prefix) {
174
+ return strings.TrimSpace(text[len(prefix):])
175
+ }
176
+ }
177
+ // Bare "botname:" with no space.
178
+ prefix := r.botNick + ":"
179
+ if strings.HasPrefix(lower, prefix) {
180
+ return strings.TrimSpace(text[len(prefix):])
181
+ }
182
+ return text
183
+}
184
+
185
+func (r *CommandRouter) replyTarget(ctx *Context) string {
186
+ if ctx.IsDM {
187
+ return ctx.Nick
188
+ }
189
+ return ctx.Channel
190
+}
191
+
192
+func (r *CommandRouter) helpReply(ctx *Context, args string) *Reply {
193
+ args = strings.TrimSpace(args)
194
+
195
+ if args != "" {
196
+ cmdKey := strings.ToLower(args)
197
+ cmd, ok := r.commands[cmdKey]
198
+ if !ok {
199
+ return &Reply{
200
+ Target: r.replyTarget(ctx),
201
+ Text: fmt.Sprintf("unknown command %q — type HELP for a list of commands", args),
202
+ }
203
+ }
204
+ return &Reply{
205
+ Target: r.replyTarget(ctx),
206
+ Text: fmt.Sprintf("%s — %s\nusage: %s", strings.ToUpper(cmd.Name), cmd.Description, cmd.Usage),
207
+ }
208
+ }
209
+
210
+ names := make([]string, 0, len(r.commands))
211
+ for k := range r.commands {
212
+ names = append(names, k)
213
+ }
214
+ sort.Strings(names)
215
+
216
+ var sb strings.Builder
217
+ sb.WriteString(fmt.Sprintf("commands for %s:\n", r.botNick))
218
+ for _, name := range names {
219
+ cmd := r.commands[name]
220
+ sb.WriteString(fmt.Sprintf(" %-12s %s\n", strings.ToUpper(cmd.Name), cmd.Description))
221
+ }
222
+ sb.WriteString("type HELP <command> for details")
223
+
224
+ return &Reply{
225
+ Target: r.replyTarget(ctx),
226
+ Text: sb.String(),
227
+ }
228
+}
229
+
230
+func (r *CommandRouter) unknownReply(ctx *Context, cmdName string) *Reply {
231
+ names := make([]string, 0, len(r.commands))
232
+ for k := range r.commands {
233
+ names = append(names, strings.ToUpper(k))
234
+ }
235
+ sort.Strings(names)
236
+
237
+ return &Reply{
238
+ Target: r.replyTarget(ctx),
239
+ Text: fmt.Sprintf("unknown command %q — available commands: %s. Type HELP for details.",
240
+ cmdName, string
--- a/internal/bots/cmdparse/cmdparse.go
+++ b/internal/bots/cmdparse/cmdparse.go
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/cmdparse/cmdparse.go
+++ b/internal/bots/cmdparse/cmdparse.go
@@ -0,0 +1,240 @@
1 // Package cmdparse provides a shared command framework for system bots.
2 //
3 // It handles three IRC input forms:
4 // - DM: /msg botname COMMAND args
5 // - Fantasy: !command args (in a channel)
6 // - Addressed: botname: command args (in a channel)
7 //
8 // Bots register commands into a CommandRouter, which dispatches incoming
9 // messages and auto-generates HELP output.
10 package cmdparse
11
12 import (
13 "fmt"
14 "sort"
15 "strings"
16 )
17
18 // HandlerFunc is called when a command is matched.
19 // args is everything after the command name, already trimmed.
20 // Returns the text to send back (may be multi-line).
21 type HandlerFunc func(ctx *Context, args string) string
22
23 // Command describes a single bot command.
24 type Command struct {
25 // Name is the canonical command name (case-insensitive matching).
26 Name string
27 // Usage is a one-line usage string shown in help, e.g. "replay #channel [last=N]".
28 Usage string
29 // Description is a short description shown in help.
30 Description string
31 // Handler is called when the command is matched.
32 Handler HandlerFunc
33 }
34
35 // Context carries information about the parsed incoming message.
36 type Context struct {
37 // Nick is the sender's IRC nick.
38 Nick string
39 // Channel is the channel the message was sent in, or "" for a DM.
40 Channel string
41 // IsDM is true when the message was sent as a private message.
42 IsDM bool
43 }
44
45 // Reply is a response produced by the router after dispatching a message.
46 type Reply struct {
47 // Target is where the reply should be sent (nick for DM, channel for channel).
48 Target string
49 // Text is the reply text (may be multi-line).
50 Text string
51 }
52
53 // CommandRouter dispatches IRC messages to registered command handlers.
54 type CommandRouter struct {
55 botNick string
56 commands map[string]*Command // lowercase name → command
57 }
58
59 // NewRouter creates a CommandRouter for a bot with the given IRC nick.
60 func NewRouter(botNick string) *CommandRouter {
61 return &CommandRouter{
62 botNick: strings.ToLower(botNick),
63 commands: make(map[string]*Command),
64 }
65 }
66
67 // Register adds a command to the router. Panics if name is empty or duplicate.
68 func (r *CommandRouter) Register(cmd Command) {
69 key := strings.ToLower(cmd.Name)
70 if key == "" {
71 panic("cmdparse: command name must not be empty")
72 }
73 if _, ok := r.commands[key]; ok {
74 panic(fmt.Sprintf("cmdparse: duplicate command %q", cmd.Name))
75 }
76 r.commands[key] = &cmd
77 }
78
79 // Dispatch parses an IRC message and dispatches it to the appropriate handler.
80 // Returns nil if the message is not a command addressed to this bot.
81 //
82 // Parameters:
83 // - nick: sender's IRC nick
84 // - target: IRC target param (channel name for channel messages, bot nick for DMs)
85 // - text: the message body
86 func (r *CommandRouter) Dispatch(nick, target, text string) *Reply {
87 text = strings.TrimSpace(text)
88 if text == "" {
89 return nil
90 }
91
92 var ctx Context
93 ctx.Nick = nick
94
95 isChannel := strings.HasPrefix(target, "#")
96
97 var cmdLine string
98
99 if !isChannel {
100 // DM — entire text is the command line.
101 ctx.IsDM = true
102 cmdLine = text
103 } else {
104 ctx.Channel = target
105 if strings.HasPrefix(text, "!") {
106 // Fantasy: !command args
107 cmdLine = text[1:]
108 } else if r.isAddressed(text) {
109 // Addressed: botname: command args
110 cmdLine = r.stripAddress(text)
111 } else {
112 return nil
113 }
114 }
115
116 cmdLine = strings.TrimSpace(cmdLine)
117 if cmdLine == "" {
118 return nil
119 }
120
121 cmdName, args := splitFirst(cmdLine)
122 cmdKey := strings.ToLower(cmdName)
123
124 // Built-in HELP.
125 if cmdKey == "help" {
126 return r.helpReply(&ctx, args)
127 }
128
129 cmd, ok := r.commands[cmdKey]
130 if !ok {
131 return r.unknownReply(&ctx, cmdName)
132 }
133
134 response := cmd.Handler(&ctx, args)
135 if response == "" {
136 return nil
137 }
138
139 return &Reply{
140 Target: r.replyTarget(&ctx),
141 Text: response,
142 }
143 }
144
145 func splitFirst(s string) (first, rest string) {
146 s = strings.TrimSpace(s)
147 idx := strings.IndexAny(s, " \t")
148 if idx < 0 {
149 return s, ""
150 }
151 return s[:idx], strings.TrimSpace(s[idx+1:])
152 }
153
154 func (r *CommandRouter) isAddressed(text string) bool {
155 lower := strings.ToLower(text)
156 for _, sep := range []string{": ", ","} {
157 prefix := r.botNick + sep
158 if strings.HasPrefix(lower, prefix) {
159 return true
160 }
161 }
162 // Also handle "botname:" with no space after colon.
163 if strings.HasPrefix(lower, r.botNick+":") {
164 return true
165 }
166 return false
167 }
168
169 func (r *CommandRouter) stripAddress(text string) string {
170 lower := strings.ToLower(text)
171 for _, sep := range []string{": ", " _, sep := range []string{": ", ","} {
172 prefix := r.botNick + sep
173 if strings.HasPrefix(lower, prefix) {
174 return strings.TrimSpace(text[len(prefix):])
175 }
176 }
177 // Bare "botname:" with no space.
178 prefix := r.botNick + ":"
179 if strings.HasPrefix(lower, prefix) {
180 return strings.TrimSpace(text[len(prefix):])
181 }
182 return text
183 }
184
185 func (r *CommandRouter) replyTarget(ctx *Context) string {
186 if ctx.IsDM {
187 return ctx.Nick
188 }
189 return ctx.Channel
190 }
191
192 func (r *CommandRouter) helpReply(ctx *Context, args string) *Reply {
193 args = strings.TrimSpace(args)
194
195 if args != "" {
196 cmdKey := strings.ToLower(args)
197 cmd, ok := r.commands[cmdKey]
198 if !ok {
199 return &Reply{
200 Target: r.replyTarget(ctx),
201 Text: fmt.Sprintf("unknown command %q — type HELP for a list of commands", args),
202 }
203 }
204 return &Reply{
205 Target: r.replyTarget(ctx),
206 Text: fmt.Sprintf("%s — %s\nusage: %s", strings.ToUpper(cmd.Name), cmd.Description, cmd.Usage),
207 }
208 }
209
210 names := make([]string, 0, len(r.commands))
211 for k := range r.commands {
212 names = append(names, k)
213 }
214 sort.Strings(names)
215
216 var sb strings.Builder
217 sb.WriteString(fmt.Sprintf("commands for %s:\n", r.botNick))
218 for _, name := range names {
219 cmd := r.commands[name]
220 sb.WriteString(fmt.Sprintf(" %-12s %s\n", strings.ToUpper(cmd.Name), cmd.Description))
221 }
222 sb.WriteString("type HELP <command> for details")
223
224 return &Reply{
225 Target: r.replyTarget(ctx),
226 Text: sb.String(),
227 }
228 }
229
230 func (r *CommandRouter) unknownReply(ctx *Context, cmdName string) *Reply {
231 names := make([]string, 0, len(r.commands))
232 for k := range r.commands {
233 names = append(names, strings.ToUpper(k))
234 }
235 sort.Strings(names)
236
237 return &Reply{
238 Target: r.replyTarget(ctx),
239 Text: fmt.Sprintf("unknown command %q — available commands: %s. Type HELP for details.",
240 cmdName, string
--- a/internal/bots/cmdparse/cmdparse_test.go
+++ b/internal/bots/cmdparse/cmdparse_test.go
@@ -0,0 +1,421 @@
1
+package cmdparse
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+)
7
+
8
+func testRouter() *CommandRouter {
9
+ r := NewRouter("scroll")
10
+ r.Register(Command{
11
+ Name: "replay",
12
+ Usage: "replay #channel [last=N]",
13
+ Description: "replay channel history",
14
+ Handler: func(ctx *Context, args string) string {
15
+ return "replaying: " + args
16
+ },
17
+ })
18
+ r.Register(Command{
19
+ Name: "status",
20
+ Usage: "status",
21
+ Description: "show bot status",
22
+ Handler: func(ctx *Context, args string) string {
23
+ return "ok"
24
+ },
25
+ })
26
+ return r
27
+}
28
+
29
+// --- DM input form ---
30
+
31
+func TestDM_BasicCommand(t *testing.T) {
32
+ r := testRouter()
33
+ reply := r.Dispatch("alice", "scroll", "replay #general last=10")
34
+ if reply == nil {
35
+ t.Fatal("expected reply, got nil")
36
+ }
37
+ if reply.Target != "alice" {
38
+ t.Errorf("target = %q, want %q", reply.Target, "alice")
39
+ }
40
+ if reply.Text != "replaying: #general last=10" {
41
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #general last=10")
42
+ }
43
+}
44
+
45
+func TestDM_CaseInsensitive(t *testing.T) {
46
+ r := testRouter()
47
+ reply := r.Dispatch("alice", "scroll", "REPLAY #general")
48
+ if reply == nil {
49
+ t.Fatal("expected reply, got nil")
50
+ }
51
+ if reply.Text != "replaying: #general" {
52
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #general")
53
+ }
54
+}
55
+
56
+func TestDM_NoArgs(t *testing.T) {
57
+ r := testRouter()
58
+ reply := r.Dispatch("alice", "scroll", "status")
59
+ if reply == nil {
60
+ t.Fatal("expected reply, got nil")
61
+ }
62
+ if reply.Text != "ok" {
63
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
64
+ }
65
+}
66
+
67
+func TestDM_EmptyMessage(t *testing.T) {
68
+ r := testRouter()
69
+ reply := r.Dispatch("alice", "scroll", "")
70
+ if reply != nil {
71
+ t.Errorf("expected nil for empty message, got %+v", reply)
72
+ }
73
+}
74
+
75
+func TestDM_WhitespaceOnly(t *testing.T) {
76
+ r := testRouter()
77
+ reply := r.Dispatch("alice", "scroll", " ")
78
+ if reply != nil {
79
+ t.Errorf("expected nil for whitespace-only, got %+v", reply)
80
+ }
81
+}
82
+
83
+func TestDM_ContextFields(t *testing.T) {
84
+ r := NewRouter("testbot")
85
+ var gotCtx *Context
86
+ r.Register(Command{
87
+ Name: "ping",
88
+ Usage: "ping",
89
+ Description: "ping",
90
+ Handler: func(ctx *Context, args string) string {
91
+ gotCtx = ctx
92
+ return "pong"
93
+ },
94
+ })
95
+ r.Dispatch("bob", "testbot", "ping")
96
+ if gotCtx == nil {
97
+ t.Fatal("handler not called")
98
+ }
99
+ if !gotCtx.IsDM {
100
+ t.Error("expected IsDM=true")
101
+ }
102
+ if gotCtx.Channel != "" {
103
+ t.Errorf("channel = %q, want empty", gotCtx.Channel)
104
+ }
105
+ if gotCtx.Nick != "bob" {
106
+ t.Errorf("nick = %q, want %q", gotCtx.Nick, "bob")
107
+ }
108
+}
109
+
110
+// --- Fantasy input form ---
111
+
112
+func TestFantasy_BasicCommand(t *testing.T) {
113
+ r := testRouter()
114
+ reply := r.Dispatch("alice", "#general", "!replay #logs last=20")
115
+ if reply == nil {
116
+ t.Fatal("expected reply, got nil")
117
+ }
118
+ if reply.Target != "#general" {
119
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
120
+ }
121
+ if reply.Text != "replaying: #logs last=20" {
122
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs last=20")
123
+ }
124
+}
125
+
126
+func TestFantasy_CaseInsensitive(t *testing.T) {
127
+ r := testRouter()
128
+ reply := r.Dispatch("alice", "#general", "!STATUS")
129
+ if reply == nil {
130
+ t.Fatal("expected reply, got nil")
131
+ }
132
+ if reply.Text != "ok" {
133
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
134
+ }
135
+}
136
+
137
+func TestFantasy_ContextFields(t *testing.T) {
138
+ r := NewRouter("testbot")
139
+ var gotCtx *Context
140
+ r.Register(Command{
141
+ Name: "ping",
142
+ Usage: "ping",
143
+ Description: "ping",
144
+ Handler: func(ctx *Context, args string) string {
145
+ gotCtx = ctx
146
+ return "pong"
147
+ },
148
+ })
149
+ r.Dispatch("bob", "#dev", "!ping")
150
+ if gotCtx == nil {
151
+ t.Fatal("handler not called")
152
+ }
153
+ if gotCtx.IsDM {
154
+ t.Error("expected IsDM=false")
155
+ }
156
+ if gotCtx.Channel != "#dev" {
157
+ t.Errorf("channel = %q, want %q", gotCtx.Channel, "#dev")
158
+ }
159
+}
160
+
161
+func TestFantasy_BangOnly(t *testing.T) {
162
+ r := testRouter()
163
+ reply := r.Dispatch("alice", "#general", "!")
164
+ if reply != nil {
165
+ t.Errorf("expected nil for bare !, got %+v", reply)
166
+ }
167
+}
168
+
169
+func TestFantasy_NotAddressed(t *testing.T) {
170
+ r := testRouter()
171
+ reply := r.Dispatch("alice", "#general", "just a normal message")
172
+ if reply != nil {
173
+ t.Errorf("expected nil for unaddressed channel message, got %+v", reply)
174
+ }
175
+}
176
+
177
+// --- Addressed input form ---
178
+
179
+func TestAddressed_ColonSpace(t *testing.T) {
180
+ r := testRouter()
181
+ reply := r.Dispatch("alice", "#general", "scroll: replay #logs")
182
+ if reply == nil {
183
+ t.Fatal("expected reply, got nil")
184
+ }
185
+ if reply.Target != "#general" {
186
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
187
+ }
188
+ if reply.Text != "replaying: #logs" {
189
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
190
+ }
191
+}
192
+
193
+func TestAddressed_ColonNoSpace(t *testing.T) {
194
+ r := testRouter()
195
+ reply := r.Dispatch("alice", "#general", "scroll:replay #logs")
196
+ if reply == nil {
197
+ t.Fatal("expected reply, got nil")
198
+ }
199
+ if reply.Text != "replaying: #logs" {
200
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
201
+ }
202
+}
203
+
204
+func TestAddressed_Comma(t *testing.T) {
205
+ r := testRouter()
206
+ reply := r.Dispatch("alice", "#general", "scroll, status")
207
+ if reply == nil {
208
+ t.Fatal("expected reply, got nil")
209
+ }
210
+ if reply.Text != "ok" {
211
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
212
+ }
213
+}
214
+
215
+func TestAddressed_CaseInsensitiveBotNick(t *testing.T) {
216
+ r := testRouter()
217
+ reply := r.Dispatch("alice", "#general", "Scroll: status")
218
+ if reply == nil {
219
+ t.Fatal("expected reply, got nil")
220
+ }
221
+ if reply.Text != "ok" {
222
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
223
+ }
224
+}
225
+
226
+func TestAddressed_ContextFields(t *testing.T) {
227
+ r := NewRouter("testbot")
228
+ var gotCtx *Context
229
+ r.Register(Command{
230
+ Name: "ping",
231
+ Usage: "ping",
232
+ Description: "ping",
233
+ Handler: func(ctx *Context, args string) string {
234
+ gotCtx = ctx
235
+ return "pong"
236
+ },
237
+ })
238
+ r.Dispatch("bob", "#ops", "testbot: ping")
239
+ if gotCtx == nil {
240
+ t.Fatal("handler not called")
241
+ }
242
+ if gotCtx.IsDM {
243
+ t.Error("expected IsDM=false")
244
+ }
245
+ if gotCtx.Channel != "#ops" {
246
+ t.Errorf("channel = %q, want %q", gotCtx.Channel, "#ops")
247
+ }
248
+}
249
+
250
+// --- HELP generation ---
251
+
252
+func TestHelp_DM(t *testing.T) {
253
+ r := testRouter()
254
+ reply := r.Dispatch("alice", "scroll", "help")
255
+ if reply == nil {
256
+ t.Fatal("expected reply, got nil")
257
+ }
258
+ if reply.Target != "alice" {
259
+ t.Errorf("target = %q, want %q", reply.Target, "alice")
260
+ }
261
+ if !strings.Contains(reply.Text, "REPLAY") {
262
+ t.Errorf("help should list REPLAY, got: %s", reply.Text)
263
+ }
264
+ if !strings.Contains(reply.Text, "STATUS") {
265
+ t.Errorf("help should list STATUS, got: %s", reply.Text)
266
+ }
267
+ if !strings.Contains(reply.Text, "commands for scroll") {
268
+ t.Errorf("help should include bot name, got: %s", reply.Text)
269
+ }
270
+}
271
+
272
+func TestHelp_Fantasy(t *testing.T) {
273
+ r := testRouter()
274
+ reply := r.Dispatch("alice", "#general", "!help")
275
+ if reply == nil {
276
+ t.Fatal("expected reply, got nil")
277
+ }
278
+ if reply.Target != "#general" {
279
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
280
+ }
281
+ if !strings.Contains(reply.Text, "REPLAY") {
282
+ t.Errorf("help should list REPLAY, got: %s", reply.Text)
283
+ }
284
+}
285
+
286
+func TestHelp_Addressed(t *testing.T) {
287
+ r := testRouter()
288
+ reply := r.Dispatch("alice", "#general", "scroll: help")
289
+ if reply == nil {
290
+ t.Fatal("expected reply, got nil")
291
+ }
292
+ if reply.Target != "#general" {
293
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
294
+ }
295
+}
296
+
297
+func TestHelp_SpecificCommand(t *testing.T) {
298
+ r := testRouter()
299
+ reply := r.Dispatch("alice", "scroll", "help replay")
300
+ if reply == nil {
301
+ t.Fatal("expected reply, got nil")
302
+ }
303
+ if !strings.Contains(reply.Text, "replay channel history") {
304
+ t.Errorf("help replay should show description, got: %s", reply.Text)
305
+ }
306
+ if !strings.Contains(reply.Text, "replay #channel [last=N]") {
307
+ t.Errorf("help replay should show usage, got: %s", reply.Text)
308
+ }
309
+}
310
+
311
+func TestHelp_SpecificCommandCaseInsensitive(t *testing.T) {
312
+ r := testRouter()
313
+ reply := r.Dispatch("alice", "scroll", "HELP REPLAY")
314
+ if reply == nil {
315
+ t.Fatal("expected reply, got nil")
316
+ }
317
+ if !strings.Contains(reply.Text, "replay channel history") {
318
+ t.Errorf("expected description, got: %s", reply.Text)
319
+ }
320
+}
321
+
322
+func TestHelp_UnknownCommand(t *testing.T) {
323
+ r := testRouter()
324
+ reply := r.Dispatch("alice", "scroll", "help nosuchcmd")
325
+ if reply == nil {
326
+ t.Fatal("expected reply, got nil")
327
+ }
328
+ if !strings.Contains(reply.Text, "unknown command") {
329
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
330
+ }
331
+}
332
+
333
+// --- Unknown command handling ---
334
+
335
+func TestUnknown_DM(t *testing.T) {
336
+ r := testRouter()
337
+ reply := r.Dispatch("alice", "scroll", "frobnicate something")
338
+ if reply == nil {
339
+ t.Fatal("expected reply, got nil")
340
+ }
341
+ if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
342
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
343
+ }
344
+ if !strings.Contains(reply.Text, "REPLAY") {
345
+ t.Errorf("should list available commands, got: %s", reply.Text)
346
+ }
347
+ if !strings.Contains(reply.Text, "STATUS") {
348
+ t.Errorf("should list available commands, got: %s", reply.Text)
349
+ }
350
+}
351
+
352
+func TestUnknown_Fantasy(t *testing.T) {
353
+ r := testRouter()
354
+ reply := r.Dispatch("alice", "#general", "!frobnicate")
355
+ if reply == nil {
356
+ t.Fatal("expected reply, got nil")
357
+ }
358
+ if reply.Target != "#general" {
359
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
360
+ }
361
+ if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
362
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
363
+ }
364
+}
365
+
366
+func TestUnknown_Addressed(t *testing.T) {
367
+ r := testRouter()
368
+ reply := r.Dispatch("alice", "#general", "scroll: frobnicate")
369
+ if reply == nil {
370
+ t.Fatal("expected reply, got nil")
371
+ }
372
+ if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
373
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
374
+ }
375
+}
376
+
377
+// --- Edge cases ---
378
+
379
+func TestRegister_EmptyNamePanics(t *testing.T) {
380
+ r := NewRouter("bot")
381
+ defer func() {
382
+ if r := recover(); r == nil {
383
+ t.Error("expected panic for empty command name")
384
+ }
385
+ }()
386
+ r.Register(Command{Name: "", Handler: func(*Context, string) string { return "" }})
387
+}
388
+
389
+func TestRegister_DuplicatePanics(t *testing.T) {
390
+ r := NewRouter("bot")
391
+ r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
392
+ defer func() {
393
+ if r := recover(); r == nil {
394
+ t.Error("expected panic for duplicate command")
395
+ }
396
+ }()
397
+ r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
398
+}
399
+
400
+func TestHandlerReturnsEmpty(t *testing.T) {
401
+ r := NewRouter("bot")
402
+ r.Register(Command{
403
+ Name: "quiet",
404
+ Handler: func(*Context, string) string { return "" },
405
+ })
406
+ reply := r.Dispatch("alice", "bot", "quiet")
407
+ if reply != nil {
408
+ t.Errorf("expected nil reply for empty handler return, got %+v", reply)
409
+ }
410
+}
411
+
412
+func TestLeadingTrailingWhitespace(t *testing.T) {
413
+ r := testRouter()
414
+ reply := r.Dispatch("alice", "scroll", " status ")
415
+ if reply == nil {
416
+ t.Fatal("expected reply, got nil")
417
+ }
418
+ if reply.Text != "ok" {
419
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
420
+ }
421
+}
--- a/internal/bots/cmdparse/cmdparse_test.go
+++ b/internal/bots/cmdparse/cmdparse_test.go
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/cmdparse/cmdparse_test.go
+++ b/internal/bots/cmdparse/cmdparse_test.go
@@ -0,0 +1,421 @@
1 package cmdparse
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 func testRouter() *CommandRouter {
9 r := NewRouter("scroll")
10 r.Register(Command{
11 Name: "replay",
12 Usage: "replay #channel [last=N]",
13 Description: "replay channel history",
14 Handler: func(ctx *Context, args string) string {
15 return "replaying: " + args
16 },
17 })
18 r.Register(Command{
19 Name: "status",
20 Usage: "status",
21 Description: "show bot status",
22 Handler: func(ctx *Context, args string) string {
23 return "ok"
24 },
25 })
26 return r
27 }
28
29 // --- DM input form ---
30
31 func TestDM_BasicCommand(t *testing.T) {
32 r := testRouter()
33 reply := r.Dispatch("alice", "scroll", "replay #general last=10")
34 if reply == nil {
35 t.Fatal("expected reply, got nil")
36 }
37 if reply.Target != "alice" {
38 t.Errorf("target = %q, want %q", reply.Target, "alice")
39 }
40 if reply.Text != "replaying: #general last=10" {
41 t.Errorf("text = %q, want %q", reply.Text, "replaying: #general last=10")
42 }
43 }
44
45 func TestDM_CaseInsensitive(t *testing.T) {
46 r := testRouter()
47 reply := r.Dispatch("alice", "scroll", "REPLAY #general")
48 if reply == nil {
49 t.Fatal("expected reply, got nil")
50 }
51 if reply.Text != "replaying: #general" {
52 t.Errorf("text = %q, want %q", reply.Text, "replaying: #general")
53 }
54 }
55
56 func TestDM_NoArgs(t *testing.T) {
57 r := testRouter()
58 reply := r.Dispatch("alice", "scroll", "status")
59 if reply == nil {
60 t.Fatal("expected reply, got nil")
61 }
62 if reply.Text != "ok" {
63 t.Errorf("text = %q, want %q", reply.Text, "ok")
64 }
65 }
66
67 func TestDM_EmptyMessage(t *testing.T) {
68 r := testRouter()
69 reply := r.Dispatch("alice", "scroll", "")
70 if reply != nil {
71 t.Errorf("expected nil for empty message, got %+v", reply)
72 }
73 }
74
75 func TestDM_WhitespaceOnly(t *testing.T) {
76 r := testRouter()
77 reply := r.Dispatch("alice", "scroll", " ")
78 if reply != nil {
79 t.Errorf("expected nil for whitespace-only, got %+v", reply)
80 }
81 }
82
83 func TestDM_ContextFields(t *testing.T) {
84 r := NewRouter("testbot")
85 var gotCtx *Context
86 r.Register(Command{
87 Name: "ping",
88 Usage: "ping",
89 Description: "ping",
90 Handler: func(ctx *Context, args string) string {
91 gotCtx = ctx
92 return "pong"
93 },
94 })
95 r.Dispatch("bob", "testbot", "ping")
96 if gotCtx == nil {
97 t.Fatal("handler not called")
98 }
99 if !gotCtx.IsDM {
100 t.Error("expected IsDM=true")
101 }
102 if gotCtx.Channel != "" {
103 t.Errorf("channel = %q, want empty", gotCtx.Channel)
104 }
105 if gotCtx.Nick != "bob" {
106 t.Errorf("nick = %q, want %q", gotCtx.Nick, "bob")
107 }
108 }
109
110 // --- Fantasy input form ---
111
112 func TestFantasy_BasicCommand(t *testing.T) {
113 r := testRouter()
114 reply := r.Dispatch("alice", "#general", "!replay #logs last=20")
115 if reply == nil {
116 t.Fatal("expected reply, got nil")
117 }
118 if reply.Target != "#general" {
119 t.Errorf("target = %q, want %q", reply.Target, "#general")
120 }
121 if reply.Text != "replaying: #logs last=20" {
122 t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs last=20")
123 }
124 }
125
126 func TestFantasy_CaseInsensitive(t *testing.T) {
127 r := testRouter()
128 reply := r.Dispatch("alice", "#general", "!STATUS")
129 if reply == nil {
130 t.Fatal("expected reply, got nil")
131 }
132 if reply.Text != "ok" {
133 t.Errorf("text = %q, want %q", reply.Text, "ok")
134 }
135 }
136
137 func TestFantasy_ContextFields(t *testing.T) {
138 r := NewRouter("testbot")
139 var gotCtx *Context
140 r.Register(Command{
141 Name: "ping",
142 Usage: "ping",
143 Description: "ping",
144 Handler: func(ctx *Context, args string) string {
145 gotCtx = ctx
146 return "pong"
147 },
148 })
149 r.Dispatch("bob", "#dev", "!ping")
150 if gotCtx == nil {
151 t.Fatal("handler not called")
152 }
153 if gotCtx.IsDM {
154 t.Error("expected IsDM=false")
155 }
156 if gotCtx.Channel != "#dev" {
157 t.Errorf("channel = %q, want %q", gotCtx.Channel, "#dev")
158 }
159 }
160
161 func TestFantasy_BangOnly(t *testing.T) {
162 r := testRouter()
163 reply := r.Dispatch("alice", "#general", "!")
164 if reply != nil {
165 t.Errorf("expected nil for bare !, got %+v", reply)
166 }
167 }
168
169 func TestFantasy_NotAddressed(t *testing.T) {
170 r := testRouter()
171 reply := r.Dispatch("alice", "#general", "just a normal message")
172 if reply != nil {
173 t.Errorf("expected nil for unaddressed channel message, got %+v", reply)
174 }
175 }
176
177 // --- Addressed input form ---
178
179 func TestAddressed_ColonSpace(t *testing.T) {
180 r := testRouter()
181 reply := r.Dispatch("alice", "#general", "scroll: replay #logs")
182 if reply == nil {
183 t.Fatal("expected reply, got nil")
184 }
185 if reply.Target != "#general" {
186 t.Errorf("target = %q, want %q", reply.Target, "#general")
187 }
188 if reply.Text != "replaying: #logs" {
189 t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
190 }
191 }
192
193 func TestAddressed_ColonNoSpace(t *testing.T) {
194 r := testRouter()
195 reply := r.Dispatch("alice", "#general", "scroll:replay #logs")
196 if reply == nil {
197 t.Fatal("expected reply, got nil")
198 }
199 if reply.Text != "replaying: #logs" {
200 t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
201 }
202 }
203
204 func TestAddressed_Comma(t *testing.T) {
205 r := testRouter()
206 reply := r.Dispatch("alice", "#general", "scroll, status")
207 if reply == nil {
208 t.Fatal("expected reply, got nil")
209 }
210 if reply.Text != "ok" {
211 t.Errorf("text = %q, want %q", reply.Text, "ok")
212 }
213 }
214
215 func TestAddressed_CaseInsensitiveBotNick(t *testing.T) {
216 r := testRouter()
217 reply := r.Dispatch("alice", "#general", "Scroll: status")
218 if reply == nil {
219 t.Fatal("expected reply, got nil")
220 }
221 if reply.Text != "ok" {
222 t.Errorf("text = %q, want %q", reply.Text, "ok")
223 }
224 }
225
226 func TestAddressed_ContextFields(t *testing.T) {
227 r := NewRouter("testbot")
228 var gotCtx *Context
229 r.Register(Command{
230 Name: "ping",
231 Usage: "ping",
232 Description: "ping",
233 Handler: func(ctx *Context, args string) string {
234 gotCtx = ctx
235 return "pong"
236 },
237 })
238 r.Dispatch("bob", "#ops", "testbot: ping")
239 if gotCtx == nil {
240 t.Fatal("handler not called")
241 }
242 if gotCtx.IsDM {
243 t.Error("expected IsDM=false")
244 }
245 if gotCtx.Channel != "#ops" {
246 t.Errorf("channel = %q, want %q", gotCtx.Channel, "#ops")
247 }
248 }
249
250 // --- HELP generation ---
251
252 func TestHelp_DM(t *testing.T) {
253 r := testRouter()
254 reply := r.Dispatch("alice", "scroll", "help")
255 if reply == nil {
256 t.Fatal("expected reply, got nil")
257 }
258 if reply.Target != "alice" {
259 t.Errorf("target = %q, want %q", reply.Target, "alice")
260 }
261 if !strings.Contains(reply.Text, "REPLAY") {
262 t.Errorf("help should list REPLAY, got: %s", reply.Text)
263 }
264 if !strings.Contains(reply.Text, "STATUS") {
265 t.Errorf("help should list STATUS, got: %s", reply.Text)
266 }
267 if !strings.Contains(reply.Text, "commands for scroll") {
268 t.Errorf("help should include bot name, got: %s", reply.Text)
269 }
270 }
271
272 func TestHelp_Fantasy(t *testing.T) {
273 r := testRouter()
274 reply := r.Dispatch("alice", "#general", "!help")
275 if reply == nil {
276 t.Fatal("expected reply, got nil")
277 }
278 if reply.Target != "#general" {
279 t.Errorf("target = %q, want %q", reply.Target, "#general")
280 }
281 if !strings.Contains(reply.Text, "REPLAY") {
282 t.Errorf("help should list REPLAY, got: %s", reply.Text)
283 }
284 }
285
286 func TestHelp_Addressed(t *testing.T) {
287 r := testRouter()
288 reply := r.Dispatch("alice", "#general", "scroll: help")
289 if reply == nil {
290 t.Fatal("expected reply, got nil")
291 }
292 if reply.Target != "#general" {
293 t.Errorf("target = %q, want %q", reply.Target, "#general")
294 }
295 }
296
297 func TestHelp_SpecificCommand(t *testing.T) {
298 r := testRouter()
299 reply := r.Dispatch("alice", "scroll", "help replay")
300 if reply == nil {
301 t.Fatal("expected reply, got nil")
302 }
303 if !strings.Contains(reply.Text, "replay channel history") {
304 t.Errorf("help replay should show description, got: %s", reply.Text)
305 }
306 if !strings.Contains(reply.Text, "replay #channel [last=N]") {
307 t.Errorf("help replay should show usage, got: %s", reply.Text)
308 }
309 }
310
311 func TestHelp_SpecificCommandCaseInsensitive(t *testing.T) {
312 r := testRouter()
313 reply := r.Dispatch("alice", "scroll", "HELP REPLAY")
314 if reply == nil {
315 t.Fatal("expected reply, got nil")
316 }
317 if !strings.Contains(reply.Text, "replay channel history") {
318 t.Errorf("expected description, got: %s", reply.Text)
319 }
320 }
321
322 func TestHelp_UnknownCommand(t *testing.T) {
323 r := testRouter()
324 reply := r.Dispatch("alice", "scroll", "help nosuchcmd")
325 if reply == nil {
326 t.Fatal("expected reply, got nil")
327 }
328 if !strings.Contains(reply.Text, "unknown command") {
329 t.Errorf("expected unknown command message, got: %s", reply.Text)
330 }
331 }
332
333 // --- Unknown command handling ---
334
335 func TestUnknown_DM(t *testing.T) {
336 r := testRouter()
337 reply := r.Dispatch("alice", "scroll", "frobnicate something")
338 if reply == nil {
339 t.Fatal("expected reply, got nil")
340 }
341 if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
342 t.Errorf("expected unknown command message, got: %s", reply.Text)
343 }
344 if !strings.Contains(reply.Text, "REPLAY") {
345 t.Errorf("should list available commands, got: %s", reply.Text)
346 }
347 if !strings.Contains(reply.Text, "STATUS") {
348 t.Errorf("should list available commands, got: %s", reply.Text)
349 }
350 }
351
352 func TestUnknown_Fantasy(t *testing.T) {
353 r := testRouter()
354 reply := r.Dispatch("alice", "#general", "!frobnicate")
355 if reply == nil {
356 t.Fatal("expected reply, got nil")
357 }
358 if reply.Target != "#general" {
359 t.Errorf("target = %q, want %q", reply.Target, "#general")
360 }
361 if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
362 t.Errorf("expected unknown command message, got: %s", reply.Text)
363 }
364 }
365
366 func TestUnknown_Addressed(t *testing.T) {
367 r := testRouter()
368 reply := r.Dispatch("alice", "#general", "scroll: frobnicate")
369 if reply == nil {
370 t.Fatal("expected reply, got nil")
371 }
372 if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
373 t.Errorf("expected unknown command message, got: %s", reply.Text)
374 }
375 }
376
377 // --- Edge cases ---
378
379 func TestRegister_EmptyNamePanics(t *testing.T) {
380 r := NewRouter("bot")
381 defer func() {
382 if r := recover(); r == nil {
383 t.Error("expected panic for empty command name")
384 }
385 }()
386 r.Register(Command{Name: "", Handler: func(*Context, string) string { return "" }})
387 }
388
389 func TestRegister_DuplicatePanics(t *testing.T) {
390 r := NewRouter("bot")
391 r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
392 defer func() {
393 if r := recover(); r == nil {
394 t.Error("expected panic for duplicate command")
395 }
396 }()
397 r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
398 }
399
400 func TestHandlerReturnsEmpty(t *testing.T) {
401 r := NewRouter("bot")
402 r.Register(Command{
403 Name: "quiet",
404 Handler: func(*Context, string) string { return "" },
405 })
406 reply := r.Dispatch("alice", "bot", "quiet")
407 if reply != nil {
408 t.Errorf("expected nil reply for empty handler return, got %+v", reply)
409 }
410 }
411
412 func TestLeadingTrailingWhitespace(t *testing.T) {
413 r := testRouter()
414 reply := r.Dispatch("alice", "scroll", " status ")
415 if reply == nil {
416 t.Fatal("expected reply, got nil")
417 }
418 if reply.Text != "ok" {
419 t.Errorf("text = %q, want %q", reply.Text, "ok")
420 }
421 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button