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