DMN/Software

ProjectsDiscord bot

Discord bot

A bot in TypeScript that builds Discord servers from JSON templates, moderates them and runs tickets through to the transcript. Templates from a file and AI drafts go through the same schema check with zod.

Runs on a self-hosted server, not publicly accessible

Bot message headed Hier wird nicht geschrieben: anyone who writes in this channel is muted for seven days and the message is deleted. Compromised accounts get caught this way before they reach the rest of the server. Team and support are exempt
The message in the honeypot channel, in German like the whole server. Anyone who writes there is muted without a warning.

Key facts

As of

Size
around 7,000 lines in 60 TypeScript files, 13 documentation files
Tests
43 with node --test
Database
10 migrations
Operations
Docker container with no open port

A server from a file

Roles, categories, channels and their permissions live in a JSON template. /serveraufbau (server setup) applies it in one of three modes: ergänzen (add) only creates what is missing, ersetzen (replace) also brings existing entries in line with the template, leeren (clear) clears the server and builds it again.

Before every run the bot shows the planned steps, and anything that would be deleted is listed by name. Only a button press starts the run.

Every run writes a journal. Through it the bot removes what it created and resets changed permissions. It does not bring back anything deleted, and a run with leeren cannot be undone at all.

The bot runs as a Docker container with no open port and independently of the game server, built for the Discord server that goes with the FiveM resources.

  1. Template as a file or as a draft from /serveraufbau erzeugen
  2. Check with zod, the same for file and draft
  3. Preview with every step, deletions listed by name
  4. Run as ergänzen, ersetzen or leeren
  5. Journal undoes created entries and changed permissions, not after leeren

A draft from a description

/serveraufbau erzeugen (generate) turns a description in plain text into a template, through Anthropic or Gemini. The model's answer goes through the same schema check as a file, and there is no separate check path for the model.

If the draft does not hold up, the bot reports the first ten findings with their path in the template. If Gemini answers with 404, 429 or 503, the bot tries again with a fallback model.

The model only drafts templates. Moderation and tickets run without AI.

Tickets

A panel with buttons opens tickets of the types Support, Kauf (purchase), Beschwerde (complaint) and Tebex. Every ticket gets its own channel that only the creator, the team and whoever the team adds can see.

On closing, the bot files a transcript in the archive for that type. After that the ticket can be rated with one to five stars and a comment.

/ticket statistik (statistics) shows the handled tickets, the average rating and the average duration for each team member.

The card Ticket öffnen that explains the types Support, Kauf, Beschwerde and Tebex, below it the four buttons
The ticket panel in the channel ticket-erstellen.

Moderation

The invite filter also catches spellings like “disc0rd . gg / abc” or invisible characters between the letters. Spam gets a warning, a suspected raid is recorded in the mod log. On top of that comes a word filter whose patterns the bot checks against ReDoS before storing them.

The third warning brings a one hour timeout, the fifth twelve hours, the seventh a kick and the ninth a ban. Every action, automatic or by hand, is recorded in the mod log.

The exception is the honeypot channel from the first image, where the bot mutes straight away for seven days.

In the code

The template schema is a strictObject, so unknown fields are rejected. The check on top of it reports two names that are identical after normalising, because the bot matches by name and not by key.

The moderation steps compare for equality instead of “at least”. Every warning raises the count by one, so a step fires when it is reached, not again on every further warning.

The database migrates through user_version, each migration in its own transaction. If the database is newer than the build, the bot refuses to start instead of writing to it with older code.

Excerpts from the real code, shortened.

Real code from the Discord bot

src/templates/schema.ts

export const VorlageBasis = z.strictObject({    version: z.literal(1),    schluessel,    name: z.string().min(1).max(100),    })export const Vorlage = VorlageBasis.superRefine((vorlage, ctx) => {            const gleich = normalisiere(name)        const erster = namen.get(gleich)                ctx.addIssue({            code: 'custom',            path: [...pfad, 'name'],    

src/commands/serveraufbau.ts

const geprueft = Vorlage.safeParse(roh)if (!geprueft.success) {    const zeilen = geprueft.error.issues        .slice(0, 10)        .map(i => `\`${i.path.length > 0 ? i.path.join('.') : '(Wurzel)'}\` — ${i.message}`)    await interaction.editReply({        embeds: [Embed.fehler(            'Die erzeugte Vorlage ist nicht gültig',                    )],    })    return}

src/moderation/escalation.ts

const stufen: Stufe[] = [    { ab: 3, aktion: 'stumm', dauerMs: 3_600_000 },    { ab: 5, aktion: 'stumm', dauerMs: 43_200_000 },    { ab: 7, aktion: 'kick' },    { ab: 9, aktion: 'bann' },]export function stufeFuer(anzahl: number): Stufe | null {    return stufen.find(s => s.ab === anzahl) ?? null}

src/core/db.ts

function migriere(verbindung: DatabaseSync): void {    const zeile = verbindung.prepare('PRAGMA user_version').get() as {  }    const stand = zeile.user_version    if (stand > migrationen.length) {        throw new Error(`database is at schema version ${stand}, `)    }    for (let i = stand; i < migrationen.length; i++) {        verbindung.exec('BEGIN')        try {            verbindung.exec(migrationen[i]!)            verbindung.exec(`PRAGMA user_version = ${i + 1}`)            verbindung.exec('COMMIT')        } catch (fehler) {            verbindung.exec('ROLLBACK')    

Other tasks

The bot's message with six rules, below it the section Regeln annehmen and the green button Regeln akzeptieren
The rules channel. The button below the message hands out the role that makes the rest of the server visible.

Technology

Runtime
Node 24, TypeScript without a build step, Node runs the .ts files directly
Discord
discord.js 14
Validation
zod 4
Data
node:sqlite, volumes for the database and the generated templates, backups on demand
AI
@anthropic-ai/sdk and @google/genai, only for template drafts
CI
GitHub Actions with type check and tests, Dependabot weekly
Operations
Docker Compose on a self-hosted server, read-only file system, all capabilities dropped, no-new-privileges, image pinned by digest, runs as the user node
Network
no open port, every connection goes out: to Discord, to the Tebex Headless API and, for drafts, to Anthropic or Gemini