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: "<channels> [keys]",
Desc: "join a channel",
Handle: commandDoJoin,
},
"ME": {
AllowHome: true,
MinArgs: 1,
MaxArgs: 1,
Usage: "<message>",
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: "<target> <message>",
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: "<nickname>",
Desc: "change your nickname",
Handle: commandDoNick,
},
"OPER": {
AllowHome: true,
MinArgs: 2,
MaxArgs: 2,
Usage: "<username> <password>",
Desc: "log in to an operator account",
Handle: commandDoOper,
},
"MODE": {
AllowHome: true,
MaxArgs: maxArgsInfinite,
Usage: "[<nick/channel>] [<flags>] [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: "<raw message>",
Desc: "send raw protocol data",
Handle: commandDoQuote,
},
"REPLY": {
AllowHome: true,
MinArgs: 1,
MaxArgs: 1,
Usage: "<message>",
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: "<name>",
Desc: "switch to the buffer containing a substring",
Handle: commandDoBuffer,
},
"WHOIS": {
AllowHome: false,
MinArgs: 0,
MaxArgs: 1,
Usage: "<nick>",
Desc: "get information about someone",
Handle: commandDoWhois,
},
"INVITE": {
AllowHome: true,
MinArgs: 1,
MaxArgs: 2,
Usage: "<name> [channel]",
Desc: "invite someone to a channel",
Handle: commandDoInvite,
},
"KICK": {
AllowHome: false,
MinArgs: 1,
MaxArgs: 2,
Usage: "<nick> [channel]",
Desc: "eject someone from the channel",
Handle: commandDoKick,
},
"BAN": {
AllowHome: false,
MinArgs: 1,
MaxArgs: 2,
Usage: "<nick> [channel]",
Desc: "ban someone from entering the channel",
Handle: commandDoBan,
},
"UNBAN": {
AllowHome: false,
MinArgs: 1,
MaxArgs: 2,
Usage: "<nick> [channel]",
Desc: "remove effect of a ban from the user",
Handle: commandDoUnban,
},
"SEARCH": {
AllowHome: true,
MaxArgs: 1,
Usage: "<text>",
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
}