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

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.
- Template as a file or as a draft from /serveraufbau erzeugen
- Check with zod, the same for file and draft
- Preview with every step, deletions listed by name
- Run as ergänzen, ersetzen or leeren
- 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.

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
- Rules gate, visible in the image below: a button below the rules hands out the role for the rest of the server
- Welcome message for new members as an embed
- Product channels: /scripts anlegen (create) sets up a channel for one package from the store, the server setup creates them for all packages at once. /scripts aktualisieren (update) pulls in changes, both through the Tebex Headless API

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