Devlog
How this project was built and deployed, step by step. It includes the parts that went wrong and were fixed and the difficulties of running it for real on a Raspberry Pi 5, because that is where most of the learning was.
Version 1: a few small bots
It started with a simple idea
make the chat less empty
Split out of the music bot
The companions were born inside the music bot's repo, because the idea came up there: a server with a music bot can still feel empty between songs. A few days later they moved to their own project. They share nothing at runtime, someone who only wants lively chat should not have to download a music bot, and the two repos can be updated or rolled back separately. On the Raspberry Pi they are just two folders and two containers.
Manners before jokes
One channel only, quiet hours, a daily limit, and stepping back when people are talking. The bots do not read message content: knowing that someone wrote something is enough.
Bots stay bots
They keep the BOT tag, never pretend to be human and never claim to eat, sleep or travel. Making a server feel alive is fine; pretending to be people is not.
Version 2: they can play
Ask, quiz, vote
and see who joins in
Question of the day, polls, trivia
A question at a fixed hour, real Discord polls, trivia with answer buttons and a weekly board. Each server can add its own questions, jokes and facts with a command.
Measuring whether it helps
/companions statsshows how many conversations actually got people talking. If the bots only talk to themselves, you should know.Timing is the hard part, not the jokes
The first tests used time windows that were too short, so a punchline test passed for the wrong reason. Moving the clock in small steps and asserting who speaks when (and who does not) was far more honest. Another bug: a person's message in the exact millisecond a conversation began was not counted as joining in; comparing with
>=fixed it.
Version 3: sitting with people
Present 24/7
even when everyone has left
Voice is a reconcile loop
Reacting to every disconnect event gets messy (Discord moves bots between voice servers, kicks, restarts). Instead every 15 seconds a bot compares what should be true (saved room, number of bots) with what is true and repairs it, waiting longer after failed joins. Restarting the container restores the room; I checked that on the Pi.
Several rooms, sticky bots
Each room remembers its own bots: new or bigger rooms only use free ones. At first I re-dealt everything whenever a room changed and bots were pulled out of other rooms. The complaint was fair.
The "+ New Channel" loop
A create-a-room channel made each bot spawn its own room and keep rejoining the original. Now the bots learn the room that was created, share it and no longer go back to the creator channel.
Tools around the room
A greeting when someone joins, Pomodoro, a voice time board, a welcome for new members (reading the system message type instead of asking for a special intent), reminders and event countdowns. Anything holding user IDs got its own erase command and a line in the "What is stored" table.
Version 4: safe to leave alone
Nobody watching
and it still has to be reliable
A tick must never overlap itself
With many servers and slow voice joins one round could outlast the 15 second timer and a second one would start on top of it, joining the same bot twice. Each loop now skips a round while the last one still runs.
Learning from usage, carefully
Adaptive tuning multiplies the default weights by how well each kind engages in that server, bounded to half and double, pulled toward the average while data is thin, and off until 20 conversations exist. Because it can be explained in one sentence, it can be reported in
/companions stats.A measurement instead of a guess
300 simulated servers for 8 hours: the slowest tick took about 30 ms, memory grew a few tens of MB, every server stayed inside its daily cap.
Alerts and backups
An optional webhook tells you when a bot loses its login or cannot rejoin voice, and the data files are backed up daily with 7 copies kept. Per-server data export and delete, and an automatic clean-up a day after the last bot leaves a server.
Secrets in tests
A test that wanted a token-shaped string was blocked by GitHub push protection. The string is now built from pieces; the warning was not bypassed.
Version 4.x: thirty personalities
From nine to thirty
each bot with its own voice
From 10 to 30 bots
21 new personalities, each with its own lines and 25 funny notes under its name in each language. Logging in a few bots at a time and lighter caches keep 30 bots small: with 23 running, a Raspberry Pi 5 sits at about 5% CPU and 113 MB of RAM.
furyZ and Chamy
A pair of bots: furyZ is a Valorant gamer and Chamy is her close best friend, with matching notes about their friendship.
Thousands of lines
About 1,000 lines per language for each purpose: welcoming a new member, greeting in the voice room, saying goodbye, reminders, and 500 for each step of a focus session. After that every personality speaks in its own voice, 20 lines per purpose.
1,259 trivia questions per language
About 960 new questions in 20 topics, each with four options. A second reader checked every question, 20 doubtful ones were dropped, and I read a random 70 myself.
A small bug worth remembering
A command description over Discord's 100 character limit crashed a deploy during command registration. Checking command descriptions is now part of CI and runs before every deploy.
The website
A static page
with some cinema
From a static page to an experience
A field of orbs that follow the pointer, a kinetic headline, a pinned story scene when scrolling and a trivia game you can play on the page. It is plain HTML, CSS and JS, respects reduced motion, comes in two languages and was checked in a real Chrome at several screen sizes.
Running it on a Raspberry Pi 5
Live on a real Pi
next to the music bot
Two projects, one Pi
My Pi 5 (aarch64, 8 GB RAM) runs both the music bot musiDISCORD and the companions. The two share nothing at runtime: each has its own folder (
~/musiDISCORDand~/companionsDISCORD), compose project,.env,data/folder and container. The companions need no Lavalink and no database, so they are one small Node.js process that publishes no port. The music bot, Quynh Anh, is a different project with a different token, so no companions command can touch it.One command to install, a nightly self-update
scripts/install-pi.shsets everything up (--dry-runpreviews it,--timerturns on the daily update). Updating runs from a systemd timer: fetch new commits, accept fast-forward only, rebuild, wait 45 seconds to see whether the container stays up, and keep the new version only if it does, otherwise roll back. Aflocklock stops two updates from overlapping, and the script refuses to run when the folder has uncommitted changes.The container has no healthcheck, so "healthy" had to be defined
There is no endpoint to ask "is the bot alive". For the update script, healthy therefore means: running, and not restarting after a short wait. It is not perfect, but it catches the most common failure, a new version that crashes right at startup.
data/belongs to rootThe container writes
data/as root, so the host cannot edit the config directly (it needs sudo, and sudo on the Pi asks for a password). I edit it by running a small Node snippet inside the container's own environment:docker compose run --rm --no-deps --entrypoint node. The permissions match, no sudo is needed and nobody types a password into a command line.Tokens are secrets, deployment included
.envis gitignored and a token is never printed or put on the website. On the Pi I replace only the one line, by copying a temporary file over SSH (key login, no password), and when I need to check something I print variable names, not values. That keeps tokens out of shell history and logs.The network is not ready the moment the container starts
Right after the container starts, DNS sometimes is not working, so the first registration of the
/companionscommand could fail. Registration is now retried with a growing wait instead of giving up.Restarts are normal
On each deploy the 23 bots log in five at a time and rejoin their voice rooms one by one. Now and then Discord's voice gateway answers 522 for a bot or two for a moment. Nobody has to step in: the voice loop retries with a growing wait, and the log stays short.
Measured on the real Pi instead of guessed
With 23 bots running, the companions use about 113 MB and about 5% of the Pi 5's CPU, and the Pi still has about 5.6 GB of RAM free with both projects running. The numbers come from running it, and they tell me I can add bots without worrying about load.
Versions 4.4 to 4.7: the last additions
A pretend camera, optional AI
and calling bots around
The camera is only a sign
A bot cannot send real video, so
/companions voice cameraonly shows "Camera on" under the name of the bots sitting in a voice room, and one bot announces it in the chat with a joke that says plainly it is only a sign. The lesson: say what a feature does not do, instead of letting people think it is real.Optional AI with Gemini, off by default
There are two parts: a bot answers when someone mentions it or replies to it (
/companions ai replies), and once a day one posts a riddle with a hidden answer, a question or a would-you-rather (/companions ai daily). Discord gives a bot the text of a message only when that message mentions the bot, so no privileged intent is needed: a bot reads only the message that is talking to it. The riddle part sends no member data at all.The "no message content" promise had to become a new, true sentence
The README, the privacy page and the setup page all used to say the bots do not read messages. Once AI existed, that was only true by default. I rewrote all of it, in both languages, to say it plainly: not read by default, and only when AI is switched on is exactly one message that mentions a bot sent to Google's Gemini, and nothing is stored.
The first model was closed
The first real call returned 404. I read the error body and asked the API for its list of models:
gemini-2.5-flashwas still listed but no longer available to new accounts. Switching togemini-3.5-flashworked. The lesson: read the API's error instead of guessing, and do not copy model names from old examples.Newer models "think" with the answer's own tokens
With newer models part of the output token limit goes to thinking, so an answer could come back empty or cut off mid-sentence. I raised the token limit, and when Google says an answer was cut off it is dropped: the bot says a funny fallback line instead of posting half a sentence.
The free quota is lower than I assumed
A handful of quick tries was enough to get a 429. So the limits are built in: one answer per member every 20 seconds, 5 a minute for the whole system, 60 a day per server, and a one-minute pause after a quota error. When the day's limit is used up the bot says one line and then stays quiet.
Keeping the key and the content safe
The key lives only in
.env, is sent in a header and not in the address, never reaches a log, and a test checks that. Answers are cleaned (no@, links or markdown headings), the prompt makes the bot always say it is a bot, and a member's message is treated as data, not as instructions, so it cannot talk the bot into changing its rules./assembleand/random/assemblecalls the bots to the voice room you are in, and/randomscatters them over random rooms. Both replace the saved room list and make the bots that were not chosen leave their old rooms. By default only people with the Move Members permission can use them, once a minute per server. I deliberately did not run the joining loop inside the command and left it to the normal loop, because of the lesson in D-04: two loops overlapping would put one bot in twice.
What I learned
Nine things
I take to the next project
Repair, do not react
For sitting in voice, the most durable approach is a loop that compares "what should be true" with "what is true" and repairs it, not one that listens to every event.
Move time in small steps
Testing with a fake clock and asserting who speaks when (and who does not) is far more honest than checking a final state.
Content is data, and data gets checked
Thousands of lines and trivia questions went through scripts (placeholders, length, duplicates, Vietnamese diacritics) and a second reader before they entered the repo.
Secrets travel one way
Tokens and keys live only in
.env: never printed, never in an address, never in a log, with a test for the places that could leak.Read the API's error
Both the 404 for the model and the 429 explained themselves in the response body. Reading it was faster than guessing.
Say what a feature is, and is not
The camera is only a sign, and AI sends one message to Google. Honest wording matters as much as code.
Limits on everything that costs money or makes noise
A gap between two answers, a daily cap, a per-minute cap, a pause after being blocked. Without limits the project would sooner or later annoy itself.
Measure on real hardware
113 MB and 5% CPU on the Pi 5 came from running it, not from guessing.
Keep projects apart
The music bot and the companions share nothing at runtime, so updating or rolling back one never affects the other.
Still open
Honestly
what is not done
Not watched for long on a real server
It has run on the Pi with real bots and real voice rooms, but the notes under the names, goodbyes, weekly recap, trivia streaks, seasonal packs,
/assemble,/randomand the Gemini answers were mostly tried with fakes, plus one real Gemini call. A few days on a live server would show what pacing and content need tuning.The free Gemini quota is fairly low
The per-minute and per-day limits of the free tier may be lower than the numbers I preset. The real quota of the account needs checking, and
AI_DAILY_LIMITlowering if needed.Only English and Vietnamese
Another language means another content bank and a parity test.
No live streaming
A bot cannot stream like a user; that would need an automated user account, which Discord forbids. Each bot has a funny note under its name instead, and the camera is only a sign.
The project is finished
Version 4.7
complete
The project stops at version 4.7: 30 personalities (23 bots running), 96 passing tests, docs and website up to date, running on a Raspberry Pi 5 next to the music bot. The source is open, so anyone can run their own. I am stopping here to move on to other things.