package senpai import ( "errors" "fmt" "net/url" "sort" "strconv" "strings" "time" "git.sr.ht/~taiite/senpai/irc" "git.sr.ht/~taiite/senpai/ui" "github.com/delthas/go-libnp" "github.com/gdamore/tcell/v2" "golang.org/x/net/context" ) var ( errOffline = fmt.Errorf("you are disconnected from the server, retry later") ) const maxArgsInfinite = -1 type command struct { AllowHome bool MinArgs int MaxArgs int Usage string Desc string Handle func(app *App, args []string) error } type commandSet map[string]*command var commands commandSet func init() { commands = commandSet{ "HELP": { AllowHome: true, MaxArgs: 1, Usage: "[command]", Desc: "show the list of commands, or how to use the given one", Handle: commandDoHelp, }, "JOIN": { AllowHome: true, MinArgs: 1, MaxArgs: 2, Usage: " [keys]", Desc: "join a channel", Handle: commandDoJoin, }, "ME": { AllowHome: true, MinArgs: 1, MaxArgs: 1, Usage: "", Desc: "send an action (reply to last query if sent from home)", Handle: commandDoMe, }, "NP": { AllowHome: true, Desc: "send the current song that is being played on the system", Handle: commandDoNP, }, "MSG": { AllowHome: true, MinArgs: 2, MaxArgs: 2, Usage: " ", Desc: "send a message to the given target", Handle: commandDoMsg, }, "MOTD": { Desc: "show the message of the day (MOTD)", Handle: commandDoMOTD, }, "NAMES": { Desc: "show the member list of the current channel", Handle: commandDoNames, }, "NICK": { AllowHome: true, MinArgs: 1, MaxArgs: 1, Usage: "", Desc: "change your nickname", Handle: commandDoNick, }, "OPER": { AllowHome: true, MinArgs: 2, MaxArgs: 2, Usage: " ", Desc: "log in to an operator account", Handle: commandDoOper, }, "MODE": { AllowHome: true, MaxArgs: maxArgsInfinite, Usage: "[] [] [args]", Desc: "change channel or user modes", Handle: commandDoMode, }, "PART": { AllowHome: true, MaxArgs: 2, Usage: "[channel] [reason]", Desc: "part a channel", Handle: commandDoPart, }, "QUERY": { AllowHome: true, MinArgs: 1, MaxArgs: 2, Usage: "[nick] [message]", Desc: "opens a buffer to a user", Handle: commandDoQuery, }, "QUIT": { AllowHome: true, MaxArgs: 1, Usage: "[reason]", Desc: "quit senpai", Handle: commandDoQuit, }, "QUOTE": { AllowHome: true, MinArgs: 1, MaxArgs: 1, Usage: "", Desc: "send raw protocol data", Handle: commandDoQuote, }, "REPLY": { AllowHome: true, MinArgs: 1, MaxArgs: 1, Usage: "", Desc: "reply to the last query", Handle: commandDoR, }, "TOPIC": { MaxArgs: 1, Usage: "[topic]", Desc: "show or set the topic of the current channel", Handle: commandDoTopic, }, "BUFFER": { AllowHome: true, MinArgs: 1, MaxArgs: 1, Usage: "", Desc: "switch to the buffer containing a substring", Handle: commandDoBuffer, }, "WHOIS": { AllowHome: false, MinArgs: 0, MaxArgs: 1, Usage: "", Desc: "get information about someone", Handle: commandDoWhois, }, "INVITE": { AllowHome: true, MinArgs: 1, MaxArgs: 2, Usage: " [channel]", Desc: "invite someone to a channel", Handle: commandDoInvite, }, "KICK": { AllowHome: false, MinArgs: 1, MaxArgs: 2, Usage: " [channel]", Desc: "eject someone from the channel", Handle: commandDoKick, }, "BAN": { AllowHome: false, MinArgs: 1, MaxArgs: 2, Usage: " [channel]", Desc: "ban someone from entering the channel", Handle: commandDoBan, }, "UNBAN": { AllowHome: false, MinArgs: 1, MaxArgs: 2, Usage: " [channel]", Desc: "remove effect of a ban from the user", Handle: commandDoUnban, }, "SEARCH": { AllowHome: true, MaxArgs: 1, Usage: "", Desc: "searches messages in a target", Handle: commandDoSearch, }, } } func noCommand(app *App, content string) error { netID, buffer := app.win.CurrentBuffer() if buffer == "" { return fmt.Errorf("can't send message to this buffer") } s := app.sessions[netID] if s == nil { return errOffline } s.PrivMsg(buffer, content) if !s.HasCapability("echo-message") { buffer, line := app.formatMessage(s, irc.MessageEvent{ User: s.Nick(), Target: buffer, TargetIsChannel: s.IsChannel(buffer), Command: "PRIVMSG", Content: content, Time: time.Now(), }) app.win.AddLine(netID, buffer, line) } return nil } func commandDoBuffer(app *App, args []string) error { name := args[0] i, err := strconv.Atoi(name) if err == nil { if app.win.JumpBufferIndex(i) { return nil } } if !app.win.JumpBuffer(args[0]) { return fmt.Errorf("none of the buffers match %q", name) } return nil } func commandDoHelp(app *App, args []string) (err error) { t := time.Now() netID, buffer := app.win.CurrentBuffer() addLineCommand := func(sb *ui.StyledStringBuilder, name string, cmd *command) { sb.Reset() sb.Grow(len(name) + 1 + len(cmd.Usage)) sb.SetStyle(tcell.StyleDefault.Bold(true)) sb.WriteString(name) sb.SetStyle(tcell.StyleDefault) sb.WriteByte(' ') sb.WriteString(cmd.Usage) app.win.AddLine(netID, buffer, ui.Line{ At: t, Body: sb.StyledString(), }) app.win.AddLine(netID, buffer, ui.Line{ At: t, Body: ui.PlainSprintf(" %s", cmd.Desc), }) app.win.AddLine(netID, buffer, ui.Line{ At: t, }) } addLineCommands := func(names []string) { sort.Strings(names) var sb ui.StyledStringBuilder for _, name := range names { addLineCommand(&sb, name, commands[name]) } } if len(args) == 0 { app.win.AddLine(netID, buffer, ui.Line{ At: t, Head: "--", Body: ui.PlainString("Available commands:"), }) cmdNames := make([]string, 0, len(commands)) for cmdName := range commands { cmdNames = append(cmdNames, cmdName) } addLineCommands(cmdNames) } else { search := strings.ToUpper(args[0]) app.win.AddLine(netID, buffer, ui.Line{ At: t, Head: "--", Body: ui.PlainSprintf("Commands that match \"%s\":", search), }) cmdNames := make([]string, 0, len(commands)) for cmdName := range commands { if !strings.Contains(cmdName, search) { continue } cmdNames = append(cmdNames, cmdName) } if len(cmdNames) == 0 { app.win.AddLine(netID, buffer, ui.Line{ At: t, Body: ui.PlainSprintf(" no command matches %q", args[0]), }) } else { addLineCommands(cmdNames) } } return nil } func commandDoJoin(app *App, args []string) (err error) { s := app.CurrentSession() if s == nil { return errOffline } channel := args[0] key := "" if len(args) == 2 { key = args[1] } s.Join(channel, key) return nil } func commandDoMe(app *App, args []string) (err error) { netID, buffer := app.win.CurrentBuffer() if buffer == "" { netID = app.lastQueryNet buffer = app.lastQuery } s := app.sessions[netID] if s == nil { return errOffline } content := fmt.Sprintf("\x01ACTION %s\x01", args[0]) s.PrivMsg(buffer, content) if !s.HasCapability("echo-message") { buffer, line := app.formatMessage(s, irc.MessageEvent{ User: s.Nick(), Target: buffer, TargetIsChannel: s.IsChannel(buffer), Command: "PRIVMSG", Content: content, Time: time.Now(), }) app.win.AddLine(netID, buffer, line) } return nil } func commandDoNP(app *App, args []string) (err error) { song, err := getSong() if err != nil { return fmt.Errorf("failed detecting the song: %v", err) } if song == "" { return fmt.Errorf("no song was detected") } return commandDoMe(app, []string{fmt.Sprintf("np: %s", song)}) } func commandDoMsg(app *App, args []string) (err error) { target := args[0] content := args[1] return commandSendMessage(app, target, content) } func commandDoMOTD(app *App, args []string) (err error) { netID, _ := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } s.MOTD() return nil } func commandDoNames(app *App, args []string) (err error) { netID, buffer := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } if !s.IsChannel(buffer) { return fmt.Errorf("this is not a channel") } var sb ui.StyledStringBuilder sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) sb.WriteString("Names: ") for _, name := range s.Names(buffer) { if name.PowerLevel != "" { sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen)) sb.WriteString(name.PowerLevel) sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) } sb.WriteString(name.Name.Name) sb.WriteByte(' ') } body := sb.StyledString() // TODO remove last space app.win.AddLine(netID, buffer, ui.Line{ At: time.Now(), Head: "--", HeadColor: tcell.ColorGray, Body: body, }) return nil } func commandDoNick(app *App, args []string) (err error) { nick := args[0] if i := strings.IndexAny(nick, " :"); i >= 0 { return fmt.Errorf("illegal char %q in nickname", nick[i]) } s := app.CurrentSession() if s == nil { return errOffline } s.ChangeNick(nick) return } func commandDoOper(app *App, args []string) (err error) { s := app.CurrentSession() if s == nil { return errOffline } s.Oper(args[0], args[1]) return } func commandDoMode(app *App, args []string) (err error) { _, target := app.win.CurrentBuffer() if len(args) > 0 && !strings.HasPrefix(args[0], "+") && !strings.HasPrefix(args[0], "-") { target = args[0] args = args[1:] } flags := "" if len(args) > 0 { flags = args[0] args = args[1:] } modeArgs := args s := app.CurrentSession() if s == nil { return errOffline } s.ChangeMode(target, flags, modeArgs) return nil } func commandDoPart(app *App, args []string) (err error) { netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } reason := "" if 0 < len(args) { if s.IsChannel(args[0]) { channel = args[0] if 1 < len(args) { reason = args[1] } } else { reason = args[0] } } if channel == "" { return fmt.Errorf("cannot part this buffer") } if s.IsChannel(channel) { s.Part(channel, reason) } else { app.win.RemoveBuffer(netID, channel) } return nil } func commandDoQuery(app *App, args []string) (err error) { netID, _ := app.win.CurrentBuffer() s := app.sessions[netID] target := args[0] if s.IsChannel(target) { return fmt.Errorf("cannot query a channel, use JOIN instead") } i, added := app.win.AddBuffer(netID, "", target) app.win.JumpBufferIndex(i) if len(args) > 1 { if err := commandSendMessage(app, target, args[1]); err != nil { return err } } if added { s.MonitorAdd(target) s.ReadGet(target) s.NewHistoryRequest(target).WithLimit(200).Before(time.Now()) } return nil } func commandDoQuit(app *App, args []string) (err error) { reason := "" if 0 < len(args) { reason = args[0] } for _, session := range app.sessions { session.Quit(reason) } app.win.Exit() return nil } func commandDoQuote(app *App, args []string) (err error) { s := app.CurrentSession() if s == nil { return errOffline } s.SendRaw(args[0]) return nil } func commandDoR(app *App, args []string) (err error) { s := app.sessions[app.lastQueryNet] if s == nil { return errOffline } s.PrivMsg(app.lastQuery, args[0]) if !s.HasCapability("echo-message") { buffer, line := app.formatMessage(s, irc.MessageEvent{ User: s.Nick(), Target: app.lastQuery, TargetIsChannel: s.IsChannel(app.lastQuery), Command: "PRIVMSG", Content: args[0], Time: time.Now(), }) app.win.AddLine(app.lastQueryNet, buffer, line) } return nil } func commandDoTopic(app *App, args []string) (err error) { netID, buffer := app.win.CurrentBuffer() var ok bool if len(args) == 0 { ok = app.printTopic(netID, buffer) } else { s := app.sessions[netID] if s != nil { s.ChangeTopic(buffer, args[0]) ok = true } } if !ok { return errOffline } return nil } func commandDoWhois(app *App, args []string) (err error) { netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } var nick string if len(args) == 0 { if s.IsChannel(channel) { return fmt.Errorf("either send this command from a DM, or specify the user") } nick = channel } else { nick = args[0] } s.Whois(nick) return nil } func commandDoInvite(app *App, args []string) (err error) { nick := args[0] netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } if len(args) == 2 { channel = args[1] } else if channel == "" { return fmt.Errorf("either send this command from a channel, or specify the channel") } s.Invite(nick, channel) return nil } func commandDoKick(app *App, args []string) (err error) { nick := args[0] netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } if len(args) >= 2 { channel = args[1] } else if channel == "" { return fmt.Errorf("either send this command from a channel, or specify the channel") } comment := "" if len(args) == 3 { comment = args[2] } s.Kick(nick, channel, comment) return nil } func commandDoBan(app *App, args []string) (err error) { nick := args[0] netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } if len(args) == 2 { channel = args[1] } else if channel == "" { return fmt.Errorf("either send this command from a channel, or specify the channel") } s.ChangeMode(channel, "+b", []string{nick}) return nil } func commandDoUnban(app *App, args []string) (err error) { nick := args[0] netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } if len(args) == 2 { channel = args[1] } else if channel == "" { return fmt.Errorf("either send this command from a channel, or specify the channel") } s.ChangeMode(channel, "-b", []string{nick}) return nil } func commandDoSearch(app *App, args []string) (err error) { if len(args) == 0 { app.win.CloseOverlay() return nil } text := args[0] netID, channel := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } if !s.HasCapability("soju.im/search") { return errors.New("server does not support searching") } s.Search(channel, text) return nil } // implemented from https://golang.org/src/strings/strings.go?s=8055:8085#L310 func fieldsN(s string, n int) []string { s = strings.TrimSpace(s) if s == "" || n == 0 { return nil } if n == 1 { return []string{s} } // Start of the ASCII fast path. var a []string na := 0 fieldStart := 0 i := 0 // Skip spaces in front of the input. for i < len(s) && s[i] == ' ' { i++ } fieldStart = i for i < len(s) { if s[i] != ' ' { i++ continue } a = append(a, s[fieldStart:i]) na++ i++ // Skip spaces in between fields. for i < len(s) && s[i] == ' ' { i++ } fieldStart = i if n != maxArgsInfinite && na+1 >= n { a = append(a, s[fieldStart:]) return a } } if fieldStart < len(s) { // Last field ends at EOF. a = append(a, s[fieldStart:]) } return a } func parseCommand(s string) (command, args string, isCommand bool) { if s[0] != '/' { return "", s, false } if len(s) > 1 && s[1] == '/' { // Input starts with two slashes. return "", s[1:], false } i := strings.IndexByte(s, ' ') if i < 0 { i = len(s) } return strings.ToUpper(s[1:i]), strings.TrimLeft(s[i:], " "), true } func commandSendMessage(app *App, target string, content string) error { netID, _ := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return errOffline } s.PrivMsg(target, content) if !s.HasCapability("echo-message") { buffer, line := app.formatMessage(s, irc.MessageEvent{ User: s.Nick(), Target: target, TargetIsChannel: s.IsChannel(target), Command: "PRIVMSG", Content: content, Time: time.Now(), }) if buffer != "" && !s.IsChannel(target) { app.monitor[netID][buffer] = struct{}{} s.MonitorAdd(buffer) s.ReadGet(buffer) app.win.AddBuffer(netID, "", buffer) } app.win.AddLine(netID, buffer, line) } return nil } func (app *App) handleInput(buffer, content string) error { if content == "" { return nil } cmdName, rawArgs, isCommand := parseCommand(content) if !isCommand { return noCommand(app, rawArgs) } if cmdName == "" { return fmt.Errorf("lone slash at the beginning") } var chosenCMDName string var found bool for key := range commands { if !strings.HasPrefix(key, cmdName) { continue } if found { return fmt.Errorf("ambiguous command %q (could mean %v or %v)", cmdName, chosenCMDName, key) } chosenCMDName = key found = true } if !found { return fmt.Errorf("command %q doesn't exist", cmdName) } cmd := commands[chosenCMDName] var args []string if rawArgs != "" && cmd.MaxArgs != 0 { args = fieldsN(rawArgs, cmd.MaxArgs) } if len(args) < cmd.MinArgs { return fmt.Errorf("usage: %s %s", chosenCMDName, cmd.Usage) } if buffer == "" && !cmd.AllowHome { return fmt.Errorf("command %s cannot be executed from a server buffer", chosenCMDName) } return cmd.Handle(app, args) } func getSong() (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second) defer cancel() info, err := libnp.GetInfo(ctx) if err != nil { return "", err } if info == nil { return "", nil } if info.Title == "" { return "", nil } var sb strings.Builder fmt.Fprintf(&sb, "\x02%s\x02", info.Title) if len(info.Artists) > 0 { fmt.Fprintf(&sb, " by \x02%s\x02", info.Artists[0]) } if info.Album != "" { fmt.Fprintf(&sb, " from \x02%s\x02", info.Album) } if u, err := url.Parse(info.URL); err == nil { switch u.Scheme { case "http", "https": fmt.Fprintf(&sb, " — %s", info.URL) } } return sb.String(), nil }