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