Discord-Community-Server: Bauplan und Einrichtungsbefehl

Der Server ist kein Tool aus der Neunerliste, aber Broadcaster, Recap und
Clipper posten dorthin. Deshalb steht die Struktur als Code im Repo und
nicht nur in einer Oberflaeche.

packages/discord:
- permissions.ts  Rechtebits als BigInt, das Feld ist 64 Bit breit
- api.ts          REST-Client: Warteschlange, Rate Limits, lesbare Fehler
- plan.ts         Rollen, Kanaele, Rechte, AutoMod, Willkommenstext
- apply.ts        traegt den Bauplan auf einen bestehenden Server auf

Neuer Befehl `abdulive discord:setup [--server ID] [--vorschau]`.

Zugeschnitten auf 5 bis 30 gleichzeitige Zuschauer: fuenf sichtbare Kanaele
statt zwanzig, und eine selbst vergebene Rolle Live-Ping statt eines
Rundrufs an alle bei jedem Stream.

Wiederholbar statt einmalig: gefunden wird ueber Namen und frueheren Namen,
danach wird angeglichen. Der zweite Lauf aendert nichts. Geloescht wird nie,
und von Hand gesetzte Rechte-Ausnahmen bleiben stehen.

Drei Dinge, die am echten Server nachgemessen und nicht angenommen wurden:
- Discord schreibt Textkanaele klein und macht aus Leerzeichen Bindestriche;
  Emoji, Umlaute und U+2502 bleiben. normalizeName() bildet das ab, sonst
  geht bei jedem Lauf dieselbe Umbenennung erneut raus.
- Kanalnamen lassen sich nur zweimal je zehn Minuten aendern, danach
  verlangt Discord bis zu neun Minuten Pause. Der Lauf wartet hoechstens
  eine Minute und meldet den Rest.
- Eine Rolle kann nur Rollen unterhalb der eigenen verschieben, auch mit
  Administrator. rolePositions() rechnet den Platz unter der Bot-Rolle aus,
  statt blind 3, 2, 1 zu verlangen.

Zugangsdaten laufen ueber den verschluesselten Speicher (ADR-0005): der
Bot-Token wird verdeckt abgefragt, die drei Webhook-URLs werden nie
ausgegeben und nie geloggt.

Im Quelltext bleibt es bei ae/oe/ue, alles was ein Mitglied auf Discord
liest steht in richtigem Deutsch. Ein Test haelt das fest.

Begruendung, Fallstricke und die Schritte von Hand:
docs/projects/discord-community.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-15 20:43:21 +02:00
1 parent d231e1c722
commit 3b744caf6a
13 files changed
+2210 -1

No files matched your search

+450
View File
@@ -0,0 +1,450 @@
// Der ganze Durchlauf gegen einen nachgebauten Server. Kein Netz, kein Token.
//
// Die Frage, die hier beantwortet wird, ist nicht "kompiliert es", sondern:
// Was passiert beim zweiten Lauf? Ein Einrichtungsskript, das beim zweiten Mal
// alles doppelt anlegt, richtet mehr Schaden an, als es Arbeit spart.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts';
import { applyPlan, type Step } from './apply.ts';
import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts';
import { str } from './permissions.ts';
const GUILD = '100';
/** Ein Discord-Server im Arbeitsspeicher. Kennt nur die Routen, die wir nutzen. */
class FakeDiscord implements Rest {
guild: ApiGuild;
channels: ApiChannel[] = [];
webhooks: ApiWebhook[] = [];
automod: Array<{ id: string; name: string; actions?: unknown[] }> = [];
messages = new Map<string, Array<{ id: string; content: string; author: { id: string } }>>();
calls: string[] = [];
private next = 1000;
/** Platz der Bot-Rolle. Discord haengt sie beim Einladen auf 1. */
botRolePosition: number;
readonly botUserId = '5000';
readonly botRoleId = '5001';
constructor(opts: { botRolePosition?: number } = {}) {
this.botRolePosition = opts.botRolePosition ?? 8;
this.guild = {
id: GUILD, name: 'abdulive', owner_id: '1', features: [],
roles: [
{
id: GUILD, name: '@everyone', permissions: '0', position: 0,
color: 0, hoist: false, mentionable: false, managed: false,
},
{
id: this.botRoleId, name: 'abdulive_ Bot', permissions: '8',
position: this.botRolePosition, color: 0, hoist: false,
mentionable: false, managed: true,
},
],
};
}
private id(): string { return String(this.next++); }
async get<T>(path: string): Promise<T> {
this.calls.push(`GET ${path}`);
if (path === '/users/@me') return { id: this.botUserId, username: 'abdulive_ Bot', bot: true } as T;
if (path === `/guilds/${GUILD}/members/${this.botUserId}`) return { roles: [this.botRoleId] } as T;
if (path === `/guilds/${GUILD}/roles`) return structuredClone(this.guild.roles) as T;
if (path === `/guilds/${GUILD}`) return structuredClone(this.guild) as T;
if (path === `/guilds/${GUILD}/channels`) return structuredClone(this.channels) as T;
if (path === `/guilds/${GUILD}/auto-moderation/rules`) return structuredClone(this.automod) as T;
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
if (wh) return this.webhooks.filter((w) => w.channel_id === wh[1]) as T;
const msg = /^\/channels\/(\d+)\/messages\?limit=(\d+)$/.exec(path);
if (msg) return structuredClone(this.messages.get(msg[1]!) ?? []).slice(0, Number(msg[2])) as T;
throw new Error(`GET ${path} nicht nachgebaut`);
}
async post<T>(path: string, body: unknown): Promise<T> {
this.calls.push(`POST ${path}`);
const b = body as Record<string, never>;
if (path === `/guilds/${GUILD}/roles`) {
const role = { id: this.id(), position: 1, managed: false, ...b } as unknown as ApiRole;
this.guild.roles.push(role);
return structuredClone(role) as T;
}
if (path === `/guilds/${GUILD}/channels`) {
const ch = {
id: this.id(), position: this.channels.length, parent_id: null,
permission_overwrites: [], ...b,
} as unknown as ApiChannel;
this.channels.push(ch);
return structuredClone(ch) as T;
}
if (path === `/guilds/${GUILD}/auto-moderation/rules`) {
const src = body as Record<string, unknown>;
const rule = { id: this.id(), name: String(src['name']), actions: src['actions'] as unknown[] };
this.automod.push(rule);
return structuredClone(rule) as T;
}
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
if (wh) {
const made: ApiWebhook = { id: this.id(), name: String(b['name']), channel_id: wh[1]!, token: 'tok-' + this.id() };
this.webhooks.push(made);
return structuredClone(made) as T;
}
const msg = /^\/channels\/(\d+)\/messages$/.exec(path);
if (msg) {
const made = {
id: this.id(),
content: String((b as Record<string, unknown>)['content'] ?? ''),
author: { id: this.botUserId },
};
const list = this.messages.get(msg[1]!) ?? [];
list.push(made);
this.messages.set(msg[1]!, list);
return structuredClone(made) as T;
}
throw new Error(`POST ${path} nicht nachgebaut`);
}
async patch<T>(path: string, body: unknown): Promise<T> {
this.calls.push(`PATCH ${path}`);
if (path === `/guilds/${GUILD}`) return {} as T;
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
for (const e of body as Array<{ id: string; position: number }>) {
const r = this.guild.roles.find((x) => x.id === e.id);
if (!r) continue;
if (e.position >= this.botRolePosition) throw new Error('Missing Permissions');
r.position = e.position;
}
return structuredClone(this.guild.roles) as T;
}
const role = /^\/guilds\/\d+\/roles\/(\d+)$/.exec(path);
if (role) {
const r = this.guild.roles.find((x) => x.id === role[1]);
if (r) Object.assign(r, body);
return structuredClone(r) as T;
}
const rule = /^\/guilds\/\d+\/auto-moderation\/rules\/(\d+)$/.exec(path);
if (rule) {
const r = this.automod.find((x) => x.id === rule[1]);
if (r) Object.assign(r, body);
return structuredClone(r) as T;
}
const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path);
if (msg) {
const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]);
if (m) m.content = String((body as Record<string, unknown>)['content'] ?? '');
return structuredClone(m) as T;
}
const ch = /^\/channels\/(\d+)$/.exec(path);
if (ch) {
const c = this.channels.find((x) => x.id === ch[1]);
if (c) Object.assign(c, body);
return structuredClone(c) as T;
}
throw new Error(`PATCH ${path} nicht nachgebaut`);
}
}
const run = (api: Rest, dryRun = false) =>
applyPlan(api, GUILD, { dryRun, saveSecrets: false });
/** Der Kanal aus dem Bauplan, so wie er im nachgebauten Server gelandet ist. */
const live = (api: FakeDiscord, key: string): ApiChannel | undefined => {
const plan = CHANNELS.find((c) => c.key === key);
assert.ok(plan, `key ${key} gibt es im Bauplan nicht`);
return api.channels.find((c) => c.name === plan.name && c.type === plan.type);
};
const katName = (key: string): string => {
const k = CATEGORIES.find((c) => c.key === key);
assert.ok(k);
return k.name;
};
const counts = (steps: Step[]): Record<string, number> => {
const out: Record<string, number> = {};
for (const s of steps) out[s.change] = (out[s.change] ?? 0) + 1;
return out;
};
// ---------------------------------------------------------------- Erstlauf
test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () => {
const api = new FakeDiscord();
const res = await run(api);
assert.equal(res.guild, 'abdulive');
for (const r of ROLES) {
assert.ok(api.guild.roles.find((x) => x.name === r.name), `Rolle ${r.name} fehlt`);
}
assert.equal(api.channels.filter((c) => c.type === CH.CATEGORY).length, 3);
for (const c of CHANNELS) {
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
}
assert.equal(res.webhooks, 3);
assert.equal(api.automod.length, 2);
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
});
test('@everyone bekommt die Grundrechte aus dem Bauplan', async () => {
const api = new FakeDiscord();
await run(api);
const everyone = api.guild.roles.find((r) => r.id === GUILD);
assert.equal(everyone?.permissions, str(EVERYONE));
});
test('Kanaele haengen unter der richtigen Kategorie', async () => {
const api = new FakeDiscord();
await run(api);
const kat = (key: string) =>
api.channels.find((c) => c.name === katName(key) && c.type === CH.CATEGORY)?.id;
for (const plan of CHANNELS) {
const c = live(api, plan.key);
assert.equal(c?.parent_id, kat(plan.category!), `${plan.name} haengt falsch`);
}
});
test('der interne Bereich ist fuer @everyone gesperrt und fuer Mod offen', async () => {
const api = new FakeDiscord();
await run(api);
const intern = api.channels.find((c) => c.name === katName('intern'));
const mod = api.guild.roles.find((r) => r.name === 'Mod');
const ow = intern?.permission_overwrites ?? [];
const jeder = ow.find((o) => o.id === GUILD);
assert.ok(jeder && (BigInt(jeder.deny) & 1024n) === 1024n, 'VIEW_CHANNEL muss fuer @everyone gesperrt sein');
const fuerMod = ow.find((o) => o.id === mod?.id);
assert.ok(fuerMod && (BigInt(fuerMod.allow) & 1024n) === 1024n, 'Mod muss hineinsehen koennen');
});
test('die Begruessung wird gepostet und die Kanalnamen sind aufgeloest', async () => {
const api = new FakeDiscord();
await run(api);
const willkommen = live(api, 'willkommen');
const posts = api.messages.get(willkommen!.id) ?? [];
assert.equal(posts.length, 1);
assert.match(posts[0]!.content, /<#\d+> - alles andere/, 'die Kanalnamen muessen aufgeloest sein');
});
// ---------------------------------------------------------------- Zweitlauf
test('der zweite Lauf aendert nichts', async () => {
const api = new FakeDiscord();
await run(api);
const nachErstlauf = {
rollen: api.guild.roles.length,
kanaele: api.channels.length,
webhooks: api.webhooks.length,
automod: api.automod.length,
};
api.calls = [];
const res = await run(api);
assert.deepEqual({
rollen: api.guild.roles.length,
kanaele: api.channels.length,
webhooks: api.webhooks.length,
automod: api.automod.length,
}, nachErstlauf, 'beim zweiten Lauf wurde etwas doppelt angelegt');
// Kein POST ausser den Lesevorgaengen - nichts Neues, nichts Geaendertes.
const schreibend = api.calls.filter((c) => c.startsWith('POST') || (c.startsWith('PATCH') && !c.endsWith('/roles')));
assert.deepEqual(schreibend.filter((c) => c !== `PATCH /guilds/${GUILD}`), [],
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
assert.equal(res.webhooks, 3, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
});
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
const api = new FakeDiscord();
await run(api);
await run(api);
const willkommen = live(api, 'willkommen');
assert.equal((api.messages.get(willkommen!.id) ?? []).length, 1);
});
// ---------------------------------------------------------------- Vorschau
test('die Vorschau schreibt nichts', async () => {
const api = new FakeDiscord();
const res = await run(api, true);
assert.equal(api.guild.roles.length, 2, 'nur @everyone und die Bot-Rolle, die es schon gab');
assert.equal(api.channels.length, 0);
assert.equal(api.webhooks.length, 0);
assert.equal(api.automod.length, 0);
assert.deepEqual(api.calls.filter((c) => !c.startsWith('GET')), []);
assert.ok((counts(res.steps)['erstellt'] ?? 0) > 10, 'die Vorschau muss zeigen, was entstehen wuerde');
});
// ---------------------------------------------------------------- Bestand
test('von Hand angelegte Kanaele und Rechte bleiben stehen', async () => {
const api = new FakeDiscord();
api.channels.push({
id: '900', name: 'memes', type: CH.TEXT, position: 0, parent_id: null,
permission_overwrites: [{ id: '777', type: 1, allow: '2048', deny: '0' }],
});
api.channels.push({
id: '901', name: 'allgemein', type: CH.TEXT, position: 1, parent_id: null,
permission_overwrites: [{ id: '777', type: 1, allow: '0', deny: '2048' }],
});
await run(api);
assert.ok(api.channels.find((c) => c.name === 'memes'), 'ein fremder Kanal darf nicht verschwinden');
const allgemein = api.channels.find((c) => c.id === '901');
assert.equal(live(api, 'allgemein')?.id, '901', 'der bestehende Kanal muss umbenannt werden');
assert.equal(api.channels.filter((c) => c.name.includes('allgemein')).length, 1, 'nicht doppelt anlegen');
const fremd = (allgemein?.permission_overwrites ?? []).find((o: ApiOverwrite) => o.id === '777');
assert.deepEqual(fremd, { id: '777', type: 1, allow: '0', deny: '2048' },
'eine von Hand gesetzte Ausnahme darf nicht wegfallen');
});
test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', async () => {
const api = new FakeDiscord();
await run(api); // legt alles an
// Jemand loescht den Go-Live-Webhook und baut ihn in der Oberflaeche nach -
// dann fehlt uns der Token, und das muss auffallen statt still zu scheitern.
const i = api.webhooks.findIndex((w) => w.name === 'Go-Live');
delete api.webhooks[i]!.token;
const res = await run(api);
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /nicht auslesbar/);
assert.equal(res.webhooks, 2);
});
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
const api = new FakeDiscord();
// Die Twitch-Anbindung legt eigene Rollen an. Die gehoeren Discord.
api.guild.roles.push({
id: '800', name: 'Stammgast', permissions: '0', position: 2,
color: 0, hoist: false, mentionable: false, managed: true,
});
const res = await run(api);
const step = res.steps.find((s) => s.kind === 'Rolle' && s.name === 'Stammgast');
assert.equal(step?.change, 'uebersprungen');
assert.equal(api.guild.roles.find((r) => r.id === '800')?.permissions, '0', 'unveraendert geblieben');
});
test('scheitert die Rangfolge, laeuft der Rest trotzdem durch', async () => {
const api = new FakeDiscord();
const echt = api.patch.bind(api);
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions');
return echt<T>(path, body);
};
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Rangfolge');
assert.equal(step?.change, 'uebersprungen');
assert.equal(api.channels.length, 3 + CHANNELS.length, 'die Kanaele muessen trotzdem entstehen');
});
// ---------------------------------------------------------------- Rangfolge
test('die Rollen landen unter der Bot-Rolle, in der Reihenfolge des Bauplans', async () => {
const api = new FakeDiscord({ botRolePosition: 8 });
await run(api);
const pos = (name: string) => api.guild.roles.find((r) => r.name === name)?.position;
assert.deepEqual(ROLES.map((r) => pos(r.name)), [7, 6, 5, 4]);
for (const r of ROLES) {
assert.ok(pos(r.name)! < api.botRolePosition, `${r.name} steht ueber der Bot-Rolle`);
}
});
test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async () => {
// Der reale Fall direkt nach dem Einladen. Discord haengt die Bot-Rolle auf
// Platz 1, darunter ist kein Platz - frueher gab das ein Missing Permissions.
const api = new FakeDiscord({ botRolePosition: 1 });
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Rangfolge');
assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /nach oben ziehen/);
// Alles andere muss trotzdem stehen.
assert.equal(api.channels.length, 3 + CHANNELS.length);
assert.equal(res.webhooks, 3);
});
// ---------------------------------------------------------------- Umbenennen
test('ein Kanal unter altem Namen wird umbenannt, nicht verdoppelt', async () => {
const api = new FakeDiscord();
// So sah der Server nach dem ersten Lauf ohne Emoji aus.
const alt = new Map<string, string>();
CHANNELS.forEach((plan, i) => {
const id = String(900 + i); // der Fake erwartet Ziffern-IDs
alt.set(plan.key, id);
api.channels.push({
id, name: plan.aliases![0]!, type: plan.type,
position: 0, parent_id: null, permission_overwrites: [],
});
});
const res = await run(api);
for (const plan of CHANNELS) {
assert.equal(live(api, plan.key)?.id, alt.get(plan.key), `${plan.key} wurde neu angelegt statt umbenannt`);
}
assert.equal(api.channels.filter((c) => c.type !== CH.CATEGORY).length, CHANNELS.length,
'es darf kein Kanal dazugekommen sein');
assert.ok(res.steps.some((s) => s.name.includes('->')), 'die Umbenennung muss im Bericht stehen');
});
test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', async () => {
const api = new FakeDiscord();
api.channels.push({
id: '901', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null,
permission_overwrites: [],
});
const echt = api.patch.bind(api);
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause');
return echt<T>(path, body);
};
const res = await run(api);
const step = res.steps.find((s) => s.name.includes('allgemein'));
assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /530 s/);
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
assert.equal(res.webhooks, 3);
assert.equal(api.automod.length, 2);
});
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
const api = new FakeDiscord();
await run(api);
const regel = api.automod[0]!;
(regel.actions as Array<{ type: number; metadata: { custom_message: string } }>)[0]!
.metadata.custom_message = 'alter Text';
const res = await run(api);
const step = res.steps.find((s) => s.kind === 'AutoMod' && s.name === regel.name);
assert.equal(step?.change, 'angepasst');
assert.match(step?.note ?? '', /actions/);
assert.equal(api.automod.length, 2, 'die Regel darf nicht neu angelegt werden');
});
test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', async () => {
const api = new FakeDiscord();
await run(api);
const regel = api.automod[0]!;
const actions = regel.actions as Array<{ type: number; metadata: Record<string, string> }>;
actions[0]!.metadata['custom_message'] = 'alter Text';
actions[1]!.metadata['channel_id'] = '424242'; // jemand hat umgestellt
await run(api);
const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2);
assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden');
});