ScuttleBot

scuttlebot / internal / bots / cmdparse / cmdparse.go
Source Blame History 242 lines
3420a83… lmata 1 // Package cmdparse provides a shared command framework for system bots.
3420a83… lmata 2 //
3420a83… lmata 3 // It handles three IRC input forms:
3420a83… lmata 4 // - DM: /msg botname COMMAND args
3420a83… lmata 5 // - Fantasy: !command args (in a channel)
3420a83… lmata 6 // - Addressed: botname: command args (in a channel)
3420a83… lmata 7 //
3420a83… lmata 8 // Bots register commands into a CommandRouter, which dispatches incoming
3420a83… lmata 9 // messages and auto-generates HELP output.
3420a83… lmata 10 package cmdparse
3420a83… lmata 11
3420a83… lmata 12 import (
3420a83… lmata 13 "fmt"
3420a83… lmata 14 "sort"
3420a83… lmata 15 "strings"
3420a83… lmata 16 )
3420a83… lmata 17
3420a83… lmata 18 // HandlerFunc is called when a command is matched.
3420a83… lmata 19 // args is everything after the command name, already trimmed.
3420a83… lmata 20 // Returns the text to send back (may be multi-line).
3420a83… lmata 21 type HandlerFunc func(ctx *Context, args string) string
3420a83… lmata 22
3420a83… lmata 23 // Command describes a single bot command.
3420a83… lmata 24 type Command struct {
3420a83… lmata 25 // Name is the canonical command name (case-insensitive matching).
3420a83… lmata 26 Name string
3420a83… lmata 27 // Usage is a one-line usage string shown in help, e.g. "replay #channel [last=N]".
3420a83… lmata 28 Usage string
3420a83… lmata 29 // Description is a short description shown in help.
3420a83… lmata 30 Description string
3420a83… lmata 31 // Handler is called when the command is matched.
3420a83… lmata 32 Handler HandlerFunc
3420a83… lmata 33 }
3420a83… lmata 34
3420a83… lmata 35 // Context carries information about the parsed incoming message.
3420a83… lmata 36 type Context struct {
3420a83… lmata 37 // Nick is the sender's IRC nick.
3420a83… lmata 38 Nick string
3420a83… lmata 39 // Channel is the channel the message was sent in, or "" for a DM.
3420a83… lmata 40 Channel string
3420a83… lmata 41 // IsDM is true when the message was sent as a private message.
3420a83… lmata 42 IsDM bool
3420a83… lmata 43 }
3420a83… lmata 44
3420a83… lmata 45 // Reply is a response produced by the router after dispatching a message.
3420a83… lmata 46 type Reply struct {
3420a83… lmata 47 // Target is where the reply should be sent (nick for DM, channel for channel).
3420a83… lmata 48 Target string
3420a83… lmata 49 // Text is the reply text (may be multi-line).
3420a83… lmata 50 Text string
3420a83… lmata 51 }
3420a83… lmata 52
3420a83… lmata 53 // CommandRouter dispatches IRC messages to registered command handlers.
3420a83… lmata 54 type CommandRouter struct {
3420a83… lmata 55 botNick string
3420a83… lmata 56 commands map[string]*Command // lowercase name → command
3420a83… lmata 57 }
3420a83… lmata 58
3420a83… lmata 59 // NewRouter creates a CommandRouter for a bot with the given IRC nick.
3420a83… lmata 60 func NewRouter(botNick string) *CommandRouter {
3420a83… lmata 61 return &CommandRouter{
3420a83… lmata 62 botNick: strings.ToLower(botNick),
3420a83… lmata 63 commands: make(map[string]*Command),
3420a83… lmata 64 }
3420a83… lmata 65 }
3420a83… lmata 66
3420a83… lmata 67 // Register adds a command to the router. Panics if name is empty or duplicate.
3420a83… lmata 68 func (r *CommandRouter) Register(cmd Command) {
3420a83… lmata 69 key := strings.ToLower(cmd.Name)
3420a83… lmata 70 if key == "" {
3420a83… lmata 71 panic("cmdparse: command name must not be empty")
3420a83… lmata 72 }
3420a83… lmata 73 if _, ok := r.commands[key]; ok {
3420a83… lmata 74 panic(fmt.Sprintf("cmdparse: duplicate command %q", cmd.Name))
3420a83… lmata 75 }
3420a83… lmata 76 r.commands[key] = &cmd
3420a83… lmata 77 }
3420a83… lmata 78
3420a83… lmata 79 // Dispatch parses an IRC message and dispatches it to the appropriate handler.
3420a83… lmata 80 // Returns nil if the message is not a command addressed to this bot.
3420a83… lmata 81 //
3420a83… lmata 82 // Parameters:
3420a83… lmata 83 // - nick: sender's IRC nick
3420a83… lmata 84 // - target: IRC target param (channel name for channel messages, bot nick for DMs)
3420a83… lmata 85 // - text: the message body
3420a83… lmata 86 func (r *CommandRouter) Dispatch(nick, target, text string) *Reply {
3420a83… lmata 87 text = strings.TrimSpace(text)
3420a83… lmata 88 if text == "" {
3420a83… lmata 89 return nil
3420a83… lmata 90 }
3420a83… lmata 91
3420a83… lmata 92 var ctx Context
3420a83… lmata 93 ctx.Nick = nick
3420a83… lmata 94
3420a83… lmata 95 isChannel := strings.HasPrefix(target, "#")
3420a83… lmata 96
3420a83… lmata 97 var cmdLine string
3420a83… lmata 98
3420a83… lmata 99 if !isChannel {
3420a83… lmata 100 // DM — entire text is the command line.
3420a83… lmata 101 ctx.IsDM = true
3420a83… lmata 102 cmdLine = text
3420a83… lmata 103 } else {
3420a83… lmata 104 ctx.Channel = target
3420a83… lmata 105 if strings.HasPrefix(text, "!") {
3420a83… lmata 106 // Fantasy: !command args
3420a83… lmata 107 cmdLine = text[1:]
3420a83… lmata 108 } else if r.isAddressed(text) {
3420a83… lmata 109 // Addressed: botname: command args
3420a83… lmata 110 cmdLine = r.stripAddress(text)
3420a83… lmata 111 } else {
3420a83… lmata 112 return nil
3420a83… lmata 113 }
3420a83… lmata 114 }
3420a83… lmata 115
3420a83… lmata 116 cmdLine = strings.TrimSpace(cmdLine)
3420a83… lmata 117 if cmdLine == "" {
3420a83… lmata 118 return nil
3420a83… lmata 119 }
3420a83… lmata 120
3420a83… lmata 121 cmdName, args := splitFirst(cmdLine)
3420a83… lmata 122 cmdKey := strings.ToLower(cmdName)
3420a83… lmata 123
3420a83… lmata 124 // Built-in HELP.
3420a83… lmata 125 if cmdKey == "help" {
3420a83… lmata 126 return r.helpReply(&ctx, args)
3420a83… lmata 127 }
3420a83… lmata 128
3420a83… lmata 129 cmd, ok := r.commands[cmdKey]
3420a83… lmata 130 if !ok {
3420a83… lmata 131 return r.unknownReply(&ctx, cmdName)
3420a83… lmata 132 }
3420a83… lmata 133
3420a83… lmata 134 response := cmd.Handler(&ctx, args)
3420a83… lmata 135 if response == "" {
3420a83… lmata 136 return nil
3420a83… lmata 137 }
3420a83… lmata 138
3420a83… lmata 139 return &Reply{
3420a83… lmata 140 Target: r.replyTarget(&ctx),
3420a83… lmata 141 Text: response,
3420a83… lmata 142 }
3420a83… lmata 143 }
3420a83… lmata 144
3420a83… lmata 145 func splitFirst(s string) (first, rest string) {
3420a83… lmata 146 s = strings.TrimSpace(s)
3420a83… lmata 147 idx := strings.IndexAny(s, " \t")
3420a83… lmata 148 if idx < 0 {
3420a83… lmata 149 return s, ""
3420a83… lmata 150 }
3420a83… lmata 151 return s[:idx], strings.TrimSpace(s[idx+1:])
3420a83… lmata 152 }
3420a83… lmata 153
3420a83… lmata 154 func (r *CommandRouter) isAddressed(text string) bool {
3420a83… lmata 155 lower := strings.ToLower(text)
3420a83… lmata 156 for _, sep := range []string{": ", ","} {
3420a83… lmata 157 prefix := r.botNick + sep
3420a83… lmata 158 if strings.HasPrefix(lower, prefix) {
3420a83… lmata 159 return true
3420a83… lmata 160 }
3420a83… lmata 161 }
3420a83… lmata 162 // Also handle "botname:" with no space after colon.
3420a83… lmata 163 if strings.HasPrefix(lower, r.botNick+":") {
3420a83… lmata 164 return true
3420a83… lmata 165 }
3420a83… lmata 166 return false
3420a83… lmata 167 }
3420a83… lmata 168
3420a83… lmata 169 func (r *CommandRouter) stripAddress(text string) string {
3420a83… lmata 170 lower := strings.ToLower(text)
3420a83… lmata 171 for _, sep := range []string{": ", ","} {
3420a83… lmata 172 prefix := r.botNick + sep
3420a83… lmata 173 if strings.HasPrefix(lower, prefix) {
3420a83… lmata 174 return strings.TrimSpace(text[len(prefix):])
3420a83… lmata 175 }
3420a83… lmata 176 }
3420a83… lmata 177 // Bare "botname:" with no space.
3420a83… lmata 178 prefix := r.botNick + ":"
3420a83… lmata 179 if strings.HasPrefix(lower, prefix) {
3420a83… lmata 180 return strings.TrimSpace(text[len(prefix):])
3420a83… lmata 181 }
3420a83… lmata 182 return text
3420a83… lmata 183 }
3420a83… lmata 184
3420a83… lmata 185 func (r *CommandRouter) replyTarget(ctx *Context) string {
3420a83… lmata 186 if ctx.IsDM {
3420a83… lmata 187 return ctx.Nick
3420a83… lmata 188 }
3420a83… lmata 189 return ctx.Channel
3420a83… lmata 190 }
3420a83… lmata 191
3420a83… lmata 192 func (r *CommandRouter) helpReply(ctx *Context, args string) *Reply {
3420a83… lmata 193 args = strings.TrimSpace(args)
3420a83… lmata 194
3420a83… lmata 195 if args != "" {
3420a83… lmata 196 cmdKey := strings.ToLower(args)
3420a83… lmata 197 cmd, ok := r.commands[cmdKey]
3420a83… lmata 198 if !ok {
3420a83… lmata 199 return &Reply{
3420a83… lmata 200 Target: r.replyTarget(ctx),
3420a83… lmata 201 Text: fmt.Sprintf("unknown command %q — type HELP for a list of commands", args),
3420a83… lmata 202 }
3420a83… lmata 203 }
3420a83… lmata 204 return &Reply{
3420a83… lmata 205 Target: r.replyTarget(ctx),
3420a83… lmata 206 Text: fmt.Sprintf("%s — %s\nusage: %s", strings.ToUpper(cmd.Name), cmd.Description, cmd.Usage),
3420a83… lmata 207 }
3420a83… lmata 208 }
3420a83… lmata 209
3420a83… lmata 210 names := make([]string, 0, len(r.commands))
3420a83… lmata 211 for k := range r.commands {
3420a83… lmata 212 names = append(names, k)
3420a83… lmata 213 }
3420a83… lmata 214 sort.Strings(names)
3420a83… lmata 215
3420a83… lmata 216 var sb strings.Builder
3420a83… lmata 217 sb.WriteString(fmt.Sprintf("commands for %s:\n", r.botNick))
3420a83… lmata 218 for _, name := range names {
3420a83… lmata 219 cmd := r.commands[name]
3420a83… lmata 220 sb.WriteString(fmt.Sprintf(" %-12s %s\n", strings.ToUpper(cmd.Name), cmd.Description))
3420a83… lmata 221 }
3420a83… lmata 222 sb.WriteString("type HELP <command> for details")
3420a83… lmata 223
3420a83… lmata 224 return &Reply{
3420a83… lmata 225 Target: r.replyTarget(ctx),
3420a83… lmata 226 Text: sb.String(),
3420a83… lmata 227 }
3420a83… lmata 228 }
3420a83… lmata 229
3420a83… lmata 230 func (r *CommandRouter) unknownReply(ctx *Context, cmdName string) *Reply {
3420a83… lmata 231 names := make([]string, 0, len(r.commands))
3420a83… lmata 232 for k := range r.commands {
3420a83… lmata 233 names = append(names, strings.ToUpper(k))
3420a83… lmata 234 }
3420a83… lmata 235 sort.Strings(names)
3420a83… lmata 236
3420a83… lmata 237 return &Reply{
3420a83… lmata 238 Target: r.replyTarget(ctx),
3420a83… lmata 239 Text: fmt.Sprintf("unknown command %q — available commands: %s. Type HELP for details.",
3420a83… lmata 240 cmdName, strings.Join(names, ", ")),
3420a83… lmata 241 }
3420a83… lmata 242 }

Keyboard Shortcuts

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