Relaxy!

Under the hood

Technical Breakdown

The complete breakdown of the bot's codebase. As well as a quick look into what services are running on the server.

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.

~120klines of JS
433source files
186command modules
27prefix only commands
58event handlers
16database models
47npm dependencies
16toggleable log channels
43loggable modlog events
7years of development
~3085$estimated development cost
1soul perished during development

Live from the hardware

How everything is looking right now

Reading live status straight off the Pi…

connecting

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 hostowns the console, SelfRepair, the dashboard API, host reportinghas 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.

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

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

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

Node.js 24 JavaScript (ESM) JSDoc TypeScript (.d.ts, checkJs) dotenvx worker_threads

Discord core & sharding

discord.js 14 discord-api-types discord-hybrid-sharding discord-cross-hosting discord-voip

Database

MongoDB Mongoose 9

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

yt-dlp discord-player 7 (my own fork) @discord-player/equalizer discord-player-soundcloud @discordjs/voice @discordjs/opus sodium-native ffmpeg-static fluent-ffmpeg youtube-sr metadata-filter LrcLib (synced lyrics)

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

canvas canvacord sharp discord-image-generation gifencoder emoji-mixer

Text, search & OCR

natural (NLP) fuse.js (fuzzy search) stopword normalize-text weird-to-normal-chars replace-special-characters word-list @iamtraction/google-translate tesseract-ocr (native)

OCR is the native tesseract-ocr binary driven as a child process. Keeps it out of the event loop.

Networking & utilities

axios undici lodash fs-extra archiver queue pretty-ms dirty-json chalk figlet systeminformation

The box itself

Void Linux aarch64 bubblewrap nvm rsync

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.