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}