discord music bot
A music bot for Discord written in Go. Plays songs and playlists in a voice channel through Lavalink, with the usual set of commands you'd expect: queue, skip, pause, seek, volume, loop, shuffle.
Why Go
Discord bots are usually a Node or Python thing, but I wanted something that starts instantly, uses barely any memory while idle, and doesn't need a runtime installed on the host. Go fits that well, and disgo turned out to be a solid, well typed library to build on top of.
Stack
- disgo for the Discord gateway and REST client
- disgolink to talk to Lavalink and manage player state
- Lavalink does the actual audio streaming and decoding, running as a separate process
- godotenv for loading
.envfiles in local development - the standard
slogpackage for logging, nothing fancier than that
How it's organized
cmd/bot entry point, wires everything together and starts the bot
internal/config reads and validates environment variables
internal/logging slog setup
internal/lavalink connects to the Lavalink node
internal/discord disgo client, voice event handling, command router
internal/commands one file per slash command
internal/music per guild queue and player, no Discord imports at all
internal/music is kept independent from disgo on purpose. It's the part with actual logic (queueing, shuffling, looping) so it made sense to be able to test it without spinning up a bot or a Lavalink server. That's what queue_test.go covers.
Running it
You need Go 1.27+ and a Lavalink server. Lavalink is just a jar file, running it locally only needs Java 17+:
java -jar Lavalink.jar
By default that listens on localhost:2333 with password youshallnotpass, both configurable in Lavalink's own application.yml.
Then copy the example env file and fill in your bot token:
cp .env.example .env
| Variable | Required | What it's for |
|---|---|---|
DISCORD_TOKEN |
yes | your bot's token from the Discord Developer Portal |
LAVALINK_ADDRESS |
yes | host:port of the Lavalink node |
LAVALINK_PASSWORD |
yes | Lavalink node password |
LAVALINK_NODE_NAME |
no | just an internal label, defaults to main |
LAVALINK_SECURE |
no | set to true if the node uses wss:// |
DEBUG |
no | true turns on debug level logs |
Never commit your .env. If a token leaks anywhere (a commit, a screenshot, whatever) go revoke it right away from the Developer Portal and generate a new one.
Once configured:
go run ./cmd/bot
Commands register globally on startup, which means the bot works in every server it's in without extra setup, though Discord can take up to an hour to propagate a fresh global registration. Existing servers keep working fine while that happens.
Commands
| Command | Does what |
|---|---|
/play query |
searches and plays a song or a whole playlist, or queues it if something's already playing |
/skip |
skips the current song |
/stop |
stops everything, clears the queue and leaves the channel |
/pause |
pauses |
/resume |
resumes |
/queue |
lists what's coming up |
/nowplaying |
shows the current track |
/seek position |
jumps to a spot in the track, accepts seconds, mm:ss or hh:mm:ss |
/volume level |
sets volume from 0 to 200 percent, default is 100 |
/loop mode |
off, track to repeat the current song, or queue to send it to the back once it ends |
/shuffle |
shuffles the upcoming tracks |
/remove position |
removes a track by its position in /queue |
/play takes a direct link or plain text, which gets searched on YouTube through ytsearch:. If your Lavalink instance doesn't have YouTube configured you can pass a link from whatever source it does support, or prefix your search manually, scsearch: for SoundCloud for example.
Queueing a playlist link adds every track from it: if nothing's playing yet the first one starts right away and the rest go into the queue, otherwise the whole thing just gets appended at the end.
About voice channels
Only one listening session per server at a time. Once the bot has joined a channel, /play, /skip, /stop, /pause, /resume, /seek, /volume, /loop, /shuffle and /remove only respond to people in that same channel, so another group somewhere else in the server can't take over or interrupt playback. Before the bot has joined anywhere, anyone can start it. /queue and /nowplaying are read only and work for anybody.
If everyone leaves the channel the bot is in (or the last real member moves elsewhere), it stops playback, empties the queue and disconnects on its own.
Adding a command
Create a new file under internal/commands with a struct implementing the Command interface, Definition() and Execute(), copying the shape of any existing command. Then register it in commands.NewRegistry(...) inside cmd/bot/main.go. Nothing else needs touching, the router picks it up automatically.
Tests
go test ./...