At a quick glance
What Relaxy! is, technically
Relaxy! is a multipurpose Discord bot I first wrote in early 2019 and have rewritten more times than I would like to admit. It is a plain
Node.js program in modern JavaScript (ES6 modules), typed with JSDoc and .d.ts files rather than TypeScript syntax, so there is no build step at all: the bot runs
directly from source, and reloads most of itself while it is still running. It runs as one supervisor process plus a few worker processes, and it hs the possibility of scaling across
machines easily if needed.
Live from the hardware
How everything is looking right now
Reading live status straight off the Pi…
Service health
Click to view current/historical uptimeChecking every service…
Machines running the bot
Asking the fleet…
Architecture
The runtime model
A guild lives on exactly one cluster. Only that cluster may act on it, so anything fleet-wide has to be broadcast, and anything that must happen exactly once needs a designated cluster to happen on.
./start │ ├─ bot/Manager.js ──► src/Manager/Manager.js ONE per host │ owns the console, SelfRepair, the dashboard API, host reporting │ has NO gateway connection and NO Mongoose connection of its own │ └─ forks N × bot/Client.js ──► src/Client/Relaxy.js ONE per cluster owns guilds, commands, events, the database cache, the music player
Manager
The supervisor. It spawns clusters, watches their heartbeats, reclusters, and respawns the dead ones. Anything it needs from the gateway it asks a cluster for. Anything it needs from Mongo it reads through its own small dedicated connection, because by design it doesn't have its own database module.
Cluster / Client
One OS process running one Relaxy class instance (Discord.Client extension) that owns one or more shards. Guilds are distributed across
shards by Discord's own formula, which is what makes a guild the property of exactly one process.
The designated cluster
Exactly one cluster holds _trueRelaxy. It owns the support server's log channel, the requests manager, and every sweep that writes shared rather than per-guild
state. Logging to it is safe from anywhere; the call relays over IPC to whichever cluster that is.
Broadcasting
The function is serialized with toString() and re-evaluated in another process, so it may use only its two parameters and can close over nothing. Context crosses as
JSON, so a builder arrives stripped of its methods and a Map arrives as {}.
IPC & heartbeats
Node IPC, with custom message types. Liveness is push: each cluster self-reports every 5s and the manager only watches for silence, respawning after 12 missed beats. Twelve, not five, because a GC pause is indistinguishable from a hang and the respawn makes it worse.
Hot reload
A SelfRepair module watches the source tree. A module is re-imported and swapped at its path, a command is re-imported into the collection, and an event is re-imported
with the old listeners removed. The client class itself is patched method by method, or triggers a recluster if its constructor changed.
Dashboard write API
The dashboard never touches Mongo. It posts mutations to the manager, which runs each one on the cluster that owns the guild. The cache flushes documents whole, so a direct database write would be silently overwritten minutes later by whichever cluster still held the old copy. (caused headaches)
Cross-process rate limiter
discord.js's limiter is per process, which is the wrong scope when several processes share one bot token. Mine lives on the manager and synchronizes across the fleet, and it takes any limitable action, not just REST calls.
Lifecycle
From _main to a ready shard
Three hand-offs and one watchdog.
_mainexecs bwrap. System paths come in read-only, the bot's own directory is the single writable mount,/tmpis a throwaway tmpfs, and the PID, IPC and UTS namespaces are unshared.$HOMEis deliberately not bound, so the cache directory is pointed at the sandbox's own tmpfs instead../startsources nvm if Node is not already onPATH, exportsUV_THREADPOOL_SIZE=64for libuv's fs and crypto work, and execs Node undernice -n -20with the highest scheduling priority along with--stack_size=1000,--trace-uncaughtand--max-old-space-size=6144. It also picks the entry point. The manager normally, a single client under--client, the cross-hosting bridge under--bridge.- The manager creates every directory the fleet writes to, then does two things before a single cluster is forked. It overlays the operator-mutable settings (version, owners, status
lines) from MongoDB onto the
.envseeds. Then it warms the CDN text cache, so a shard that boots into a CDN outage still finds its word lists on disk. - Clusters are rolled out ~7s apart. Each one loads its modules in parallel, starts loading commands and connecting to Mongo at the same time (command loading is file-only and does not need the database), then logs into the gateway. Music extractors load afterwards, deliberately off the critical path, so commands work before the music engine is finished.
- Only once every shard it owns is actually properly connected does a cluster report ready. Readiness is counted off each cluster's own
clientReadyevent. - Once the last cluster lands, the feature intervals start: mutes, reminders, ticket systems, forums, clearing channels. A stalled startup is caught by a watchdog whose budget scales
with the fleet (
clusters × 7s + 60s, floor of 5 minutes); past that it keeps naming the clusters that are still not ready, every 60s, until they recover automatically or I step in.
Persistence
The database layer
MongoDB behind a schema engine that stores diffs only, not documents. Here are some decisions I've had to make.
Defaults are never stored
A write keeps only the leaves whose value differs from the default. A read merges the defaults back on. Adding a field therefore costs nothing, give it a default and every existing document gains it on the next read, with no migration. The price is that a field sitting at its default does not exist in the database, so a query matching on a default misses every document, and an aggregation has to normalize before it compares.
A guild is two documents
The hot half holds what is touched on essentially every message: prefixes, counters, command gating, restrictions, censoring, levels. The cold half holds what only a config command changes: welcome cards, tickets, forums, heartboards, appeals, role sync, modlogs. Reads hand back the two merged so callers see one object; writes route each path back to whichever half owns it, and a path belonging to neither is rejected rather than guessed at.
What a write actually does
client.save(guildId, { alter: 'ch.t.cl', value: entries })
└─ load() make sure the document is cached
└─ queue() mutate the cached object SYNCHRONOUSLY, mark it dirty
└─ 200 ms debounce, per document
└─ flush -> strip defaults -> mongoose.updateOne
- The actual Mongo I/O is offloaded to a worker thread, so nothing on the command path ever waits on the database.
- A read immediately after a write sees the new value on this cluster, because the queue mutates the cached object before the flush ever happens.
- Caches never expire, but they do have a ceiling. A sweep every minute drops the oldest entries past a per-cache limit, and drops much harder when the process is near its memory budget. Anything with a write still pending is never evicted. Evicting a dirty document makes the change silently revert minutes later, which is a big headache waiting to happen.
- Repeated strings: moderator ids, mute reasons, role ids are interned once per guild and referenced by integer index. The registry is append-only by construction and unwritable from outside, because setting it wholesale would detach every warning and mute in the guild at once. (BAD)
Common command architecture
Writing a command, or an event
Only one file per, hot-readable.
export default {
name: 'commandname', // The key it is stored under.
aliases: ['alias'],
usage: '=commandname <args>',
description: 'Long help text. Markdown, multi-line.',
slashDescription: 'One line, 100 chars max.',
slash: new Discord.SlashCommandBuilder()
.addStringOption(o => o.setName('target')...),
args: true, // refuse with the usage line when given no arguments
defer: true, // make discord wait longer before marking interaction as invalid
cooldown: 20, // seconds, per user
owner: false,
leaveout: false, // exclude from registration
permissionsUser: ['SEND_MESSAGES', 'MANAGE_MESSAGES' and so on],
permissionsBot: ['SEND_MESSAGES', 'EMBED_LINKS' and so on],
There's also suggestedPermissionsUser and suggestedPermissionsBot for more complicated commands
async run(client, message, args, guild, interaction) { … }
};
Three ways to run a command
| Reached by | message |
args |
interaction |
|---|---|---|---|
Prefix, =cmd a b |
the real message | ['a', 'b'] |
absent |
Slash, /cmd |
a synthetic stand-in | one entry per option, not per word | the interaction |
| Button, menu or modal | a synthetic stand-in | empty | the interaction |
- Slash arguments arrive per option.
/appealchannel target:"thread Moderators"is one argument where the prefix path gives two, so anything that reads its line word by word has to normalize first. - Components route by the
customIdprefix, straight back into the command of that name. They are not re-checked againstpermissionsUseron the way in. That is right for a component that only affects whoever pressed it and wrong for one that reads moderation data, which has to check the presser itself. - Discord caps a slash description at 100 characters. So a long description on a complicated command usually made registration fail, so I created slashDescription which always fits parameters and is checked for correctness upon being loaded in.
- Three seconds. Discord kills an interaction that has not been acknowledged in that time, so anything doing several reads, a member fetch, a broadcast or an image
render sets
defer. A deferred command must never callinteraction.reply()afterwards, it kinda just breaks the interaction flow.
Resilience
What keeps it alive
Layered watchdogs, a garbage collector and external system health services.
Heartbeats
Every cluster pushes one beat every 5s and the manager only watches for silence. Twelve missed beats (1 minute) and it is respawned. Push halves the message count of a ping-and-wait model and removes the manager's outbound timer fan-out entirely, with identical detection latency.
Error-rate watchdog
Each client counts its own errors and warnings and checks them every 10s against randomized thresholds. Sustained misbehaviour respawns that shard through the manager, before it becomes everybody's problem. Randomized so the whole fleet cannot decide to restart at the same time and cause a wide outage.
Internet watchdog
The one that matters most. It recognizes a network outage across the whole fleet and holds off respawning until it is over. Without it, shards could respawn during a network outage, creating wrongly formed writes and corrupting the database (VERY VERY BAD)
Marks, not deletes
Nothing is deleted when it stops existing, it is marked. A departed member's mark has to stand untouched for 30 days, a departed guild's for 180. Rejoining clears it.
Sweeps, because sometimes Relaxy! fucking dies
Events are only as reliable as the process receiving them. A channel deleted while the bot is down raises its event to nobody, and the stale reference survives forever. So the collector assumes it missed things and goes looking, handing anything absent to the same handler the real event would have reached.
Every purge gate must pass
Before one document goes: the designated sweeper is ready, the mark is older than 180 days, a circuit breaker has not tripped on an implausible number of eligible guilds at once, and no cluster in the fleet can currently see the guild. A guild-removal event also fires during a Discord outage, so the mark is never written when the guild is merely unavailable.
Memory pressure watch
Usually Relaxy! gives the V8 engine ~6GB of usable memory, but on a RaspberryPi with 8GB that runs so many services it can fill up quickly. The system might kill the bot right as it senses it's getting near that threshold. So the cache watch keeps removing older unused entries and keeps the bot stable.
Floors, not empty caches
Under pressure every cache goes to a floor rather than to zero. Emptying them means the next seconds of traffic re-read everything they held, which turns a memory problem into a database problem at the worst possible moment. I want everything to always run asap, dropping caches to 0 also introduced some other side effect consequences :/
Technology stack
What's inside?
Grouped by what each set of libraries actually does.
Runtime & language
Discord core & sharding
Database
16 schema blueprints: Server, GuildConfig, Member, Profile, Warning, Mute, Case, Reminder, TicketSystem, ForumChannel, ReactionRole, HeartBoard, HeartBoardPost, Stat, Settings and Host. The models themselves are thin by design; the shape, the defaults, the identifiers, the indexes and the upgrade steps all live in one file, so adding a field is a one-line change in one place.
Music & voice
The player is a custom fork of discord-player. It was TypeScript; it is rewritten into plain JavaScript in this codebase's style with local fixes on top, and it carries its own stream interceptor and a biquad DSP equalizer. A small pipeline diffs it against upstream and builds merge bundles, so the fork can still follow the original. Two of the more notable changes I've introduced: a track that resolves but produces no stream is retried before anything is said in the channel (a throttled config request looks exactly like an unplayable video in the og module), and the volume stage always stays in the pipeline so the volume command remains settable.
Images & canvas
Text, search & OCR
OCR is the native tesseract-ocr binary driven as a child process. Keeps it out of the event loop.
Networking & utilities
The box itself
Source layout
How the code is organised
bot/ is what you write. src/ is what it runs on.
relaxy-private/ ├── _main # bwrap sandbox, then execs ./start inside it ├── start # launcher: ./start [--client|--bridge], Node flags ├── detect_os.js # host detection (distro and hardware, separately) ├── package.json # ESM, 47 dependencies, no build step ├── tsconfig.json # checkJs only, nothing is ever compiled ├── bot/ # entry points, commands, events │ ├── Manager.js # supervisor process entry │ ├── Client.js # cluster/client process entry │ ├── Bridge.js # cross-hosting bridge entry (one per machine) │ ├── commands/ # 186 modules in 6 categories │ │ ├── administrator/ fun/ image/ │ │ └── miscellaneous/ moderation/ music/ │ └── events/ # discord/ (47) + music/ (11) ├── src/ # the engine │ ├── Core/ # ModuleLoader, HotPatch, DataTable (the IPC enum) │ ├── Config/ # Env, Config, Settings (.env + the database overlay) │ ├── Assets/ # CDN.js (every asset path and URL) and Storage.js │ ├── Bridge/ # MainFrame, for cross-hosting │ ├── Utils/ # CookieUtils, NetworkErrors, RestRetry │ ├── Manager/ │ │ ├── Manager.js │ │ ├── Core/ # SelfRepair, HeartBeatManager, LogWorker │ │ └── Modules/ # ClusterSpawn, ConsoleInput, DashboardApi │ └── Client/ │ ├── Relaxy.js # the bot client │ └── Modules/ │ ├── Core/ # CommonRoutines, Utilities, Statistics, LoadMonitor │ ├── Database/ # SchemaEngine, GarbageCollector, Blueprints/ │ ├── Moderation/ # Moderation, ScamOcr, CensorExceptions │ ├── Guilds/ # GuildLink (cross-server), Creators, Panels, Tickets │ ├── Progression/ # levels, rewards, voice + reaction activity │ ├── MusicPlayer/ # the vendored player, equalizer, extractors │ ├── Workers/ # DatabaseWorker, FileWorker (worker threads) │ ├── Handlers/ Cards/ Dashboard/ VoiceRecorder/ │ └── ADSR/ # active diagnosis + the cross-process rate limiter ├── types/ # .d.ts declarations ├── docs/ # 14 design documents ├── scripts/ # music/ upgrade pipeline, maintenance/ checks ├── logs/ # per-cluster log output └── storage/ # runtime caches (users, guilds, cdn-cache)
Deployment
The sandbox and where it runs
Nothing starts Node directly, and Relaxy! works out what it is running on at boot.
The launcher wraps the whole program in a bubblewrap sandbox before anything else happens. System directories come in
read-only, the bot's own directory is the single writable mount, /tmp is a throwaway tmpfs, and the PID, IPC and UTS namespaces are unshared. Then it execs the real launcher
from the inside.
Raspberry Pi IS_VOID + IS_RASPBERRY_PI
The primary host. An always-on board running Void, hosting every service in the Relaxy! ecosystem and keeping a slice of the fleet alive around the clock on low-performance tuning.
Void PC IS_VOID
My personal Void Linux desktop. Only used occasionally, it is pretty expensive to run 24/7.
Laptop IS_LINUX
A Linux secondary and development host. Used mainly for debugging when I have no access to my PC.
Every read in the host detection is best-effort. Under bwrap parts of /proc and /sys may not be mounted at all, and
a sandboxed host has to degrade gracefully to "plain Linux" rather than throw at import time and take the whole process down with it. That one caused some headaches.