summaryrefslogblamecommitdiff
path: root/commands.go
blob: 2371878f11f6efde37c8bbaf9b591b52f1a221d2 (plain) (tree)
1
2
3
4
5
6
7
8


              
                
             
                 
              
                 




                                      
                                     
                                     
                                  

 



                                                                                    

                          
                     
                      

                     

                        
                                                     







                                   

                                        
                                     




                                                                                            
                                        

                                     






                                                       
                                     



                                                                                            




                                                                                              


                                        
                                     



                                                                        



                                                                     



                                                                              







                                                          







                                                                   

                                        

                                                                       


                                                                  

                                        
                                     



                                                        


                                        

                                                      


                                                              

                                        
                                     



                                                 
                          
                                        

                                     



                                                            
                          

                                        
                                     




                                                             



                                                                                
                  







                                                                                 







                                                                   







                                                                 























                                                                           






                                                                   


         



                                                                      
         

                                
                                 

         

                                             
                                                                      
                                                  
                                                
                                                             
                                                   
                                                 

                                                    
                                                    
         

                  

 















                                                                       
                       
                                                








                                                                                       
                                                       


                                                
                                                       


                                                                
                                                       











                                                                 
                           
                                                       

                                   
                                                                    
                  



                                                            
                 
                                         

                                                  
                                                       

                                   
                                                                                     
                  


                                                            


                                                               
                                                            
                 
                                       
                                                               
                                        
                                                                                          
                          

                                                 

                 
                  

 
                                                         

                                 

                                 
                          



                             
                            
                  

 
                                                       


                                                

                                      



                                 
                                                            

                                             
                                                                      
                                                  
                                                
                                                             



                                                    
                                                    
         
                  

 
                                                       



                                                                       





                                                                      
                                                        

                          
                                                       

 









                                                         
                                                          


                                                

                                 


                                                          
                                     
                                                                   
                                 
                                              
                                          
                                                                                    
                                                       
                                                                                   

                                              
                                 
         

                                 
                                               



                                           
          
                  

 
                                                         
                       
                                                      

                                                                         

                                 

                                 


                          
 








                                                         
                                                         



                                                                                                  
         



                               
         
                        
 



                                 
                                             
                  

 
                                                         


                                                 

                                 

                          
                                         







                                                


                                                            
         





                                                    
                  

 






                                                                             
                                                        
                                  




                                                                                




                                                                             


                  
                                                         



                                

                                              
         
                      
                  

 
                                                          

                                 

                                 
                          
                  

 
                                                      

                                           

                                 

                                             
                                                                      
                                                  
                                                       
                                                                    



                                                    
                                                               
         
                  

 
                                                          

                                                
                           
                                                  
                







                                                      
         
                  

 


















                                                                                                    
                                                           



                                                 

                                 

                                 

                                                                                                    
         
                               


                  



















































                                                                                                    










                                                           


                                                                      



                               








                                                                              






















                                                 
                                                      










                                                     
                                                                    
                        

                                   
                                      

                                                 






                                      
                                                                          

 







                                                                        
                                                                      













                                                               
                                                    



                  
                                                           



                          

                                                            
                                              

                          
                                                                

         
                                
                      
                                   


                                                     
                          


                                                                                                                    
                            
         
                   


                                                                      

                                      
                         
                                              
                                                    


                                    
                                                                           
         
                                           
                                                                                                      

         
                                    
 
 

                                                                                 
                      
                                       
                       
                              
         
                        
                              
         
                             
                              













                                                                   

                 
                               
 
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
}