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