ScuttleBot

scuttlebot / internal / bots / cmdparse / cmdparse.go
Blame History Raw 243 lines
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{": ", ","} {
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, strings.Join(names, ", ")),
241
}
242
}
243

Keyboard Shortcuts

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