discord-jukebox-bot/pkg/discord/bot.go

395 lines
10 KiB
Go

package discord
import (
"fmt"
"log/slog"
"sync"
"time"
"discord-jukebox-bot/pkg/config"
"discord-jukebox-bot/pkg/subsonic"
"github.com/bwmarrin/discordgo"
)
// Bot represents a Discord bot with voice capabilities
type Bot struct {
config *config.Config
session *discordgo.Session
subsonic *subsonic.Client
commandsMux sync.RWMutex
commands map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)
voiceConn *discordgo.VoiceConnection
playing bool
stopChan chan struct{}
mu sync.Mutex
}
// New creates a new Discord bot
func New(cfg *config.Config, subsonic *subsonic.Client) (*Bot, error) {
session, err := discordgo.New("Bot " + cfg.DiscordToken)
if err != nil {
return nil, fmt.Errorf("error creating Discord session: %w", err)
}
bot := &Bot{
config: cfg,
session: session,
subsonic: subsonic,
commands: make(map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)),
stopChan: make(chan struct{}),
}
// Set up event handlers
session.AddHandler(bot.onReady)
session.AddHandler(bot.onInteractionCreate)
// Add needed intents
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
return bot, nil
}
// RegisterCommand registers a new slash command handler
func (b *Bot) RegisterCommand(name string, handler func(s *discordgo.Session, i *discordgo.InteractionCreate)) {
b.commandsMux.Lock()
defer b.commandsMux.Unlock()
b.commands[name] = handler
}
// Start starts the Discord bot
func (b *Bot) Start() error {
// Add voice state update handler
b.session.AddHandler(b.onVoiceStateUpdate)
if err := b.session.Open(); err != nil {
return fmt.Errorf("error opening connection: %w", err)
}
// Register slash commands
commands := []*discordgo.ApplicationCommand{
{
Name: "jukebox",
Description: "Control the jukebox",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "play",
Description: "Start playing random music",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "stop",
Description: "Stop playing music",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "skip",
Description: "Skip to the next song",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "info",
Description: "Display information about the current song",
},
},
},
}
for _, cmd := range commands {
// Check if guild ID is provided, create global command if not
guildID := b.config.DiscordGuildID
if guildID == "" {
slog.Warn("No Guild ID provided, registering commands globally")
}
_, err := b.session.ApplicationCommandCreate(b.session.State.User.ID, guildID, cmd)
if err != nil {
return fmt.Errorf("error creating command '%s': %w", cmd.Name, err)
}
}
return nil
}
// Stop stops the Discord bot
func (b *Bot) Stop() error {
slog.Info("Shutting down Discord bot")
// Clear Discord status
if b.session != nil {
b.session.UpdateGameStatus(0, "")
}
// Stop any playing music
b.stopPlaying()
// Clean up voice connection if it exists
b.leaveVoiceChannel()
// Close the Discord session with timeout
disconnectDone := make(chan error)
go func() {
disconnectDone <- b.session.Close()
}()
select {
case err := <-disconnectDone:
if err != nil {
return fmt.Errorf("error closing connection: %w", err)
}
case <-time.After(10 * time.Second):
return fmt.Errorf("timeout closing Discord session")
}
slog.Info("Discord bot shutdown complete")
return nil
}
// JoinVoiceChannel joins a voice channel
func (b *Bot) JoinVoiceChannel(guildID, channelID string) error {
b.mu.Lock()
// Store current playing state if we're already connected
wasPlaying := b.playing && b.voiceConn != nil
// Check if we're already in the requested channel
alreadyInChannel := b.voiceConn != nil && b.voiceConn.ChannelID == channelID
b.mu.Unlock()
// If we're already in this channel, no need to reconnect
if alreadyInChannel {
slog.Info("Already in the requested voice channel", "channel_id", channelID)
return nil
}
// Leave the current voice channel if we're in one
b.leaveVoiceChannel()
// Join the new voice channel
slog.Info("Joining voice channel", "guild_id", guildID, "channel_id", channelID)
vc, err := b.session.ChannelVoiceJoin(guildID, channelID, false, true)
if err != nil {
return fmt.Errorf("error joining voice channel: %w", err)
}
// Wait for the connection to be established
timeout := time.After(10 * time.Second)
for !vc.Ready {
select {
case <-timeout:
b.leaveVoiceChannel()
return fmt.Errorf("timeout waiting for voice connection to be ready")
default:
slog.Debug("Waiting for voice connection to be ready...")
time.Sleep(100 * time.Millisecond)
}
}
slog.Info("Successfully connected to voice channel", "guild_id", guildID, "channel_id", channelID)
b.voiceConn = vc
// Restore playing state if we were playing before
if wasPlaying {
b.mu.Lock()
b.playing = true
b.mu.Unlock()
slog.Info("Restoring playback state after rejoining channel")
}
return nil
}
// leaveVoiceChannel leaves the current voice channel
func (b *Bot) leaveVoiceChannel() {
b.mu.Lock()
defer b.mu.Unlock()
if b.voiceConn != nil {
slog.Info("Leaving voice channel", "channel_id", b.voiceConn.ChannelID)
// Make sure we're not sending audio
b.voiceConn.Speaking(false)
// Disconnect with a timeout
disconnectErr := make(chan error, 1)
go func() {
disconnectErr <- b.voiceConn.Disconnect()
}()
// Wait for disconnect or timeout
select {
case err := <-disconnectErr:
if err != nil {
slog.Error("Error disconnecting from voice channel", "error", err)
}
case <-time.After(5 * time.Second):
slog.Warn("Timeout disconnecting from voice channel")
}
// Cleanup resources
b.voiceConn = nil
slog.Info("Voice channel disconnected")
// Note: We intentionally don't reset the playing state here
// so that if we rejoin, we can resume playing
}
}
// GetSubsonicClient returns the Subsonic client
func (b *Bot) GetSubsonicClient() *subsonic.Client {
return b.subsonic
}
// IsPlaying returns true if the bot is currently playing music
func (b *Bot) IsPlaying() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.playing
}
// stopPlaying stops playing music completely
func (b *Bot) stopPlaying() {
b.mu.Lock()
defer b.mu.Unlock()
if b.playing {
slog.Debug("Stopping playback")
// Signal all audio processors to stop
close(b.stopChan)
// Create a new channel for future use
b.stopChan = make(chan struct{})
// Mark as not playing anymore
b.playing = false
// Update Discord status
if b.session != nil {
err := b.session.UpdateGameStatus(0, "Ready to play music | /jukebox play")
if err != nil {
slog.Warn("Failed to update status after stopping", "error", err)
}
}
slog.Debug("Playback stopped completely")
}
}
// onReady is called when the Discord bot is ready
func (b *Bot) onReady(s *discordgo.Session, event *discordgo.Ready) {
slog.Info("Bot is now running",
"username", s.State.User.Username,
"discriminator", s.State.User.Discriminator,
"guilds", len(event.Guilds))
// Set initial status
err := s.UpdateGameStatus(0, "Ready to play music | /jukebox play")
if err != nil {
slog.Warn("Failed to set initial status", "error", err)
}
}
// onVoiceStateUpdate handles voice state changes
func (b *Bot) onVoiceStateUpdate(s *discordgo.Session, v *discordgo.VoiceStateUpdate) {
b.mu.Lock()
defer b.mu.Unlock()
// If we're not connected to voice, nothing to do
if b.voiceConn == nil {
return
}
// Check if this update is for our bot
if v.UserID == s.State.User.ID {
// If we got disconnected
if v.ChannelID == "" && b.voiceConn != nil && b.voiceConn.ChannelID != "" {
slog.Warn("Bot was disconnected from voice channel",
"previous_channel", b.voiceConn.ChannelID)
// Stop playing and clean up
b.stopPlaying()
b.voiceConn = nil
} else if v.ChannelID != "" && b.voiceConn != nil && v.ChannelID != b.voiceConn.ChannelID {
slog.Info("Bot was moved to different voice channel",
"previous_channel", b.voiceConn.ChannelID,
"new_channel", v.ChannelID)
// Update our voice connection to the new channel
oldChannelID := b.voiceConn.ChannelID
// Store the current playing state before reconnecting
wasPlaying := b.playing
// We need to create a new voice connection for the new channel
err := b.voiceConn.ChangeChannel(v.ChannelID, false, true)
if err != nil {
slog.Error("Failed to update voice channel", "error", err)
// Since changing channel failed, attempt a full reconnect
b.leaveVoiceChannel()
err = b.JoinVoiceChannel(v.GuildID, v.ChannelID)
if err != nil {
slog.Error("Failed to rejoin voice channel after move", "error", err)
return
}
}
slog.Info("Successfully moved to new voice channel",
"from", oldChannelID,
"to", v.ChannelID)
// If we were playing before, and we're not playing now, restore playing state
if wasPlaying && !b.playing {
b.playing = true
slog.Info("Restoring playback state after channel move")
}
}
}
// Check if we're in a voice channel but alone
if b.voiceConn != nil && b.playing {
// Count how many users are in our voice channel
usersInChannel := 0
guild, err := s.State.Guild(b.voiceConn.GuildID)
if err == nil {
for _, state := range guild.VoiceStates {
if state.ChannelID == b.voiceConn.ChannelID {
usersInChannel++
}
}
}
// If the bot is alone (just the bot itself), stop playing and disconnect
if usersInChannel <= 1 {
slog.Info("Bot is alone in voice channel, disconnecting")
b.stopPlaying()
b.leaveVoiceChannel()
}
}
}
// onInteractionCreate is called when a slash command is invoked
func (b *Bot) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
// Handle jukebox commands
if i.ApplicationCommandData().Name == "jukebox" {
options := i.ApplicationCommandData().Options
if len(options) > 0 {
subCommand := options[0].Name
b.commandsMux.RLock()
handler, exists := b.commands[subCommand]
b.commandsMux.RUnlock()
if exists {
handler(s, i)
return
}
}
}
// Unknown command
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Unknown command",
},
})
}