395 lines
10 KiB
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",
|
|
},
|
|
})
|
|
}
|