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