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:
1 parent
d231e1c722
commit
3b744caf6a
13 files changed
+2210
-1
No files matched your search
@@ -0,0 +1,324 @@
|
||||
// Der Bauplan wird gegen einen echten Server gefahren, auf dem danach Leute
|
||||
// sitzen. Ein vertipptes Recht faellt dort nicht auf - es faellt auf, wenn
|
||||
// jemand es ausnutzt. Deshalb pruefen diese Tests vor allem das, was nicht
|
||||
// drinstehen darf.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { P, bits, names, str } from './permissions.ts';
|
||||
import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, WELCOME } from './plan.ts';
|
||||
import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts';
|
||||
import { CH } from './api.ts';
|
||||
|
||||
const role = (name: string) => {
|
||||
const r = ROLES.find((x) => x.name === name);
|
||||
assert.ok(r, `Rolle ${name} fehlt im Bauplan`);
|
||||
return r;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Bits
|
||||
|
||||
test('Rechtebits entsprechen den Werten der Discord-API', () => {
|
||||
assert.equal(P.VIEW_CHANNEL, 1024n);
|
||||
assert.equal(P.SEND_MESSAGES, 2048n);
|
||||
assert.equal(P.ADMINISTRATOR, 8n);
|
||||
assert.equal(P.MENTION_EVERYONE, 131072n);
|
||||
// Jenseits von 2^31 - hier wuerde eine number-Rechnung still falsch werden.
|
||||
assert.equal(P.CREATE_PUBLIC_THREADS, 34359738368n);
|
||||
assert.equal(P.MODERATE_MEMBERS, 1099511627776n);
|
||||
assert.equal(P.SEND_POLLS, 562949953421312n);
|
||||
});
|
||||
|
||||
test('bits vereinigt, str liefert den Dezimalstring der API', () => {
|
||||
assert.equal(bits('VIEW_CHANNEL', 'SEND_MESSAGES'), 3072n);
|
||||
assert.equal(str(bits('VIEW_CHANNEL', 'SEND_MESSAGES')), '3072');
|
||||
assert.equal(bits(), 0n);
|
||||
// Doppelt genannt bleibt einfach gesetzt.
|
||||
assert.equal(bits('VIEW_CHANNEL', 'VIEW_CHANNEL'), 1024n);
|
||||
});
|
||||
|
||||
test('names liest ein Bitfeld wieder auf', () => {
|
||||
assert.deepEqual(names(bits('KICK_MEMBERS', 'BAN_MEMBERS')), ['KICK_MEMBERS', 'BAN_MEMBERS']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rollen
|
||||
|
||||
test('@everyone bekommt kein Recht, mit dem sich der Server sprengen laesst', () => {
|
||||
for (const verboten of [
|
||||
'ADMINISTRATOR', 'MENTION_EVERYONE', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS',
|
||||
'MANAGE_MESSAGES', 'MANAGE_WEBHOOKS', 'KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_TTS_MESSAGES',
|
||||
] as const) {
|
||||
assert.equal(EVERYONE & P[verboten], 0n, `@everyone darf nicht ${verboten}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('@everyone kann das Noetige: lesen, schreiben, einladen', () => {
|
||||
for (const noetig of ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY', 'CREATE_INSTANT_INVITE'] as const) {
|
||||
assert.equal(EVERYONE & P[noetig], P[noetig], `@everyone braucht ${noetig}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Mod darf moderieren, aber nicht umbauen', () => {
|
||||
const mod = role('Mod').permissions;
|
||||
for (const darf of ['KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', 'MANAGE_MESSAGES'] as const) {
|
||||
assert.equal(mod & P[darf], P[darf], `Mod braucht ${darf}`);
|
||||
}
|
||||
for (const nicht of ['ADMINISTRATOR', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_WEBHOOKS'] as const) {
|
||||
assert.equal(mod & P[nicht], 0n, `Mod darf nicht ${nicht}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Administrator traegt genau eine Rolle, und das ist Abdu', () => {
|
||||
const mitAdmin = ROLES.filter((r) => (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR);
|
||||
assert.deepEqual(mitAdmin.map((r) => r.name), ['Abdu']);
|
||||
});
|
||||
|
||||
test('Abdu steht ganz oben in der Rangfolge', () => {
|
||||
// Die Reihenfolge in ROLES ist die Rangfolge auf dem Server.
|
||||
assert.equal(ROLES[0]?.name, 'Abdu');
|
||||
});
|
||||
|
||||
test('Stammgast ist Auszeichnung, keine Macht', () => {
|
||||
const s = role('Stammgast');
|
||||
assert.equal(s.hoist, true, 'muss in der Mitgliederliste sichtbar sein - das ist der ganze Zweck');
|
||||
// Ueber @everyone hinaus nur Kosmetik und Sprachkanal-Vorrang.
|
||||
assert.equal(s.permissions & ~(EVERYONE | P.PRIORITY_SPEAKER), 0n);
|
||||
});
|
||||
|
||||
test('Live-Ping ist erwaehnbar, sonst nichts', () => {
|
||||
const l = role('Live-Ping');
|
||||
assert.equal(l.mentionable, true, 'sonst kann die Go-Live-Meldung sie nicht anpingen');
|
||||
assert.equal(l.permissions & ~EVERYONE, 0n);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanaele
|
||||
|
||||
test('Kanalnamen sind eindeutig und ueberleben Discords Normalisierung', () => {
|
||||
const seen = new Set<string>();
|
||||
for (const c of CHANNELS) {
|
||||
// Discord schreibt Textkanaele klein. Steht im Plan ein Grossbuchstabe,
|
||||
// weicht der gespeicherte Name ab und wir benennen bei jedem Lauf erneut um.
|
||||
if (c.type === CH.TEXT) {
|
||||
assert.equal(c.name, normalizeName(c.name, c.type),
|
||||
`${c.name}: Discord wuerde den Namen veraendern`);
|
||||
}
|
||||
assert.equal(seen.has(c.name), false, `${c.name} kommt doppelt vor`);
|
||||
seen.add(c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Eintrag hat einen eindeutigen key', () => {
|
||||
const keys = [...CATEGORIES.map((c) => c.key), ...CHANNELS.map((c) => c.key)];
|
||||
assert.equal(new Set(keys).size, keys.length, 'keys muessen eindeutig sein');
|
||||
});
|
||||
|
||||
test('der alte Name steht als alias drin, sonst entsteht ein zweiter Kanal', () => {
|
||||
// Beim Umstellen auf Emoji-Namen war genau das die Gefahr.
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.aliases?.length, `${c.key} hat keinen frueheren Namen hinterlegt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Kanal haengt in einer Kategorie, die es gibt', () => {
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.category, `${c.name} hat keine Kategorie`);
|
||||
assert.ok(CATEGORIES.some((k) => k.key === c.category),
|
||||
`${c.name}: Kategorie ${c.category} ist unbekannt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Rechte-Ausnahmen verweisen nur auf Rollen aus dem Bauplan', () => {
|
||||
const bekannt = new Set(['@everyone', ...ROLES.map((r) => r.name)]);
|
||||
const alle = [...CHANNELS.flatMap((c) => c.overwrites ?? []), ...CATEGORIES.flatMap((c) => c.overwrites ?? [])];
|
||||
for (const o of alle) assert.equal(bekannt.has(o.role), true, `unbekannte Rolle: ${o.role}`);
|
||||
});
|
||||
|
||||
test('die interne Kategorie ist fuer Mitglieder unsichtbar, fuer Mods nicht', () => {
|
||||
const ow = CATEGORIES.find((c) => c.key === 'intern')?.overwrites;
|
||||
assert.ok(ow);
|
||||
const jeder = ow.find((o) => o.role === '@everyone');
|
||||
const mod = ow.find((o) => o.role === 'Mod');
|
||||
assert.equal(jeder?.deny! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
assert.equal(mod?.allow! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
});
|
||||
|
||||
test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
|
||||
for (const key of ['mod-chat', 'technik-log']) {
|
||||
const c = CHANNELS.find((x) => x.key === key);
|
||||
assert.equal(c?.category, 'intern', `${key} waere sonst oeffentlich`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Webhook-Platz kommt genau einmal vor', () => {
|
||||
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
|
||||
assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']);
|
||||
});
|
||||
|
||||
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
|
||||
// Go-Live und Recap muessen obenauf bleiben; in #clips darf jeder posten.
|
||||
const ank = CHANNELS.find((c) => c.key === 'ankuendigungen');
|
||||
const deny = ank?.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
|
||||
assert.equal(deny & P.SEND_MESSAGES, P.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => {
|
||||
const keys = new Set(CHANNELS.map((c) => c.key));
|
||||
for (const [, key] of WELCOME.matchAll(/\{(\w[\w-]*)\}/g)) {
|
||||
assert.equal(keys.has(key!), true, `${key} aus dem Willkommenstext gibt es nicht`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Merge
|
||||
|
||||
const ID = (name: string): string | undefined =>
|
||||
({ '@everyone': '1', Mod: '2', Stammgast: '3' })[name];
|
||||
|
||||
test('fremde Rechte-Ausnahmen bleiben stehen', () => {
|
||||
// "99" hat jemand von Hand gesetzt. Das Skript darf das nicht wegraeumen.
|
||||
const vorher = [{ id: '99', type: 0 as const, allow: '8', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.ok(out);
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(out.find((o) => o.id === '99'), vorher[0]);
|
||||
assert.deepEqual(out.find((o) => o.id === '2'), { id: '2', type: 0, allow: '1024', deny: '0' });
|
||||
});
|
||||
|
||||
test('nichts zu tun heisst null - dann faellt das PATCH weg', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '2048' }];
|
||||
assert.equal(mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID), null);
|
||||
});
|
||||
|
||||
test('abweichende Rechte werden ueberschrieben, nicht ergaenzt', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(out, [{ id: '1', type: 0, allow: '0', deny: '2048' }]);
|
||||
});
|
||||
|
||||
test('eine Ausnahme fuer ein Mitglied wird nicht mit einer Rolle verwechselt', () => {
|
||||
// type 1 ist ein einzelnes Mitglied. Gleiche ID, anderer Typ - darf nicht
|
||||
// ueberschrieben werden.
|
||||
const vorher = [{ id: '2', type: 1 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out?.[0], vorher[0]);
|
||||
});
|
||||
|
||||
test('Rollen, die es noch nicht gibt, werden uebergangen statt zu raten', () => {
|
||||
assert.equal(mergeOverwrites([], [{ role: 'Gibtsnicht', allow: 1024n }], ID), null);
|
||||
});
|
||||
|
||||
test('die Vorlage wird nicht verbogen', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '0' }];
|
||||
const kopie = structuredClone(vorher);
|
||||
mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(vorher, kopie, 'mergeOverwrites darf die Eingabe nicht veraendern');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Namen
|
||||
|
||||
test('normalizeName bildet ab, was Discord mit dem Namen macht', () => {
|
||||
// Am echten Server nachgemessen: kleinschreiben, Leerzeichen zu Bindestrich,
|
||||
// Emoji, Umlaute und U+2502 bleiben unveraendert.
|
||||
assert.equal(normalizeName('Aü Test Kanal', CH.TEXT), 'aü-test-kanal');
|
||||
assert.equal(normalizeName('📢│ankündigungen', CH.TEXT), '📢│ankündigungen');
|
||||
// Sprachkanaele und Kategorien behalten Grossbuchstaben und Leerzeichen.
|
||||
assert.equal(normalizeName('🔊 Zusammen zocken', CH.VOICE), '🔊 Zusammen zocken');
|
||||
assert.equal(normalizeName('📌 Info', CH.CATEGORY), '📌 Info');
|
||||
});
|
||||
|
||||
test('findChannel erkennt den Kanal am frueheren Namen', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: 'sonstwas', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '1');
|
||||
});
|
||||
|
||||
test('findChannel bevorzugt den neuen Namen vor dem alten', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: '💬│allgemein', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '2');
|
||||
});
|
||||
|
||||
test('findChannel verwechselt Sprach- und Textkanal nicht', () => {
|
||||
const vorhanden = [{ id: '1', name: 'clips', type: CH.VOICE, position: 0, parent_id: null }];
|
||||
assert.equal(findChannel(vorhanden, { name: '🎬│clips', aliases: ['clips'], type: CH.TEXT }), undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('rolePositions setzt die Rollen unter die Bot-Rolle', () => {
|
||||
// Bot auf Platz 5, drei Rollen -> 4, 3, 2. Platz 1 bleibt frei, 0 ist @everyone.
|
||||
assert.deepEqual(rolePositions(3, 5), { positions: [4, 3, 2] });
|
||||
});
|
||||
|
||||
test('rolePositions nutzt den Platz genau aus', () => {
|
||||
assert.deepEqual(rolePositions(3, 4), { positions: [3, 2, 1] });
|
||||
});
|
||||
|
||||
test('steht die Bot-Rolle zu weit unten, sagt rolePositions was zu tun ist', () => {
|
||||
// Der Normalfall direkt nach dem Einladen: Bot auf Platz 1, kein Platz darunter.
|
||||
const out = rolePositions(3, 1);
|
||||
assert.ok('problem' in out);
|
||||
assert.match(out.problem, /Platz fuer 0 von 3/);
|
||||
assert.match(out.problem, /nach oben ziehen/);
|
||||
});
|
||||
|
||||
test('rolePositions verlangt nie einen Platz ueber der Bot-Rolle', () => {
|
||||
// Genau das hat Discord mit "Missing Permissions" quittiert.
|
||||
for (let botTop = 1; botTop <= 8; botTop++) {
|
||||
const out = rolePositions(3, botTop);
|
||||
if ('problem' in out) continue;
|
||||
for (const p of out.positions) {
|
||||
assert.ok(p < botTop, `Platz ${p} liegt nicht unter der Bot-Rolle (${botTop})`);
|
||||
assert.ok(p > 0, `Platz ${p} wuerde @everyone verdraengen`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Deutsch
|
||||
|
||||
test('was Mitglieder lesen, steht in richtigem Deutsch', () => {
|
||||
// Im Quelltext ist ae/oe/ue Konvention. In allem, was auf Discord sichtbar
|
||||
// ist, waere es ein Tippfehler - "Sei anstaendig" liest sich wie einer.
|
||||
const sichtbar = [
|
||||
WELCOME,
|
||||
...CHANNELS.map((c) => c.name),
|
||||
...CHANNELS.map((c) => c.topic ?? ''),
|
||||
...CATEGORIES.map((c) => c.name),
|
||||
...ROLES.map((r) => r.name),
|
||||
...AUTOMOD.map((r) => r.name),
|
||||
AUTOMOD_BLOCK,
|
||||
].join('\n');
|
||||
|
||||
// Wortstaemme, die im Deutschen praktisch immer einen Umlaut tragen.
|
||||
for (const falsch of ['ae', 'oe', 'ue']) {
|
||||
const treffer = sichtbar.match(new RegExp('\\w*' + falsch + '\\w*', 'g')) ?? [];
|
||||
// "Neuer", "Feuer" und Aehnliches sind echte Vorkommen - nur die bekannten
|
||||
// Umschreibungen aus diesem Projekt duerfen nicht auftauchen.
|
||||
for (const wort of treffer) {
|
||||
assert.equal(
|
||||
/gehoert|anstaendig|Probelaeufe|Erwaehnung|aendert|laeuft|zusaetzlich|Aenderung/i.test(wort),
|
||||
false,
|
||||
`"${wort}" gehoert in richtiges Deutsch geschrieben`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Abdu traegt Administrator, sonst niemand', () => {
|
||||
for (const r of ROLES) {
|
||||
const admin = (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR;
|
||||
assert.equal(admin, r.name === 'Abdu', `${r.name}: Administrator=${admin}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('die AutoMod-Regel kennt ihren frueheren Namen', () => {
|
||||
const regel = AUTOMOD.find((r) => r.name === 'Erwähnungs-Spam');
|
||||
assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'),
|
||||
'sonst steht die Regel nach der Umbenennung zweimal da');
|
||||
});
|
||||
Reference in new issue
Block a user