596 lines
17 KiB
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
|
|
}
|