|
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
|
|