initial bot
This commit is contained in:
		
						commit
						2c7a5dff6c
					
				
					 7 changed files with 747 additions and 0 deletions
				
			
		
							
								
								
									
										23
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal 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
									
								
							
							
						
						
									
										45
									
								
								Dockerfile
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										139
									
								
								README.md
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										12
									
								
								docker-compose.yml
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										18
									
								
								go.mod
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										18
									
								
								go.sum
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										492
									
								
								main.go
									
										
									
									
									
										Normal 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")
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue