package main import ( "context" "flag" "fmt" "log/slog" "os" "os/exec" "os/signal" "strings" "syscall" "time" "github.com/bwmarrin/dgvoice" "github.com/bwmarrin/discordgo" "github.com/lmittmann/tint" ) var ( token string radioURL = "https://atres-live.europafm.com/live/europafm/bitrate_1.m3u8" logger *slog.Logger debug bool guildID string // For testing in a specific guild ) func init() { flag.StringVar(&token, "token", "", "Discord Bot Token") flag.BoolVar(&debug, "debug", false, "Enable debug logging") flag.StringVar(&guildID, "guild", "", "Specific guild ID for command registration (leave empty for global commands)") flag.Parse() // Initialize logger with tint var logLevel slog.Level if debug { logLevel = slog.LevelDebug } else { logLevel = slog.LevelInfo } logger = slog.New(tint.NewHandler(os.Stderr, &tint.Options{ Level: logLevel, TimeFormat: time.RFC3339, })) slog.SetDefault(logger) } func main() { logger.Info("Starting EuropaFM Discord bot") if token == "" { token = os.Getenv("DISCORD_TOKEN") if token == "" { logger.Error("No token provided. Please provide it via -token flag or DISCORD_TOKEN environment variable") return } } // Check for guild-specific deployment from environment if guildID == "" { guildID = os.Getenv("GUILD_ID") if guildID != "" { logger.Info("Using guild-specific deployment from environment variable", "guild_id", guildID) } } // Create a new Discord session using the provided bot token dg, err := discordgo.New("Bot " + token) if err != nil { logger.Error("Error creating Discord session", "error", err) return } // Enable debug logging for DiscordGo if debug flag is set if debug { dg.LogLevel = discordgo.LogDebug dg.Debug = true logger.Debug("DiscordGo debug logging enabled") } // Register command handlers dg.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { logInteraction(s, i) if i.Type != discordgo.InteractionApplicationCommand { logger.Debug("Ignoring non-application command interaction", "type", i.Type, "guild_id", i.GuildID) return } cmdName := i.ApplicationCommandData().Name logger.Info("Received command interaction", "command", cmdName, "user", i.Member.User.Username, "guild_id", i.GuildID) switch cmdName { case "europafm": handleEuropaFMCommand(s, i) default: logger.Warn("Unknown command received", "command", cmdName) } }) // Add handlers for checking interactions and commands dg.AddHandler(logRawInteraction) // Add a handler for connection events dg.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { logger.Info("Bot is ready", "username", r.User.Username, "discriminator", r.User.Discriminator, "id", r.User.ID, "session_id", r.SessionID) // Log guilds the bot is connected to logger.Info("Connected to guilds", "count", len(r.Guilds)) for i, guild := range r.Guilds { if i < 10 { // Limit to 10 guilds in logs logger.Debug("Connected to guild", "name", guild.Name, "id", guild.ID) } } }) // Set necessary intents dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates | discordgo.IntentsGuildMessages // Open a websocket connection to Discord logger.Info("Opening connection to Discord") err = dg.Open() if err != nil { logger.Error("Error opening connection", "error", err) return } // Get application info to verify bot identity app, err := dg.Application("@me") if err != nil { logger.Error("Error getting application info", "error", err) } else { logger.Info("Bot application info", "id", app.ID, "name", app.Name, "public", app.BotPublic, "require_code_grant", app.BotRequireCodeGrant) } // Register slash commands targetID := "" // For global commands if guildID != "" { // For guild-specific commands (faster updates during testing) targetID = guildID logger.Info("Registering commands for specific guild", "guild_id", guildID) } else { logger.Info("Registering global commands (may take up to an hour to propagate)") } // Check existing commands existingCmds, err := dg.ApplicationCommands(dg.State.User.ID, targetID) if err != nil { logger.Error("Error fetching existing commands", "error", err) } else { logger.Info("Found existing commands that will be deleted", "count", len(existingCmds)) for _, cmd := range existingCmds { logger.Debug("Found existing command", "name", cmd.Name, "id", cmd.ID, "description", cmd.Description) // Delete existing command logger.Info("Deleting existing command", "name", cmd.Name, "id", cmd.ID) err := dg.ApplicationCommandDelete(dg.State.User.ID, targetID, cmd.ID) if err != nil { logger.Error("Error deleting command", "name", cmd.Name, "id", cmd.ID, "error", err) } } // Wait a bit for Discord to process the deletions logger.Info("Waiting for command deletions to process...") time.Sleep(2 * time.Second) } // Define the command cmd := &discordgo.ApplicationCommand{ Name: "europafm", Description: "Control EuropaFM radio streaming", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "play", Description: "Start streaming EuropaFM radio in your voice channel", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "stop", Description: "Stop streaming and leave the voice channel", }, }, } // Register the command registeredCmd, err := dg.ApplicationCommandCreate(dg.State.User.ID, targetID, cmd) if err != nil { logger.Error("Error creating slash command", "error", err, "command", cmd.Name, "details", err.Error()) } else { logger.Info("Slash command registered successfully", "command", registeredCmd.Name, "id", registeredCmd.ID, "target_id", targetID) } // Verify registered commands time.Sleep(1 * time.Second) // Give Discord API a moment to catch up verifyCommands, err := dg.ApplicationCommands(dg.State.User.ID, targetID) if err != nil { logger.Error("Error fetching commands for verification", "error", err) } else { logger.Info("Verified registered commands", "count", len(verifyCommands)) for _, cmd := range verifyCommands { logger.Debug("Verified command", "name", cmd.Name, "id", cmd.ID) } } // Wait for the signal to terminate logger.Info("Bot is now running. Press CTRL-C to exit.") sc := make(chan os.Signal, 1) signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) <-sc // Cleanup before exit logger.Info("Shutting down...") // Disconnect from all voice channels for _, vc := range dg.VoiceConnections { vc.Disconnect() } // Close the Discord session dg.Close() logger.Info("Shutdown complete") } // Log handler for raw interaction events func logRawInteraction(s *discordgo.Session, e *discordgo.Event) { if !debug { return } if strings.HasPrefix(e.Type, "INTERACTION") { logger.Debug("Raw interaction event received", "type", e.Type, "operation", e.Operation) } } // Detailed logging for interactions func logInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { if !debug { return } logger.Debug("Interaction details", "id", i.ID, "application_id", i.AppID, "type", i.Type, "token", i.Token[:5]+"...", // Log only first few chars of token for security "guild_id", i.GuildID, "channel_id", i.ChannelID, "member", i.Member != nil, "user", i.User != nil) } func handleEuropaFMCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { ctx := context.Background() options := i.ApplicationCommandData().Options if len(options) == 0 { logger.Warn("Received europafm command with no subcommand", "interaction_id", i.ID) return } subCommand := options[0].Name logger := logger.With("subcommand", subCommand, "user", i.Member.User.Username, "guild_id", i.GuildID) logger.Info("Processing command") // Respond to the interaction err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Processing your request...", }, }) if err != nil { logger.Error("Error responding to interaction", "error", err) return } switch subCommand { case "play": // Find the guild for that channel guildID := i.GuildID logger := logger.With("guild_id", guildID) // Find the voice channel the user is in vs, err := findUserVoiceState(ctx, s, guildID, i.Member.User.ID) if err != nil { logger.Warn("User not in voice channel", "error", err) followupMsg(s, i, "You need to be in a voice channel to use this command.") return } // Get channel details channel, err := s.Channel(vs.ChannelID) if err != nil { logger.Warn("Failed to get channel details", "channel_id", vs.ChannelID, "error", err) } else { logger = logger.With("channel_name", channel.Name, "channel_id", channel.ID) } // Connect to the voice channel logger.Info("Joining voice channel") vc, err := s.ChannelVoiceJoin(guildID, vs.ChannelID, false, true) if err != nil { logger.Error("Error joining voice channel", "error", err) followupMsg(s, i, "Error joining voice channel. Please try again.") return } // Wait for connection to be ready time.Sleep(2 * time.Second) // Update message followupMsg(s, i, "Now streaming EuropaFM radio in your voice channel!") // Start streaming - this is a blocking call, so we run it in a goroutine logger.Info("Starting audio stream") go func() { // Create a stop channel that's never closed for continuous streaming stopChan := make(chan bool) // Log when streaming ends (should only happen if there's an error) defer logger.Info("Audio streaming ended") // Start playing audio with enhanced error logging err := playAudioStream(vc, radioURL, stopChan, logger) if err != nil { logger.Error("Audio streaming error", "error", err) followupMsg(s, i, "The audio stream has ended unexpectedly. Please try again later.") } }() case "stop": // Find voice connection and disconnect vc, err := findExistingVoiceConnection(ctx, s, i.GuildID) if err != nil { logger.Info("No active voice connection found", "error", err) followupMsg(s, i, "Not currently streaming in any channel.") return } logger.Info("Disconnecting from voice channel") vc.Disconnect() followupMsg(s, i, "Disconnected from voice channel.") } } // Helper function to send followup messages func followupMsg(s *discordgo.Session, i *discordgo.InteractionCreate, content string) { _, err := s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ Content: content, }) if err != nil { logger.Error("Error sending followup message", "error", err, "content", content) } } func findUserVoiceState(ctx context.Context, s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) { guild, err := s.State.Guild(guildID) if err != nil { logger.Error("Error getting guild", "guild_id", guildID, "error", err) return nil, err } for _, vs := range guild.VoiceStates { if vs.UserID == userID { return vs, nil } } return nil, fmt.Errorf("user not in any voice channel") } func findExistingVoiceConnection(ctx context.Context, s *discordgo.Session, guildID string) (*discordgo.VoiceConnection, error) { for _, vc := range s.VoiceConnections { if vc.GuildID == guildID { return vc, nil } } return nil, fmt.Errorf("no active voice connection found") } func playAudioStream(vc *discordgo.VoiceConnection, url string, stopChan chan bool, logger *slog.Logger) error { logger.Info("Starting audio stream with enhanced logging", "url", url) // Set up panic recovery defer func() { if r := recover(); r != nil { logger.Error("Recovered from panic in audio stream", "panic", r) } }() // Try to detect what stream format we're dealing with if strings.HasSuffix(url, ".m3u8") { logger.Info("Detected HLS stream format (.m3u8)") } else if strings.HasSuffix(url, ".mp3") { logger.Info("Detected MP3 audio format") } else { logger.Info("Unknown stream format, attempting to play anyway") } // Before we start playing, make sure ffmpeg is available ffmpegPath, err := exec.LookPath("ffmpeg") if err != nil { logger.Error("ffmpeg not found in PATH", "error", err) return fmt.Errorf("ffmpeg not found: %w", err) } logger.Info("Found ffmpeg", "path", ffmpegPath) // Check ffmpeg version versionCmd := exec.Command(ffmpegPath, "-version") versionOutput, err := versionCmd.CombinedOutput() if err != nil { logger.Error("Failed to get ffmpeg version", "error", err) } else { versionLines := strings.Split(string(versionOutput), "\n") if len(versionLines) > 0 { logger.Info("ffmpeg version", "version", versionLines[0]) } } // Try to run a test of ffmpeg with the URL to see if it can connect testCmd := exec.Command(ffmpegPath, "-i", url, "-t", "1", "-f", "null", "-") logger.Info("Testing stream with ffmpeg", "command", testCmd.String()) testOutput, err := testCmd.CombinedOutput() if err != nil { logger.Warn("ffmpeg test returned error but this might be normal", "error", err, "output", string(testOutput)) // Continue anyway - some errors are normal here } else { logger.Info("ffmpeg test succeeded", "output", string(testOutput)) } // Set up a context with timeout for the stream ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Start a goroutine to monitor the stream go func() { select { case <-ctx.Done(): // Context was canceled, nothing to do return case <-time.After(5 * time.Second): // If we're still running after 5 seconds, assume the stream is working logger.Info("Stream appears to be working") } }() // Start the stream logger.Info("Starting audio stream with dgvoice", "url", url) dgvoice.PlayAudioFile(vc, url, stopChan) // If we get here, the stream ended unexpectedly logger.Warn("Audio stream ended unexpectedly") return fmt.Errorf("audio stream ended unexpectedly") }