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