diff --git a/bot/bot.go b/bot/bot.go index 5f463c8..c2eeae7 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -20,6 +20,7 @@ type BotInstance struct { Soundcloud *soundcloud.Client Voice *discordgo.VoiceConnection Player *Player + Vote *VoteHolder } func NewBotInstance(lc fx.Lifecycle, c *cmd.Conf, log *zerolog.Logger, sc *soundcloud.Client) *BotInstance { @@ -45,6 +46,7 @@ func NewBotInstance(lc fx.Lifecycle, c *cmd.Conf, log *zerolog.Logger, sc *sound Session: dg, Voice: voice, Player: &Player{tracks: soundcloud.Tracks{}}, + Vote: &VoteHolder{Voters: make(map[string]bool)}, } b.Session.AddHandler(b.MessageCreated) @@ -56,7 +58,9 @@ func NewBotInstance(lc fx.Lifecycle, c *cmd.Conf, log *zerolog.Logger, sc *sound b.Player.session.Stop() // nolint:errcheck b.Player.session.Cleanup() } - b.Voice.Close() + if b.Voice != nil { + b.Voice.Close() + } b.Session.Close() return nil }, diff --git a/bot/display.go b/bot/display.go index 112b915..9af6656 100644 --- a/bot/display.go +++ b/bot/display.go @@ -8,10 +8,12 @@ import ( "github.com/Depado/soundcloud" "github.com/bwmarrin/discordgo" "github.com/hako/durafmt" - "github.com/rs/zerolog/log" ) func (b *BotInstance) SendNowPlaying(t soundcloud.Track) { + b.Player.tracksM.Lock() + defer b.Player.tracksM.Unlock() + e := &discordgo.MessageEmbed{ Title: t.Title, URL: t.PermalinkURL, @@ -29,11 +31,14 @@ func (b *BotInstance) SendNowPlaying(t soundcloud.Track) { {Name: "Reposts", Value: strconv.Itoa(t.RepostsCount), Inline: true}, {Name: "Duration", Value: durafmt.Parse(time.Duration(t.Duration) * time.Millisecond).LimitFirstN(2).String(), Inline: true}, }, + Footer: &discordgo.MessageEmbedFooter{ + Text: fmt.Sprintf("%d tracks left in queue", len(b.Player.tracks)), + }, } _, err := b.Session.ChannelMessageSendEmbed(b.conf.Bot.Channels.Public, e) if err != nil { - log.Err(err).Msg("unable to send embed") + b.log.Err(err).Msg("unable to send embed") } } @@ -64,14 +69,15 @@ func (b *BotInstance) DisplayQueue(m *discordgo.MessageCreate) { Fields: []*discordgo.MessageEmbedField{ {Name: "Tracks", Value: strconv.Itoa(len(b.Player.tracks)), Inline: true}, {Name: "Duration", Value: durafmt.Parse(time.Duration(tot) * time.Millisecond).LimitFirstN(2).String(), Inline: true}, + {Name: "Requested by", Value: fmt.Sprintf("<@%s>", m.Author.ID), Inline: true}, }, Footer: &discordgo.MessageEmbedFooter{ - Text: fmt.Sprintf("Tip: Add new tracks using '%s add' or '%s a'", b.conf.Bot.Prefix, b.conf.Bot.Prefix), + Text: fmt.Sprintf("Tip: Add new tracks using '%s add' or '%s next'", b.conf.Bot.Prefix, b.conf.Bot.Prefix), }, } _, err := b.Session.ChannelMessageSendEmbed(m.ChannelID, e) if err != nil { - log.Err(err).Msg("unable to send embed") + b.log.Err(err).Msg("unable to send embed") } } @@ -92,7 +98,13 @@ func (b *BotInstance) SendNotice(title, body, footer string, channel string) { } _, err := b.Session.ChannelMessageSendEmbed(channel, e) if err != nil { - log.Err(err).Msg("unable to send embed") + b.log.Err(err).Msg("unable to send embed") + } +} + +func (b *BotInstance) DeleteUserMessage(m *discordgo.MessageCreate) { + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") } } @@ -108,6 +120,26 @@ func (b *BotInstance) SendNamedNotice(m *discordgo.MessageCreate, prefix, title, } _, err := b.Session.ChannelMessageSendEmbed(m.ChannelID, e) if err != nil { - log.Err(err).Msg("unable to send embed") + b.log.Err(err).Msg("unable to send embed") + } +} + +func (b *BotInstance) DisplayTemporaryMessage(m *discordgo.MessageCreate, title, body, footer string) { + e := &discordgo.MessageEmbed{ + Title: title, + Description: body, + Footer: &discordgo.MessageEmbedFooter{Text: footer}, + Color: 0xff5500, + } + mess, err := b.Session.ChannelMessageSendEmbed(m.ChannelID, e) + if err != nil { + b.log.Err(err).Msg("unable to send embed") } + + go func(mess *discordgo.Message) { + time.Sleep(5 * time.Second) + if err := b.Session.ChannelMessageDelete(mess.ChannelID, mess.ID); err != nil { + b.log.Err(err).Msg("unable to delete message") + } + }(mess) } diff --git a/bot/documentation.go b/bot/documentation.go deleted file mode 100644 index 44aa057..0000000 --- a/bot/documentation.go +++ /dev/null @@ -1,8 +0,0 @@ -package bot - -var QueueDoc = `!fox : Displays the current queue (todo) -!fox : Adds to queue` - -// var QueueDoc = &discordgo.MessageEmbed{ - -// } diff --git a/bot/handlers_control.go b/bot/handlers_control.go new file mode 100644 index 0000000..4fbd596 --- /dev/null +++ b/bot/handlers_control.go @@ -0,0 +1,123 @@ +package bot + +import ( + "github.com/bwmarrin/discordgo" +) + +func (b *BotInstance) PlayHandler(m *discordgo.MessageCreate) { + b.PlayQueue() + b.SendNamedNotice(m, "Requested by", "⏯️ Play", "", "") + b.DeleteUserMessage(m) +} + +func (b *BotInstance) PauseHandler(m *discordgo.MessageCreate) { + defer b.DeleteUserMessage(m) + if !b.Player.playing { + b.SendNamedNotice(m, "Requested by", "⏯️ Pause", "Nothing to do", "") + return + } + + if !b.Player.pause { + b.Player.stream.SetPaused(true) + b.Player.pause = true + b.SendNamedNotice(m, "Requested by", "⏯️ Pause", "", "") + } else { + b.SendNamedNotice(m, "Requested by", "⏯️ Pause", "Nothing to do", "") + } +} + +func (b *BotInstance) ResumeHandler(m *discordgo.MessageCreate) { + defer b.DeleteUserMessage(m) + if !b.Player.playing { + b.SendNamedNotice(m, "Requested by", "⏯️ Resume", "Nothing to do", "") + return + } + if b.Player.pause { + b.Player.stream.SetPaused(false) + b.Player.pause = false + b.SendNamedNotice(m, "Requested by", "⏯️ Resume", "", "") + } else { + b.SendNamedNotice(m, "Requested by", "⏯️ Resume", "Nothing to do", "") + } +} + +func (b *BotInstance) StopHandler(m *discordgo.MessageCreate) { + defer b.DeleteUserMessage(m) + if !b.restricted(m) { + b.DisplayTemporaryMessage(m, "", "You do not have permission to stop the player", "") + return + } + if !b.Player.playing { + b.SendNamedNotice(m, "Requested by", "⏹️ Stop", "Nothing to do", "") + return + } + b.Player.session.Stop() // nolint:errcheck + b.Player.stop = true + b.SendNamedNotice(m, "Requested by", "⏹️ Stop", "", "") +} + +func (b *BotInstance) SkipHandler(m *discordgo.MessageCreate) { + defer b.DeleteUserMessage(m) + if !b.restricted(m) { + b.DisplayTemporaryMessage(m, "", "You do not have permission to arbitrarily skip a track", "Tip: Start a vote using '!fox vote'") + return + } + + if b.Player.playing { + b.Player.session.Stop() // nolint:errcheck + b.SendNamedNotice(m, "Requested by", "⏭️ Skip", "The currently playing track has been skipped", "Note: This can take a few seconds") + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } + } else { + b.Player.Pop() + b.SendNamedNotice(m, "Requested by", "⏭️ Skip", "The next track in queue has been skipped", "") + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } + } +} + +func (b *BotInstance) JoinHandler(m *discordgo.MessageCreate) { + defer b.DeleteUserMessage(m) + if !b.restricted(m) { + b.DisplayTemporaryMessage(m, "", "Permission denied", "Tip: Only admins and DJs can do that") + return + } + + b.log.Debug().Str("user", m.Author.Username).Str("method", "join").Msg("called") + if b.Voice == nil { + voice, err := b.Session.ChannelVoiceJoin(b.conf.Bot.Guild, b.conf.Bot.Channels.Voice, false, true) + if err != nil { + b.SendNamedNotice(m, "Requested by", "Unable to join voice channel", "", "Error was: "+err.Error()) + b.log.Error().Err(err).Msg("unable to connect to voice channel") + return + } + b.Voice = voice + b.log.Debug().Str("user", m.Author.Username).Str("method", "join").Msg("bot joined vocal channel") + } +} + +func (b *BotInstance) LeaveHandler(m *discordgo.MessageCreate) { + defer b.DeleteUserMessage(m) + if !b.restricted(m) { + b.DisplayTemporaryMessage(m, "", "Permission denied", "Tip: Only admins and DJs can do that") + return + } + + b.log.Debug().Str("user", m.Author.Username).Str("method", "leave").Msg("called") + if b.Voice != nil { + if b.Player.playing { + b.Player.session.Stop() // nolint:errcheck + b.Player.stop = true + } + if err := b.Voice.Disconnect(); err != nil { + b.SendNamedNotice(m, "Requested by", "Unable to leave voice channel", "", "Error was: "+err.Error()) + b.log.Error().Err(err).Msg("unable to disconnect from voice channel") + return + } + b.Voice = nil + b.log.Debug().Str("user", m.Author.Username).Str("method", "leave").Msg("bot left vocal channel") + return + } +} diff --git a/bot/handlers_public.go b/bot/handlers_public.go new file mode 100644 index 0000000..1008856 --- /dev/null +++ b/bot/handlers_public.go @@ -0,0 +1,104 @@ +package bot + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" +) + +// AddHandler is in charge of pushing a track or playlist to the end of the +// current queue +func (b *BotInstance) AddHandler(m *discordgo.MessageCreate, args []string) { + if len(args) < 1 { + b.SendNotice("", fmt.Sprintf("Usage: `%s `", b.conf.Bot.Prefix), "", m.ChannelID) + return + } + + url := args[0] + url = strings.Trim(url, "<>") + if !strings.HasPrefix(url, "https://soundcloud.com") { + b.SendNotice("", "This doesn't look like a SoundCloud URL", "", m.ChannelID) + return + } + + b.AddToQueue(m, url, false) + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } +} + +// NextHandler is in charge of pushing a track or playlist in front of the rest +// of the queue +func (b *BotInstance) NextHandler(m *discordgo.MessageCreate, args []string) { + if len(args) < 1 { + b.SendNotice("", fmt.Sprintf("Usage: `%s `", b.conf.Bot.Prefix), "", m.ChannelID) + return + } + + url := args[0] + url = strings.Trim(url, "<>") + if !strings.HasPrefix(url, "https://soundcloud.com") { + b.SendNotice("", "This doesn't look like a SoundCloud URL", "", m.ChannelID) + return + } + + b.AddToQueue(m, url, true) + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } +} + +// HelpHandler will handle incoming requests for help +func (b *BotInstance) HelpHandler(m *discordgo.MessageCreate) { + var doc = &discordgo.MessageEmbed{ + Title: "Fox Help", + Color: 0xff5500, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Help", Value: fmt.Sprintf("%s ", b.conf.Bot.Prefix), Inline: true}, + {Name: "Add tracks", Value: fmt.Sprintf("%s ", b.conf.Bot.Prefix), Inline: true}, + {Name: "Add next tracks", Value: fmt.Sprintf("%s ", b.conf.Bot.Prefix)}, + {Name: "Channel", Value: fmt.Sprintf("%s ", b.conf.Bot.Prefix)}, + {Name: "Display Queue", Value: fmt.Sprintf("%s ", b.conf.Bot.Prefix), Inline: true}, + {Name: "Shuffle Queue", Value: fmt.Sprintf("%s shuffle", b.conf.Bot.Prefix), Inline: true}, + {Name: "Clear Queue", Value: fmt.Sprintf("%s clear", b.conf.Bot.Prefix), Inline: true}, + {Name: "Control", Value: fmt.Sprintf("%s ", b.conf.Bot.Prefix)}, + }, + } + _, err := b.Session.ChannelMessageSendEmbed(m.ChannelID, doc) + if err != nil { + log.Err(err).Msg("unable to send embed") + } + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } +} + +// QueueHandler is in charge of dealing with queue commands such as displaying +// the current queue, shuffling the queue or in the control channel, clearing it +func (b *BotInstance) QueueHandler(m *discordgo.MessageCreate, args []string) { + if len(args) == 0 { + b.DisplayQueue(m) + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } + return + } + switch args[0] { + case "shuffle": + b.Player.Shuffle() + b.SendNamedNotice(m, "Requested by", "🎲 Shuffle!", fmt.Sprintf("I shuffled %d tracks for you.", len(b.Player.tracks)), "") + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } + case "clear": // The clear command is not public and shouldn't be used + if b.restricted(m) { + b.Player.Clear() + b.SendNamedNotice(m, "Requested by", "🚮 Clear", "The queue has been reset", "") + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } + } + } +} diff --git a/bot/player.go b/bot/player.go index 2228034..aa370b8 100644 --- a/bot/player.go +++ b/bot/player.go @@ -7,7 +7,6 @@ import ( "github.com/Depado/soundcloud" "github.com/jonas747/dca" - "github.com/rs/zerolog/log" ) func (b *BotInstance) PlayQueue() { @@ -23,6 +22,7 @@ func (b *BotInstance) PlayQueue() { }() b.log.Debug().Int("length", len(b.Player.tracks)).Msg("starting playing queue") for { + b.Vote.Reset() if len(b.Player.tracks) == 0 { b.log.Debug().Int("length", len(b.Player.tracks)).Msg("track length") b.SendPublicMessage("Nothing left to play!", fmt.Sprintf("You can give me more by using the %s command!", b.conf.Bot.Prefix)) @@ -65,7 +65,7 @@ func (b *BotInstance) PlayQueue() { func (b *BotInstance) Play(url string) error { err := b.Voice.Speaking(true) if err != nil { - log.Err(err).Msg("Failed setting speaking") + b.log.Err(err).Msg("Failed setting speaking") return err } defer b.Voice.Speaking(false) // nolint:errcheck @@ -76,8 +76,9 @@ func (b *BotInstance) Play(url string) error { encodeSession, err := dca.EncodeFile(url, opts) if err != nil { - log.Err(err).Msg("failed creating an encoding session") + b.log.Err(err).Msg("failed creating an encoding session") } + defer encodeSession.Cleanup() b.Player.session = encodeSession done := make(chan error) @@ -87,12 +88,20 @@ func (b *BotInstance) Play(url string) error { for { // nolint:gosimple select { case err := <-done: - if err != nil && err != io.EOF { - log.Err(err).Msg("error occured during playback") - } else { - err = nil + if err != nil && err == io.EOF { + return nil + } + if errors.Is(err, dca.ErrVoiceConnClosed) { + b.log.Info().Err(err).Msg("voice connection closed, attempting reconnection") + if err = b.SalvageVoice(); err != nil { + b.log.Err(err).Msg("unable to reconnect voice") + return err + } + b.log.Info().Msg("voice reconnected, recreating stream") + stream = dca.NewStream(encodeSession, b.Voice, done) + b.Player.stream = stream + continue } - encodeSession.Cleanup() return err } } diff --git a/bot/queue.go b/bot/queue.go index 3d3b6e6..b23948b 100644 --- a/bot/queue.go +++ b/bot/queue.go @@ -16,13 +16,25 @@ type Player struct { session *dca.EncodeSession playing bool stop bool + pause bool // loop bool } -func (p *Player) Add(t soundcloud.Track) { +func (p *Player) Next(tr ...soundcloud.Track) { p.tracksM.Lock() defer p.tracksM.Unlock() - p.tracks = append(p.tracks, t) + if p.playing && len(p.tracks) != 0 { + tr = append(soundcloud.Tracks{p.tracks[0]}, tr...) + p.tracks = append(tr, p.tracks[1:]...) + } else { + p.tracks = append(tr, p.tracks...) + } +} + +func (p *Player) Append(tr ...soundcloud.Track) { + p.tracksM.Lock() + defer p.tracksM.Unlock() + p.tracks = append(p.tracks, tr...) } func (p *Player) Pop() { @@ -36,7 +48,7 @@ func (p *Player) Pop() { func (p *Player) Loop() { p.tracksM.Lock() defer p.tracksM.Unlock() - if len(p.tracks) != 0 { + if len(p.tracks) > 1 { t := p.tracks[0] p.tracks = p.tracks[1:] p.tracks = append(p.tracks, t) @@ -63,3 +75,16 @@ func (p *Player) Shuffle() { rand.Shuffle(len(ts), func(i, j int) { ts[i], ts[j] = ts[j], ts[i] }) p.tracks = append(soundcloud.Tracks{t}, ts...) } + +func (p *Player) Clear() { + p.tracksM.Lock() + defer p.tracksM.Unlock() + if len(p.tracks) == 0 { + return + } + if p.playing { + p.tracks = soundcloud.Tracks{p.tracks[0]} + } else { + p.tracks = soundcloud.Tracks{} + } +} diff --git a/bot/router.go b/bot/router.go index 8a4525c..0ec7fca 100644 --- a/bot/router.go +++ b/bot/router.go @@ -1,164 +1,63 @@ package bot import ( - "fmt" "strings" "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog/log" ) +// ack tells whether or not the bot should react to a message +// Basically it checks whether the command was issued in the public or control +// channel, if the message contains the defined prefix, and if it didn't react +// to its own message func (b *BotInstance) ack(s *discordgo.Session, m *discordgo.MessageCreate) bool { return strings.HasPrefix(m.Content, b.conf.Bot.Prefix) && (m.ChannelID == b.conf.Bot.Channels.Public || m.ChannelID == b.conf.Bot.Channels.Control) && m.Author.ID != s.State.User.ID } +// restricted will return true if the message was posted in the control channel +func (b *BotInstance) restricted(m *discordgo.MessageCreate) bool { + return m.ChannelID == b.conf.Bot.Channels.Control +} + +// MessageCreated is the main handler and will act as a router for all the +// commands func (b *BotInstance) MessageCreated(s *discordgo.Session, m *discordgo.MessageCreate) { if !b.ack(s, m) { return } fields := strings.Fields(m.Content) - if len(fields) < 2 { - if _, err := s.ChannelMessageSend(m.ChannelID, "TODO:usage"); err != nil { - log.Err(err).Msg("unable to send usage message") - } - return + b.HelpHandler(m) } + args := fields[2:] switch fields[1] { + case "next", "n": + b.NextHandler(m, args) + case "help", "h": + b.HelpHandler(m) case "join", "j": b.JoinHandler(m) case "leave", "l": b.LeaveHandler(m) case "queue", "q": - b.QueueHandler(m, fields[2:]) + b.QueueHandler(m, args) case "add", "a": - b.AddHandler(m, fields[2:]) - case "shuffle", "s": - b.ShuffleHandler(m) + b.AddHandler(m, args) case "pause": - b.PauseHandler() + b.PauseHandler(m) case "play": - b.PlayHandler() + b.PlayHandler(m) case "skip": b.SkipHandler(m) case "resume": - b.ResumeHandler() + b.ResumeHandler(m) case "stop": - b.StopHandler() - case "info", "i": - b.InfoHandler(m, fields[2:]) - } -} - -func (b *BotInstance) ShuffleHandler(m *discordgo.MessageCreate) { - b.Player.Shuffle() - b.SendNamedNotice(m, "Requested by", "🎲 Shuffle!", fmt.Sprintf("I shuffled %d tracks for you.", len(b.Player.tracks)), "") - if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { - b.log.Err(err).Msg("unable to delete user message") - } -} - -func (b *BotInstance) AddHandler(m *discordgo.MessageCreate, args []string) { - if len(args) < 1 { - b.SendNotice("", fmt.Sprintf("Usage: `%s `", b.conf.Bot.Prefix), "", m.ChannelID) - return - } - - url := args[0] - url = strings.Trim(url, "<>") - if !strings.HasPrefix(url, "https://soundcloud.com") { - b.SendNotice("", "This doesn't look like a SoundCloud URL", "", m.ChannelID) - return - } - - b.AddToQueue(m, url) - if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { - b.log.Err(err).Msg("unable to delete user message") - } -} - -func (b *BotInstance) QueueHandler(m *discordgo.MessageCreate, args []string) { - b.DisplayQueue(m) - if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { - b.log.Err(err).Msg("unable to delete user message") - } -} - -func (b *BotInstance) PlayHandler() { - b.PlayQueue() -} - -func (b *BotInstance) PauseHandler() { - if b.Player.playing { - b.Player.stream.SetPaused(true) - } -} - -func (b *BotInstance) ResumeHandler() { - if b.Player.playing { - b.Player.stream.SetPaused(false) - } -} - -func (b *BotInstance) StopHandler() { - if b.Player.playing { - b.Player.session.Stop() // nolint:errcheck - b.Player.stop = true - } -} - -func (b *BotInstance) SkipHandler(m *discordgo.MessageCreate) { - if b.Player.playing { - b.Player.session.Stop() // nolint:errcheck - b.SendNamedNotice(m, "Requested by", "⏭️ Skip", "The currently playing track has been skipped", "Note: This can take a few seconds") - b.Session.ChannelMessageDelete(m.ChannelID, m.ID) - } else { - b.Player.Pop() - b.SendNamedNotice(m, "Requested by", "⏭️ Skip", "The next track in queue has been skipped", "") - b.Session.ChannelMessageDelete(m.ChannelID, m.ID) - } -} - -func (b *BotInstance) InfoHandler(m *discordgo.MessageCreate, args []string) { - if len(args) < 1 { - //TODO:Print usage - } - url := args[0] - url = strings.Trim(url, "<>") - if !strings.HasPrefix(url, "https://soundcloud.com") { - if _, err := b.Session.ChannelMessageSend(m.ChannelID, "This doesn't look like a Soundcloud URL"); err != nil { - log.Err(err).Msg("unable to send usage message") - } - return - } - b.handleURL(b.Session, m, url) -} - -func (b *BotInstance) JoinHandler(m *discordgo.MessageCreate) { - b.log.Debug().Str("user", m.Author.Username).Str("method", "join").Msg("called") - if b.Voice == nil { - voice, err := b.Session.ChannelVoiceJoin(b.conf.Bot.Guild, b.conf.Bot.Channels.Voice, false, true) - if err != nil { - log.Fatal().Err(err).Msg("unable to initiate voice connection") - } - b.Voice = voice - b.log.Debug().Str("user", m.Author.Username).Str("method", "join").Msg("bot joined vocal channel") - } -} - -func (b *BotInstance) LeaveHandler(m *discordgo.MessageCreate) { - b.log.Debug().Str("user", m.Author.Username).Str("method", "leave").Msg("called") - if b.Voice != nil { - if err := b.Voice.Disconnect(); err != nil { - b.log.Error().Err(err).Msg("unable to disconnect from voice channel") - return - } - b.Voice = nil - b.log.Debug().Str("user", m.Author.Username).Str("method", "leave").Msg("bot left vocal channel") - return + b.StopHandler(m) + case "vote": + b.VoteHandler(m) } } diff --git a/bot/track.go b/bot/track.go index 76c3b57..7cec1bd 100644 --- a/bot/track.go +++ b/bot/track.go @@ -5,76 +5,11 @@ import ( "strconv" "time" - "github.com/Depado/soundcloud" "github.com/bwmarrin/discordgo" "github.com/hako/durafmt" - "github.com/rs/zerolog/log" ) -func handlePlaylist(s *discordgo.Session, m *discordgo.MessageCreate, pls *soundcloud.PlaylistService) { - pl, err := pls.Get() - if err != nil { - log.Err(err).Msg("unable to fetch playlist info") - return - } - - msg := "```" - for _, t := range pl.Tracks { - msg += fmt.Sprintf("\n!add <%s>", t.PermalinkURL) - } - msg += "\n```" - s.ChannelMessageSend(m.ChannelID, msg) -} - -func (b *BotInstance) handleTrack(s *discordgo.Session, m *discordgo.MessageCreate, ts *soundcloud.TrackService, t *soundcloud.Track) { - e := &discordgo.MessageEmbed{ - Title: t.Title, - URL: t.PermalinkURL, - Image: &discordgo.MessageEmbedImage{URL: t.ArtworkURL}, - Author: &discordgo.MessageEmbedAuthor{ - IconURL: t.User.AvatarURL, - Name: t.User.Username, - URL: t.User.PermalinkURL, - }, - Description: t.Description, - Color: 0xff5500, - Fields: []*discordgo.MessageEmbedField{ - {Name: "Plays", Value: strconv.Itoa(t.PlaybackCount), Inline: true}, - {Name: "Likes", Value: strconv.Itoa(t.LikesCount), Inline: true}, - {Name: "Reposts", Value: strconv.Itoa(t.RepostsCount), Inline: true}, - {Name: "Duration", Value: durafmt.Parse(time.Duration(t.Duration) * time.Millisecond).LimitFirstN(2).String(), Inline: true}, - }, - Footer: &discordgo.MessageEmbedFooter{ - Text: t.ReleaseDate.String(), - }, - } - - _, err := s.ChannelMessageSendEmbed(m.ChannelID, e) - if err != nil { - log.Err(err).Msg("unable to send embed") - } - - // s.MessageReactionAdd(m.ChannelID, msg.ID, "⏏️") - // s.MessageReactionAdd(m.ChannelID, msg.ID, "▶️") -} - -func (b *BotInstance) handleURL(s *discordgo.Session, m *discordgo.MessageCreate, url string) { - pls, err := b.Soundcloud.Playlist().FromURL(url) - if err == nil { - handlePlaylist(s, m, pls) - return - } - ts, t, err := b.Soundcloud.Track().FromURL(url) - if err == nil { - b.handleTrack(s, m, ts, t) - return - } - if _, err := s.ChannelMessageSend(m.ChannelID, "This is not a track or a playlist"); err != nil { - log.Err(err).Msg("unable to send usage message") - } -} - -func (b *BotInstance) AddToQueue(m *discordgo.MessageCreate, url string) { +func (b *BotInstance) AddToQueue(m *discordgo.MessageCreate, url string, next bool) { pls, err := b.Soundcloud.Playlist().FromURL(url) if err == nil { pl, err := pls.Get() @@ -82,9 +17,13 @@ func (b *BotInstance) AddToQueue(m *discordgo.MessageCreate, url string) { b.log.Err(err).Msg("unable to get playlist details") return } - for _, t := range pl.Tracks { - b.Player.Add(t) + + if next { + b.Player.Next(pl.Tracks...) + } else { + b.Player.Append(pl.Tracks...) } + e := &discordgo.MessageEmbed{ Title: pl.Title, URL: pl.PermalinkURL, @@ -99,19 +38,50 @@ func (b *BotInstance) AddToQueue(m *discordgo.MessageCreate, url string) { {Name: "Tracks", Value: strconv.Itoa(len(pl.Tracks)), Inline: true}, {Name: "Duration", Value: durafmt.Parse(time.Duration(pl.Duration) * time.Millisecond).LimitFirstN(2).String(), Inline: true}, }, - Description: fmt.Sprintf("Added **%d** tracks to queue", len(pl.Tracks)), - Thumbnail: &discordgo.MessageEmbedThumbnail{URL: pl.ArtworkURL}, + Thumbnail: &discordgo.MessageEmbedThumbnail{URL: pl.ArtworkURL}, + } + + if next { + e.Description = fmt.Sprintf("Added **%d** tracks to start of queue", len(pl.Tracks)) + } else { + e.Description = fmt.Sprintf("Added **%d** tracks to end of queue", len(pl.Tracks)) } if _, err = b.Session.ChannelMessageSendEmbed(m.ChannelID, e); err != nil { - log.Err(err).Msg("unable to send embed") + b.log.Err(err).Msg("unable to send embed") } return } _, t, err := b.Soundcloud.Track().FromURL(url) if err == nil { - b.Player.Add(*t) - b.SendNotice("", "Added one track to queue", "", m.ChannelID) + e := &discordgo.MessageEmbed{ + Title: t.Title, + URL: t.PermalinkURL, + Color: 0xff5500, + Author: &discordgo.MessageEmbedAuthor{ + IconURL: t.User.AvatarURL, + Name: t.User.Username, + URL: t.User.PermalinkURL, + }, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Plays", Value: strconv.Itoa(t.PlaybackCount), Inline: true}, + {Name: "Likes", Value: strconv.Itoa(t.LikesCount), Inline: true}, + {Name: "Reposts", Value: strconv.Itoa(t.RepostsCount), Inline: true}, + {Name: "Duration", Value: durafmt.Parse(time.Duration(t.Duration) * time.Millisecond).LimitFirstN(2).String(), Inline: true}, + {Name: "Added by", Value: fmt.Sprintf("<@%s>", m.Author.ID), Inline: false}, + }, + Thumbnail: &discordgo.MessageEmbedThumbnail{URL: t.ArtworkURL}, + } + if next { + b.Player.Next(*t) + e.Description = "**Track added to start of queue**" + } else { + b.Player.Append(*t) + e.Description = "**Track added to end of queue**" + } + if _, err = b.Session.ChannelMessageSendEmbed(m.ChannelID, e); err != nil { + b.log.Err(err).Msg("unable to send embed") + } return } b.SendNotice("", "This is not a track or a playlist", "", m.ChannelID) diff --git a/bot/vote.go b/bot/vote.go new file mode 100644 index 0000000..cb7b4a0 --- /dev/null +++ b/bot/vote.go @@ -0,0 +1,64 @@ +package bot + +import ( + "fmt" + "sync" + + "github.com/bwmarrin/discordgo" +) + +const ( + voteThreshold = 2 +) + +type VoteHolder struct { + sync.RWMutex + Voters map[string]bool +} + +func (v *VoteHolder) Reset() { + v.Lock() + defer v.Unlock() + v.Voters = make(map[string]bool) +} + +func (b *BotInstance) VoteHandler(m *discordgo.MessageCreate) { + b.Vote.Lock() + defer b.Vote.Unlock() + + if _, ok := b.Vote.Voters[m.Author.ID]; ok { + return + } + b.Vote.Voters[m.Author.ID] = true + e := &discordgo.MessageEmbed{ + Description: fmt.Sprintf("<@%s> voted to skip this track", m.Author.ID), + Color: 0xff5500, + } + + if len(b.Vote.Voters) >= 2 { + if b.Player.playing { + b.Player.session.Stop() // nolint:errcheck + e.Description += fmt.Sprintf("\n%d total votes, skipping current track", len(b.Vote.Voters)) + e.Footer = &discordgo.MessageEmbedFooter{ + Text: "Note: This may take a few seconds", + } + } else { + b.Player.Pop() + e.Description += fmt.Sprintf("\n%d total votes, the next track in queue has been skipped", len(b.Vote.Voters)) + } + b.Vote.Voters = make(map[string]bool) + } else { + if b.Player.playing { + e.Description += fmt.Sprintf("\n%d/%d votes to skip this track", len(b.Vote.Voters), voteThreshold) + } else { + e.Description += fmt.Sprintf("\n%d/%d votes to skip the next track", len(b.Vote.Voters), voteThreshold) + } + } + + if _, err := b.Session.ChannelMessageSendEmbed(m.ChannelID, e); err != nil { + b.log.Err(err).Msg("unable to send embed") + } + if err := b.Session.ChannelMessageDelete(m.ChannelID, m.ID); err != nil { + b.log.Err(err).Msg("unable to delete user message") + } +}