1205 lines
32 KiB
Go
1205 lines
32 KiB
Go
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(©right, "copyright", false, "print copyright info and quit")
|
||
flag.BoolVar(&autoconf, "autoconf", false, "launch automatic configurator program (overwriting any existing configuration)")
|
||
flag.BoolVar(®isterCommands, "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()
|
||
}
|