From d7edfea45eda95ed307bdc2c6e31a48d19637deb Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Wed, 18 Mar 2026 03:47:42 +0000 Subject: [PATCH] feat: integrate music commands with Lavalink support - Added new music commands: play, queue, and volume. - Implemented music management using disgolink for Lavalink integration. - Updated bot initialization to include Lavalink host and password. - Enhanced interaction handling for music commands, requiring DJ role for usage. - Introduced now playing message with interactive buttons for controlling playback. - Updated dependencies in go.mod for disgolink and snowflake. --- go.mod | 5 +- go.sum | 9 +- internal/bot/bot.go | 23 +- internal/commands/commands.go | 20 +- internal/commands/music/public/permissions.go | 36 ++ internal/commands/music/public/play.go | 130 +++++ internal/commands/music/public/queue.go | 77 +++ internal/commands/music/public/volume.go | 63 ++ internal/commands/music/registry.go | 20 + internal/config/config.go | 23 +- internal/music/components.go | 108 ++++ internal/music/manager.go | 538 ++++++++++++++++++ internal/music/nowplaying.go | 179 ++++++ internal/music/permissions.go | 36 ++ internal/music/repeat_buttons.go | 18 + main.go | 2 +- 16 files changed, 1268 insertions(+), 19 deletions(-) create mode 100644 internal/commands/music/public/permissions.go create mode 100644 internal/commands/music/public/play.go create mode 100644 internal/commands/music/public/queue.go create mode 100644 internal/commands/music/public/volume.go create mode 100644 internal/commands/music/registry.go create mode 100644 internal/music/components.go create mode 100644 internal/music/manager.go create mode 100644 internal/music/nowplaying.go create mode 100644 internal/music/permissions.go create mode 100644 internal/music/repeat_buttons.go diff --git a/go.mod b/go.mod index 1381a83..82844e6 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.26.1 require ( github.com/bwmarrin/discordgo v0.29.0 + github.com/disgoorg/disgolink/v3 v3.1.0 + github.com/disgoorg/snowflake/v2 v2.0.3 github.com/fogleman/gg v1.3.0 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 @@ -11,8 +13,9 @@ require ( ) require ( + github.com/disgoorg/json v1.2.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index b7ebed6..a38ba93 100644 --- a/go.sum +++ b/go.sum @@ -3,12 +3,19 @@ github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disgoorg/disgolink/v3 v3.1.0 h1:IwhycUfvw87VbvvNSwBcdGMQNSFvNQ7/AQofmbp1NS8= +github.com/disgoorg/disgolink/v3 v3.1.0/go.mod h1:UjHfrC4NT4vzibG3GyqtY5l3aMzFwfkU+B3RiW3AQQ8= +github.com/disgoorg/json v1.2.0 h1:6e/j4BCfSHIvucG1cd7tJPAOp1RgnnMFSqkvZUtEd1Y= +github.com/disgoorg/json v1.2.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA= +github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro= +github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 225c29c..1101ef5 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -5,6 +5,7 @@ import ( "velox-bot/internal/commands" "velox-bot/internal/db/services" "velox-bot/internal/events" + "velox-bot/internal/music" "github.com/bwmarrin/discordgo" ) @@ -16,9 +17,11 @@ type Bot struct { Commands []*discordgo.ApplicationCommand registeredCommands []*discordgo.ApplicationCommand Services *services.Services + LavalinkHost string + LavalinkPass string } -func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) { +func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) { session, err := discordgo.New("Bot " + token) if err != nil { return nil, err @@ -31,11 +34,13 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates return &Bot{ - Session: session, - AppID: appID, - GuildID: guildID, - Commands: cmds, - Services: services, + Session: session, + AppID: appID, + GuildID: guildID, + Commands: cmds, + Services: services, + LavalinkHost: lavalinkHost, + LavalinkPass: lavalinkPass, }, nil } @@ -44,6 +49,8 @@ func (b *Bot) Start() error { return err } + _ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass) + b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands)) for _, cmd := range b.Commands { created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd) @@ -62,8 +69,12 @@ func (b *Bot) Start() error { b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) { events.HandleVoiceStateUpdate(s, vs, b.Services) + music.OnVoiceStateUpdate(vs) }) + b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) { + music.OnVoiceServerUpdate(ev) + }) return nil } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 717213a..6bf4dfe 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -5,7 +5,9 @@ import ( "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" "velox-bot/internal/commands/meeting" + cmdmusic "velox-bot/internal/commands/music" "velox-bot/internal/commands/projects" + "velox-bot/internal/music" "github.com/bwmarrin/discordgo" ) @@ -23,6 +25,9 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdlevel.Rank, cmdlevel.Leaderboard, cmdlevel.Rewards, + cmdmusic.Play, + cmdmusic.Queue, + cmdmusic.Volume, meeting.Meeting, projects.Projects, } @@ -42,10 +47,21 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "rewards": cmdlevel.RewardsHandler, "meeting": meeting.MeetingHandler, "projects": projects.ProjectsHandler, + "play": cmdmusic.PlayHandler, + "queue": cmdmusic.QueueHandler, + "volume": cmdmusic.VolumeHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { - if h, ok := handlers[i.ApplicationCommandData().Name]; ok { - h(s, i) + switch i.Type { + case discordgo.InteractionApplicationCommand: + if h, ok := handlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + case discordgo.InteractionMessageComponent: + data := i.MessageComponentData() + if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" { + music.HandleComponent(s, i) + } } } diff --git a/internal/commands/music/public/permissions.go b/internal/commands/music/public/permissions.go new file mode 100644 index 0000000..b88d124 --- /dev/null +++ b/internal/commands/music/public/permissions.go @@ -0,0 +1,36 @@ +package public + +import "github.com/bwmarrin/discordgo" + +func memberHasDJRole(s *discordgo.Session, guildID string, m *discordgo.Member) bool { + if s == nil || guildID == "" || m == nil { + return false + } + if len(m.Roles) == 0 { + return false + } + + roles, err := s.GuildRoles(guildID) + if err != nil { + return false + } + + djRoleID := "" + for _, r := range roles { + if r != nil && r.Name == "DJ" { + djRoleID = r.ID + break + } + } + if djRoleID == "" { + return false + } + + for _, rid := range m.Roles { + if rid == djRoleID { + return true + } + } + return false +} + diff --git a/internal/commands/music/public/play.go b/internal/commands/music/public/play.go new file mode 100644 index 0000000..d4abaf4 --- /dev/null +++ b/internal/commands/music/public/play.go @@ -0,0 +1,130 @@ +package public + +import ( + "fmt" + "net/url" + "strings" + "velox-bot/internal/music" + + "github.com/bwmarrin/discordgo" +) + +var Play = &discordgo.ApplicationCommand{ + Name: "play", + Description: "Play a song via Lavalink", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "query", + Description: "Song name or URL", + Required: true, + }, + }, +} + +func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return + } + query := data.Options[0].StringValue() + + // Always acknowledge quickly to avoid "application did not respond". + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Searching...", + }, + }) + + if !memberHasDJRole(s, i.GuildID, i.Member) { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("You need the **DJ** role to use music commands."), + }) + return + } + + // If user provided plain text, turn it into a YouTube search + if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") { + query = "ytsearch:" + query + } else { + query = normalizeYouTubeRadioURL(query) + } + + vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID) + if err != nil { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Error: %v", err)), + }) + return + } + + entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username) + if err != nil { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Error: %v", err)), + }) + return + } + + title := entry.Track.Info.Title + author := entry.Track.Info.Author + length := entry.Track.Info.Length + + if started { + // manager will post now-playing message & manage invalidating old buttons + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)), + }) + } else { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)), + }) + } +} + +func ptr[T any](v T) *T { return new(v) } + +// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like: +// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID +// It intentionally does NOT strip normal playlist URLs. +func normalizeYouTubeRadioURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u == nil { + return raw + } + + host := strings.ToLower(u.Host) + if !strings.Contains(host, "youtube.com") || u.Path != "/watch" { + return raw + } + + q := u.Query() + v := q.Get("v") + if v == "" { + return raw + } + + list := q.Get("list") + _, hasStartRadio := q["start_radio"] + if !hasStartRadio && !strings.HasPrefix(list, "RD") { + return raw + } + + u.RawQuery = url.Values{"v": []string{v}}.Encode() + u.Fragment = "" + return u.String() +} + +func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) { + g, err := s.State.Guild(guildID) + if err != nil { + return nil, fmt.Errorf("cannot find guild voice state") + } + for _, vs := range g.VoiceStates { + if vs.UserID == userID { + return vs, nil + } + } + return nil, fmt.Errorf("you must be in a voice channel") +} diff --git a/internal/commands/music/public/queue.go b/internal/commands/music/public/queue.go new file mode 100644 index 0000000..0281bd3 --- /dev/null +++ b/internal/commands/music/public/queue.go @@ -0,0 +1,77 @@ +package public + +import ( + "fmt" + "strings" + "velox-bot/internal/music" + + "github.com/bwmarrin/discordgo" +) + +var Queue = &discordgo.ApplicationCommand{ + Name: "queue", + Description: "Show the current music queue", +} + +func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !memberHasDJRole(s, i.GuildID, i.Member) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You need the **DJ** role to use music commands.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + current, rest := music.GetQueue(i.GuildID) + + if current == nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "The queue is currently empty.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author) + + var descBuilder strings.Builder + if len(rest) > 0 { + limit := len(rest) + if limit > 10 { + limit = 10 + } + for idx, entry := range rest[:limit] { + fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy) + } + if len(rest) > limit { + fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit) + } + } else { + descBuilder.WriteString("Nothing else in the queue.") + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Music Queue", + Description: descBuilder.String(), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Now Playing", + Value: nowPlaying, + }, + }, + }, + }, + }, + }) +} + diff --git a/internal/commands/music/public/volume.go b/internal/commands/music/public/volume.go new file mode 100644 index 0000000..4221b17 --- /dev/null +++ b/internal/commands/music/public/volume.go @@ -0,0 +1,63 @@ +package public + +import ( + "fmt" + "velox-bot/internal/music" + + "github.com/bwmarrin/discordgo" +) + +var Volume = &discordgo.ApplicationCommand{ + Name: "volume", + Description: "Set playback volume (0-150)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "value", + Description: "Volume percent (0-150)", + Required: true, + MinValue: ptrFloat(0), + MaxValue: 150, + }, + }, +} + +func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Updating volume...", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + if !memberHasDJRole(s, i.GuildID, i.Member) { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("You need the **DJ** role to use music commands."), + }) + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("Missing volume value."), + }) + return + } + + vol := int(data.Options[0].IntValue()) + if err := music.SetVolume(i.GuildID, vol); err != nil { + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Error: %v", err)), + }) + return + } + + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)), + }) +} + +func ptrFloat(v float64) *float64 { return &v } + diff --git a/internal/commands/music/registry.go b/internal/commands/music/registry.go new file mode 100644 index 0000000..fd22966 --- /dev/null +++ b/internal/commands/music/registry.go @@ -0,0 +1,20 @@ +package music + +import ( + "velox-bot/internal/commands/music/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Play *discordgo.ApplicationCommand = public.Play + Queue *discordgo.ApplicationCommand = public.Queue + Volume *discordgo.ApplicationCommand = public.Volume +) + +func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) } +func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.QueueHandler(s, i) +} +func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) } + diff --git a/internal/config/config.go b/internal/config/config.go index 5f1fbb4..c6b3766 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,10 +8,12 @@ import ( ) type Config struct { - BotToken string - AppID string - GuildID string - DBHost string + BotToken string + AppID string + GuildID string + DBHost string + LavalinkHost string + LavalinkPass string } func LoadConfig() (*Config, error) { @@ -39,10 +41,15 @@ func LoadConfig() (*Config, error) { return nil, fmt.Errorf("DB_HOST is not set") } + lavalinkHost := os.Getenv("LAVALINK_HOST") + lavalinkPass := os.Getenv("LAVALINK_PASSWORD") + return &Config{ - BotToken: token, - AppID: appID, - GuildID: guildID, - DBHost: dbHost, + BotToken: token, + AppID: appID, + GuildID: guildID, + DBHost: dbHost, + LavalinkHost: lavalinkHost, + LavalinkPass: lavalinkPass, }, nil } diff --git a/internal/music/components.go b/internal/music/components.go new file mode 100644 index 0000000..214b1e5 --- /dev/null +++ b/internal/music/components.go @@ -0,0 +1,108 @@ +package music + +import ( + "strings" + + "github.com/bwmarrin/discordgo" +) + +func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { + // DJ role is required for all music controls. + if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You need the **DJ** role to use music commands.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + customID := i.MessageComponentData().CustomID + parts := strings.Split(customID, ":") + if len(parts) < 2 { + return + } + + action := parts[1] + guildID := i.GuildID + + // Ignore controls from old now-playing messages + currentMsgID := CurrentNowPlayingMessageID(guildID) + if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "These controls are outdated.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Acknowledge immediately to avoid "This interaction failed". + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredMessageUpdate, + }) + + var err error + content := "" + + switch action { + case "pause": + err = Pause(guildID, true) + content = "Paused." + case "resume": + err = Pause(guildID, false) + content = "Resumed." + case "toggle_pause": + paused, _ := pausedAndVolume(guildID) + err = Pause(guildID, !paused) + if paused { + content = "Resumed." + } else { + content = "Paused." + } + updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID) + case "skip": + err = Skip(guildID) + content = "Skipped." + case "stop": + err = Stop(guildID) + content = "Stopped." + case "repeat_song": + rs, rq := ToggleRepeatSong(guildID) + _ = rs + _ = rq + updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID) + if rs { + content = "Repeat song enabled." + } else { + content = "Repeat song disabled." + } + case "repeat_queue": + rs, rq := ToggleRepeatQueue(guildID) + _ = rs + _ = rq + updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID) + if rq { + content = "Repeat queue enabled." + } else { + content = "Repeat queue disabled." + } + } + + if content == "" { + content = "Done." + } + if err != nil { + content = "Error: " + err.Error() + } + + // send ephemeral confirmation + _, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/internal/music/manager.go b/internal/music/manager.go new file mode 100644 index 0000000..1a4fce1 --- /dev/null +++ b/internal/music/manager.go @@ -0,0 +1,538 @@ +package music + +import ( + "context" + "fmt" + "net/url" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/disgoorg/disgolink/v3/disgolink" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/snowflake/v2" +) + +type TrackEntry struct { + Track lavalink.Track + RequestedBy string +} + +type guildPlayer struct { + Player disgolink.Player + Queue []TrackEntry + RepeatSong bool + RepeatQueue bool + Paused bool + Volume int + IdleSince time.Time + TextChannelID string + NowPlayingMsgID string +} + +type Manager struct { + client disgolink.Client + session *discordgo.Session + + mu sync.Mutex + players map[string]*guildPlayer +} + +var manager *Manager + +func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error { + if lavalinkHost == "" { + return nil + } + + userID, err := snowflake.Parse(appID) + if err != nil { + return fmt.Errorf("parse app id for lavalink: %w", err) + } + + m := &Manager{ + client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)), + session: session, + players: make(map[string]*guildPlayer), + } + + u, err := url.Parse(lavalinkHost) + if err != nil { + return fmt.Errorf("parse lavalink host: %w", err) + } + + secure := u.Scheme == "https" || u.Scheme == "wss" + password := lavalinkPass + if password == "" { + password = "youshallnotpass" + } + + _, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{ + Name: "main", + Address: u.Host, + Password: password, + Secure: secure, + }) + if err != nil { + return fmt.Errorf("add lavalink node: %w", err) + } + + manager = m + go idleDisconnectLoop() + return nil +} + +func idleDisconnectLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if manager == nil || manager.session == nil { + continue + } + + now := time.Now() + var toDisconnect []string + + manager.mu.Lock() + for guildID, gp := range manager.players { + if gp == nil { + continue + } + + // If we're paused or have anything queued/playing, clear idle timer. + if gp.Paused || len(gp.Queue) > 0 { + gp.IdleSince = time.Time{} + continue + } + + if gp.IdleSince.IsZero() { + gp.IdleSince = now + continue + } + + if now.Sub(gp.IdleSince) >= time.Minute { + toDisconnect = append(toDisconnect, guildID) + gp.IdleSince = time.Time{} + } + } + manager.mu.Unlock() + + for _, guildID := range toDisconnect { + // Best-effort: disconnect. Safe even if we're already disconnected. + _ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false) + } + } +} + +func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) { + if manager == nil { + return + } + if vs.UserID != manager.session.State.User.ID { + return + } + + guildID := snowflake.MustParse(vs.GuildID) + var channelID *snowflake.ID + if vs.ChannelID != "" { + id := snowflake.MustParse(vs.ChannelID) + channelID = &id + } + + manager.client.OnVoiceStateUpdate( + context.Background(), + guildID, + channelID, + vs.SessionID, + ) +} + +func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) { + if manager == nil { + return + } + endpoint := "" + if ev.Endpoint != "" { + endpoint = ev.Endpoint + } + + manager.client.OnVoiceServerUpdate( + context.Background(), + snowflake.MustParse(ev.GuildID), + ev.Token, + endpoint, + ) +} + +func getOrCreateGuildPlayer(guildID string) *guildPlayer { + manager.mu.Lock() + defer manager.mu.Unlock() + + if gp, ok := manager.players[guildID]; ok { + return gp + } + + player := manager.client.Player(snowflake.MustParse(guildID)) + gp := &guildPlayer{ + Player: player, + Queue: make([]TrackEntry, 0), + Volume: 100, + } + manager.players[guildID] = gp + return gp +} + +func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) { + if manager == nil { + return nil, false, fmt.Errorf("music manager not initialized") + } + + // Join voice channel + if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil { + return nil, false, fmt.Errorf("join voice channel: %w", err) + } + + gp := getOrCreateGuildPlayer(guildID) + if textChannelID != "" { + manager.mu.Lock() + gp.TextChannelID = textChannelID + manager.mu.Unlock() + } + + var loadedTracks []lavalink.Track + + manager.client.BestNode().LoadTracksHandler( + context.Background(), + query, + disgolink.NewResultHandler( + func(track lavalink.Track) { + loadedTracks = append(loadedTracks, track) + }, + func(playlist lavalink.Playlist) { + if len(playlist.Tracks) == 0 { + return + } + + // Respect Lavalink's selectedTrack when present by rotating the playlist. + selected := playlist.Info.SelectedTrack + if selected >= 0 && selected < len(playlist.Tracks) { + loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...) + loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...) + return + } + + loadedTracks = append(loadedTracks, playlist.Tracks...) + }, + func(tracks []lavalink.Track) { + if len(tracks) > 0 { + loadedTracks = append(loadedTracks, tracks[0]) + } + }, + func() { + }, + func(err error) { + }, + ), + ) + + if len(loadedTracks) == 0 { + return nil, false, fmt.Errorf("no tracks found for query") + } + + // Some sources may return unplayable entries; pick the first with an encoded track. + firstPlayableIdx := 0 + for idx := range loadedTracks { + if loadedTracks[idx].Encoded != "" { + firstPlayableIdx = idx + break + } + } + if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) { + loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...) + } + + entries := make([]TrackEntry, len(loadedTracks)) + for idx, t := range loadedTracks { + entries[idx] = TrackEntry{ + Track: t, + RequestedBy: requester, + } + } + firstEntry := entries[0] + + manager.mu.Lock() + shouldStart := len(gp.Queue) == 0 + gp.Queue = append(gp.Queue, entries...) + gp.IdleSince = time.Time{} + manager.mu.Unlock() + + if shouldStart { + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil { + return nil, false, fmt.Errorf("start track: %w", err) + } + if gp.TextChannelID != "" { + postNowPlaying(guildID, gp.TextChannelID, firstEntry) + } + } + + return &firstEntry, shouldStart, nil +} + +func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) { + if manager == nil { + return + } + if !event.Reason.MayStartNext() { + return + } + + guildID := player.GuildID().String() + + manager.mu.Lock() + gp, ok := manager.players[guildID] + if !ok || len(gp.Queue) == 0 { + manager.mu.Unlock() + return + } + + // repeat current track + if gp.RepeatSong { + next := gp.Queue[0] + textChannelID := gp.TextChannelID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil { + return + } + if textChannelID != "" { + postNowPlaying(guildID, textChannelID, next) + } + return + } + + // pop current and optionally cycle it to end + finished := gp.Queue[0] + gp.Queue = gp.Queue[1:] + if gp.RepeatQueue { + gp.Queue = append(gp.Queue, finished) + } + + if len(gp.Queue) == 0 { + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + gp.NowPlayingMsgID = "" + gp.IdleSince = time.Now() + manager.mu.Unlock() + if textChannelID != "" && nowPlayingMsgID != "" { + disableNowPlaying(textChannelID, nowPlayingMsgID) + } + _ = gp.Player.Update(context.Background(), lavalink.WithNullTrack()) + return + } + + next := gp.Queue[0] + textChannelID := gp.TextChannelID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil { + return + } + if textChannelID != "" { + postNowPlaying(guildID, textChannelID, next) + } +} + +func Pause(guildID string, pause bool) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + gp.Paused = pause + if pause { + gp.IdleSince = time.Time{} + } + manager.mu.Unlock() + return gp.Player.Update(context.Background(), lavalink.WithPaused(pause)) +} + +func Skip(guildID string) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + if len(gp.Queue) == 0 { + manager.mu.Unlock() + return nil + } + // skip always advances regardless of repeat song + skipped := gp.Queue[0] + gp.Queue = gp.Queue[1:] + if gp.RepeatQueue { + gp.Queue = append(gp.Queue, skipped) + } + + if len(gp.Queue) == 0 { + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + gp.NowPlayingMsgID = "" + gp.IdleSince = time.Now() + manager.mu.Unlock() + if textChannelID != "" && nowPlayingMsgID != "" { + disableNowPlaying(textChannelID, nowPlayingMsgID) + } + return gp.Player.Update(context.Background(), lavalink.WithNullTrack()) + } + + next := gp.Queue[0] + textChannelID := gp.TextChannelID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil { + return err + } + if textChannelID != "" { + postNowPlaying(guildID, textChannelID, next) + } + return nil +} + +func Stop(guildID string) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + gp.Queue = nil + gp.Paused = false + gp.NowPlayingMsgID = "" + gp.IdleSince = time.Now() + manager.mu.Unlock() + + if textChannelID != "" && nowPlayingMsgID != "" { + disableNowPlaying(textChannelID, nowPlayingMsgID) + } + + return gp.Player.Update(context.Background(), lavalink.WithNullTrack()) +} + +func GetQueue(guildID string) (*TrackEntry, []TrackEntry) { + if manager == nil { + return nil, nil + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + defer manager.mu.Unlock() + + if len(gp.Queue) == 0 { + return nil, nil + } + + current := gp.Queue[0] + rest := make([]TrackEntry, len(gp.Queue)-1) + copy(rest, gp.Queue[1:]) + return ¤t, rest +} + +func ToggleRepeatSong(guildID string) (bool, bool) { + if manager == nil { + return false, false + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + gp.RepeatSong = !gp.RepeatSong + if gp.RepeatSong { + gp.RepeatQueue = false + } + return gp.RepeatSong, gp.RepeatQueue +} + +func ToggleRepeatQueue(guildID string) (bool, bool) { + if manager == nil { + return false, false + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + gp.RepeatQueue = !gp.RepeatQueue + if gp.RepeatQueue { + gp.RepeatSong = false + } + return gp.RepeatSong, gp.RepeatQueue +} + +func repeatFlags(guildID string) (bool, bool) { + if manager == nil { + return false, false + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + return gp.RepeatSong, gp.RepeatQueue +} + +func pausedAndVolume(guildID string) (bool, int) { + if manager == nil { + return false, 100 + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + vol := gp.Volume + if vol <= 0 { + vol = 100 + } + return gp.Paused, vol +} + +func SetVolume(guildID string, volume int) error { + if manager == nil { + return fmt.Errorf("music manager not initialized") + } + if volume < 0 { + volume = 0 + } + if volume > 150 { + volume = 150 + } + + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + gp.Volume = volume + textChannelID := gp.TextChannelID + nowPlayingMsgID := gp.NowPlayingMsgID + manager.mu.Unlock() + + if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil { + return err + } + + // Refresh the embed so the displayed volume matches. + if textChannelID != "" && nowPlayingMsgID != "" { + current, _ := GetQueue(guildID) + if current != nil { + postNowPlaying(guildID, textChannelID, *current) + } + } + return nil +} + +func CurrentNowPlayingMessageID(guildID string) string { + if manager == nil { + return "" + } + gp := getOrCreateGuildPlayer(guildID) + manager.mu.Lock() + defer manager.mu.Unlock() + return gp.NowPlayingMsgID +} + + diff --git a/internal/music/nowplaying.go b/internal/music/nowplaying.go new file mode 100644 index 0000000..a5067f2 --- /dev/null +++ b/internal/music/nowplaying.go @@ -0,0 +1,179 @@ +package music + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/snowflake/v2" +) + +func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent { + repeatSongStyle := discordgo.SecondaryButton + if repeatSong { + repeatSongStyle = discordgo.SuccessButton + } + repeatQueueStyle := discordgo.SecondaryButton + if repeatQueue { + repeatQueueStyle = discordgo.SuccessButton + } + + return []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Style: discordgo.SecondaryButton, + Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]}, + CustomID: "music:toggle_pause", + Disabled: disabled, + }, + discordgo.Button{ + Style: discordgo.SecondaryButton, + Emoji: &discordgo.ComponentEmoji{Name: "⏭️"}, + CustomID: "music:skip", + Disabled: disabled, + }, + discordgo.Button{ + Style: repeatSongStyle, + Emoji: &discordgo.ComponentEmoji{Name: "🔂"}, + CustomID: "music:repeat_song", + Disabled: disabled, + }, + discordgo.Button{ + Style: repeatQueueStyle, + Emoji: &discordgo.ComponentEmoji{Name: "🔁"}, + CustomID: "music:repeat_queue", + Disabled: disabled, + }, + discordgo.Button{ + Style: discordgo.DangerButton, + Emoji: &discordgo.ComponentEmoji{Name: "✖️"}, + CustomID: "music:stop", + Disabled: disabled, + }, + }, + }, + } +} + +func disableNowPlaying(textChannelID, messageID string) { + if manager == nil || textChannelID == "" || messageID == "" { + return + } + disabled := nowPlayingComponents(true, false, false, false) + _, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: textChannelID, + ID: messageID, + Components: &disabled, + }) +} + +func postNowPlaying(guildID, textChannelID string, entry TrackEntry) { + if manager == nil || textChannelID == "" { + return + } + + manager.mu.Lock() + gp, ok := manager.players[guildID] + var oldMsgID string + if ok { + oldMsgID = gp.NowPlayingMsgID + } + manager.mu.Unlock() + + if oldMsgID != "" { + disableNowPlaying(textChannelID, oldMsgID) + } + + repeatSong, repeatQueue := repeatFlags(guildID) + paused, volume := pausedAndVolume(guildID) + + info := entry.Track.Info + title := info.Title + + embed := &discordgo.MessageEmbed{ + Author: &discordgo.MessageEmbedAuthor{ + Name: info.Author, + IconURL: sourceIconURL(info.SourceName), + }, + Title: title, + Color: 0x2B2D31, // matches Discord dark-ish embed accent + Fields: []*discordgo.MessageEmbedField{ + {Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true}, + {Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true}, + {Name: "Requested by", Value: entry.RequestedBy, Inline: true}, + }, + } + + if info.URI != nil && *info.URI != "" { + embed.URL = *info.URI + } + + if art := artworkURL(info); art != "" { + embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art} + } + + msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused), + }) + if err != nil { + return + } + + manager.mu.Lock() + // Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks). + gp = manager.players[guildID] + if gp == nil { + gp = &guildPlayer{ + Player: manager.client.Player(snowflake.MustParse(guildID)), + Queue: make([]TrackEntry, 0), + } + manager.players[guildID] = gp + } + gp.TextChannelID = textChannelID + gp.NowPlayingMsgID = msg.ID + manager.mu.Unlock() +} + +func artworkURL(info lavalink.TrackInfo) string { + if info.ArtworkURL != nil && *info.ArtworkURL != "" { + return *info.ArtworkURL + } + // Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback. + if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" { + return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg" + } + return "" +} + +func formatDuration(d lavalink.Duration, isStream bool) string { + if isStream { + return "Live" + } + secs := d.Seconds() + if secs < 0 { + secs = 0 + } + h := secs / 3600 + m := (secs % 3600) / 60 + s := secs % 60 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%d:%02d", m, s) +} + +func sourceIconURL(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "youtube": + // Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds) + return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com" + case "soundcloud": + return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com" + default: + return "" + } +} + diff --git a/internal/music/permissions.go b/internal/music/permissions.go new file mode 100644 index 0000000..5bd94f9 --- /dev/null +++ b/internal/music/permissions.go @@ -0,0 +1,36 @@ +package music + +import "github.com/bwmarrin/discordgo" + +func memberHasDJRoleForComponents(s *discordgo.Session, guildID string, m *discordgo.Member) bool { + if s == nil || guildID == "" || m == nil { + return false + } + if len(m.Roles) == 0 { + return false + } + + roles, err := s.GuildRoles(guildID) + if err != nil { + return false + } + + djRoleID := "" + for _, r := range roles { + if r != nil && r.Name == "DJ" { + djRoleID = r.ID + break + } + } + if djRoleID == "" { + return false + } + + for _, rid := range m.Roles { + if rid == djRoleID { + return true + } + } + return false +} + diff --git a/internal/music/repeat_buttons.go b/internal/music/repeat_buttons.go new file mode 100644 index 0000000..ff6f05d --- /dev/null +++ b/internal/music/repeat_buttons.go @@ -0,0 +1,18 @@ +package music + +import "github.com/bwmarrin/discordgo" + +func updateNowPlayingButtons(channelID, messageID string, guildID string) { + if manager == nil || channelID == "" || messageID == "" { + return + } + repeatSong, repeatQueue := repeatFlags(guildID) + paused, _ := pausedAndVolume(guildID) + comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused) + _, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: channelID, + ID: messageID, + Components: &comps, + }) +} + diff --git a/main.go b/main.go index fe7ea0f..cfbc565 100644 --- a/main.go +++ b/main.go @@ -47,7 +47,7 @@ func main() { rpsService := rps.New(rpsRepo) services := services.NewServices(levelService, levelSettingsService, meetingService, projectsService, rpsService) - bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) + bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services) if err != nil { log.Fatalf("Error creating bot: %v", err) return