1// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu> 2// released under the MIT license 3 4package irc 5 6import ( 7 "bytes" 8 "fmt" 9 "log" 10 "sort" 11 "strings" 12 "time" 13 14 "github.com/ergochat/ergo/irc/utils" 15 "github.com/ergochat/irc-go/ircfmt" 16 "github.com/ergochat/irc-go/ircmsg" 17) 18 19// defines an IRC service, e.g., NICKSERV 20type ircService struct { 21 Name string 22 ShortName string 23 prefix string // NUH source of messages from this service 24 CommandAliases []string 25 Commands map[string]*serviceCommand 26 HelpBanner string 27} 28 29// defines a command associated with a service, e.g., NICKSERV IDENTIFY 30type serviceCommand struct { 31 aliasOf string // marks this command as an alias of another 32 capabs []string // oper capabs the given user has to have to access this command 33 handler func(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) 34 help string 35 helpStrings []string 36 helpShort string 37 enabled func(*Config) bool // is this command enabled in the server config? 38 authRequired bool 39 hidden bool 40 minParams int 41 maxParams int // optional, if set it's an error if the user passes more than this many params 42 unsplitFinalParam bool // split into at most maxParams, with last param containing unsplit text 43} 44 45// looks up a command in the table of command definitions for a service, resolving aliases 46func lookupServiceCommand(commands map[string]*serviceCommand, command string) *serviceCommand { 47 maxDepth := 1 48 depth := 0 49 for depth <= maxDepth { 50 result, ok := commands[command] 51 if !ok { 52 return nil 53 } else if result.aliasOf == "" { 54 return result 55 } else { 56 command = result.aliasOf 57 depth += 1 58 } 59 } 60 return nil 61} 62 63var ( 64 nickservService = &ircService{ 65 Name: "NickServ", 66 ShortName: "NS", 67 CommandAliases: []string{"NICKSERV", "NS"}, 68 Commands: nickservCommands, 69 HelpBanner: nickservHelp, 70 } 71 chanservService = &ircService{ 72 Name: "ChanServ", 73 ShortName: "CS", 74 CommandAliases: []string{"CHANSERV", "CS"}, 75 Commands: chanservCommands, 76 HelpBanner: chanservHelp, 77 } 78 hostservService = &ircService{ 79 Name: "HostServ", 80 ShortName: "HS", 81 CommandAliases: []string{"HOSTSERV", "HS"}, 82 Commands: hostservCommands, 83 HelpBanner: hostservHelp, 84 } 85 histservService = &ircService{ 86 Name: "HistServ", 87 ShortName: "HISTSERV", 88 CommandAliases: []string{"HISTSERV"}, 89 Commands: histservCommands, 90 HelpBanner: histservHelp, 91 } 92) 93 94// all services, by lowercase name 95var OragonoServices = map[string]*ircService{ 96 "nickserv": nickservService, 97 "chanserv": chanservService, 98 "hostserv": hostservService, 99 "histserv": histservService, 100} 101 102func (service *ircService) Notice(rb *ResponseBuffer, text string) { 103 rb.Add(nil, service.prefix, "NOTICE", rb.target.Nick(), text) 104} 105 106// all service commands at the protocol level, by uppercase command name 107// e.g., NICKSERV, NS 108var oragonoServicesByCommandAlias map[string]*ircService 109 110// special-cased command shared by all services 111var servHelpCmd serviceCommand = serviceCommand{ 112 help: `Syntax: $bHELP [command]$b 113 114HELP returns information on the given command.`, 115 helpShort: `$bHELP$b shows in-depth information about commands.`, 116} 117 118// generic handler for IRC commands like `/NICKSERV INFO` 119func serviceCmdHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { 120 service, ok := oragonoServicesByCommandAlias[msg.Command] 121 if !ok { 122 server.logger.Warning("internal", "can't handle unrecognized service", msg.Command) 123 return false 124 } 125 126 if len(msg.Params) == 0 { 127 return false 128 } 129 commandName := strings.ToLower(msg.Params[0]) 130 params := msg.Params[1:] 131 cmd := lookupServiceCommand(service.Commands, commandName) 132 // for a maxParams command, join all final parameters together if necessary 133 if cmd != nil && cmd.unsplitFinalParam && cmd.maxParams < len(params) { 134 newParams := make([]string, cmd.maxParams) 135 copy(newParams, params[:cmd.maxParams-1]) 136 newParams[cmd.maxParams-1] = strings.Join(params[cmd.maxParams-1:], " ") 137 params = newParams 138 } 139 serviceRunCommand(service, server, client, cmd, commandName, params, rb) 140 return false 141} 142 143// generic handler for service PRIVMSG, like `/msg NickServ INFO` 144func servicePrivmsgHandler(service *ircService, server *Server, client *Client, message string, rb *ResponseBuffer) { 145 if strings.HasPrefix(message, "\x01") { 146 serviceCTCPHandler(service, client, message) 147 return 148 } 149 150 params := strings.Fields(message) 151 if len(params) == 0 { 152 return 153 } 154 155 // look up the service command to see how to parse it 156 commandName := strings.ToLower(params[0]) 157 cmd := lookupServiceCommand(service.Commands, commandName) 158 // reparse if needed 159 if cmd != nil && cmd.unsplitFinalParam { 160 params = utils.FieldsN(message, cmd.maxParams+1)[1:] 161 } else { 162 params = params[1:] 163 } 164 serviceRunCommand(service, server, client, cmd, commandName, params, rb) 165} 166 167func serviceCTCPHandler(service *ircService, client *Client, message string) { 168 ctcp := strings.TrimSuffix(message[1:], "\x01") 169 170 ctcpSplit := utils.FieldsN(ctcp, 2) 171 ctcpCmd := strings.ToUpper(ctcpSplit[0]) 172 ctcpOut := "" 173 174 switch ctcpCmd { 175 case "VERSION": 176 ctcpOut = fmt.Sprintf("%s (%s)", service.Name, Ver) 177 case "PING": 178 if len(ctcpSplit) > 1 { 179 ctcpOut = ctcpSplit[1] 180 } 181 case "TIME": 182 ctcpOut = time.Now().UTC().Format(time.RFC1123) 183 } 184 185 if ctcpOut != "" { 186 client.Send(nil, service.prefix, "NOTICE", client.Nick(), fmt.Sprintf("\x01%s %s\x01", ctcpCmd, ctcpOut)) 187 } 188} 189 190// actually execute a service command 191func serviceRunCommand(service *ircService, server *Server, client *Client, cmd *serviceCommand, commandName string, params []string, rb *ResponseBuffer) { 192 nick := rb.target.Nick() 193 sendNotice := func(notice string) { 194 rb.Add(nil, service.prefix, "NOTICE", nick, notice) 195 } 196 197 if cmd == nil { 198 sendNotice(fmt.Sprintf(client.t("Unknown command. To see available commands, run: /%s HELP"), service.ShortName)) 199 return 200 } 201 202 if len(params) < cmd.minParams || (0 < cmd.maxParams && cmd.maxParams < len(params)) { 203 sendNotice(fmt.Sprintf(client.t("Invalid parameters. For usage, do /msg %[1]s HELP %[2]s"), service.Name, strings.ToUpper(commandName))) 204 return 205 } 206 207 if cmd.enabled != nil && !cmd.enabled(server.Config()) { 208 sendNotice(client.t("This command has been disabled by the server administrators")) 209 return 210 } 211 212 if 0 < len(cmd.capabs) && !client.HasRoleCapabs(cmd.capabs...) { 213 sendNotice(client.t("Command restricted")) 214 return 215 } 216 217 if cmd.authRequired && client.Account() == "" { 218 sendNotice(client.t("You're not logged into an account")) 219 return 220 } 221 222 server.logger.Debug("services", fmt.Sprintf("Client %s ran %s command %s", client.Nick(), service.Name, commandName)) 223 if commandName == "help" { 224 serviceHelpHandler(service, server, client, params, rb) 225 } else { 226 cmd.handler(service, server, client, commandName, params, rb) 227 } 228} 229 230// generic handler that displays help for service commands 231func serviceHelpHandler(service *ircService, server *Server, client *Client, params []string, rb *ResponseBuffer) { 232 nick := rb.target.Nick() 233 config := server.Config() 234 sendNotice := func(notice string) { 235 rb.Add(nil, service.prefix, "NOTICE", nick, notice) 236 } 237 238 sendNotice(fmt.Sprintf(ircfmt.Unescape("*** $b%s HELP$b ***"), service.Name)) 239 240 if len(params) == 0 { 241 helpBannerLines := strings.Split(client.t(service.HelpBanner), "\n") 242 helpBannerLines = append(helpBannerLines, []string{ 243 "", 244 client.t("To see in-depth help for a specific command, try:"), 245 fmt.Sprintf(ircfmt.Unescape(client.t(" $b/msg %s HELP <command>$b")), service.Name), 246 "", 247 client.t("Here are the commands you can use:"), 248 }...) 249 // show general help 250 var shownHelpLines sort.StringSlice 251 var disabledCommands bool 252 for _, commandInfo := range service.Commands { 253 // skip commands user can't access 254 if 0 < len(commandInfo.capabs) && !client.HasRoleCapabs(commandInfo.capabs...) { 255 continue 256 } 257 if commandInfo.aliasOf != "" || commandInfo.hidden { 258 continue // don't show help lines for aliases 259 } 260 if commandInfo.enabled != nil && !commandInfo.enabled(config) { 261 disabledCommands = true 262 continue 263 } 264 265 shownHelpLines = append(shownHelpLines, " "+ircfmt.Unescape(client.t(commandInfo.helpShort))) 266 } 267 268 if disabledCommands { 269 shownHelpLines = append(shownHelpLines, " "+client.t("... and other commands which have been disabled")) 270 } 271 272 // sort help lines 273 sort.Sort(shownHelpLines) 274 275 // push out help text 276 for _, line := range helpBannerLines { 277 sendNotice(line) 278 } 279 for _, line := range shownHelpLines { 280 sendNotice(line) 281 } 282 } else { 283 commandName := strings.ToLower(params[0]) 284 commandInfo := lookupServiceCommand(service.Commands, commandName) 285 if commandInfo == nil { 286 sendNotice(client.t(fmt.Sprintf("Unknown command. To see available commands, run /%s HELP", service.ShortName))) 287 } else { 288 helpStrings := commandInfo.helpStrings 289 if helpStrings == nil { 290 hsArray := [1]string{commandInfo.help} 291 helpStrings = hsArray[:] 292 } 293 for i, helpString := range helpStrings { 294 if 0 < i { 295 sendNotice("") 296 } 297 for _, line := range strings.Split(ircfmt.Unescape(client.t(helpString)), "\n") { 298 sendNotice(line) 299 } 300 } 301 } 302 } 303 304 sendNotice(fmt.Sprintf(ircfmt.Unescape(client.t("*** $bEnd of %s HELP$b ***")), service.Name)) 305} 306 307func makeServiceHelpTextGenerator(cmd string, banner string) func(*Client) string { 308 return func(client *Client) string { 309 var buf bytes.Buffer 310 fmt.Fprintf(&buf, client.t("%s <subcommand> [params]"), cmd) 311 buf.WriteRune('\n') 312 buf.WriteString(client.t(banner)) // may contain newlines, that's fine 313 buf.WriteRune('\n') 314 fmt.Fprintf(&buf, client.t("For more details, try /%s HELP"), cmd) 315 return buf.String() 316 } 317} 318 319func overrideServicePrefixes(hostname string) error { 320 if hostname == "" { 321 return nil 322 } 323 if !utils.IsHostname(hostname) { 324 return fmt.Errorf("`%s` is an invalid services hostname", hostname) 325 } 326 for _, serv := range OragonoServices { 327 serv.prefix = fmt.Sprintf("%s!%s@%s", serv.Name, serv.Name, hostname) 328 } 329 return nil 330} 331 332func initializeServices() { 333 // this modifies the global Commands map, 334 // so it must be called from irc/commands.go's init() 335 oragonoServicesByCommandAlias = make(map[string]*ircService) 336 337 for serviceName, service := range OragonoServices { 338 service.prefix = fmt.Sprintf("%s!%s@localhost", service.Name, service.Name) 339 340 // make `/MSG ServiceName HELP` work correctly 341 service.Commands["help"] = &servHelpCmd 342 343 // reserve the nickname 344 restrictedNicknames = append(restrictedNicknames, service.Name) 345 346 // register the protocol-level commands (NICKSERV, NS) that talk to the service, 347 // and their associated help entries 348 var ircCmdDef Command 349 ircCmdDef.handler = serviceCmdHandler 350 for _, ircCmd := range service.CommandAliases { 351 Commands[ircCmd] = ircCmdDef 352 oragonoServicesByCommandAlias[ircCmd] = service 353 Help[strings.ToLower(ircCmd)] = HelpEntry{ 354 textGenerator: makeServiceHelpTextGenerator(ircCmd, service.HelpBanner), 355 } 356 } 357 358 // force devs to write a help entry for every command 359 for commandName, commandInfo := range service.Commands { 360 if commandInfo.aliasOf == "" && !commandInfo.hidden { 361 if (commandInfo.help == "" && commandInfo.helpStrings == nil) || commandInfo.helpShort == "" { 362 log.Fatal(fmt.Sprintf("help entry missing for %s command %s", serviceName, commandName)) 363 } 364 } 365 366 if commandInfo.maxParams == 0 && commandInfo.unsplitFinalParam { 367 log.Fatal("unsplitFinalParam requires use of maxParams") 368 } 369 } 370 } 371 372 for _, restrictedNickname := range restrictedNicknames { 373 cfName, err := CasefoldName(restrictedNickname) 374 if err != nil { 375 panic(err) 376 } 377 restrictedCasefoldedNicks.Add(cfName) 378 skeleton, err := Skeleton(restrictedNickname) 379 if err != nil { 380 panic(err) 381 } 382 restrictedSkeletons.Add(skeleton) 383 } 384} 385