Making a bot
A bot is a program that joins your server as a member of its own: it reads the chat, answers, and plays audio in a voice channel. There is a music bot you can run as it is, and a door for writing your own in any language — thirty lines of Python is a bot.
What a bot is
To the server a bot is a person with a device: it has its own key, its own name in the roster, and the role its invite code gave it. It can do what that role allows and nothing else — a guest can join channels, speak and write in General, a member can also answer direct messages. There is no bot flag yet (what is coming), so it shows up like anyone else, and you kick, ban or mute it like anyone else.
Two shapes, pick by what you want to do:
-
Run the music bot. One program, nothing to
install: it decodes files and audio links itself. People type
!playin the chat; it joins their channel and plays. - Write your own. The server speaks QUIC, a binary format and Ed25519 keys, which is a tax in every language but Rust — so a small program, the gateway, does that part once and hands your bot a WebSocket on the same machine: JSON events in, JSON actions out, raw audio to play. Your bot can be in Python, JavaScript, Go, a shell script with a WebSocket tool — anything.
What you need
-
A server you can invite to. Yours, or one whose
admin mints you a code (hosting
guide). A guest code is enough for a music bot;
--grants memberif the bot should answer direct messages. - A computer to run the bot on, left on: the server's own machine is the usual place, any other works (it connects out to the server's UDP port, like the app).
-
For now, Rust to build with. The gateway and the
music bot are not in the downloads yet; they build from the
source with the
Rust toolchain (rustup.rs) and
CMake, which the Opus codec needs:
With CMake 4 (Homebrew's), setgit clone https://github.com/mpwsh/baucord cd baucord cargo build --release -p gateway # target/release/baucord-gateway cargo build --release -p gateway --example music # target/release/examples/musicCMAKE_POLICY_VERSION_MINIMUM=3.5in the environment, or the Opus build stops at its old CMakeLists.
The music bot
The bot is the program music built above. It needs
the server's address, a name, a file to keep its key in, and an
invite code the first time:
./target/release/examples/music --server 203.0.113.5:51000 --name MusicBot \
--key musicbot.key --invite K7FQ-2MX9-ABCD --library ~/Music
| Flag (environment variable) | What |
|---|---|
--server (BAUCORD_SERVER) |
The server's address and port, as people type it in the app. |
--name (BAUCORD_BOT_NAME, default MusicBot) |
The bot's name in the roster. |
--key (BAUCORD_KEY, default musicbot.key; the gateway's is bot.key) |
Its identity, created on the first run. Whoever has this file is the bot: keep it like a password, and keep it — a new key is a new member needing a new invite. |
--invite (BAUCORD_INVITE) |
An invite code. Needed on the first run only: it links the key, and from then on the key alone logs in. Leaving the flag in place afterwards is harmless. |
--pin (BAUCORD_PIN) |
The server's identity (SHA256:…, printed by
the server at start; Settings → Servers in the app).
Without it the first connection's identity is trusted and
written to a .pin file beside the key
(musicbot.key → musicbot.pin),
and every later run insists on it — what the app does.
|
--library (BAUCORD_LIBRARY) |
The folder !play <file> may read from,
and never outside it. Without the flag, links only.
|
--voice-kbps (BAUCORD_VOICE_KBPS, default 64) |
Opus bitrate of what it plays, 16–128. |
Its log says who it is, which identity it pinned, when it was admitted, and what it is playing. Then, in the server's chat:
| In General | What happens |
|---|---|
!play song.mp3 |
Joins the voice channel you are in and plays the file from
the library (a path under it: !play albums/x/03.flac).
You have to be in a voice channel; it says so otherwise.
|
!play https://…/track.mp3 |
Plays a direct link to an audio file or stream: an
mp3, ogg, flac, m4a or wav somewhere on the web, or an
internet radio stream, which plays until !stop.
It is fetched as it plays, so there is no waiting for a
download.
|
!play … while something plays |
Queues it; the bot answers with its place in the line. |
!skip, !stop, !queue, !help |
Next track; stop and drop the queue; how many are waiting; the list of commands. |
A YouTube, Spotify, SoundCloud or Bandcamp link is the page
about a track, not the track; the bot says so and plays
nothing. Those sites need an extractor that changes monthly, which
is yt-dlp's job — see
YouTube and the sites yt-dlp knows.
It says little in chat on purpose: the server's chat limit (five lines in five seconds) applies to a bot as to anyone, and a chatty bot loses the lines that matter.
A bot of your own, in Python
Two programs: the gateway, which is the member of the server, and
your bot, which talks to the gateway. Start the gateway with the
same flags as the music bot (no --library):
./target/release/baucord-gateway --server 203.0.113.5:51000 --name HelloBot \
--key hellobot.key --invite K7FQ-2MX9-ABCD
It listens for your bot on 127.0.0.1:8765
(--listen to change it; see
keeping it running before you listen on
anything but the loopback). Then the bot — this one answers
!ping and greets whoever joins. pip install
websockets, save it as echo_bot.py, run it:
import json
from websockets.sync.client import connect
with connect("ws://127.0.0.1:8765/") as ws:
hello = json.loads(ws.recv())
print("gateway", hello["d"]["gateway"], "connected", hello["d"]["connected"])
while True:
f = json.loads(ws.recv())
if f["op"] != "dispatch":
continue
if f["t"] == "MESSAGE_CREATE" and not f["d"]["own"] and f["d"]["text"] == "!ping":
ws.send(json.dumps({"op": "action", "t": "CREATE_MESSAGE", "d": {"text": "pong"}}))
if f["t"] == "MEMBER_ADD":
ws.send(json.dumps({"op": "action", "t": "CREATE_MESSAGE",
"d": {"text": "hi " + f["d"]["name"]}}))
That is the whole shape of a bot: read frames, act on the ones you
care about, send actions. Type !ping in the server's
chat and the bot answers pong. The bot's own lines
come back to it too, marked own — that check is what
keeps it from answering itself.
The gateway can be left running while you restart the bot as often as you like; the bot's connection to it is the only thing that drops. Several bots may connect to one gateway; they share its one identity and all see every event. One bot per gateway is the normal shape.
What a bot sees and does
One WebSocket; text frames carry JSON, binary frames carry audio.
Every JSON frame is {"op": …, "t": …, "d": {…}}:
op is the kind of frame, t its name,
d its fields. Numbers that are 64-bit
(client_id) travel as strings of digits and user ids
as hex strings, so JavaScript keeps every bit; message ids,
timestamps (milliseconds) and channel ids are plain numbers.
From the gateway
The first frame is {"op":"hello"}, with the gateway's
version, whether it is connected, and the READY
fields (below) if it is. Then, as its session comes and goes,
{"op":"status"} frames: CONNECTED,
DISCONNECTED (with reconnecting),
REFUSED (a bad code, a ban),
REMOVED (a kick). A frame it could not use comes
back as {"op":"error","d":{"message":…}}: unknown
JSON, a missing field, an action sent before the session is
ready, or a refusal the server answered with words.
Events: {"op":"dispatch","t":…}
t |
Fields in d |
When |
|---|---|---|
READY |
client_id, user_id,
permissions, server_name,
greeting, channels
(id, name,
has_password, user_limit),
members (client_id,
name, channel_id,
user_id, roles,
level)
|
Admitted, roster received. channel_id is null in the lobby. |
MESSAGE_CREATE |
id, ts, client_id,
user_id, name,
text, conv
("general" or {"dm": user_id}),
own, attachment when it is a file
|
A line in General, a direct message to the bot, a file. |
MESSAGE_UPDATE, MESSAGE_DELETE |
id, text / id |
An edit, a deletion. |
MEMBER_ADD, MEMBER_REMOVE, MEMBER_UPDATE |
client_id, name, channel_id; what changed |
Someone connected, left, was renamed or given a role. |
VOICE_STATE |
client_id, channel_id |
Someone moved channel — the bot too, after JOIN_CHANNEL. |
CHANNELS, JOIN_DENIED |
channels, memberships / channel_id, reason |
The channel list changed; a join the server refused. |
SPEAKING, AUDIO_STATE |
client_id, speaking / muted, deafened |
The lights in the roster. |
RATE_LIMITED |
retry_ms |
The bot wrote too fast; that line was dropped. |
HISTORY |
conv, messages, more |
The answer to FETCH_HISTORY. |
VOICE_REPORT |
received, expected, lost, late, rtt_ms |
Every 2 s while the bot plays: what the server got of it. |
RESTRICTED, ROLES, MOVED_IDLE, SHARE_START, SHARE_STOP |
… | A mute on the bot; the role table; moved to the AFK channel; screen shares (it cannot watch them). |
Actions: {"op":"action","t":…}
Each becomes the message a person's app would send, and the server checks the bot's permissions as it would anyone's.
t |
Fields in d |
|---|---|
CREATE_MESSAGE |
text — a line in General. |
CREATE_DM |
user_id, text, client_ref (optional; comes back in MESSAGE_ACK or MESSAGE_FAILED). |
EDIT_MESSAGE, DELETE_MESSAGE |
id, text / id — its own messages. |
JOIN_CHANNEL |
channel_id, password (optional).
Audio goes to the channel the bot is in:
join one before playing; the lobby's audio is dropped.
|
SET_NICKNAME, AUDIO_STATE, SPEAKING |
name; muted, deafened; speaking (the light; playing sets it by itself). |
FETCH_HISTORY, MARK_READ |
conv, before, limit (50, at most 200); conv, id. |
The complete list, with every field, is in the gateway's README. The names follow Discord's where the thing is the same, so a bot written for Discord ports without relearning them.
Playing audio
A binary frame is sound: 32-bit little-endian floats, 48 kHz,
mono, between −1 and 1, any length. The gateway cuts it into 20 ms
pieces, encodes them as Opus and sends one every 20 ms, so the bot
may push a whole song at once: up to ten seconds ahead, after
which its socket waits. That back-pressure is how a decoder's
output paces itself to the room — ffmpeg … | bot
works out on its own. The speaking light goes on with the first
frame and off 200 ms after the last.
{"op":"voice","t":"STOP"} drops what is queued.
From anything ffmpeg can read, this is the whole conversion;
stereo is the bot's to mix down (-ac 1), the apps
play mono:
ffmpeg -loglevel error -i song.flac -f f32le -ar 48000 -ac 1 -
Join the asker's channel first: their channel_id is
in READY's roster, and VOICE_STATE
events keep it current. A bot hears nothing yet
(what is coming).
YouTube and the sites yt-dlp knows
music_bot.py, next to the echo bot in the
examples,
is the music bot for links the Rust one refuses: !play
<url> runs yt-dlp, pipes it through
ffmpeg into the gateway, and joins the asker's
channel; !stop stops. It needs pip install
websockets, yt-dlp and ffmpeg
on the path, and a gateway running as above. Its ninety lines
are a fair template for any bot that plays something.
Why two music bots: an extractor for those sites changes monthly
and is a project of its own, which yt-dlp is. The
Rust bot stays small and needs nothing installed; the Python one
knows every site yt-dlp does, at the price of two
more programs to keep updated.
In Rust, as one program
The gateway is also a library, and the music bot is built on it:
no second process, the bot is the member. With the
gateway crate as a dependency:
use gateway::{bot::Bot, net::Config, wire::{Action, Outbound}};
let bot = Bot::connect(config, 64)?; // threads of its own; returns at once
for event in bot.events() { // Outbound::Status / Dispatch / Error
if let Outbound::Dispatch { t: "MESSAGE_CREATE", d } = &event
&& d["text"] == "!ping"
{
bot.act(Action::CreateMessage { text: "pong".into() });
}
}
bot.play(&samples) plays 48 kHz mono floats in the
bot's channel and blocks while ten seconds are queued, so a
decoder calling it runs at the room's pace;
bot.stop_playing() cuts it within a frame;
bot.shutdown() says goodbye to the server. Admission,
the pin and reconnecting are the gateway's, shared. The music
bot's source, crates/gateway/examples/music.rs, is
the worked example: decoding with symphonia, resampling with
rubato, a queue, and the chat thread.
Keeping it running
- The key file is the bot. The gateway writes it readable by its owner only; a bot through the gateway never sees it (it has the WebSocket, not the key). Back it up with the server's; never commit it.
- It reconnects by itself when the link drops, after one second, then doubling up to thirty, and exits with status 1 when the server says no: a bad code, a kick, a ban, a server identity that changed without proof. A service manager should not restart it in a loop then — nothing changes until someone acts. Ctrl-C closes the session properly (the server sees a goodbye, not a 30 s timeout).
-
On the loopback no token is needed: whoever runs
programs on that machine already owns the bot. To listen
anywhere else,
--tokenis mandatory (the bot sendsAuthorization: Bearer …or?token=…), and put TLS in front — the gateway speaks plainws://. Anyone who reaches the socket is the bot. -
With Docker, the gateway's
Dockerfileincrates/gatewaybuilds the same binary on the server image's base; run it beside the server with the key on a volume:docker run -v musicbot:/data -e BAUCORD_SERVER=server:51000 \ -e BAUCORD_BOT_NAME=MusicBot -e BAUCORD_KEY=/data/musicbot.key \ -e BAUCORD_INVITE=K7FQ-2MX9-ABCD -e BAUCORD_GATEWAY_LISTEN=0.0.0.0:8765 \ -e BAUCORD_GATEWAY_TOKEN=… -p 127.0.0.1:8765:8765 baucord-gateway
Rules and limits
- Its permissions are its roles, checked on the server; nothing the gateway or the bot says changes them. Change them from the admin panel like anyone's.
-
Chat limits apply: five lines in five seconds,
after which lines are dropped and the bot gets
RATE_LIMITED. Answer what matters, not everything. - Audio out only. A bot plays; it does not hear the room, and it cannot watch a screen share.
- Join a channel before playing; audio sent from the lobby is dropped.
- A bot that stops reading its frames is dropped after 4,096 queued ones rather than buffered without bound; a bot pushing audio faster than the room plays waits on its socket.
-
No slash commands yet: a bot reacts to lines in
the chat (
!play), and everyone sees them.
What is coming
In this order: a bot role and /bot create,
so a bot is marked as one and its code is minted from the app;
slash commands with replies only the asker sees; audio in (what
the room says, for a recorder or a soundboard that reacts); a way
for a bot to opt out of the roster's lights; and the gateway and
the music bot in the downloads and the Docker image, so nothing
has to be built.