initial bot

This commit is contained in:
Felipe M 2025-05-07 10:12:54 +02:00
commit 2c7a5dff6c
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
7 changed files with 747 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
discord-europa-fm
europafm-bot
# Environment files
.env
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work

45
Dockerfile Normal file
View file

@ -0,0 +1,45 @@
FROM golang:1.22-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY *.go ./
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -o discord-europa-fm
# Use a smaller base image for the final stage
FROM alpine:latest
# Install ffmpeg
RUN apk add --no-cache ffmpeg ca-certificates
# Copy the built executable
WORKDIR /app
COPY --from=builder /app/discord-europa-fm .
# Environment variables
ENV DISCORD_TOKEN="" \
DEBUG="" \
GUILD_ID=""
# Run the bot
CMD if [ -n "$DEBUG" ] && [ -n "$GUILD_ID" ]; then \
./discord-europa-fm -debug -guild "$GUILD_ID"; \
elif [ -n "$DEBUG" ]; then \
./discord-europa-fm -debug; \
elif [ -n "$GUILD_ID" ]; then \
./discord-europa-fm -guild "$GUILD_ID"; \
else \
./discord-europa-fm; \
fi

139
README.md Normal file
View file

@ -0,0 +1,139 @@
# Discord EuropaFM Radio Bot
A Discord bot that streams EuropaFM Spanish radio to your voice channel.
## Requirements
- Go 1.22 or higher
- FFmpeg installed on your system
- Discord Bot Token
## Setup
1. Install FFmpeg on your system:
- Ubuntu/Debian: `sudo apt install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [FFmpeg website](https://ffmpeg.org/download.html)
2. Create a Discord Bot:
- Go to [Discord Developer Portal](https://discord.com/developers/applications)
- Create a new application
- Go to the "Bot" tab and click "Add Bot"
- Under "Privileged Gateway Intents", enable "Server Members Intent"
- Copy your bot token
3. Invite the bot to your server:
- Go to OAuth2 > URL Generator
- Select "bot" and "applications.commands" scopes
- Select permissions: "Connect", "Speak", "Send Messages"
- Open the generated URL and authorize the bot for your server
4. Get your Server ID (for guild-specific command registration):
- Enable Developer Mode in Discord (User Settings > Advanced > Developer Mode)
- Right-click on your server name in the server list and select "Copy ID"
- Alternatively, you can see the server ID in the bot's debug logs when it connects
## Usage
### Running from source
1. Clone this repository
2. Run the bot with your Discord token:
```
DISCORD_TOKEN=your_token_here go run main.go
```
Or
```
go run main.go -token your_token_here
```
### Command-line options
The bot accepts the following command-line options:
- `-token string`: Discord bot token (can also be set via DISCORD_TOKEN environment variable)
- `-debug`: Enable debug logging for more verbose output (helpful for troubleshooting)
- `-guild string`: Register commands for a specific guild (server) instead of globally - this is faster for testing (commands update instantly)
Example with debug mode and guild-specific registration:
```
go run main.go -token your_token_here -debug -guild your_server_id
```
### Using Docker
1. Build the Docker image:
```
docker build -t discord-europa-fm .
```
2. Run the container:
```
docker run -e DISCORD_TOKEN=your_token_here discord-europa-fm
```
With debug mode:
```
docker run -e DISCORD_TOKEN=your_token_here -e DEBUG=1 discord-europa-fm
```
With guild-specific command registration:
```
docker run -e DISCORD_TOKEN=your_token_here -e GUILD_ID=your_server_id discord-europa-fm
```
Alternatively, use docker-compose:
1. Create a `.env` file with your Discord token:
```
DISCORD_TOKEN=your_token_here
DEBUG=1
GUILD_ID=your_server_id # Optional: for guild-specific command registration
```
2. Run with docker-compose:
```
docker-compose up -d
```
## Commands
Once the bot is running, you can use the following slash commands in your Discord server:
- `/europafm play` - Start streaming EuropaFM radio in your voice channel
- `/europafm stop` - Stop streaming and leave the voice channel
## Troubleshooting
If you're having audio issues:
1. Make sure FFmpeg is properly installed and available in your system PATH
2. Verify that the bot has permissions to join and speak in voice channels
3. Check that you're in a voice channel before using the play command
4. Make sure the radio stream URL is still valid
### Unknown Integration Error
If you see "Unknown Integration" when using slash commands, it might be because:
1. The bot is offline or not properly connected to Discord
2. The slash commands haven't been properly registered with Discord
3. The bot may need permissions it doesn't currently have
Solutions to try:
1. Run the bot with `-debug` flag to get more detailed logs about what's happening
2. Use guild-specific command registration with `-guild your_server_id` for faster updates (global commands can take up to an hour to propagate)
3. Check if your bot has the "applications.commands" scope when you invited it to your server
4. Try re-inviting the bot with the correct permissions and scopes
5. Verify that your bot token is correct and not expired
6. Check the Discord Developer Portal to make sure your application has the bot feature enabled

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
version: "3"
services:
bot:
build: .
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN}
- DEBUG=${DEBUG:-}
- GUILD_ID=${GUILD_ID:-}
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro # Sync container time with host

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module github.com/fmartingr/discord-europa-fm
go 1.23.0
toolchain go1.24.2
require (
github.com/bwmarrin/dgvoice v0.0.0-20210225172318-caaac756e02e
github.com/bwmarrin/discordgo v0.28.1
github.com/lmittmann/tint v1.0.7
)
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 // indirect
)

18
go.sum Normal file
View file

@ -0,0 +1,18 @@
github.com/bwmarrin/dgvoice v0.0.0-20210225172318-caaac756e02e h1:IdfGDWLNL/ZAHda33oiKnkoaWWsQ6vyO+MDslrcJ43M=
github.com/bwmarrin/dgvoice v0.0.0-20210225172318-caaac756e02e/go.mod h1:DT3heoMAQGrOExZ3Rb3TBOQ4Bm+wD4H48KFnt1YfLoQ=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y=
github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 h1:/S1gOotFo2sADAIdSGk1sDq1VxetoCWr6f5nxOG0dpY=
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32/go.mod h1:yDtyzWZDFCVnva8NGtg38eH2Ns4J0D/6hD+MMeUGdF0=

492
main.go Normal file
View file

@ -0,0 +1,492 @@
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"
backupRadioURL = "https://playerservices.streamtheworld.com/api/livestream-redirect/EUROPA_FM.mp3"
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")
// If primary URL fails and it's not the backup URL, try the backup URL
if url == radioURL && backupRadioURL != "" {
logger.Info("Primary URL failed, trying backup URL", "backup_url", backupRadioURL)
dgvoice.PlayAudioFile(vc, backupRadioURL, stopChan)
// If we get here, the backup stream also ended unexpectedly
logger.Warn("Backup audio stream also ended unexpectedly")
}
return fmt.Errorf("audio stream ended unexpectedly")
}