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