Playergency is a gaming community, formerly known as eRPe, running since 2016. It is not mine: since 2017 I have been building its bot and website. In October 2024 its Discord server was attacked: members were kicked and banned en masse. The most likely cause was compromised or infected public bots that held moderator permissions on the server.
The scale, and no button
When it was over, practically everyone was on the ban list. Discord had no “unban all” option at the time, and Discord support did not help. What was left was clicking: one user, menu, “Revoke ban”, confirm, next. With a whole community on the ban list, that is tedious work and a recipe for mistakes.
Instead of clicking, I wrote a discord.js script that walked through the whole ban list and lifted the bans one by one.
How I would write it today
The original code did not survive. Below is a reconstruction: how I would write it today, with the same assumptions. It is not the 2024 code.
// Reconstruction (2026), not the original 2024 code.
import { Client, Events, GatewayIntentBits } from 'discord.js';
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, async () => {
const guild = await client.guilds.fetch(process.env.GUILD_ID!);
let after: string | undefined;
let lifted = 0;
// Discord returns bans in pages of up to 1000, sorted by user ID.
for (;;) {
const page = await guild.bans.fetch({ limit: 1000, after, cache: false });
if (page.size === 0) break;
for (const ban of page.values()) {
// One at a time on purpose: discord.js queues requests per route
// and waits on a 429 by itself, so the loop stays inside the limit.
await guild.bans.remove(ban.user.id, 'Mass unban after the raid');
lifted += 1;
if (lifted % 100 === 0) console.log(`lifted ${lifted}`);
}
after = page.lastKey();
}
console.log(`done, lifted ${lifted}`);
await client.destroy();
});
await client.login(process.env.DISCORD_TOKEN);
Three things in this code are worth a closer look.
Pagination. The ban list does not arrive in one piece. The endpoint returns at most a thousand entries, and you fetch the next page with after set to the last user’s ID. The cursor is an ID, not a page number, so lifting bans while you page through the list does not shift anything.
Rate limits. The temptation is to fire every request at once with Promise.all. Discord has limits per route and for the bot as a whole, and answers 429 once you cross them. discord.js has a built-in queue that waits as long as the response header says, but parallel requests still hit the same limit, so nothing is gained and the logs turn into a mess. A loop that sends requests one after another goes as fast as Discord allows, and nothing more is needed here.
A reason in the audit log. The second argument to remove ends up in the server’s audit log. After an incident like this, moderators should be able to tell bans lifted by a script from decisions made by hand.
If I were doing it today I would add one more step: instead of lifting every ban, I would read the audit log (AuditLogEvent.MemberBanAdd), pick the bans issued by the specific compromised bot account, and revoke only those. Bans that moderators had placed deliberately before would then stay in place.
The cost no script can undo
The script lifted the bans, but it did not bring people back. Someone kicked from a server has to rejoin through an invite on their own, and roughly half never did. Restoring the technical state turned out to be only part of responding to the incident. Trust and the habit of dropping by cannot be restored with a loop.
What I would tell anyone running a Discord server
This is not a list of what was done after the incident. It is advice that follows from it:
- Give bots the least privilege possible. A public bot with the right to ban is a key to your server in someone else’s pocket. If a bot does not need to moderate, it should not be able to.
- Review your bots every now and then. Every bot you add is a dependency whose code and security you do not control. The ones nobody uses any more are best removed.
- Have a tool for the worst day before it comes. A script that mass-reverts actions, tested on a test server, is a few dozen lines of code. Writing it in the middle of an incident is stress you can avoid.