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") }