← blog

 /  7 min read

rejs: a Discord moderation bot with a 7-day undo

A moderation bot that can undo itself.

rejs is a Discord moderation and automation bot with a FiveM server monitor built in. It began as a game server watcher, took over the jobs of an older bot of mine, and is now one slash-command bot. The game server monitor is one feature among about thirty, and the part I like most is a command I did not plan at the start: /undo. This is what rejs is, and what changed in how I think about a bot that can delete things.

1bot, with moderation, tickets, protection and a game server monitor in it
50+slash commands, all hidden from members who can't use them
7 daysevery staff action can be undone
7daily snapshots of each server's roles and channels
10 sgame server poll, down from 1 s in the first version
43tests, run with Node's built-in runner

Where it came from

rejs started in 2024 as a small Node bot: poll the game server once a second, compare the player list with the last one, post joins and leaves. In September 2026 I rewrote it as a slash-command bot and moved everything my older leveling bot did into it, so one community runs one token, one host and one database. I dropped the thank-you XP on the way. It was fun, but it made the server about the bot instead of the people, and a moderation bot needs to be boring in the right way.

What stayed is one loop: watch something, remember what changed, tell the right channel. Anti-raid watches joins, anti-nuke watches destructive actions, the monitor watches the game server. The loop is the same and only the thing being watched changes.

Moderation with a paper trail

Every action (warn, timeout, kick, ban, softban) becomes a numbered case, posted to a mod log and to Discord's own audit log, and the member gets a DM when possible. /history shows everything for one person, including private staff notes. A moderator cannot act on someone with an equal or higher role, and that check is one function with its own test, because it is the one that must never be wrong.

Bans can have a duration. The unban is stored in the database, not in a timer, so a restart cannot make a temporary ban permanent. Ban DMs carry an Appeal button: it opens a form, the appeal lands in a staff channel with Accept and Deny, and an accepted member gets a single-use invite back.

layerwatchesdoes
anti-spamfloods, repeats, caps, emoji, linksdeletes and warns, so escalation takes over
anti-raid20 joins in 60 s, by defaultraises verification, locks channels, kicks raiders, pings staff
age gateaccounts younger than a set agequarantine role that only sees one channel
anti-nukeevery account except the ownerstrips its roles and alerts the owner

Anti-nuke is the one I would defend hardest. A hijacked admin account is the worst case for a Discord server, and the account already has permission to do everything, so permission checks cannot stop it. Counting is what can: too many bans, kicks or deleted channels in a short window and the bot takes its roles away. Only the server owner can change that setting, because an admin who could switch it off would be exactly the account it exists for.

Undo

A bot with ban and delete permissions will eventually do something you did not mean, through a typo, a wrong ID or a bulk command with the wrong list. So every staff command and staff button runs inside a journal entry, and /undo lists the entries from the last 7 days and reverses the one you pick.

command runsinside one journal entryjournalold rows + reverse steps/undoreplays newest first
Two kinds of change need two kinds of record: the database and Discord itself.

Database changes are captured without any command knowing about it. Every table gets three SQLite triggers, and while a journal entry is open they copy the old row into an undo table. A new command that saves a setting is undoable the day it is written, with no extra code.

-- generated for every table: while an entry is open, keep the old row CREATE TRIGGER undo_<table>_update AFTER UPDATE ON <table> WHEN (SELECT tx FROM undo_state) IS NOT NULL BEGIN INSERT INTO undo_rows (tx, tbl, op, row_id, old) VALUES ((SELECT tx FROM undo_state), '<table>', 'update', OLD.rowid, <old row as json>); END;

Discord changes cannot be captured that way, so a command records a step next to the change: how to reverse it. "I created this channel, delete it." "I changed this overwrite, here is what it was." The journal knows which entry it is in through Node's AsyncLocalStorage, which is why two commands running at the same moment keep separate entries and there is a test for exactly that. The entry row is only created on the first change, so read-only commands leave nothing behind.

Some things cannot be taken back: a kick, a deleted message. The command says so at the time it happens, and /undo shows that list before it does anything, next to what it can reverse. A tool that promises "undo" and then quietly skips half of it is worse than none.

The hardest case was deleting a channel or a category. It comes back with a new ID, and the log channel, sticky messages, schedules and older undo entries all pointed at the old one. So after recreating it the bot rewrites every text column that mentions the old ID, in every table that has a guild_id:

for (const { name, type } of columns) { if (type !== 'TEXT' || name === 'guild_id') continue; rawPrepare(`UPDATE ${table} SET ${name} = REPLACE(${name}, ?, ?) WHERE guild_id = ? AND instr(${name}, ?) > 0`) .run(oldId, newId, guildId, oldId); }

It is blunt, and it works because Discord IDs are long enough that a false match is not a real risk. I would not do it with short IDs. Roles are harder still: a deleted role comes back with its members and its channel permissions, and permission overwrites for roles that are gone for good are dropped, because Discord refuses the whole channel if one of them points at nothing.

When undo is not enough

Undo covers a mistake. It does not cover someone who deletes fifty roles and channels on purpose, and then the journal is not the tool. Every day the bot saves a snapshot of each server's roles, with who had them, and of every channel with its settings and permissions, keeping the last 7. /restore compares the newest snapshot with the server, shows what is missing, and rebuilds it: roles first, then categories, then channels inside them, then gives members their roles back.

The order matters more than anything else in that command. A channel can have a permission overwrite for a role, and a role can only be given to a member after it exists, so each step waits for the one before it and each recreated thing gets a new ID that the next step has to use. The snapshot also asks for every member of the server once a day, and I marked that in the code as a limit: fine up to about 100,000 members, and something to rethink beyond that. The database itself is copied to backups/ every day, last 7 kept, for the day the file is the problem.

One look for every message

The last big change was visual. Every message the bot sends, replies, DMs, logs and panels, is now a Components V2 card: an accent color for the outcome (green done, red error, orange warning), headings, avatar thumbnails and buttons inside the card. The commands did not change to get there. They still build ordinary text, embeds and buttons, and one function converts them right before they reach Discord.

// commands still write "✅ Done"; the marker picks the color and is removed from the text const MARKERS = [['✅', Colors.Green], ['❌', Colors.Red], ['⛔', Colors.Red], ['⚠️', Colors.Orange]];

That made the look one decision in one file, and it made the commit that removed the decorative emojis almost mechanical: the color already says whether it worked. The cost is Discord's limit of 4,000 characters of text per card message, so long content is shortened, longest text first, and there is a test for that too.

The game server monitor, again

The monitor is still there, and it fixed what the first version got wrong. The retry counters were module-level globals; they are now one small state object per Discord server, and a server counts as offline only after 3 failed polls in a row, because a single miss is usually a hiccup. The hard-coded restart windows are gone: the bot posts once when the server goes offline, once when it comes back, and marks everyone as left in between.

The other problem was identity. FiveM reuses server IDs, so a player who leaves and one who joins can have the same number a minute apart. Sessions are now keyed by the license identifier, with the name only as a fallback:

// FiveM server ids get reused, the license identifier does not. const playerKey = (p) => p.identifiers?.find((id) => id.startsWith('license:')) ?? `name:${p.name}`;

Joins and leaves come in one card per update, with how long each player stayed, instead of a line each. The bot's status and an optional voice channel name show the live count (Discord allows two renames per 10 minutes, so that one is throttled). /server draws the last 24 hours as a text sparkline with the peak, /playtime ranks players from the recorded sessions, and /watchlist pings staff when a chosen player joins. The poll went from 1 s to 10 s: nobody reads a join message that fast, and a public bot polling every community's server once a second was not going to scale.

One shortcut is still marked in the code: a session left open by a bot restart counts the downtime as playtime. It needs a "bot was down" marker, and it has not been worth it yet.

Lessons learned

The useful design question for a bot with power is not "what can it do" but "what happens after it does the wrong thing". Undo, snapshots, confirmation buttons on every bulk action, a role hierarchy check and an owner-only anti-nuke are all answers to that. The features people ask for are the small part; the safety net around them is where the time went.

And the rewrite showed me what rejs really is: a database file, a command loader and a habit of telling a channel what changed. SQLite in one file, no dashboard, on your own PC or a VPS kept alive by pm2. Chat is the interface people already have open.