discord-radio-bot/main.go

596 lines
17 KiB
Go

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"
)
// Station represents a radio station
type Station struct {
Name string // Display name
StreamURL string // URL for the audio stream
Description string // Optional description
}
var (
token string
stations = map[string]Station{
"europafm": {
Name: "Europa FM",
StreamURL: "https://atres-live.europafm.com/live/europafm/bitrate_1.m3u8",
Description: "La radio de éxitos musicales",
},
"cope": {
Name: "COPE",
StreamURL: "http://flucast31-h-cloud.flumotion.com/cope/net1.mp3",
Description: "A tope con la",
},
}
logger *slog.Logger
debug bool
guildID string // For testing in a specific guild
currentStation string // Track current playing station
)
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 "radio":
handleRadioCommand(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: "radio",
Description: "Controla la transmisión de radio",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "play",
Description: "Comienza a transmitir una radio en tu canal de voz",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "station",
Description: "Estación de radio a reproducir",
Required: true,
Choices: getStationChoices(),
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "stop",
Description: "Detiene la transmisión y abandona el canal de voz",
},
},
}
// 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 {
if vc.Disconnect() != nil {
logger.Error("Error disconnecting from voice channel", "error", err)
}
}
// 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(_ *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 handleRadioCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
options := i.ApplicationCommandData().Options
if len(options) == 0 {
logger.Warn("Received radio 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: "Procesando tu solicitud...",
},
})
if err != nil {
logger.Error("Error responding to interaction", "error", err)
return
}
switch subCommand {
case "play":
// Check if station is specified
stationName := "europafm" // Default value
if len(options[0].Options) > 0 {
stationName = options[0].Options[0].StringValue()
}
// Get station URL
station, ok := stations[stationName]
if !ok {
logger.Warn("Unknown station requested", "station", stationName)
followupMsg(s, i, "Estación de radio desconocida.")
return
}
logger = logger.With("station", stationName, "station_name", station.Name)
// Find the guild for that channel
guildID := i.GuildID
logger = logger.With("guild_id", guildID)
// Check if we're already connected to a voice channel in this guild
existingVC, err := findExistingVoiceConnection(ctx, s, guildID)
if err == nil && existingVC != nil {
// We're already connected, disconnect first
logger.Info("Already connected to voice channel, disconnecting first")
if err := existingVC.Disconnect(); err != nil {
logger.Error("Error disconnecting from voice channel", "error", err)
}
// Give Discord a moment to process the disconnection
time.Sleep(1 * time.Second)
}
// 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, "Necesitas estar en un canal de voz para usar este comando.")
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 al unirse al canal de voz. Por favor, inténtalo de nuevo.")
return
}
// Wait for connection to be ready
time.Sleep(2 * time.Second)
// Update bot status to show the current station
currentStation = stationName
updateBotStatus(s, stationName)
// Update message
followupMsg(s, i, fmt.Sprintf("¡Ahora transmitiendo la radio %s en tu canal de voz!", station.Name))
// 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, station.StreamURL, stopChan, logger)
if err != nil {
logger.Error("Audio streaming error", "error", err)
followupMsg(s, i, "La transmisión de audio ha terminado inesperadamente. Por favor, inténtalo más tarde.")
// Clear status if streaming ends with error
currentStation = ""
updateBotStatus(s, "")
}
}()
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, "No hay transmisión activa en ningún canal.")
return
}
logger.Info("Disconnecting from voice channel")
if vc.Disconnect() != nil {
logger.Error("Error disconnecting from voice channel", "error", err)
}
// Clear the bot status
currentStation = ""
updateBotStatus(s, "")
followupMsg(s, i, "Desconectado del canal de voz.")
}
}
// updateBotStatus updates the bot's status to show the current station
func updateBotStatus(s *discordgo.Session, stationName string) {
var status string
if stationName != "" {
station, ok := stations[stationName]
if ok {
status = station.Name
}
}
err := s.UpdateStatusComplex(discordgo.UpdateStatusData{
Status: "online",
Activities: []*discordgo.Activity{
{
Name: status,
Type: discordgo.ActivityTypeListening,
},
},
})
if err != nil {
logger.Error("Error updating bot status", "error", err, "station", stationName)
} else {
logger.Info("Bot status updated", "status", status)
}
}
// 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(_ 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(_ 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")
}
// getStationChoices generates the choices for the command options based on the stations map
func getStationChoices() []*discordgo.ApplicationCommandOptionChoice {
choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(stations))
for key, station := range stations {
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{
Name: station.Name,
Value: key,
})
}
return choices
}