master
commands.go
1package senpai
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/url"
8 "os"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14
15 "git.sr.ht/~rockorager/vaxis"
16 "github.com/delthas/go-libnp"
17
18 "git.sr.ht/~delthas/senpai/irc"
19 "git.sr.ht/~delthas/senpai/ui"
20)
21
22var (
23 errOffline = fmt.Errorf("you are disconnected from the server, retry later")
24 errNotSupported = fmt.Errorf("command not supported by the server; try using latest soju")
25)
26
27const maxArgsInfinite = -1
28
29type command struct {
30 AllowHome bool
31 MinArgs int
32 MaxArgs int
33 Usage string
34 Desc string
35 Handle func(app *App, args []string) error // nil = passthrough
36}
37
38type commandSet map[string]*command
39
40var commands commandSet
41
42func init() {
43 commands = commandSet{
44 "HELP": {
45 AllowHome: true,
46 MaxArgs: 1,
47 Usage: "[command]",
48 Desc: "show the list of commands, or how to use the given one",
49 Handle: commandDoHelp,
50 },
51 "BOUNCER": {
52 AllowHome: true,
53 MinArgs: 1,
54 MaxArgs: 1,
55 Usage: "<bouncer message>",
56 Desc: "send command to the bouncer service (only works with soju); e.g. /bouncer help",
57 Handle: commandDoBouncer,
58 },
59 "JOIN": {
60 AllowHome: true,
61 MinArgs: 1,
62 MaxArgs: 2,
63 Usage: "<channels> [keys]",
64 Desc: "join a channel",
65 Handle: commandDoJoin,
66 },
67 "ME": {
68 MinArgs: 1,
69 MaxArgs: 1,
70 Usage: "<message>",
71 Desc: "send an action (reply to last query if sent from home)",
72 Handle: commandDoMe,
73 },
74 "NP": {
75 Desc: "send the current song that is being played on the system",
76 Handle: commandDoNP,
77 },
78 "UPLOAD": {
79 AllowHome: true,
80 MaxArgs: 1,
81 Usage: "[file path]",
82 Desc: "upload a local file to the bouncer, or the current clipboard content if no path is given",
83 Handle: commandDoUpload,
84 },
85 "SCREENSHOT": {
86 AllowHome: true,
87 Desc: "take and upload a screenshot to the bouncer",
88 Handle: commandDoScreenshot,
89 },
90 "MSG": {
91 AllowHome: true,
92 MinArgs: 2,
93 MaxArgs: 2,
94 Usage: "<target> <message>",
95 Desc: "send a message to the given target",
96 Handle: commandDoMsg,
97 },
98 "MOTD": {
99 AllowHome: true,
100 Desc: "show the message of the day (MOTD)",
101 },
102 "NAMES": {
103 Desc: "show the member list of the current channel",
104 Handle: commandDoNames,
105 },
106 "NICK": {
107 AllowHome: true,
108 MinArgs: 1,
109 MaxArgs: 1,
110 Usage: "<nickname>",
111 Desc: "change your nickname",
112 Handle: commandDoNick,
113 },
114 "OPER": {
115 AllowHome: true,
116 MinArgs: 2,
117 MaxArgs: 2,
118 Usage: "<username> <password>",
119 Desc: "log in to an operator account",
120 },
121 "MODE": {
122 AllowHome: true,
123 MaxArgs: maxArgsInfinite,
124 Usage: "[<nick/channel>] [<flags>] [args]",
125 Desc: "change channel or user modes",
126 Handle: commandDoMode,
127 },
128 "PART": {
129 AllowHome: true,
130 MaxArgs: 2,
131 Usage: "[channel] [reason]",
132 Desc: "part a channel",
133 Handle: commandDoPart,
134 },
135 "QUERY": {
136 AllowHome: true,
137 MinArgs: 1,
138 MaxArgs: 2,
139 Usage: "[nick] [message]",
140 Desc: "opens a buffer to a user",
141 Handle: commandDoQuery,
142 },
143 "QUIT": {
144 AllowHome: true,
145 MaxArgs: 1,
146 Usage: "[reason]",
147 Desc: "quit senpai",
148 Handle: commandDoQuit,
149 },
150 "QUOTE": {
151 AllowHome: true,
152 MinArgs: 1,
153 MaxArgs: 1,
154 Usage: "<raw message>",
155 Desc: "send raw protocol data",
156 Handle: commandDoQuote,
157 },
158 "LIST": {
159 AllowHome: true,
160 MaxArgs: 1,
161 Usage: "[pattern]",
162 Desc: "list public channels",
163 Handle: commandDoList,
164 },
165 "REPLY": {
166 AllowHome: true,
167 MinArgs: 1,
168 MaxArgs: 1,
169 Usage: "<message>",
170 Desc: "reply to the last query",
171 Handle: commandDoR,
172 },
173 "TOPIC": {
174 MaxArgs: 1,
175 Usage: "[topic]",
176 Desc: "show or set the topic of the current channel",
177 Handle: commandDoTopic,
178 },
179 "MUTE": {
180 Desc: "mute the current channel (preventing color on new non-highlight messages)",
181 Handle: commandDoMute,
182 },
183 "UNMUTE": {
184 Desc: "unmute the current channel",
185 Handle: commandDoUnmute,
186 },
187 "PIN": {
188 Desc: "pin the current channel (moving it to the top of the channel list)",
189 Handle: commandDoPin,
190 },
191 "UNPIN": {
192 Desc: "unpin the current channel",
193 Handle: commandDoUnpin,
194 },
195 "BUFFER": {
196 AllowHome: true,
197 MinArgs: 1,
198 MaxArgs: 1,
199 Usage: "<index|name>",
200 Desc: "switch to the buffer at the position or containing a substring",
201 Handle: commandDoBuffer,
202 },
203 "WHOIS": {
204 AllowHome: true,
205 MinArgs: 0,
206 MaxArgs: 1,
207 Usage: "<nick>",
208 Desc: "get information about someone who is connected",
209 Handle: commandDoWhois,
210 },
211 "WHOWAS": {
212 AllowHome: true,
213 MinArgs: 0,
214 MaxArgs: 1,
215 Usage: "<nick>",
216 Desc: "get information about someone who is disconnected",
217 Handle: commandDoWhowas,
218 },
219 "INVITE": {
220 AllowHome: true,
221 MinArgs: 1,
222 MaxArgs: 2,
223 Usage: "<name> [channel]",
224 Desc: "invite someone to a channel",
225 Handle: commandDoInvite,
226 },
227 "KICK": {
228 AllowHome: true,
229 MinArgs: 1,
230 MaxArgs: 3,
231 Usage: "<nick> [channel] [message]",
232 Desc: "eject someone from the channel",
233 Handle: commandDoKick,
234 },
235 "BAN": {
236 AllowHome: true,
237 MinArgs: 1,
238 MaxArgs: 2,
239 Usage: "<nick> [channel]",
240 Desc: "ban someone from entering the channel",
241 Handle: commandDoBan,
242 },
243 "UNBAN": {
244 AllowHome: true,
245 MinArgs: 1,
246 MaxArgs: 2,
247 Usage: "<nick> [channel]",
248 Desc: "remove effect of a ban from the user",
249 Handle: commandDoUnban,
250 },
251 "CONNECT": {
252 AllowHome: true,
253 MinArgs: 1,
254 MaxArgs: 3,
255 Usage: "<target server> [<port> [remote server]]",
256 Desc: "connect a server to the network",
257 },
258 "SQUIT": {
259 AllowHome: true,
260 MinArgs: 1,
261 MaxArgs: 2,
262 Usage: "<server> [comment]",
263 Desc: "disconnect a server from the network",
264 },
265 "KILL": {
266 AllowHome: true,
267 MinArgs: 1,
268 MaxArgs: 2,
269 Usage: "<nick> [message]",
270 Desc: "eject someone from the server",
271 },
272 "SEARCH": {
273 MaxArgs: 1,
274 Usage: "<text>",
275 Desc: "search messages in a target",
276 Handle: commandDoSearch,
277 },
278 "AWAY": {
279 AllowHome: true,
280 MinArgs: 0,
281 MaxArgs: 1,
282 Usage: "[message]",
283 Desc: "mark yourself as away (use /BACK to unset away)",
284 Handle: commandDoAway,
285 },
286 "BACK": {
287 AllowHome: true,
288 Desc: "mark yourself as back from being away",
289 Handle: commandDoBack,
290 },
291 "SHRUG": {
292 Desc: "send a shrug to the current channel ¯\\_(ツ)_/¯",
293 MaxArgs: maxArgsInfinite,
294 Handle: commandDoShrug,
295 },
296 "TABLEFLIP": {
297 Desc: "send a tableflip to the current channel (╯°□°)╯︵ ┻━┻",
298 Handle: commandDoTableFlip,
299 },
300 "VERSION": {
301 AllowHome: true,
302 MaxArgs: 1,
303 Usage: "[target]",
304 Desc: "query the server software version",
305 },
306 "ADMIN": {
307 AllowHome: true,
308 MaxArgs: 1,
309 Usage: "[target]",
310 Desc: "query the server administrative information",
311 },
312 "LUSERS": {
313 AllowHome: true,
314 Desc: "query the server user information",
315 },
316 "TIME": {
317 AllowHome: true,
318 MaxArgs: 1,
319 Usage: "[target]",
320 Desc: "query the server local time",
321 },
322 "STATS": {
323 AllowHome: true,
324 MinArgs: 1,
325 MaxArgs: 2,
326 Usage: "<query> [target]",
327 Desc: "query server statistics",
328 },
329 "INFO": {
330 AllowHome: true,
331 Desc: "query server information",
332 },
333 "REHASH": {
334 AllowHome: true,
335 Desc: "make the server reload its configuration",
336 },
337 "RESTART": {
338 AllowHome: true,
339 Desc: "make the server restart",
340 },
341 "LINKS": {
342 AllowHome: true,
343 Desc: "query the servers of the network",
344 },
345 "WALLOPS": {
346 AllowHome: true,
347 MinArgs: 1,
348 MaxArgs: 1,
349 Usage: "<text>",
350 Desc: "broadcast a message to all users",
351 },
352 }
353}
354
355func noCommand(app *App, content string) error {
356 netID, buffer := app.win.CurrentBuffer()
357 if buffer == "" {
358 return fmt.Errorf("can't send message to this buffer")
359 }
360 s := app.sessions[netID]
361 if s == nil {
362 return errOffline
363 }
364
365 s.PrivMsg(buffer, content)
366 if !s.HasCapability("echo-message") {
367 buffer, line := app.formatMessage(s, irc.MessageEvent{
368 User: s.Nick(),
369 Target: buffer,
370 TargetIsChannel: s.IsChannel(buffer),
371 Command: "PRIVMSG",
372 Content: content,
373 Time: time.Now(),
374 })
375 app.win.AddLine(netID, buffer, line)
376 }
377
378 return nil
379}
380
381func commandDoBuffer(app *App, args []string) error {
382 name := args[0]
383 i, err := strconv.Atoi(name)
384 if err == nil {
385 if app.win.JumpBufferIndex(i - 1) {
386 return nil
387 }
388 }
389 if !app.win.JumpBuffer(args[0]) {
390 return fmt.Errorf("none of the buffers match %q", name)
391 }
392
393 return nil
394}
395
396func commandDoHelp(app *App, args []string) (err error) {
397 t := time.Now()
398 netID, buffer := app.win.CurrentBuffer()
399
400 addLineCommand := func(sb *ui.StyledStringBuilder, name string, cmd *command) {
401 sb.Reset()
402 sb.Grow(len(name) + 1 + len(cmd.Usage))
403 sb.SetStyle(vaxis.Style{
404 Attribute: vaxis.AttrBold,
405 })
406 sb.WriteString(name)
407 sb.SetStyle(vaxis.Style{})
408 sb.WriteByte(' ')
409 sb.WriteString(cmd.Usage)
410 app.win.AddLine(netID, buffer, ui.Line{
411 At: t,
412 Body: sb.StyledString(),
413 })
414 app.win.AddLine(netID, buffer, ui.Line{
415 At: t,
416 Body: ui.PlainSprintf(" %s", cmd.Desc),
417 })
418 }
419
420 addLineCommands := func(names []string) {
421 sort.Strings(names)
422 var sb ui.StyledStringBuilder
423 for _, name := range names {
424 addLineCommand(&sb, name, commands[name])
425 }
426 }
427
428 if len(args) == 0 {
429 app.win.AddLine(netID, buffer, ui.Line{
430 At: t,
431 Head: ui.PlainString("--"),
432 Body: ui.PlainString("Available commands:"),
433 })
434
435 cmdNames := make([]string, 0, len(commands))
436 for cmdName := range commands {
437 cmdNames = append(cmdNames, cmdName)
438 }
439 addLineCommands(cmdNames)
440 } else {
441 search := strings.ToUpper(args[0])
442 app.win.AddLine(netID, buffer, ui.Line{
443 At: t,
444 Head: ui.PlainString("--"),
445 Body: ui.PlainSprintf("Commands that match \"%s\":", search),
446 })
447
448 cmdNames := make([]string, 0, len(commands))
449 for cmdName := range commands {
450 if !strings.Contains(cmdName, search) {
451 continue
452 }
453 cmdNames = append(cmdNames, cmdName)
454 }
455 if len(cmdNames) == 0 {
456 app.win.AddLine(netID, buffer, ui.Line{
457 At: t,
458 Body: ui.PlainSprintf(" no command matches %q", args[0]),
459 })
460 } else {
461 addLineCommands(cmdNames)
462 }
463 }
464 return nil
465}
466
467func commandDoJoin(app *App, args []string) (err error) {
468 s := app.CurrentSession()
469 if s == nil {
470 return errOffline
471 }
472 channel := args[0]
473 key := ""
474 if len(args) == 2 {
475 key = args[1]
476 }
477 s.Join(channel, key)
478 return nil
479}
480
481func commandDoMe(app *App, args []string) (err error) {
482 netID, buffer := app.win.CurrentBuffer()
483 if buffer == "" {
484 netID = app.lastQueryNet
485 buffer = app.lastQuery
486 }
487 s := app.sessions[netID]
488 if s == nil {
489 return errOffline
490 }
491 content := fmt.Sprintf("\x01ACTION %s\x01", args[0])
492 s.PrivMsg(buffer, content)
493 if !s.HasCapability("echo-message") {
494 buffer, line := app.formatMessage(s, irc.MessageEvent{
495 User: s.Nick(),
496 Target: buffer,
497 TargetIsChannel: s.IsChannel(buffer),
498 Command: "PRIVMSG",
499 Content: content,
500 Time: time.Now(),
501 })
502 app.win.AddLine(netID, buffer, line)
503 }
504 return nil
505}
506
507func commandDoNP(app *App, args []string) (err error) {
508 song, err := getSong()
509 if err != nil {
510 return fmt.Errorf("failed detecting the song: %v", err)
511 }
512 if song == "" {
513 return fmt.Errorf("no song was detected")
514 }
515 return commandDoMe(app, []string{fmt.Sprintf("np: %s", song)})
516}
517
518func commandDoUpload(app *App, args []string) (err error) {
519 if app.cfg.Transient || !app.cfg.LocalIntegrations {
520 return fmt.Errorf("usage of UPLOAD is disabled")
521 }
522 s := app.CurrentSession()
523 if s == nil {
524 return errOffline
525 }
526 upload := s.UploadURL()
527 if upload == "" {
528 return fmt.Errorf("file upload is not supported on this server; try using soju and enabling file upload")
529 }
530
531 if len(args) == 0 {
532 rc, mimetype, err := readClipboard()
533 if err != nil {
534 return err
535 }
536 app.handleUpload(upload, rc, -1, "", mimetype, rc)
537 return nil
538 }
539
540 path := args[0]
541 if home, err := os.UserHomeDir(); err == nil && !filepath.IsAbs(path) {
542 path = filepath.Join(home, path)
543 }
544
545 fi, err := os.Stat(path)
546 if err != nil {
547 return fmt.Errorf("opening file: %v", err)
548 }
549 f, err := os.Open(path)
550 if err != nil {
551 return fmt.Errorf("opening file: %v", err)
552 }
553
554 app.handleUpload(upload, f, fi.Size(), filepath.Base(path), "", f)
555 return nil
556}
557
558func commandDoScreenshot(app *App, args []string) (err error) {
559 if app.cfg.Transient || !app.cfg.LocalIntegrations {
560 return fmt.Errorf("usage of SCREENSHOT is disabled")
561 }
562 return ui.Screenshot()
563}
564
565func commandDoMsg(app *App, args []string) (err error) {
566 target := args[0]
567 content := args[1]
568 return commandSendMessage(app, target, content)
569}
570
571func commandDoNames(app *App, args []string) (err error) {
572 netID, buffer := app.win.CurrentBuffer()
573 s := app.sessions[netID]
574 if s == nil {
575 return errOffline
576 }
577 if !s.IsChannel(buffer) {
578 return fmt.Errorf("this is not a channel")
579 }
580 var sb ui.StyledStringBuilder
581 sb.SetStyle(vaxis.Style{
582 Foreground: app.cfg.Colors.Status,
583 })
584 sb.WriteString("Names: ")
585 for _, name := range s.Names(buffer) {
586 if name.PowerLevel != "" {
587 sb.SetStyle(vaxis.Style{
588 Foreground: ui.ColorGreen,
589 })
590 sb.WriteString(name.PowerLevel)
591 sb.SetStyle(vaxis.Style{
592 Foreground: app.cfg.Colors.Status,
593 })
594 }
595 sb.WriteString(name.Name.Name)
596 sb.WriteByte(' ')
597 }
598 body := sb.StyledString()
599 // TODO remove last space
600 app.win.AddLine(netID, buffer, ui.Line{
601 At: time.Now(),
602 Head: ui.ColorString("--", app.cfg.Colors.Status),
603 Body: body,
604 })
605 return nil
606}
607
608func commandDoNick(app *App, args []string) (err error) {
609 nick := args[0]
610 if i := strings.IndexAny(nick, " :"); i >= 0 {
611 return fmt.Errorf("illegal char %q in nickname", nick[i])
612 }
613 s := app.CurrentSession()
614 if s == nil {
615 return errOffline
616 }
617 s.ChangeNick(nick)
618 return
619}
620
621func commandDoMode(app *App, args []string) (err error) {
622 _, target := app.win.CurrentBuffer()
623 if len(args) > 0 && !strings.HasPrefix(args[0], "+") && !strings.HasPrefix(args[0], "-") {
624 target = args[0]
625 args = args[1:]
626 }
627 flags := ""
628 if len(args) > 0 {
629 flags = args[0]
630 args = args[1:]
631 }
632 modeArgs := args
633
634 s := app.CurrentSession()
635 if s == nil {
636 return errOffline
637 }
638 s.ChangeMode(target, flags, modeArgs)
639 return nil
640}
641
642func commandDoPart(app *App, args []string) (err error) {
643 netID, channel := app.win.CurrentBuffer()
644 s := app.sessions[netID]
645 if s == nil {
646 return errOffline
647 }
648 reason := ""
649 if 0 < len(args) {
650 if s.IsChannel(args[0]) {
651 channel = args[0]
652 if 1 < len(args) {
653 reason = args[1]
654 }
655 } else {
656 reason = args[0]
657 }
658 }
659
660 if channel == "" {
661 return fmt.Errorf("cannot part this buffer")
662 }
663
664 if s.IsChannel(channel) {
665 s.Part(channel, reason)
666 } else {
667 app.win.RemoveBuffer(netID, channel)
668 }
669 return nil
670}
671
672func commandDoQuery(app *App, args []string) (err error) {
673 netID, _ := app.win.CurrentBuffer()
674 s := app.sessions[netID]
675 if s == nil {
676 return errOffline
677 }
678 target := args[0]
679 if s.IsChannel(target) {
680 return fmt.Errorf("cannot query a channel, use JOIN instead")
681 }
682 i, _ := app.addUserBuffer(netID, target, time.Time{})
683 app.win.JumpBufferIndex(i)
684 if len(args) > 1 {
685 if err := commandSendMessage(app, target, args[1]); err != nil {
686 return err
687 }
688 }
689 return nil
690}
691
692func commandDoQuit(app *App, args []string) (err error) {
693 reason := ""
694 if 0 < len(args) {
695 reason = args[0]
696 }
697 for _, session := range app.sessions {
698 session.Quit(reason)
699 }
700 app.win.Exit()
701 return nil
702}
703
704func commandDoBouncer(app *App, args []string) (err error) {
705 b, err := getBouncerService(app)
706 if err != nil {
707 return err
708 }
709 s := app.CurrentSession()
710 if s == nil {
711 return errOffline
712 }
713 s.PrivMsg(b, args[0])
714 return nil
715}
716
717func commandDoQuote(app *App, args []string) (err error) {
718 if app.cfg.Transient {
719 return fmt.Errorf("usage of QUOTE is disabled")
720 }
721 s := app.CurrentSession()
722 if s == nil {
723 return errOffline
724 }
725 s.SendRaw(args[0])
726 return nil
727}
728
729func commandDoList(app *App, args []string) (err error) {
730 if app.cfg.Transient {
731 return fmt.Errorf("usage of LIST is disabled")
732 }
733 s := app.CurrentSession()
734 if s == nil {
735 return errOffline
736 }
737 var pattern string
738 if len(args) > 0 {
739 pattern = args[0]
740 }
741 s.List(pattern)
742 return nil
743}
744
745func commandDoR(app *App, args []string) (err error) {
746 s := app.sessions[app.lastQueryNet]
747 if s == nil {
748 return errOffline
749 }
750 s.PrivMsg(app.lastQuery, args[0])
751 if !s.HasCapability("echo-message") {
752 buffer, line := app.formatMessage(s, irc.MessageEvent{
753 User: s.Nick(),
754 Target: app.lastQuery,
755 TargetIsChannel: s.IsChannel(app.lastQuery),
756 Command: "PRIVMSG",
757 Content: args[0],
758 Time: time.Now(),
759 })
760 app.win.AddLine(app.lastQueryNet, buffer, line)
761 }
762 return nil
763}
764
765func commandDoTopic(app *App, args []string) (err error) {
766 netID, buffer := app.win.CurrentBuffer()
767 var ok bool
768 if len(args) == 0 {
769 ok = app.printTopic(netID, buffer)
770 } else {
771 s := app.sessions[netID]
772 if s != nil {
773 s.ChangeTopic(buffer, args[0])
774 ok = true
775 }
776 }
777 if !ok {
778 return errOffline
779 }
780 return nil
781}
782
783func commandDoMute(app *App, args []string) (err error) {
784 netID, buffer := app.win.CurrentBuffer()
785 s := app.sessions[netID]
786 if s == nil {
787 return errOffline
788 }
789 if !s.MutedSet(buffer, true) {
790 return errNotSupported
791 }
792 return nil
793}
794
795func commandDoUnmute(app *App, args []string) (err error) {
796 netID, buffer := app.win.CurrentBuffer()
797 s := app.sessions[netID]
798 if s == nil {
799 return errOffline
800 }
801 if !s.MutedSet(buffer, false) {
802 return errNotSupported
803 }
804 return nil
805}
806
807func commandDoPin(app *App, args []string) (err error) {
808 netID, buffer := app.win.CurrentBuffer()
809 s := app.sessions[netID]
810 if s == nil {
811 return errOffline
812 }
813 if !s.PinnedSet(buffer, true) {
814 return errNotSupported
815 }
816 return nil
817}
818
819func commandDoUnpin(app *App, args []string) (err error) {
820 netID, buffer := app.win.CurrentBuffer()
821 s := app.sessions[netID]
822 if s == nil {
823 return errOffline
824 }
825 if !s.PinnedSet(buffer, false) {
826 return errNotSupported
827 }
828 return nil
829}
830
831func commandDoWhois(app *App, args []string) (err error) {
832 netID, channel := app.win.CurrentBuffer()
833 s := app.sessions[netID]
834 if s == nil {
835 return errOffline
836 }
837 var nick string
838 if len(args) == 0 {
839 if channel == "" || s.IsChannel(channel) {
840 return fmt.Errorf("either send this command from a DM, or specify the user")
841 }
842 nick = channel
843 } else {
844 nick = args[0]
845 }
846 s.Whois(nick)
847 return nil
848}
849
850func commandDoWhowas(app *App, args []string) (err error) {
851 netID, channel := app.win.CurrentBuffer()
852 s := app.sessions[netID]
853 if s == nil {
854 return errOffline
855 }
856 var nick string
857 if len(args) == 0 {
858 if channel == "" || s.IsChannel(channel) {
859 return fmt.Errorf("either send this command from a DM, or specify the user")
860 }
861 nick = channel
862 } else {
863 nick = args[0]
864 }
865 s.Whowas(nick)
866 return nil
867}
868
869func commandDoInvite(app *App, args []string) (err error) {
870 nick := args[0]
871 netID, channel := app.win.CurrentBuffer()
872 s := app.sessions[netID]
873 if s == nil {
874 return errOffline
875 }
876 if len(args) == 2 {
877 channel = args[1]
878 } else if channel == "" {
879 return fmt.Errorf("either send this command from a channel, or specify the channel")
880 }
881 s.Invite(nick, channel)
882 return nil
883}
884
885func commandDoKick(app *App, args []string) (err error) {
886 nick := args[0]
887 netID, channel := app.win.CurrentBuffer()
888 s := app.sessions[netID]
889 if s == nil {
890 return errOffline
891 }
892 // Check whether the argument after the user is a channel, to accept both:
893 // - KICK user #chan you are mean
894 // - KICK user you are mean
895 comment := ""
896 if len(args) >= 2 {
897 if s.IsChannel(args[1]) {
898 channel = args[1]
899 } else {
900 comment = args[1] + " "
901 }
902 }
903 if channel == "" {
904 return fmt.Errorf("either send this command from a channel, or specify the channel")
905 }
906 if len(args) == 3 {
907 comment += args[2]
908 }
909 s.Kick(nick, channel, comment)
910 return nil
911}
912
913func commandDoBan(app *App, args []string) (err error) {
914 nick := args[0]
915 netID, channel := app.win.CurrentBuffer()
916 s := app.sessions[netID]
917 if s == nil {
918 return errOffline
919 }
920 if len(args) == 2 {
921 channel = args[1]
922 } else if channel == "" {
923 return fmt.Errorf("either send this command from a channel, or specify the channel")
924 }
925 s.ChangeMode(channel, "+b", []string{nick})
926 return nil
927}
928
929func commandDoUnban(app *App, args []string) (err error) {
930 nick := args[0]
931 netID, channel := app.win.CurrentBuffer()
932 s := app.sessions[netID]
933 if s == nil {
934 return errOffline
935 }
936 if len(args) == 2 {
937 channel = args[1]
938 } else if channel == "" {
939 return fmt.Errorf("either send this command from a channel, or specify the channel")
940 }
941 s.ChangeMode(channel, "-b", []string{nick})
942 return nil
943}
944
945func commandDoSearch(app *App, args []string) (err error) {
946 if len(args) == 0 {
947 app.win.CloseOverlay()
948 return nil
949 }
950 text := args[0]
951 netID, channel := app.win.CurrentBuffer()
952 s := app.sessions[netID]
953 if s == nil {
954 return errOffline
955 }
956 if !s.HasCapability("soju.im/search") {
957 return errors.New("server does not support searching")
958 }
959 s.Search(channel, text)
960 return nil
961}
962
963func commandDoAway(app *App, args []string) (err error) {
964 reason := "Away"
965 if len(args) > 0 {
966 reason = args[0]
967 }
968 s := app.CurrentSession()
969 if s == nil {
970 return errOffline
971 }
972 s.Away(reason)
973 return nil
974}
975
976func commandDoBack(app *App, args []string) (err error) {
977 s := app.CurrentSession()
978 if s == nil {
979 return errOffline
980 }
981 s.Away("")
982 return nil
983}
984
985// implemented from https://golang.org/src/strings/strings.go?s=8055:8085#L310
986func fieldsN(s string, n int) []string {
987 s = strings.TrimSpace(s)
988 if s == "" || n == 0 {
989 return nil
990 }
991 if n == 1 {
992 return []string{s}
993 }
994 // Start of the ASCII fast path.
995 var a []string
996 na := 0
997 fieldStart := 0
998 i := 0
999 // Skip spaces in front of the input.
1000 for i < len(s) && s[i] == ' ' {
1001 i++
1002 }
1003 fieldStart = i
1004 for i < len(s) {
1005 if s[i] != ' ' {
1006 i++
1007 continue
1008 }
1009 a = append(a, s[fieldStart:i])
1010 na++
1011 i++
1012 // Skip spaces in between fields.
1013 for i < len(s) && s[i] == ' ' {
1014 i++
1015 }
1016 fieldStart = i
1017 if n != maxArgsInfinite && na+1 >= n {
1018 a = append(a, s[fieldStart:])
1019 return a
1020 }
1021 }
1022 if fieldStart < len(s) {
1023 // Last field ends at EOF.
1024 a = append(a, s[fieldStart:])
1025 }
1026 return a
1027}
1028
1029func parseCommand(s string) (command, args string, isCommand bool) {
1030 if len(s) == 0 || s[0] != '/' {
1031 return "", s, false
1032 }
1033 if len(s) > 1 && s[1] == '/' {
1034 // Input starts with two slashes.
1035 return "", s[1:], false
1036 }
1037
1038 i := strings.IndexByte(s, ' ')
1039 if i < 0 {
1040 i = len(s)
1041 }
1042
1043 return strings.ToUpper(s[1:i]), strings.TrimLeft(s[i:], " "), true
1044}
1045
1046func commandSendMessage(app *App, target string, content string) error {
1047 netID, _ := app.win.CurrentBuffer()
1048 s := app.sessions[netID]
1049 if s == nil {
1050 return errOffline
1051 }
1052 s.PrivMsg(target, content)
1053 if !s.HasCapability("echo-message") {
1054 buffer, line := app.formatMessage(s, irc.MessageEvent{
1055 User: s.Nick(),
1056 Target: target,
1057 TargetIsChannel: s.IsChannel(target),
1058 Command: "PRIVMSG",
1059 Content: content,
1060 Time: time.Now(),
1061 })
1062 if buffer != "" && !s.IsChannel(target) {
1063 app.addUserBuffer(netID, buffer, time.Time{})
1064 }
1065 app.win.AddLine(netID, buffer, line)
1066 }
1067 return nil
1068}
1069
1070func commandDoShrug(app *App, args []string) (err error) {
1071 _, buffer := app.win.CurrentBuffer()
1072 return commandSendMessage(app, buffer, `¯\_(ツ)_/¯`)
1073}
1074
1075func commandDoTableFlip(app *App, args []string) (err error) {
1076 _, buffer := app.win.CurrentBuffer()
1077 return commandSendMessage(app, buffer, `(╯°□°)╯︵ ┻━┻`)
1078}
1079
1080func (app *App) handleInput(buffer, content string) error {
1081 confirmed := content == app.lastConfirm
1082 app.lastConfirm = content
1083
1084 if content == "" {
1085 return nil
1086 }
1087
1088 cmdName, rawArgs, isCommand := parseCommand(content)
1089 if !isCommand {
1090 if _, _, command := parseCommand(strings.TrimSpace(content)); !confirmed && command {
1091 // " /FOO BAR"
1092 return fmt.Errorf("this message looks like a command; remove the spaces at the start, or press enter again to send the message as is")
1093 }
1094 return noCommand(app, rawArgs)
1095 }
1096 if cmdName == "" {
1097 return fmt.Errorf("lone slash at the beginning")
1098 }
1099 if strings.HasPrefix("BUFFER", cmdName) {
1100 cmdName = "BUFFER"
1101 }
1102
1103 var chosenCMDName string
1104 var found bool
1105 for key := range commands {
1106 if !strings.HasPrefix(key, cmdName) {
1107 continue
1108 }
1109 if found {
1110 return fmt.Errorf("ambiguous command %q (could mean %v or %v)", cmdName, chosenCMDName, key)
1111 }
1112 chosenCMDName = key
1113 found = true
1114 }
1115 if !found {
1116 if confirmed {
1117 if s := app.CurrentSession(); s != nil {
1118 if rawArgs != "" {
1119 s.SendRaw(fmt.Sprintf("%s %s", cmdName, rawArgs))
1120 } else {
1121 s.SendRaw(cmdName)
1122 }
1123 return nil
1124 } else {
1125 return errOffline
1126 }
1127 } else {
1128 return fmt.Errorf("the senpai command %q does not exist; press enter again to pass the command as is to the server", cmdName)
1129 }
1130 }
1131
1132 cmd := commands[chosenCMDName]
1133
1134 var args []string
1135 if rawArgs != "" && cmd.MaxArgs != 0 {
1136 args = fieldsN(rawArgs, cmd.MaxArgs)
1137 }
1138
1139 if len(args) < cmd.MinArgs {
1140 return fmt.Errorf("usage: %s %s", chosenCMDName, cmd.Usage)
1141 }
1142 if buffer == "" && !cmd.AllowHome {
1143 return fmt.Errorf("command %s cannot be executed from a server buffer", chosenCMDName)
1144 }
1145
1146 if cmd.Handle != nil {
1147 return cmd.Handle(app, args)
1148 } else {
1149 if s := app.CurrentSession(); s != nil {
1150 if rawArgs != "" {
1151 s.Send(cmdName, args...)
1152 } else {
1153 s.Send(cmdName)
1154 }
1155 return nil
1156 } else {
1157 return errOffline
1158 }
1159 }
1160}
1161
1162func getSong() (string, error) {
1163 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
1164 defer cancel()
1165 info, err := libnp.GetInfo(ctx)
1166 if err != nil {
1167 return "", err
1168 }
1169 if info == nil {
1170 return "", nil
1171 }
1172 if info.Title == "" {
1173 return "", nil
1174 }
1175
1176 var sb strings.Builder
1177 fmt.Fprintf(&sb, "\x02%s\x02", info.Title)
1178 if len(info.Artists) > 0 && info.Artists[0] != "" {
1179 fmt.Fprintf(&sb, " by \x02%s\x02", info.Artists[0])
1180 }
1181 if info.Album != "" {
1182 fmt.Fprintf(&sb, " from \x02%s\x02", info.Album)
1183 }
1184 if u, err := url.Parse(info.URL); err == nil {
1185 switch u.Scheme {
1186 case "http", "https":
1187 fmt.Fprintf(&sb, " — %s", info.URL)
1188 }
1189 }
1190 return sb.String(), nil
1191}
1192
1193func getBouncerService(app *App) (service string, err error) {
1194 if app.cfg.Transient {
1195 return "", fmt.Errorf("usage of BOUNCER is disabled")
1196 }
1197 s := app.CurrentSession()
1198 if s == nil {
1199 return "", errOffline
1200 }
1201 b := s.BouncerService()
1202 if b == "" {
1203 return "", fmt.Errorf("no bouncer service found on this server; try using soju")
1204 }
1205 return b, nil
1206}