initial
This commit is contained in:
commit
1a4986f294
18 changed files with 3181 additions and 0 deletions
390
pkg/discord/bot.go
Normal file
390
pkg/discord/bot.go
Normal file
|
@ -0,0 +1,390 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
},
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue