Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen seit der Discord-Einrichtung bereit, gepostet hat nie jemand. Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping anpingen. Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>` allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein eigener Test. Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm: - Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung. - Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem Verbindungsabbruch. - Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig. `stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein, wenn es die Sitzung nicht gibt - beim Probelauf etwa. Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das Ergebnis, bevor es der Server sieht. Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen anpingen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
// 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', () => {
|
|
// Zweimal derselbe Platz hiesse: der zweite Kanal ueberschreibt die URL des
|
|
// ersten im Zugangsdaten-Speicher, und einer der beiden postet ins Leere.
|
|
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
|
|
assert.deepEqual([...slots].sort(),
|
|
['clipsWebhook', 'goLiveWebhook', 'recapWebhook', 'technikWebhook']);
|
|
assert.equal(new Set(slots).size, slots.length);
|
|
});
|
|
|
|
test('der Entwurfsmodus postet in einen Kanal, den Mitglieder nicht sehen', () => {
|
|
// `broadcaster.mode: draft` schickt dieselbe Nachricht wie im Betrieb,
|
|
// nur ohne Ping. Laege der Kanal oeffentlich, waere jeder Probelauf eine
|
|
// falsche Ankuendigung im Server.
|
|
const kanal = CHANNELS.find((c) => (c.webhooks ?? []).some((w) => w.slot === 'technikWebhook'));
|
|
assert.equal(kanal?.category, 'intern');
|
|
});
|
|
|
|
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');
|
|
});
|