dischord/cmd/dischord/dischord.go

1205 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
_ "embed"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
)
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()
}