dischord/cmd/dischord/dischord.go

1205 lines
32 KiB
Go
Raw Normal View History

2022-09-20 00:54:22 +02:00
package main
import (
dc "github.com/bwmarrin/discordgo"
"git.nobrain.org/r4/dischord/config"
"git.nobrain.org/r4/dischord/extractor"
_ "git.nobrain.org/r4/dischord/extractor/builtins"
"git.nobrain.org/r4/dischord/extractor/ytdl"
"git.nobrain.org/r4/dischord/player"
"git.nobrain.org/r4/dischord/util"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
_ "embed"
)
var copyright bool
var autoconf bool
var registerCommands bool
var unregisterCommands bool
//go:embed bye.opus
var resByeOpus []byte
func init() {
flag.BoolVar(&copyright, "copyright", false, "print copyright info and quit")
flag.BoolVar(&autoconf, "autoconf", false, "launch automatic configurator program (overwriting any existing configuration)")
flag.BoolVar(&registerCommands, "register_commands", true, "register commands with Discord upon startup")
flag.BoolVar(&unregisterCommands, "unregister_commands", false, "unregister registered commands with Discord upon shutdown")
}
// A UserError is shown to the user
type UserError struct {
error
}
const (
interactionFlags = uint64(dc.MessageFlagsEphemeral)
)
var (
ErrVoiceNotConnected = UserError{errors.New("bot is currently not connected to a voice channel")}
ErrUnsupportedUrl = UserError{errors.New("unsupported URL")}
ErrStartThinkingNotInitialResponse = errors.New("StartThinking() must be the initial response")
ErrInvalidAutocompleteCall = errors.New("invalid autocomplete call")
)
type MessageData struct {
Content string
Files []*dc.File
Components []dc.MessageComponent
Embeds []*dc.MessageEmbed
}
type MessageWriter struct {
session *dc.Session
interaction *dc.Interaction
first bool
thinking bool
}
func NewMessageWriter(s *dc.Session, ia *dc.Interaction) *MessageWriter {
return &MessageWriter{
session: s,
interaction: ia,
first: true,
thinking: false,
}
}
func (m *MessageWriter) StartThinking() error {
if !m.first {
return ErrStartThinkingNotInitialResponse
}
err := m.session.InteractionRespond(m.interaction, &dc.InteractionResponse{
Type: dc.InteractionResponseDeferredChannelMessageWithSource,
Data: &dc.InteractionResponseData{
Flags: interactionFlags,
},
})
if err != nil {
return err
}
m.first = false
m.thinking = true
return nil
}
func (m *MessageWriter) Message(d *MessageData) error {
var err error
if m.first {
err = m.session.InteractionRespond(m.interaction, &dc.InteractionResponse{
Type: dc.InteractionResponseChannelMessageWithSource,
Data: &dc.InteractionResponseData{
Content: d.Content,
Flags: interactionFlags,
Files: d.Files,
Components: d.Components,
Embeds: d.Embeds,
},
})
} else if m.thinking {
_, err = m.session.InteractionResponseEdit(m.interaction, &dc.WebhookEdit{
Content: d.Content,
Files: d.Files,
Components: d.Components,
Embeds: d.Embeds,
})
} else {
_, err = m.session.FollowupMessageCreate(m.interaction, true, &dc.WebhookParams{
Content: d.Content,
Flags: interactionFlags,
Files: d.Files,
Components: d.Components,
Embeds: d.Embeds,
})
}
if err != nil {
return err
}
m.first = false
m.thinking = false
return nil
}
func main() {
flag.Parse()
if copyright {
fmt.Println(copyrightText)
return
}
var clients sync.Map // guild ID string to player.Client
// Load / create configuration file
cfgfile := "config.toml"
var cfg *config.Config
var err error
if autoconf || func() bool {cfg, err = config.Load(cfgfile); return err != nil}() {
if err != nil {
if os.IsNotExist(err) {
fmt.Println("Configuration file not found, launching automatic configurator.")
fmt.Println("Hit Ctrl+C to cancel anytime.")
} else {
fmt.Println("Error:", err)
return
}
}
cfg, err = config.Autoconf(cfgfile)
if err != nil {
if err == config.ErrPythonNotInstalled {
if runtime.GOOS == "darwin" {
fmt.Println("Python is required to run youtube-dl, but no python installation was found. To fix this, please install Xcode Command Line Tools.")
} else {
fmt.Println("Python is required to run youtube-dl, but no python installation was found. To fix this, please install Python from your package manager.")
}
} else {
fmt.Println("Error:", err)
}
return
}
if runtime.GOOS == "windows" {
fmt.Println("Hit Enter to close this window.")
fmt.Scanln()
}
return
}
getClient := func(s *dc.Session, ia *dc.Interaction, create bool) (client player.Client, err error, created bool) {
clI, exists := clients.Load(ia.GuildID)
if exists {
return clI.(player.Client), nil, false
}
if !create {
return player.Client{}, ErrVoiceNotConnected, false
}
g, err := s.State.Guild(ia.GuildID)
if err != nil {
return player.Client{}, err, false
}
voiceChannelId := ""
for _, v := range g.VoiceStates {
if v.UserID == ia.Member.User.ID {
voiceChannelId = v.ChannelID
break
}
}
if voiceChannelId == "" {
return player.Client{}, UserError{errors.New("bot doesn't know where to join, please enter a voice channel")}, false
}
vc, err := s.ChannelVoiceJoin(ia.GuildID, voiceChannelId, false, true)
if err != nil {
return player.Client{}, err, false
}
cl := player.NewClient(cfg.Extractors, cfg.FfmpegPath, vc.OpusSend, func(e player.EventStreamUpdated) {
if err := vc.Speaking(true); err != nil {
fmt.Println("Unable to speak:", err)
}
}, func(e player.EventKilled) {
vc.Disconnect()
})
clients.Store(ia.GuildID, cl)
go func() {
for err := range cl.ErrCh {
fmt.Println("Playback error:", err)
}
}()
return cl, nil, true
}
getOptions := func(d *dc.ApplicationCommandInteractionData) map[string]*dc.ApplicationCommandInteractionDataOption {
opts := make(map[string]*dc.ApplicationCommandInteractionDataOption, len(d.Options))
for _, v := range d.Options {
opts[v.Name] = v
}
return opts
}
floatptr := func(f float64) *float64 {
res := new(float64)
*res = f
return res
}
commands := []*dc.ApplicationCommand{
{
Name: "queue",
Description: "Show current playing queue",
},
{
Name: "play",
Description: "Resume playback or replace playing queue",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "url-or-query",
Description: "Video/music/playlist URL or search query to start playing",
Required: false,
Autocomplete: true,
},
},
},
{
Name: "add",
Description: "Add a track or playlist to queue",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "url-or-query",
Description: "Video/music/playlist URL or search query to add to queue",
Required: true,
Autocomplete: true,
},
},
},
{
Name: "pause",
Description: "Pause playback",
},
{
Name: "loop",
Description: "Toggle loop",
},
{
Name: "stop",
Description: "Stop playback and disconnect",
},
{
Name: "disconnect",
Description: "Alias for stop (stop playback and disconnect)",
},
{
Name: "dc",
Description: "Alias for stop (stop playback and disconnect)",
},
{
Name: "jump",
Description: "Jump to a track by number or name",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "track",
Description: "Track number or matching string",
Required: true,
Autocomplete: true,
},
},
},
{
Name: "seek",
Description: "Jump to a playback position. Format is ss, mm:ss or hh:mm:ss. Use prefixes +/- to jump relatively",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "pos",
Description: "Target playback position. Format is ss, mm:ss or hh:mm:ss. Use prefixes +/- to jump relatively",
Required: true,
},
},
},
{
Name: "pos",
Description: "Get current playback position (time)",
},
{
Name: "speed",
Description: "Get or set the playback speed",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionNumber,
Name: "speed",
Description: "New playback speed",
Required: false,
MinValue: floatptr(0.5),
MaxValue: 3.0,
},
},
},
{
Name: "shuffle",
Description: "Shuffle all items in the queue",
Options: []*dc.ApplicationCommandOption{},
},
{
Name: "unshuffle",
Description: "Undoes what shuffle did (may no longer be available after certain queue modifications)",
Options: []*dc.ApplicationCommandOption{},
},
{
Name: "swap",
Description: "Swap two items' positions in the queue",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "track-1",
Description: "Track number or matching string",
Required: true,
Autocomplete: true,
},
{
Type: dc.ApplicationCommandOptionString,
Name: "track-2",
Description: "Track number or matching string",
Required: true,
Autocomplete: true,
},
},
},
{
Name: "delete",
Description: "Delete up to five items from the queue (use delete-from to delete even more at once)",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "track-1",
Description: "Track number or matching string",
Required: true,
Autocomplete: true,
},
{
Type: dc.ApplicationCommandOptionString,
Name: "track-2",
Description: "Track number or matching string",
Required: false,
Autocomplete: true,
},
{
Type: dc.ApplicationCommandOptionString,
Name: "track-3",
Description: "Track number or matching string",
Required: false,
Autocomplete: true,
},
{
Type: dc.ApplicationCommandOptionString,
Name: "track-4",
Description: "Track number or matching string",
Required: false,
Autocomplete: true,
},
{
Type: dc.ApplicationCommandOptionString,
Name: "track-5",
Description: "Track number or matching string",
Required: false,
Autocomplete: true,
},
},
},
{
Name: "delete-from",
Description: "Delete the specified track along with all tracks following it from the queue",
Options: []*dc.ApplicationCommandOption{
{
Type: dc.ApplicationCommandOptionString,
Name: "track-1",
Description: "Track number or matching string",
Required: true,
Autocomplete: true,
},
},
},
}
addToQueue := func(s *dc.Session, m *MessageWriter, cl player.Client, input string) error {
if err := m.StartThinking(); err != nil {
return err
}
data, err := extractor.Extract(cfg.Extractors, input)
if err != nil {
if exerr, ok := err.(*extractor.Error); ok && exerr.Err == ytdl.ErrUnsupportedUrl {
return ErrUnsupportedUrl
}
return err
}
cl.CmdCh <- player.CmdAddBack(data)
var msg string
if len(data) == 1 {
msg = fmt.Sprintf("Added %v to queue", data[0].Title)
} else if len(data) > 0 {
msg = fmt.Sprintf("Added playlist %v to queue (%v items)", data[0].PlaylistTitle, len(data))
} else {
return UserError{errors.New("extractor returned no results")}
}
if err := m.Message(&MessageData{Content: msg}); err != nil {
return err
}
return nil
}
matchTracks := func(cl player.Client, search string, n int) []struct {
title string
relIdx int
} {
var res []struct {
title string
relIdx int
}
maybeAdd := func(title string, relIdx int) {
if strings.Contains(strings.ToLower(title), strings.ToLower(search)) {
res = append(res, struct {
title string
relIdx int
}{
title: title,
relIdx: relIdx,
})
}
}
queue := cl.GetQueue()
for i, v := range queue.Done {
maybeAdd(v.Title, i-len(queue.Done))
}
if queue.Playing != nil {
maybeAdd(queue.Playing.Title, 0)
}
for i, v := range queue.Ahead {
maybeAdd(v.Title, i+1)
}
sort.Slice(res, func(i, j int) bool {
cost := func(s string) int {
return strings.Index(strings.ToLower(s), strings.ToLower(search))
}
return cost(res[i].title) < cost(res[j].title)
})
if n < len(res) {
return res[:n]
} else {
return res
}
}
checkQueueBounds := func(queue *player.Queue, i int) error {
if i == 0 && queue.Playing == nil {
return UserError{errors.New("track index 0 is invalid when no track is playing")}
}
if i < 0 && -i-1 >= len(queue.Done) {
return UserError{fmt.Errorf("track index %v is too low (minimum is %v)", i, -len(queue.Done))}
}
if i > 0 && i-1 >= len(queue.Ahead) {
return UserError{fmt.Errorf("track index %v is too high (maximum is %v)", i, len(queue.Ahead))}
}
return nil
}
getTrackNum := func(cl player.Client, input string) (int, error) {
n, err := strconv.Atoi(input)
if err != nil {
tracks := matchTracks(cl, input, 1)
if len(tracks) > 0 {
return tracks[0].relIdx, nil
} else {
return 0, UserError{errors.New("no matching track found")}
}
}
return n, nil
}
getTrackEmbed := func(queue *player.Queue, i int) *dc.MessageEmbed {
if !queue.InBounds(i) {
return nil
}
track := queue.At(i)
url := track.SourceUrl
id := strings.TrimSuffix(strings.TrimPrefix(url, "https://www.youtube.com/watch?v="), "/")
var desc string
if i == 0 {
if queue.Paused {
desc = "Paused"
} else {
desc = "Playing"
}
if queue.Loop {
desc += " (loop)"
}
} else {
desc = strconv.Itoa(i)
}
return &dc.MessageEmbed{
Title: track.Title,
Description: desc,
URL: url + "/" + strconv.Itoa(i),
Thumbnail: &dc.MessageEmbedThumbnail{
URL: "https://i.ytimg.com/vi/" + id + "/mqdefault.jpg",
},
}
}
var commandHandlers map[string]func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error
commandHandlers = map[string]func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error{
"queue": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
if len(queue.Done) == 0 && queue.Playing == nil && len(queue.Ahead) == 0 {
if err := m.Message(&MessageData{Content: "Queue is empty"}); err != nil {
return err
}
return nil
}
var embeds []*dc.MessageEmbed
trySend := func(flush bool) error {
if len(embeds) >= 10 || (len(embeds) > 0 && flush) {
err := m.Message(&MessageData{
Embeds: embeds,
})
if err != nil {
return err
}
embeds = nil
}
return nil
}
for i := -len(queue.Done); i <= len(queue.Ahead); i++ {
if i == 0 && queue.Playing == nil {
continue
}
embeds = append(embeds, getTrackEmbed(queue, i))
if err := trySend(false); err != nil {
return err
}
}
if err := trySend(true); err != nil {
return err
}
return nil
},
"play": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
opts := getOptions(d)
inputI, exists := opts["url-or-query"]
if exists {
cl, err, _ := getClient(s, ia, true)
if err != nil {
return err
}
input := inputI.StringValue()
cl.CmdCh <- player.CmdSkipAll{}
err = addToQueue(s, m, cl, input)
if err != nil {
return err
}
cl.CmdCh <- player.CmdPlay{}
} else {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
if queue.Paused {
cl.CmdCh <- player.CmdPlay{}
if err := m.Message(&MessageData{Content: "Playback resumed"}); err != nil {
return err
}
} else if queue.Playing == nil && len(queue.Ahead) > 0 {
cl.CmdCh <- player.CmdPlay{}
if err := m.Message(&MessageData{Content: "Started playing"}); err != nil {
return err
}
} else if queue.Playing == nil && len(queue.Ahead) == 0 {
return UserError{errors.New("nothing in queue to resume from")}
} else {
return UserError{errors.New("already playing")}
}
}
return nil
},
"add": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, created := getClient(s, ia, true)
if err != nil {
return err
}
err = addToQueue(s, m, cl, d.Options[0].StringValue())
if err != nil {
return err
}
if created {
if err := m.Message(&MessageData{Content: "Use /play to start playing"}); err != nil {
return err
}
}
return nil
},
"pause": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
if cl.GetQueue().Paused {
return UserError{errors.New("already paused")}
} else {
cl.CmdCh <- player.CmdPause{}
if err := m.Message(&MessageData{Content: "Playback paused"}); err != nil {
return err
}
}
return nil
},
"loop": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
loop := cl.GetQueue().Loop
cl.CmdCh <- player.CmdLoop(!loop)
var msg string
if loop {
msg = "Loop disabled"
} else {
msg = "Loop enabled"
}
if err := m.Message(&MessageData{Content: msg}); err != nil {
return err
}
return nil
},
"stop": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
ch := make(chan struct{})
cl.CmdCh <- player.CmdPlayFileAndStop{ch, resByeOpus}
if err := m.Message(&MessageData{Content: "Bye, have a great time"}); err != nil {
return err
}
<-ch
clients.Delete(ia.GuildID)
close(cl.CmdCh)
return nil
},
"disconnect": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
return commandHandlers["stop"](s, m, ia, d)
},
"dc": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
return commandHandlers["stop"](s, m, ia, d)
},
"jump": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
track := d.Options[0].StringValue()
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
n, err := getTrackNum(cl, track)
if err != nil {
return err
}
cl.CmdCh <- player.CmdJump(n)
var msg string
queue := cl.GetQueue()
if queue.Playing != nil {
msg = fmt.Sprintf("Jumped to %v", queue.Playing.Title)
} else {
msg = "Playback finished"
}
if err := m.Message(&MessageData{Content: msg}); err != nil {
return err
}
return nil
},
"seek": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
var relFactor int
input := d.Options[0].StringValue()
if strings.HasPrefix(input, "+") {
relFactor = 1
input = strings.TrimPrefix(input, "+")
} else if strings.HasPrefix(input, "-") {
relFactor = -1
input = strings.TrimPrefix(input, "-")
}
ntI, err := util.ParseDurationSeconds(input)
if err != nil {
return UserError{errors.New("invalid time format")}
}
queue := cl.GetQueue()
if queue.Playing != nil {
time := int(cl.GetTime())
d := util.FormatDurationSeconds(int(queue.Playing.Duration))
if relFactor != 0 {
ntI = time + relFactor*ntI
}
if ntI < 0 || ntI >= queue.Playing.Duration {
return UserError{errors.New("time out of range")}
}
nt := util.FormatDurationSeconds(ntI)
relI := ntI - time
var rel string
if relI < 0 {
rel = "-" + util.FormatDurationSeconds(-relI)
} else {
rel = "+" + util.FormatDurationSeconds(relI)
}
cl.CmdCh <- player.CmdSeek(int64(ntI))
if err := m.Message(&MessageData{Content: fmt.Sprintf("Sought to: %v/%v (%v)", nt, d, rel)}); err != nil {
return err
}
} else {
return UserError{errors.New("not playing anything")}
}
return nil
},
"pos": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
if queue.Playing != nil {
time := cl.GetTime()
t := util.FormatDurationSeconds(int(time))
d := util.FormatDurationSeconds(int(queue.Playing.Duration))
err := m.Message(&MessageData{
Content: fmt.Sprintf("Position: %v/%v", t, d),
Embeds: []*dc.MessageEmbed{
getTrackEmbed(queue, 0),
},
})
if err != nil {
return err
}
} else {
return UserError{errors.New("not playing anything")}
}
return nil
},
"speed": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
opts := getOptions(d)
inputI, exists := opts["speed"]
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
if exists {
speed := inputI.FloatValue()
cl.CmdCh <- player.CmdSpeed(speed)
if err := m.Message(&MessageData{Content: fmt.Sprintf("Playing at %vx speed", speed)}); err != nil {
return err
}
return nil
} else {
if err := m.Message(&MessageData{Content: fmt.Sprintf("Current playback speed: %vx", cl.GetSpeed())}); err != nil {
return err
}
return nil
}
},
"shuffle": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
cl.CmdCh <- player.CmdShuffle{}
if err := m.Message(&MessageData{Content: fmt.Sprintf("Shuffled queue (%v items)", len(cl.GetQueue().Ahead))}); err != nil {
return err
}
return nil
},
"unshuffle": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
if queue.AheadUnshuffled == nil {
return UserError{errors.New("cannot unshuffle queue: either it is not shuffled, or too many modifications have been made to reverse the shuffle")}
}
cl.CmdCh <- player.CmdUnshuffle{}
if err := m.Message(&MessageData{Content: fmt.Sprintf("Unshuffled queue (%v items)", len(queue.Ahead))}); err != nil {
return err
}
return nil
},
"swap": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
sa, sb := d.Options[0].StringValue(), d.Options[1].StringValue()
var a, b int
a, err = getTrackNum(cl, sa)
if err != nil {
return err
}
b, err = getTrackNum(cl, sb)
if err != nil {
return err
}
if err := checkQueueBounds(queue, a); err != nil {
return err
}
if err := checkQueueBounds(queue, b); err != nil {
return err
}
ta, tb := queue.At(a), queue.At(b)
cl.CmdCh <- player.CmdSwap{a, b}
if err := m.Message(&MessageData{Content: fmt.Sprintf("Swapped item %v: '%v' with %v: '%v'", ta.Title, tb.Title)}); err != nil {
return err
}
return nil
},
"delete": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
var toDel []int
for _, opt := range d.Options {
a, err := getTrackNum(cl, opt.StringValue())
if err != nil {
return err
}
if err := checkQueueBounds(queue, a); err != nil {
return err
}
toDel = append(toDel, a)
}
cl.CmdCh <- player.CmdDelete(toDel)
var msg string
msg = "Deleted "
for _, i := range toDel {
msg += fmt.Sprintf("%v: '%v'", i, queue.At(i).Title)
if i == len(toDel)-2 {
msg += "and "
} else if i != len(toDel)-1 {
msg += ", "
}
}
msg += " from queue"
if err := m.Message(&MessageData{Content: msg}); err != nil {
return err
}
return nil
},
"delete-from": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
return err
}
queue := cl.GetQueue()
a, err := getTrackNum(cl, d.Options[0].StringValue())
if err != nil {
return err
}
if err := checkQueueBounds(queue, a); err != nil {
return err
}
var toDel []int
i := a
for {
if i != 0 && queue.At(i) == nil {
break
}
toDel = append(toDel, i)
i++
}
cl.CmdCh <- player.CmdDelete(toDel)
if err := m.Message(&MessageData{Content: fmt.Sprintf("Deleted %v items starting with %v: '%v'", len(toDel), a, queue.At(a).Title)}); err != nil {
return err
}
return nil
},
}
autocompleteBySearch := func(s *dc.Session, ia *dc.Interaction, input string) error {
var choices []*dc.ApplicationCommandOptionChoice
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
choices = []*dc.ApplicationCommandOptionChoice{
{
Name: input,
Value: input,
},
}
} else if input != "" {
res, err := extractor.Search(cfg.Extractors, input)
if err != nil {
return err
}
choices = make([]*dc.ApplicationCommandOptionChoice, len(res))
for i, v := range res {
switch {
case v.Title != "":
var prefix string
if v.OfficialArtist {
prefix = "🎵 "
}
choices[i] = &dc.ApplicationCommandOptionChoice{
Name: prefix + v.Title,
Value: v.SourceUrl,
}
case v.PlaylistTitle != "":
choices[i] = &dc.ApplicationCommandOptionChoice{
Name: "𝘗𝘭𝘢𝘺𝘭𝘪𝘴𝘵: " + v.PlaylistTitle,
Value: v.PlaylistUrl,
}
}
}
}
err = s.InteractionRespond(ia, &dc.InteractionResponse{
Type: dc.InteractionApplicationCommandAutocompleteResult,
Data: &dc.InteractionResponseData{
Choices: choices,
},
})
if err != nil {
return err
}
return nil
}
autocompleteTrack := func(s *dc.Session, ia *dc.Interaction, input string) error {
cl, err, _ := getClient(s, ia, false)
if err != nil {
if errors.Is(err, ErrVoiceNotConnected) {
err = s.InteractionRespond(ia, &dc.InteractionResponse{
Type: dc.InteractionApplicationCommandAutocompleteResult,
Data: &dc.InteractionResponseData{},
})
if err != nil {
return err
}
return nil
}
return err
}
tracks := matchTracks(cl, input, 10)
choices := make([]*dc.ApplicationCommandOptionChoice, len(tracks))
for i := range tracks {
choices[i] = &dc.ApplicationCommandOptionChoice{
Name: tracks[i].title,
Value: strconv.Itoa(tracks[i].relIdx),
}
}
err = s.InteractionRespond(ia, &dc.InteractionResponse{
Type: dc.InteractionApplicationCommandAutocompleteResult,
Data: &dc.InteractionResponseData{
Choices: choices,
},
})
if err != nil {
return err
}
return nil
}
autocompleteHandlers := map[string]func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error{
"play": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
opts := getOptions(d)
inputI, exists := opts["url-or-query"]
if !exists {
return nil
}
input := inputI.StringValue()
return autocompleteBySearch(s, ia, input)
},
"add": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
return autocompleteBySearch(s, ia, d.Options[0].StringValue())
},
"jump": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
return autocompleteTrack(s, ia, d.Options[0].StringValue())
},
"swap": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
for i := 0; i < 5; i++ {
if d.Options[i].Focused {
return autocompleteTrack(s, ia, d.Options[i].StringValue())
}
}
return ErrInvalidAutocompleteCall
},
"delete": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
for i := 0; i < 5; i++ {
if d.Options[i].Focused {
return autocompleteTrack(s, ia, d.Options[i].StringValue())
}
}
return ErrInvalidAutocompleteCall
},
"delete-from": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error {
return autocompleteTrack(s, ia, d.Options[0].StringValue())
},
}
componentHandlers := map[string]func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.MessageComponentInteractionData) error{}
// Create Discord session
dg, err := dc.New("Bot " + cfg.Token)
if err != nil {
fmt.Println("Error creating Discord session:", err)
return
}
dg.Identify.Intents = dc.IntentsAllWithoutPrivileged
// Set up handlers
readyCh := make(chan string)
dg.AddHandler(func(s *dc.Session, e *dc.Ready) {
u := s.State.User
readyCh <- u.Username + "#" + u.Discriminator
})
dg.AddHandler(func(s *dc.Session, e *dc.InteractionCreate) {
switch e.Type {
case dc.InteractionApplicationCommand:
d := e.ApplicationCommandData()
m := NewMessageWriter(s, e.Interaction)
if e.GuildID == "" {
if err := m.Message(&MessageData{Content: "This bot only works on servers"}); err != nil {
fmt.Printf("Error: %v\n", err)
}
return
}
if h, exists := commandHandlers[d.Name]; exists {
if err := h(s, m, e.Interaction, &d); err != nil {
if _, ok := err.(UserError); ok {
if err := m.Message(&MessageData{Content: util.CapitalizeFirst(err.Error())}); err != nil {
fmt.Printf("Error: %v\n", err)
}
} else {
fmt.Printf("Error in commandHandlers[%v]: %v\n", d.Name, err)
if err := m.Message(&MessageData{Content: "An internal error occurred :("}); err != nil {
fmt.Printf("Error: %v\n", err)
}
}
}
}
case dc.InteractionApplicationCommandAutocomplete:
d := e.ApplicationCommandData()
if h, exists := autocompleteHandlers[d.Name]; exists {
if err := h(s, e.Interaction, &d); err != nil {
fmt.Printf("Error in autocompleteHandlers[%v]: %v\n", d.Name, err)
}
}
case dc.InteractionMessageComponent:
d := e.MessageComponentData()
m := NewMessageWriter(s, e.Interaction)
if h, exists := componentHandlers[d.CustomID]; exists {
if err := h(s, m, e.Interaction, &d); err != nil {
if _, ok := err.(UserError); ok {
if err := m.Message(&MessageData{Content: util.CapitalizeFirst(err.Error())}); err != nil {
fmt.Printf("Error: %v\n", err)
}
} else {
fmt.Printf("Error in componentHandlers[%v]: %v\n", d.CustomID, err)
}
}
}
default:
fmt.Println("Unhandled interaction type:", e.Type)
}
})
// Open Discord session
err = dg.Open()
if err != nil {
fmt.Println("Error opening Discord session:", err)
return
}
// Wait until discord session ready
fmt.Printf("Logged in as %v\n", <-readyCh)
// Set up commands
if registerCommands {
fmt.Println("Registering commands...")
for i, v := range commands {
cmd, err := dg.ApplicationCommandCreate(dg.State.User.ID, "", v)
if err != nil {
fmt.Printf("Error adding command %v: %v\n", v.Name, err)
return
}
commands[i] = cmd
fmt.Printf(" %v/%v done\r", i+1, len(commands))
}
fmt.Printf("%v commands registered\n", len(commands))
}
// Exit gracefully when the program is terminated
fmt.Println("Bot is now running, press Ctrl+C to stop")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-sc
fmt.Println()
fmt.Println("Received stop signal, shutting down cleanly")
clients.Range(func(key, value any) bool {
cl := value.(player.Client)
close(cl.CmdCh)
return true
})
if registerCommands && unregisterCommands {
fmt.Println("Unregistering commands...")
for i, v := range commands {
err := dg.ApplicationCommandDelete(dg.State.User.ID, "", v.ID)
if err != nil {
fmt.Printf("Error deleting command %v: %v\n", v.Name, err)
return
}
fmt.Printf(" %v/%v done\r", i+1, len(commands))
}
fmt.Printf("%v commands unregistered\n", len(commands))
}
dg.Close()
}