diff --git a/README.md b/README.md index 38d1ef0..cd6afd8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Dazu der Dienst, der unter allen anderen liegt: - [`docs/offene-punkte.md`](docs/offene-punkte.md) — was noch fehlt - [`docs/obs-layout.md`](docs/obs-layout.md) — die Layout-Vorgabe für OBS - [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool +- [`docs/projects/discord-community.md`](docs/projects/discord-community.md) — Aufbau des Community-Servers - [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs) ## Auf dem Stream-PC installieren diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 03ac666..1cdb4d1 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -557,6 +557,95 @@ async function listSources(profileName = 'default'): Promise { const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`; +// ---------------------------------------------------------------- discord + +/** Liest eine Eingabe, ohne sie im Terminal anzuzeigen. */ +function promptHidden(frage: string): Promise { + return new Promise((resolve, reject) => { + const input = process.stdin; + if (!input.isTTY) { + reject(new Error(`${frage.trim()} - dafuer wird ein Terminal gebraucht.`)); + return; + } + process.stdout.write(frage); + input.setRawMode(true); + input.resume(); + input.setEncoding('utf8'); + let buf = ''; + const stop = (): void => { input.off('data', onData); input.setRawMode(false); input.pause(); }; + const onData = (chunk: string): void => { + for (const c of chunk) { + if (c === '\r' || c === '\n') { stop(); process.stdout.write('\n'); resolve(buf.trim()); return; } + if (c === '\u0003') { stop(); process.stdout.write('\n'); reject(new Error('Abgebrochen.')); return; } + if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue; } + buf += c; + } + }; + input.on('data', onData); + }); +} + +async function discordSetup(args: string[]): Promise { + const dry = args.includes('--vorschau'); + const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts'); + const stored = readSecrets().discord ?? {}; + + const at = args.indexOf('--server'); + const fromArgs = at >= 0 ? args[at + 1] : undefined; + const guildId = fromArgs ?? stored.guildId ?? process.env.DISCORD_GUILD_ID; + if (!guildId || guildId.startsWith('--')) { + throw new Error( + 'Keine Server-ID. Aufruf: abdulive discord:setup --server \n' + + 'Die ID steht in Discord im Rechtsklickmenue des Servers unter "Server-ID kopieren"\n' + + '(dafuer in den Einstellungen unter "Erweitert" den Entwicklermodus einschalten).' + ); + } + + const token = stored.botToken || process.env.DISCORD_BOT_TOKEN || + await promptHidden('Discord-Bot-Token (Eingabe wird nicht angezeigt): '); + if (!token) throw new Error('Ohne Bot-Token geht es nicht.'); + + const { Discord } = await import('#discord/api.ts'); + const { applyPlan } = await import('#discord/apply.ts'); + const result = await applyPlan(new Discord(token), guildId, { dryRun: dry }); + + if (!dry) updateSecrets((s) => { s.discord = { ...s.discord, botToken: token, guildId }; }); + + const mark: Record = { + erstellt: `${C.ok} NEU ${C.off}`, + angepasst: `${C.ok} GESETZT${C.off}`, + unveraendert: `${C.dim} steht ${C.off}`, + uebersprungen: `${C.warn} UEBERSPRUNGEN${C.off}`, + }; + + console.log(`\nServer: ${result.guild}${dry ? ` ${C.dim}(Vorschau - nichts geaendert)${C.off}` : ''}\n`); + let kind = ''; + for (const s of result.steps) { + if (s.kind !== kind) { console.log(`${C.dim}${s.kind}${C.off}`); kind = s.kind; } + const note = s.note ? ` ${C.dim}(${s.note})${C.off}` : ''; + console.log(` ${mark[s.change] ?? s.change} ${s.name}${note}`); + } + + const skipped = result.steps.filter((s) => s.change === 'uebersprungen').length; + console.log(''); + if (dry) { + console.log('Nichts geaendert. Ohne --vorschau wird es angewendet.'); + return; + } + console.log(`${result.webhooks} Webhook-URLs im verschluesselten Speicher abgelegt.`); + if (skipped) console.log(`${C.warn}${skipped} Punkt(e) uebersprungen - siehe Hinweise oben.${C.off}`); + console.log(` +Was jetzt noch von Hand gehoert: + 1. Dir selbst die Rolle "Abdu" geben und die Bot-Rolle in den + Servereinstellungen ueber "Abdu" ziehen. + 2. Server-Einstellungen > Onboarding: "Live-Ping" als waehlbare Rolle + anbieten, damit sie sich jeder selbst holen kann. + 3. Einladungslink erzeugen (ohne Ablaufdatum) und in Twitch-Panel, + Kanalinfo und Chat-Befehl eintragen. + 4. Twitch-Konto in den Servereinstellungen unter "Integrationen" + verbinden, dann bekommen Subs automatisch eine Rolle.`); +} + // ---------------------------------------------------------------- help function help(): void { @@ -571,6 +660,8 @@ abdulive - Werkzeugkasten fuer Abdus Stream sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt? update [--force] Nach einer neuen Fassung sehen und sie bereitlegen render [profil] Rohdatei nach 9:16 rendern (zum Nachrendern) + discord:setup [--server ID] [--vorschau] + Discord-Server nach docs/projects/discord-community.md einrichten help Diese Uebersicht Einrichtung nach dem Start: http://localhost:4792/setup @@ -601,6 +692,7 @@ try { break; } case 'render': await renderOne(args[0], args[1]); break; + case 'discord:setup': await discordSetup(args); break; case 'help': case undefined: help(); break; default: console.error(`Unbekannter Befehl "${cmd}".`); diff --git a/docs/projects/README.md b/docs/projects/README.md index 1f657b9..02ab1aa 100644 --- a/docs/projects/README.md +++ b/docs/projects/README.md @@ -15,3 +15,4 @@ Entscheidungen, Fallstricke, Abnahmekriterien. | 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` | | 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` | | 9 | [Stream-Recap](stream-recap.md) | `apps/recap` | +| — | [Discord-Community](discord-community.md) | `packages/discord` | diff --git a/docs/projects/discord-community.md b/docs/projects/discord-community.md new file mode 100644 index 0000000..242cc6a --- /dev/null +++ b/docs/projects/discord-community.md @@ -0,0 +1,245 @@ +# Discord-Community + +**Wirkrichtung:** Bindung. Twitch ist der Ort, an dem Leute zuschauen. Discord +ist der Ort, an dem sie zwischen den Streams noch da sind. Ohne den zweiten Ort +faengt jeder Stream bei null an. + +Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin: +[Go-Live-Broadcaster](go-live-broadcaster.md), [Stream-Recap](stream-recap.md) +und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo +und nicht nur in einer Oberflaeche. + +## Leitgedanke: klein anfangen + +Der Kanal hat 5 bis 30 gleichzeitige Zuschauer. Daraus werden realistisch 20 +bis 100 Discord-Mitglieder, von denen eine Handvoll schreibt. + +Ein Server mit zwanzig Kanaelen sieht bei dieser Groesse nicht nach einer +grossen Community aus, sondern nach einer verlassenen. Zwanzig leere Kanaele +sagen jedem Neuankoemmling: hier ist nichts los. Fuenf Kanaele, in denen etwas +steht, sagen das Gegenteil. + +Also: **fuenf sichtbare Kanaele.** Wachstum kommt spaeter, und nur dann, wenn +ein bestehender Kanal wirklich zu voll wird. Wann das so weit ist, steht weiter +unten unter [Wann ein Kanal dazukommt](#wann-ein-kanal-dazukommt). + +## Die Struktur + +Der Bauplan liegt in [`packages/discord/src/plan.ts`](../../packages/discord/src/plan.ts) +und ist die massgebliche Fassung. Was hier steht, ist die Begruendung dazu. + +### Kanaele + +| Kategorie | Kanal | Wer schreibt | Wofuer | +|---|---|---|---| +| 📌 Info | `👋│willkommen` | niemand | Die drei Regeln, einmal gepostet | +| 📌 Info | `📢│ankündigungen` | nur Webhooks | Stream-Start und Recap; Diskussion in Threads | +| 🎮 Community | `💬│allgemein` | alle | Der Hauptkanal. Im Zweifel hier | +| 🎮 Community | `🎬│clips` | alle + Clip-Knopf | Gute Momente | +| 🎮 Community | `🔊 Zusammen zocken` | — | Sprachkanal | +| 🔒 Intern | `🔨│mod-chat` | Mods | Fuer alle anderen unsichtbar | +| 🔒 Intern | `🤖│technik-log` | Toolkit | Probelaeufe, Fehler, AutoMod-Treffer | + +Zu den Namen: Discord schreibt Textkanaele klein und macht aus Leerzeichen +Bindestriche. Emoji, Umlaute und das Trennzeichen `│` (U+2502) bleiben stehen - +am echten Server nachgemessen, nicht angenommen. `normalizeName()` bildet das +ab, damit nicht bei jedem Lauf dieselbe Umbenennung erneut rausgeht. + +Und zur Schreibweise: im Quelltext bleibt es bei `ae/oe/ue` wie im uebrigen +Repo, aber alles, was ein Mitglied auf Discord liest, steht in richtigem +Deutsch. „Sei anstaendig" liest sich wie ein Tippfehler, und der Text im +Server ist der erste Eindruck, den jemand davon bekommt. + +`📢│ankündigungen` ist schreibgeschuetzt, erlaubt aber Reaktionen und Threads. +So bleibt die Kanalansicht eine saubere Liste von Streams, und die Diskussion +ueber das Recap haengt trotzdem direkt daran. + +Der Sprachkanal ist bewusst dabei, obwohl er anfangs leer sein wird. Ein +Sprachkanal, den zwei Leute nutzen, ist der Anfang; einer, den es nicht gibt, +wird nie einer. + +### Rollen + +Die Reihenfolge ist zugleich die Rangfolge auf dem Server. + +| Rolle | Sichtbar | Wofuer | +|---|---|---| +| `Abdu` | ja | Der Kanal selbst. Administrator | +| `Mod` | ja | Loeschen, stummschalten, rauswerfen | +| `Stammgast` | ja | Auszeichnung fuer Leute, die regelmaessig da sind | +| `Live-Ping` | nein | Wird bei Stream-Start angepingt, selbst vergeben | + +**`Abdu`** traegt `Administrator`. Das Recht schliesst alle anderen ein und +setzt jede Kanal-Ausnahme ausser Kraft, auch kuenftige. Es gehoert deshalb an +genau ein Konto und an kein zweites. Ein Test im Repo haelt fest, dass keine +andere Rolle es bekommt. + +**`Live-Ping` ist die wichtigste der vier.** Ein Rundruf an alle bei jedem +Stream ist der schnellste Weg, dass die Leute den Server stummschalten - und +ein stummgeschalteter Server ist ein toter Server. Angepingt wird nur, wer sich +die Rolle selbst geholt hat. Das sind weniger Leute, aber es sind die, die +kommen. + +**`Stammgast`** hat fast keine zusaetzlichen Rechte, und das ist Absicht. Der +Wert liegt darin, dass der Name farbig ist und in der Mitgliederliste oben +steht. Bei dieser Kanalgroesse ist sichtbare Zugehoerigkeit der wirksamste +Hebel, den es gibt, und er kostet nichts. + +**`Mod`** bekommt kein `Server verwalten`, kein `Rollen verwalten` und kein +`Kanaele verwalten`. Moderation heisst aufraeumen, nicht umbauen. Wer umbauen +soll, bekommt das Recht einzeln. + +### Was @everyone darf + +Gesetzt wird eine vollstaendige Liste statt Discords Vorgabe - die aendert sich +mit der Zeit, und dann steht irgendwann ein Recht drin, das niemand vergeben +hat. Ausdruecklich **nicht** enthalten: `@everyone` erwaehnen, TTS-Nachrichten, +private Threads, saemtliche Verwaltungsrechte. + +Ausdruecklich **enthalten**: Einladungen erstellen. Ein Server, in den +Mitglieder niemanden einladen koennen, waechst nur so schnell, wie Abdu selbst +Links verteilt. + +### Serverweit + +- **Sicherheitsstufe „mittel"** - das Konto muss aelter als fuenf Minuten sein. + Kostet echte Leute nichts und haelt Wegwerf-Konten draussen. Ein oeffentlich + von Twitch aus verlinkter Server bekommt die. +- **Benachrichtigungen nur bei Erwaehnung.** Sonst klingelt jede Nachricht bei + jedem. Siehe oben, stummgeschalteter Server. +- **Zwei AutoMod-Regeln**: Erwaehnungs-Spam (mehr als fuenf auf einmal) und + Discords eigene Spam-Erkennung. Beide blocken die Nachricht und melden sie in + `🤖│technik-log`. Kein Wortfilter - der kostet bei dieser Groesse mehr Aerger + durch Fehlalarme, als er an Spam verhindert. + +## Einrichten + +### 1. Von Hand: Server und Bot + +Beides geht nur ueber die Oberflaeche, ein Skript darf das nicht. + +1. **Server anlegen.** In Discord links unten auf `+`, „Eigenen erstellen", + Name und Bild setzen. +2. **Entwicklermodus einschalten** (Einstellungen > Erweitert), dann Rechtsklick + auf den Server > „Server-ID kopieren". +3. **App anlegen** unter : + „New Application", dann links „Bot". Dort „Reset Token", Token kopieren. + Er wird genau einmal angezeigt. +4. **Bot einladen.** Client-ID aus „General Information" einsetzen: + + ``` + https://discord.com/oauth2/authorize?client_id=DEINE_CLIENT_ID&permissions=805375024&scope=bot + ``` + + Die Zahl sind genau die sieben Rechte, die das Skript braucht: Kanaele, + Rollen, Webhooks und Server verwalten, lesen, schreiben, Verlauf lesen. + Kein Administrator. + +### 2. Der Befehl + +```powershell +abdulive discord:setup --server --vorschau +``` + +Die Vorschau liest den Server aus und zeigt Zeile fuer Zeile, was passieren +wuerde. Sie schreibt nichts. Sieht das gut aus, dasselbe ohne `--vorschau`: + +```powershell +abdulive discord:setup --server +``` + +Der Bot-Token wird beim ersten Lauf abgefragt (die Eingabe wird nicht +angezeigt) und danach zusammen mit der Server-ID verschluesselt abgelegt - +siehe [ADR-0005](../decisions/ADR-0005-zugangsdaten.md). Ab dem zweiten Lauf +genuegt `abdulive discord:setup`. + +Die drei Webhook-URLs landet das Skript direkt im selben Speicher, unter +`goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Sie werden nie ausgegeben +und nie geloggt - Broadcaster, Recap und Clipper lesen sie von dort. + +### 3. Von Hand: der Rest + +Vier Dinge kann die API nicht oder nicht sinnvoll: + +1. **Dir selbst `Abdu` geben** und die Bot-Rolle in den Servereinstellungen + ueber `Abdu` ziehen. Eine Rolle kann nur Rollen unterhalb der eigenen + verschieben - auch mit Administrator. Discord haengt die Bot-Rolle beim + Einladen ganz unten ein, dort ist also kein Platz fuer vier Rollen darunter. + Bis das erledigt ist, meldet das Skript die Rangfolge als uebersprungen und + nennt den Grund. +2. **Onboarding einrichten** (Servereinstellungen > Onboarding) und `Live-Ping` + als waehlbare Rolle anbieten. Das ist der Weg, auf dem sich die Leute die + Rolle selbst holen - ohne ihn bringt sie nichts. +3. **Einladungslink** erzeugen, ohne Ablaufdatum, und ins Twitch-Panel, in die + Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream + niemand erwaehnt, bleibt leer. +4. **Twitch-Konto verbinden** (Servereinstellungen > Integrationen). Danach + bekommen Subs automatisch eine Rolle. + +## Was das Skript nicht tut + +**Es loescht nichts.** Keine Kanaele, keine Rollen, keine Nachrichten. Ein +Einrichtungsskript, das aufraeumt, raeumt irgendwann das Falsche weg. Was von +Hand dazugekommen ist, bleibt stehen - auch eigene Rechte-Ausnahmen in einem +Kanal, den das Skript anfasst. + +**Es ueberschreibt den Willkommenstext nicht.** Gepostet wird nur, wenn +`#willkommen` leer ist. Danach gehoert der Text der Hand, die ihn zuletzt +angefasst hat. + +**Es laesst von Discord verwaltete Rollen in Ruhe.** Rollen, die zu einer App +oder zur Twitch-Anbindung gehoeren, meldet es als uebersprungen. + +Wiederholtes Ausfuehren ist der Normalfall: gefunden wird ueber den Namen, +danach wird angeglichen statt neu angelegt. Der zweite Lauf aendert nichts. + +## Wann ein Kanal dazukommt + +Nicht auf Verdacht. Jeweils erst, wenn die Bedingung wirklich eintritt: + +| Neuer Kanal | Erst wenn | +|---|---| +| `#vorschlaege` | In `#allgemein` gehen Vorschlaege zwischen anderem unter | +| `#fotos` oder `#offtopic` | `#allgemein` besteht ueberwiegend aus einem Thema, das nicht der Stream ist | +| Spiel-eigener Kanal | Zu einem Spiel gibt es ueber Wochen taeglich Gespraech | +| Zweiter Sprachkanal | Der erste ist regelmaessig belegt, wenn jemand dazu will | +| Forum statt `#clips` | Mehr als etwa zehn Clips pro Woche | + +Umgekehrt gilt genauso: ein Kanal, in dem vier Wochen nichts steht, kommt weg. + +## Fallstricke + +- **Die Bot-Rolle steht anfangs unten.** Discord haengt sie beim Einladen + unterhalb bestehender Rollen ein. Dann schlaegt das Setzen der Rangfolge + fehl. Das Skript bricht deswegen nicht ab, sondern meldet es - nachziehen und + noch einmal laufen lassen. +- **Von Hand angelegte Webhooks geben ihren Token nicht heraus.** Wer einen + Webhook in der Oberflaeche neu anlegt, bekommt eine URL, die das Skript nicht + auslesen kann. Es meldet ihn als uebersprungen; loeschen und noch einmal + laufen lassen loest es. +- **Kanalnamen lassen sich nur zweimal in zehn Minuten aendern.** Discord + verlangt danach bis zu neun Minuten Pause. Der Lauf wartet hoechstens eine + Minute, meldet den Kanal als uebersprungen und macht weiter - spaeter noch + einmal ausfuehren holt es nach. +- **Umbenennen statt verdoppeln.** Aendert sich ein Name im Bauplan, gehoert + der alte in `aliases`. Sonst findet das Skript den Kanal nicht wieder und + legt einen zweiten daneben an. Ein Test besteht darauf, dass jeder Eintrag + einen frueheren Namen hinterlegt hat. +- **Ankuendigungskanaele im Discord-Sinn** (die, denen andere Server folgen + koennen) brauchen einen Community-Server, und der braucht Regelkanal, + Update-Kanal und einige Einstellungen. Fuer diese Groesse bringt das nichts, + deshalb ist `#ankuendigungen` ein normaler Textkanal mit Schreibschutz. +- **Ein Ping ist keine Reichweite.** Wer `Live-Ping` nicht anbietet und + stattdessen `@everyone` benutzt, hat beim dritten Mal einen stummen Server. + +## Abnahmekriterien + +- `abdulive discord:setup --vorschau` laeuft auf einem frischen Server durch und + meldet keinen uebersprungenen Punkt. +- Nach dem echten Lauf existieren drei Kategorien, sieben Kanaele, vier Rollen + und drei Webhooks; `👋│willkommen` enthaelt genau eine Nachricht. +- Ein zweiter Lauf meldet ausschliesslich „steht" und legt nichts doppelt an. +- Ein Konto ohne Rolle sieht `Intern` nicht und kann in `#ankuendigungen` nicht + schreiben, aber einen Thread eroeffnen. +- Die Webhook-URLs stehen im verschluesselten Speicher und tauchen in keiner + Ausgabe und keinem Log auf. diff --git a/package.json b/package.json index ae6464d..1fe9182 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "imports": { "#shared/*": "./packages/shared/src/*", "#core/*": "./packages/core/src/*", + "#discord/*": "./packages/discord/src/*", "#db/*": "./packages/db/src/*", "#twitch/*": "./packages/twitch/src/*", "#obs/*": "./packages/obs/src/*", diff --git a/packages/discord/README.md b/packages/discord/README.md new file mode 100644 index 0000000..1eddaed --- /dev/null +++ b/packages/discord/README.md @@ -0,0 +1,24 @@ +# @abdulive/discord + +Discord-Anbindung: ein kleiner REST-Client und das Einrichten des +Community-Servers. + +| Datei | Inhalt | +|---|---| +| `src/permissions.ts` | Rechtebits als `BigInt` (das Feld ist 64 Bit breit) | +| `src/api.ts` | REST-Client: Warteschlange, Rate Limits, lesbare Fehler | +| `src/plan.ts` | **Der Bauplan**: Rollen, Kanaele, Rechte, Servereinstellungen | +| `src/apply.ts` | Traegt den Bauplan auf einen bestehenden Server auf | + +Aufruf ueber die CLI: + +```powershell +abdulive discord:setup --server --vorschau # zeigt nur, was passieren wuerde +abdulive discord:setup --server # wendet es an +``` + +Wiederholbar: gefunden wird ueber den Namen, danach wird angeglichen statt neu +angelegt. Geloescht wird nie. + +Begruendung der Struktur, die Schritte, die von Hand gehoeren, und die +Fallstricke: [docs/projects/discord-community.md](../../docs/projects/discord-community.md) diff --git a/packages/discord/src/api.ts b/packages/discord/src/api.ts new file mode 100644 index 0000000..e7fd2a6 --- /dev/null +++ b/packages/discord/src/api.ts @@ -0,0 +1,159 @@ +// Discord-REST-Client. Nur das, was fuers Einrichten und fuers Posten +// gebraucht wird - kein Gateway, keine Bibliothek. +// +// Discord drosselt pro Route mit eigenen Eimern. Wir schicken die Anfragen +// nacheinander und werten `X-RateLimit-Remaining` aus: sinkt es auf 0, warten +// wir `X-RateLimit-Reset-After` ab, bevor die naechste Anfrage rausgeht. Das +// ist bei ~25 Anfragen pro Lauf reichlich und spart uns eine Eimerverwaltung. + +import { logger } from '#core/logger.ts'; +import { retry, sleep } from '#core/retry.ts'; + +const log = logger('discord'); +const BASE = 'https://discord.com/api/v10'; + +/** Laenger warten wir bei einem 429 nicht - dann wird der Schritt gemeldet. */ +const MAX_WAIT_MS = 60_000; + +export type RestError = Error & { status: number; code: number }; + +function restError(status: number, code: number, message: string): RestError { + const err = new Error(message) as RestError; + err.status = status; + err.code = code; + return err; +} + +/** + * Fehlertexte von Discord sind verschachtelt: + * `{ code: 50013, message: "Missing Permissions", errors: { name: { _errors: [...] } } }` + * Wir ziehen die tiefen Feldfehler raus, sonst steht da nur "Invalid Form Body". + */ +function describe(body: unknown): { code: number; message: string } { + if (!body || typeof body !== 'object') return { code: 0, message: '' }; + const b = body as { code?: number; message?: string; errors?: unknown }; + const parts: string[] = []; + const walk = (node: unknown, path: string): void => { + if (!node || typeof node !== 'object') return; + const n = node as { _errors?: Array<{ message?: string }> }; + if (Array.isArray(n._errors)) { + for (const e of n._errors) parts.push(`${path}: ${e.message ?? ''}`.trim()); + return; + } + for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k); + }; + walk(b.errors, ''); + const detail = parts.length ? ` (${parts.join('; ')})` : ''; + return { code: b.code ?? 0, message: `${b.message ?? 'Unbekannter Fehler'}${detail}` }; +} + +export class Discord { + private token: string; + private queue: Promise = Promise.resolve(); + /** Zeitpunkt, ab dem wieder gesendet werden darf (ms seit Epoche). */ + private notBefore = 0; + + constructor(botToken: string) { + if (!botToken) throw new Error('Kein Discord-Bot-Token angegeben.'); + this.token = botToken; + } + + /** Reiht die Anfrage ein - es laeuft immer nur eine gleichzeitig. */ + private run(fn: () => Promise): Promise { + const next = this.queue.then(fn, fn); + // Fehler duerfen die Kette nicht vergiften, der Aufrufer bekommt sie. + this.queue = next.catch(() => undefined); + return next; + } + + async request(method: string, path: string, body?: unknown, reason?: string): Promise { + return this.run(() => retry(async () => { + const wait = this.notBefore - Date.now(); + if (wait > 0) await sleep(wait); + + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + Authorization: `Bot ${this.token}`, + 'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive, 0.1.0)', + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + // Landet im Audit-Log des Servers - so ist spaeter nachvollziehbar, + // woher eine Rolle oder ein Kanal kam. + ...(reason ? { 'X-Audit-Log-Reason': encodeURIComponent(reason) } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + const remaining = Number(res.headers.get('x-ratelimit-remaining') ?? '1'); + const resetAfter = Number(res.headers.get('x-ratelimit-reset-after') ?? '0'); + if (remaining <= 0 && resetAfter > 0) this.notBefore = Date.now() + resetAfter * 1000 + 50; + + if (res.status === 429) { + const info = await res.json().catch(() => ({})) as { retry_after?: number; global?: boolean }; + const retryAfter = Math.max(1, (info.retry_after ?? 1)) * 1000 + 100; + // Umbenennen eines Kanals ist auf zwei je zehn Minuten begrenzt. Dort + // verlangt Discord schon mal neun Minuten Pause - so lange darf ein + // Einrichtungslauf nicht stehen. Lieber melden und weitermachen. + if (retryAfter > MAX_WAIT_MS) { + const err = restError(429, 0, + `${method} ${path}: Discord verlangt ${Math.round(retryAfter / 1000)} s Pause ` + + '(bei Kanalnamen sind nur zwei Aenderungen je zehn Minuten erlaubt) - spaeter erneut ausfuehren'); + throw Object.assign(err, { final: true }); + } + this.notBefore = Date.now() + retryAfter; + log.warn(`Rate Limit${info.global ? ' (global)' : ''} auf ${method} ${path}, warte ${Math.round(retryAfter)} ms`); + throw new Error('Rate Limit erreicht'); + } + + if (res.status === 204) return undefined as T; + + const text = await res.text(); + const parsed: unknown = text ? JSON.parse(text) : null; + + if (!res.ok) { + const { code, message } = describe(parsed); + // 401/403/404 sind Einrichtungsfehler und werden durch Wiederholen + // nicht besser - sofort durchreichen. + const err = restError(res.status, code, `${method} ${path}: HTTP ${res.status} ${message}`); + if (res.status === 401 || res.status === 403 || res.status === 404) throw Object.assign(err, { final: true }); + throw err; + } + return parsed as T; + }, { + attempts: 4, + // `retry` bietet keinen Abbruch, aber `onRetry` laeuft innerhalb des + // catch-Blocks: wirft es, verlaesst der Fehler die Schleife sofort. + // So werden Einrichtungsfehler nicht viermal wiederholt. + onRetry: (err, n, ms) => { + if ((err as { final?: boolean }).final) throw err; + log.warn(`Wiederhole ${method} ${path} (${n}) in ${ms} ms`, { err: String(err) }); + }, + })); + } + + get(path: string): Promise { return this.request('GET', path); } + post(path: string, body: unknown, reason?: string): Promise { return this.request('POST', path, body, reason); } + patch(path: string, body: unknown, reason?: string): Promise { return this.request('PATCH', path, body, reason); } +} + +/** + * Was apply.ts vom Client braucht. Als Typ statt als Klasse, damit die Tests + * einen nachgebauten Server unterschieben koennen, ohne ins Netz zu gehen. + */ +export type Rest = { + get(path: string): Promise; + post(path: string, body: unknown, reason?: string): Promise; + patch(path: string, body: unknown, reason?: string): Promise; +}; + +// ---------------------------------------------------------------- Typen + +export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean }; +export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string }; +export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] }; +export type ApiGuild = { id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[] }; +export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string }; +export type ApiUser = { id: string; username: string; bot?: boolean }; + +/** Kanaltypen, die hier vorkommen. */ +export const CH = { TEXT: 0, VOICE: 2, CATEGORY: 4, FORUM: 15 } as const; diff --git a/packages/discord/src/apply.test.ts b/packages/discord/src/apply.test.ts new file mode 100644 index 0000000..e5a6144 --- /dev/null +++ b/packages/discord/src/apply.test.ts @@ -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>(); + 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(path: string): Promise { + 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(path: string, body: unknown): Promise { + this.calls.push(`POST ${path}`); + const b = body as Record; + 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; + 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)['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(path: string, body: unknown): Promise { + 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)['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 => { + const out: Record = {}; + 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 (path: string, body: unknown): Promise => { + if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions'); + return echt(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(); + 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 (path: string, body: unknown): Promise => { + if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause'); + return echt(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 }>; + 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'); +}); diff --git a/packages/discord/src/apply.ts b/packages/discord/src/apply.ts new file mode 100644 index 0000000..84dcc76 --- /dev/null +++ b/packages/discord/src/apply.ts @@ -0,0 +1,521 @@ +// Traegt den Bauplan aus plan.ts auf einen bestehenden Server auf. +// +// Wiederholbar: gefunden wird ueber Name und frueheren Namen, danach wird +// angepasst statt neu angelegt. Zweimal laufen lassen aendert nichts. Was die +// Hand am Server zusaetzlich eingerichtet hat - eigene Kanaele, eigene +// Rechte-Ausnahmen - bleibt stehen; wir setzen nur, was im Plan steht. +// +// Was hier NICHT passiert: loeschen. Ein Einrichtungsskript, das Kanaele +// wegraeumt, ist ein Einrichtungsskript, das irgendwann den falschen wegraeumt. + +import { logger } from '#core/logger.ts'; +import { updateSecrets } from '#twitch/secrets.ts'; +import { + CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiUser, + type ApiWebhook, type Rest, +} from './api.ts'; +import { str } from './permissions.ts'; +import { + AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, WELCOME, + type CategoryPlan, type ChannelPlan, type OverwritePlan, type WebhookSlot, +} from './plan.ts'; + +const log = logger('discord:setup'); + +export type Change = 'erstellt' | 'angepasst' | 'unveraendert' | 'uebersprungen'; +export type Step = { kind: string; name: string; change: Change; note?: string }; + +export type ApplyResult = { + guild: string; + steps: Step[]; + /** Nur die Anzahl - die URLs selbst werden nie zurueckgegeben oder geloggt. */ + webhooks: number; +}; + +export type ApplyOptions = { + /** Nur zeigen, was passieren wuerde. Schreibt nichts. */ + dryRun?: boolean; + /** Webhook-URLs in den verschluesselten Speicher schreiben. */ + saveSecrets?: boolean; +}; + +// ---------------------------------------------------------------- Helfer + +const eq = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); + +const byName = (list: T[], name: string): T | undefined => + list.find((x) => eq(x.name, name)); + +/** Findet einen Kanal ueber den geplanten Namen oder einen frueheren Namen. */ +export function findChannel( + existing: ApiChannel[], plan: { name: string; aliases?: string[]; type: number }, +): ApiChannel | undefined { + for (const name of [plan.name, ...(plan.aliases ?? [])]) { + const hit = existing.find((c) => c.type === plan.type && eq(c.name, name)); + if (hit) return hit; + } + return undefined; +} + +/** + * Setzt die geplanten Rechte-Ausnahmen, laesst fremde stehen. + * Gibt null zurueck, wenn sich nichts aendert - dann sparen wir das PATCH. + */ +export function mergeOverwrites( + existing: ApiOverwrite[], + planned: OverwritePlan[], + roleId: (name: string) => string | undefined, +): ApiOverwrite[] | null { + const out = existing.map((o) => ({ ...o })); + let changed = false; + + for (const p of planned) { + const id = roleId(p.role); + if (!id) continue; // Rolle gibt es (noch) nicht + const allow = str(p.allow ?? 0n); + const deny = str(p.deny ?? 0n); + const found = out.find((o) => o.id === id && o.type === 0); + if (!found) { + out.push({ id, type: 0, allow, deny }); + changed = true; + } else if (found.allow !== allow || found.deny !== deny) { + found.allow = allow; + found.deny = deny; + changed = true; + } + } + return changed ? out : null; +} + +/** + * Discord normalisiert Kanalnamen: Textkanaele werden kleingeschrieben und + * Leerzeichen werden zu Bindestrichen. Emoji, Umlaute und das Trennzeichen + * U+2502 bleiben stehen. Wer das nicht mitrechnet, schickt bei jedem Lauf + * dieselbe Umbenennung erneut - und laeuft in das Limit von zwei + * Umbenennungen je Kanal und zehn Minuten. + */ +export function normalizeName(name: string, type: number): string { + if (type !== CH.TEXT) return name; + return name.toLowerCase().replace(/\s+/g, '-'); +} + +// ---------------------------------------------------------------- Rollen + +/** + * Wohin die Rollen in der Rangfolge duerfen. + * + * Eine Rolle kann nur Rollen unterhalb der eigenen bearbeiten - auch mit + * Administrator. Die Rolle, die der Bot mitbringt, haengt Discord beim + * Einladen ganz unten ein, dort ist also erst einmal kein Platz. Statt blind + * 3, 2, 1 zu verlangen und ein "Missing Permissions" zu kassieren, rechnen wir + * aus, was geht, und sagen sonst, was zu tun ist. + */ +export function rolePositions( + count: number, botTop: number, +): { positions: number[] } | { problem: string } { + // Frei sind die Plaetze 1 bis botTop-1; Platz 0 gehoert @everyone. + const room = botTop - 1; + if (room < count) { + return { + problem: + `Bot-Rolle steht auf Platz ${botTop}, darunter ist Platz fuer ${Math.max(0, room)} ` + + `von ${count} Rollen. In den Servereinstellungen unter "Rollen" die Bot-Rolle ` + + 'nach oben ziehen und den Befehl erneut ausfuehren.', + }; + } + return { positions: Array.from({ length: count }, (_, i) => botTop - 1 - i) }; +} + +async function applyRoles( + api: Rest, guild: ApiGuild, botUserId: string, steps: Step[], dry: boolean, +): Promise> { + const ids = new Map(); + const roles = [...guild.roles]; + + // @everyone traegt in Discord die ID des Servers. + ids.set('@everyone', guild.id); + const everyone = roles.find((r) => r.id === guild.id); + if (everyone && everyone.permissions !== str(EVERYONE)) { + if (!dry) await api.patch(`/guilds/${guild.id}/roles/${guild.id}`, { permissions: str(EVERYONE) }, 'Grundrechte aus dem Bauplan'); + steps.push({ kind: 'Rolle', name: '@everyone', change: 'angepasst' }); + } else { + steps.push({ kind: 'Rolle', name: '@everyone', change: 'unveraendert' }); + } + + for (const plan of ROLES) { + const found = byName(roles, plan.name); + const want = { + name: plan.name, + permissions: str(plan.permissions), + color: plan.color, + hoist: plan.hoist, + mentionable: plan.mentionable, + }; + + if (!found) { + if (dry) { + steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt', note: 'Vorschau' }); + continue; + } + const made = await api.post(`/guilds/${guild.id}/roles`, want, `Rolle aus dem Bauplan: ${plan.purpose}`); + ids.set(plan.name, made.id); + steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt' }); + continue; + } + + ids.set(plan.name, found.id); + const differs = found.permissions !== want.permissions || found.color !== want.color || + found.hoist !== want.hoist || found.mentionable !== want.mentionable; + if (!differs) { + steps.push({ kind: 'Rolle', name: plan.name, change: 'unveraendert' }); + continue; + } + if (found.managed) { + // Rollen, die zu einer App oder zur Twitch-Anbindung gehoeren, gehoeren + // Discord. Anfassen geht nicht und waere auch falsch. + steps.push({ kind: 'Rolle', name: plan.name, change: 'uebersprungen', note: 'von Discord verwaltet' }); + continue; + } + if (!dry) await api.patch(`/guilds/${guild.id}/roles/${found.id}`, want, 'Rolle aus dem Bauplan angeglichen'); + steps.push({ kind: 'Rolle', name: plan.name, change: 'angepasst' }); + } + + await applyRoleOrder(api, guild, botUserId, ids, steps, dry); + return ids; +} + +async function applyRoleOrder( + api: Rest, guild: ApiGuild, botUserId: string, ids: Map, + steps: Step[], dry: boolean, +): Promise { + const wanted = ROLES.map((r) => ids.get(r.name)).filter((id): id is string => Boolean(id)); + if (dry || wanted.length === 0) return; + + let botTop: number; + try { + const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`); + const fresh = await api.get(`/guilds/${guild.id}/roles`); + botTop = fresh + .filter((r) => member.roles.includes(r.id)) + .reduce((max, r) => Math.max(max, r.position), 0); + } catch (err) { + steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message }); + return; + } + + const fit = rolePositions(wanted.length, botTop); + if ('problem' in fit) { + steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: fit.problem }); + log.warn(fit.problem); + return; + } + + try { + await api.patch(`/guilds/${guild.id}/roles`, + wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan'); + steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'angepasst' }); + } catch (err) { + steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message }); + log.warn(`Rangfolge nicht gesetzt: ${(err as Error).message}`); + } +} + +// ---------------------------------------------------------------- Kanaele + +function kindOf(type: number): string { + if (type === CH.CATEGORY) return 'Kategorie'; + if (type === CH.VOICE) return 'Sprachkanal'; + return 'Kanal'; +} + +/** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */ +type AnyPlan = CategoryPlan & { type: number; topic?: string }; + +async function applyChannel( + api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean, + plan: AnyPlan, + parentId: string | null, + roleId: (name: string) => string | undefined, +): Promise { + const kind = kindOf(plan.type); + const found = findChannel(existing, plan); + + if (!found) { + if (dry) { + steps.push({ kind, name: plan.name, change: 'erstellt', note: 'Vorschau' }); + return null; + } + const overwrites: ApiOverwrite[] = (plan.overwrites ?? []).flatMap((p) => { + const id = roleId(p.role); + return id ? [{ id, type: 0 as const, allow: str(p.allow ?? 0n), deny: str(p.deny ?? 0n) }] : []; + }); + + const made = await api.post(`/guilds/${guild.id}/channels`, { + name: plan.name, + type: plan.type, + ...(parentId ? { parent_id: parentId } : {}), + ...(plan.topic ? { topic: plan.topic } : {}), + ...(overwrites.length ? { permission_overwrites: overwrites } : {}), + }, 'Kanal aus dem Bauplan'); + steps.push({ kind, name: plan.name, change: 'erstellt' }); + return made; + } + + const patch: Record = {}; + const rename = !eq(found.name, normalizeName(plan.name, plan.type)); + if (rename) patch['name'] = plan.name; + if (plan.topic && (found.topic ?? '') !== plan.topic) patch['topic'] = plan.topic; + if (parentId && found.parent_id !== parentId) patch['parent_id'] = parentId; + const merged = mergeOverwrites(found.permission_overwrites ?? [], plan.overwrites ?? [], roleId); + if (merged) patch['permission_overwrites'] = merged; + + if (Object.keys(patch).length === 0) { + steps.push({ kind, name: plan.name, change: 'unveraendert' }); + return found; + } + + const label = rename ? `${found.name} -> ${plan.name}` : plan.name; + if (dry) { + steps.push({ kind, name: label, change: 'angepasst', note: `Vorschau: ${Object.keys(patch).join(', ')}` }); + return found; + } + + try { + await api.patch(`/channels/${found.id}`, patch, 'Kanal aus dem Bauplan angeglichen'); + } catch (err) { + // Umbenennungen sind bei Discord auf zwei je Kanal und zehn Minuten + // begrenzt. Das darf den Rest des Laufs nicht aufhalten. + steps.push({ kind, name: label, change: 'uebersprungen', note: (err as Error).message }); + log.warn(`${label}: ${(err as Error).message}`); + return found; + } + steps.push({ kind, name: label, change: 'angepasst', note: Object.keys(patch).join(', ') }); + return found; +} + +// ---------------------------------------------------------------- Webhooks + +async function applyWebhooks( + api: Rest, channel: ApiChannel, plan: ChannelPlan, steps: Step[], dry: boolean, + urls: Map, +): Promise { + if (!plan.webhooks?.length) return; + const existing = await api.get(`/channels/${channel.id}/webhooks`); + + for (const w of plan.webhooks) { + const found = byName(existing, w.name); + if (found?.token) { + urls.set(w.slot, `https://discord.com/api/webhooks/${found.id}/${found.token}`); + steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'unveraendert' }); + continue; + } + if (found && !found.token) { + // Von Hand angelegte Webhooks geben ihren Token nicht an uns heraus. + steps.push({ + kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'uebersprungen', + note: 'von Hand angelegt, URL nicht auslesbar - umbenennen oder loeschen', + }); + continue; + } + if (dry) { + steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt', note: 'Vorschau' }); + continue; + } + const made = await api.post(`/channels/${channel.id}/webhooks`, { name: w.name }, 'Webhook aus dem Bauplan'); + if (made.token) urls.set(w.slot, `https://discord.com/api/webhooks/${made.id}/${made.token}`); + steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt' }); + } +} + +// ---------------------------------------------------------------- AutoMod + +/** + * Die Aktionen einer Regel: Nachricht blocken, und wenn es den Log-Kanal gibt, + * dort melden. Ist die Regel schon da, behalten wir ihren bestehenden + * Meldekanal - den kann jemand bewusst umgestellt haben. + */ +function actionsFor( + rule: { name: string }, + logChannelId: string | null, + vorhanden: { actions?: Array<{ type: number; metadata?: { channel_id?: string } }> } | undefined, +): Array> { + const alterKanal = vorhanden?.actions?.find((a) => a.type === 2)?.metadata?.channel_id; + const ziel = alterKanal ?? logChannelId; + return [ + { type: 1, metadata: { custom_message: AUTOMOD_BLOCK } }, + ...(ziel ? [{ type: 2, metadata: { channel_id: ziel } }] : []), + ]; +} + +async function applyAutomod( + api: Rest, guild: ApiGuild, logChannelId: string | null, steps: Step[], dry: boolean, +): Promise { + type Action = { type: number; metadata?: { custom_message?: string; channel_id?: string } }; + type Rule = { id: string; name: string; actions?: Action[] }; + let existing: Rule[]; + try { + existing = await api.get(`/guilds/${guild.id}/auto-moderation/rules`); + } catch (err) { + steps.push({ kind: 'AutoMod', name: 'alle Regeln', change: 'uebersprungen', note: (err as Error).message }); + return; + } + + for (const rule of AUTOMOD) { + // Wie bei Kanaelen: erst unter dem geplanten Namen suchen, dann unter dem + // frueheren. Sonst steht nach einer Umbenennung dieselbe Regel zweimal da. + const found = [rule.name, ...(rule.aliases ?? [])] + .map((n) => byName(existing, n)).find((r) => r !== undefined); + + if (found) { + const patch: Record = {}; + if (!eq(found.name, rule.name)) patch['name'] = rule.name; + // Der Blocktext steht im Bauplan, also gilt der Bauplan. Ohne diesen + // Abgleich behaelt eine einmal angelegte Regel ihren alten Text fuer immer. + const blockText = found.actions?.find((a) => a.type === 1)?.metadata?.custom_message; + if (blockText !== AUTOMOD_BLOCK) patch['actions'] = actionsFor(rule, logChannelId, found); + + if (Object.keys(patch).length === 0) { + steps.push({ kind: 'AutoMod', name: rule.name, change: 'unveraendert' }); + continue; + } + const label = patch['name'] ? `${found.name} -> ${rule.name}` : rule.name; + const was = Object.keys(patch).join(', '); + if (dry) { + steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: `Vorschau: ${was}` }); + continue; + } + await api.patch(`/guilds/${guild.id}/auto-moderation/rules/${found.id}`, + patch, 'AutoMod-Regel aus dem Bauplan angeglichen'); + steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: was }); + continue; + } + + if (dry) { + steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt', note: 'Vorschau' }); + continue; + } + await api.post(`/guilds/${guild.id}/auto-moderation/rules`, { + name: rule.name, + event_type: 1, // beim Senden pruefen + trigger_type: rule.trigger_type, + trigger_metadata: rule.trigger_metadata, + enabled: true, + actions: actionsFor(rule, logChannelId, undefined), + }, 'AutoMod-Regel aus dem Bauplan'); + steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt' }); + } +} + +// ---------------------------------------------------------------- Willkommen + +/** + * Der Begruessungstext. + * + * Nachrichten von Menschen fasst das Skript nie an. Die eigene schon: bei + * Discord kann nur der Verfasser eine Nachricht bearbeiten, eine vom Bot + * gepostete kann also niemand sonst veraendert haben. So bleibt der Bauplan + * die massgebliche Fassung, auch wenn sich der Text spaeter noch aendert. + */ +async function applyWelcome( + api: Rest, channel: ApiChannel, botUserId: string, mentions: Map, + steps: Step[], dry: boolean, +): Promise { + const body = WELCOME.replace(/\{(\w[\w-]*)\}/g, (all, key: string) => { + const id = mentions.get(key); + return id ? `<#${id}>` : all; + }); + const payload = { + content: body, + allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden + }; + + type Msg = { id: string; content: string; author?: { id: string } }; + const seen = await api.get(`/channels/${channel.id}/messages?limit=50`); + const own = seen.find((m) => m.author?.id === botUserId); + + if (own) { + if (own.content === body) { + steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'unveraendert' }); + return; + } + if (dry) { + steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst', note: 'Vorschau: Text' }); + return; + } + await api.patch(`/channels/${channel.id}/messages/${own.id}`, payload); + steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst' }); + return; + } + + if (seen.length > 0) { + steps.push({ + kind: 'Nachricht', name: 'Willkommenstext', change: 'uebersprungen', + note: 'im Kanal steht schon etwas, das nicht vom Bot ist', + }); + return; + } + if (dry) { + steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt', note: 'Vorschau' }); + return; + } + await api.post(`/channels/${channel.id}/messages`, payload); + steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt' }); +} + +// ---------------------------------------------------------------- Hauptlauf + +export async function applyPlan( + api: Rest, guildId: string, opts: ApplyOptions = {}, +): Promise { + const dry = opts.dryRun ?? false; + const steps: Step[] = []; + + const guild = await api.get(`/guilds/${guildId}`); + const me = await api.get('/users/@me'); + log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`); + + // 1. Servereinstellungen + if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan'); + steps.push({ kind: 'Server', name: guild.name, change: dry ? 'unveraendert' : 'angepasst' }); + + // 2. Rollen + const roleIds = await applyRoles(api, guild, me.id, steps, dry); + const roleId = (name: string): string | undefined => roleIds.get(name); + + // 3. Kategorien, dann Kanaele + const channels = await api.get(`/guilds/${guild.id}/channels`); + const categoryIds = new Map(); + for (const cat of CATEGORIES) { + const made = await applyChannel(api, guild, channels, steps, dry, { ...cat, type: CH.CATEGORY }, null, roleId); + if (made) categoryIds.set(cat.key, made.id); + } + + const urls = new Map(); + const made = new Map(); + for (const plan of CHANNELS) { + const parent = plan.category ? categoryIds.get(plan.category) ?? null : null; + const channel = await applyChannel(api, guild, channels, steps, dry, plan, parent, roleId); + if (!channel) continue; + made.set(plan.key, channel); + await applyWebhooks(api, channel, plan, steps, dry, urls); + } + + // 4. AutoMod und Begruessung - beides braucht Kanaele, die es geben muss. + await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry); + const welcome = made.get('willkommen'); + if (welcome) { + const mentions = new Map([...made].map(([key, c]) => [key, c.id])); + await applyWelcome(api, welcome, me.id, mentions, steps, dry); + } + + // 5. Webhook-URLs wegschliessen. Sie werden nie ausgegeben - siehe ADR-0005. + if (!dry && opts.saveSecrets !== false && urls.size > 0) { + updateSecrets((s) => { + s.discord = { ...s.discord, guildId: guild.id }; + for (const [slot, url] of urls) s.discord[slot] = url; + }); + log.info(`${urls.size} Webhook-URLs im verschluesselten Speicher abgelegt`); + } + + return { guild: guild.name, steps, webhooks: urls.size }; +} diff --git a/packages/discord/src/permissions.ts b/packages/discord/src/permissions.ts new file mode 100644 index 0000000..7807f77 --- /dev/null +++ b/packages/discord/src/permissions.ts @@ -0,0 +1,75 @@ +// Discord-Rechtebits. Die Werte sind ein 64-Bit-Feld und werden in der API +// als Dezimalstring uebertragen - deshalb BigInt und nicht number. +// +// Kein `enum`: Node fuehrt die .ts-Dateien direkt aus und entfernt nur Typen, +// ein enum waere zur Laufzeit weg (ADR-0001, `erasableSyntaxOnly`). + +export const P = { + CREATE_INSTANT_INVITE: 1n << 0n, + KICK_MEMBERS: 1n << 1n, + BAN_MEMBERS: 1n << 2n, + ADMINISTRATOR: 1n << 3n, + MANAGE_CHANNELS: 1n << 4n, + MANAGE_GUILD: 1n << 5n, + ADD_REACTIONS: 1n << 6n, + VIEW_AUDIT_LOG: 1n << 7n, + PRIORITY_SPEAKER: 1n << 8n, + STREAM: 1n << 9n, + VIEW_CHANNEL: 1n << 10n, + SEND_MESSAGES: 1n << 11n, + SEND_TTS_MESSAGES: 1n << 12n, + MANAGE_MESSAGES: 1n << 13n, + EMBED_LINKS: 1n << 14n, + ATTACH_FILES: 1n << 15n, + READ_MESSAGE_HISTORY: 1n << 16n, + MENTION_EVERYONE: 1n << 17n, + USE_EXTERNAL_EMOJIS: 1n << 18n, + VIEW_GUILD_INSIGHTS: 1n << 19n, + CONNECT: 1n << 20n, + SPEAK: 1n << 21n, + MUTE_MEMBERS: 1n << 22n, + DEAFEN_MEMBERS: 1n << 23n, + MOVE_MEMBERS: 1n << 24n, + USE_VAD: 1n << 25n, + CHANGE_NICKNAME: 1n << 26n, + MANAGE_NICKNAMES: 1n << 27n, + MANAGE_ROLES: 1n << 28n, + MANAGE_WEBHOOKS: 1n << 29n, + MANAGE_GUILD_EXPRESSIONS: 1n << 30n, + USE_APPLICATION_COMMANDS: 1n << 31n, + REQUEST_TO_SPEAK: 1n << 32n, + MANAGE_EVENTS: 1n << 33n, + MANAGE_THREADS: 1n << 34n, + CREATE_PUBLIC_THREADS: 1n << 35n, + CREATE_PRIVATE_THREADS: 1n << 36n, + USE_EXTERNAL_STICKERS: 1n << 37n, + SEND_MESSAGES_IN_THREADS: 1n << 38n, + USE_EMBEDDED_ACTIVITIES: 1n << 39n, + MODERATE_MEMBERS: 1n << 40n, + USE_SOUNDBOARD: 1n << 42n, + CREATE_EVENTS: 1n << 44n, + USE_EXTERNAL_SOUNDS: 1n << 45n, + SEND_VOICE_MESSAGES: 1n << 46n, + SEND_POLLS: 1n << 49n, +} as const; + +export type PermName = keyof typeof P; + +/** Vereinigt mehrere Rechte zu einem Bitfeld. */ +export function bits(...names: PermName[]): bigint { + let out = 0n; + for (const n of names) out |= P[n]; + return out; +} + +/** Wie die API es erwartet: Dezimalstring. */ +export const str = (v: bigint): string => v.toString(); + +/** Namen eines Bitfelds - nur fuer die Vorschau und Fehlermeldungen. */ +export function names(value: bigint): PermName[] { + const out: PermName[] = []; + for (const [name, bit] of Object.entries(P) as Array<[PermName, bigint]>) { + if ((value & bit) === bit) out.push(name); + } + return out; +} diff --git a/packages/discord/src/plan.test.ts b/packages/discord/src/plan.test.ts new file mode 100644 index 0000000..4db36d3 --- /dev/null +++ b/packages/discord/src/plan.test.ts @@ -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(); + 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'); +}); diff --git a/packages/discord/src/plan.ts b/packages/discord/src/plan.ts new file mode 100644 index 0000000..2c35359 --- /dev/null +++ b/packages/discord/src/plan.ts @@ -0,0 +1,308 @@ +// Der Bauplan des Community-Servers. Deklarativ, damit man ihn lesen und +// im Pull Request diskutieren kann, statt ihn in einer Oberflaeche zu suchen. +// +// Leitgedanke: der Kanal hat 5 bis 30 gleichzeitige Zuschauer. Ein Server mit +// zwanzig Kanaelen wirkt bei dieser Groesse leer, und ein leerer Kanal haelt +// niemanden. Fuenf sichtbare Kanaele, in denen etwas passiert, sind besser. +// Wann welcher Kanal dazukommt, steht in docs/projects/discord-community.md. +// +// Jeder Eintrag hat einen `key`, der sich nie aendert, und einen `name`, der +// sich aendern darf. Gefunden wird ueber `name` und `aliases`, verwiesen wird +// ueber `key` - so wird aus einer Umbenennung eine Umbenennung und kein +// zweiter Kanal daneben. + +import { bits } from './permissions.ts'; +import { CH } from './api.ts'; + +// Zur Schreibweise: Kommentare und Bezeichner bleiben bei ae/oe/ue wie im +// restlichen Repo. Alles, was ein Mitglied auf Discord zu sehen bekommt - +// Kanalnamen, Themen, Rollennamen, der Willkommenstext, AutoMod-Meldungen - +// steht in richtigem Deutsch. "Sei anstaendig" liest sich wie ein Tippfehler, +// und der erste Eindruck eines Servers ist der Text darin. + +/** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */ +export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook'; + +export type RolePlan = { + name: string; + color: number; + /** Eigene Gruppe in der Mitgliederliste. */ + hoist: boolean; + mentionable: boolean; + permissions: bigint; + /** Wofuer es die Rolle gibt - landet in der Vorschau und in der Doku. */ + purpose: string; +}; + +export type OverwritePlan = { + /** Rollenname, oder '@everyone' fuer die Grundrolle. */ + role: string; + allow?: bigint; + deny?: bigint; +}; + +export type CategoryPlan = { + key: string; + name: string; + /** Frueher benutzte Namen. Wird einer gefunden, wird umbenannt. */ + aliases?: string[]; + overwrites?: OverwritePlan[]; +}; + +export type ChannelPlan = { + key: string; + name: string; + aliases?: string[]; + type: number; + topic?: string; + /** `key` einer Kategorie aus CATEGORIES. */ + category?: string; + overwrites?: OverwritePlan[]; + webhooks?: Array<{ name: string; slot: WebhookSlot }>; +}; + +// ---------------------------------------------------------------- Rechte + +/** + * Was ein normales Mitglied ueberall darf. Bewusst als vollstaendige Liste + * gesetzt statt auf Discords Vorgabe zu vertrauen - die aendert sich, und + * "jeder darf alle anpingen" ist der Unterschied zwischen einem Server, den + * man anlaesst, und einem, den man stummschaltet. + */ +export const EVERYONE = bits( + 'CREATE_INSTANT_INVITE', // Mitglieder duerfen einladen. Wachstum vor Kontrolle. + 'VIEW_CHANNEL', 'READ_MESSAGE_HISTORY', + 'SEND_MESSAGES', 'SEND_MESSAGES_IN_THREADS', 'CREATE_PUBLIC_THREADS', + 'ADD_REACTIONS', 'EMBED_LINKS', 'ATTACH_FILES', + 'USE_EXTERNAL_EMOJIS', 'USE_EXTERNAL_STICKERS', 'USE_APPLICATION_COMMANDS', + 'SEND_POLLS', 'CHANGE_NICKNAME', + 'CONNECT', 'SPEAK', 'STREAM', 'USE_VAD', 'USE_SOUNDBOARD', +); +// Nicht enthalten und damit verboten: MENTION_EVERYONE, SEND_TTS_MESSAGES, +// CREATE_PRIVATE_THREADS und saemtliche Verwaltungsrechte. + +const MOD = EVERYONE | bits( + 'KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', + 'MANAGE_MESSAGES', 'MANAGE_THREADS', 'MANAGE_NICKNAMES', + 'MUTE_MEMBERS', 'DEAFEN_MEMBERS', 'MOVE_MEMBERS', + 'VIEW_AUDIT_LOG', 'MENTION_EVERYONE', +); +// Kein MANAGE_GUILD, kein MANAGE_ROLES, kein MANAGE_CHANNELS: Moderation +// heisst aufraeumen, nicht umbauen. Wer umbauen soll, bekommt es einzeln. + +// Abdu bekommt ADMINISTRATOR. Das Recht schliesst alle anderen ein und setzt +// jede Kanal-Ausnahme ausser Kraft - auch kuenftige. Wer diese Rolle traegt, +// kann den Server komplett umbauen und loeschen, deshalb gehoert sie an genau +// ein Konto und an kein zweites. +const ABDU = bits('ADMINISTRATOR'); + +export const ROLES: RolePlan[] = [ + { + name: 'Abdu', + color: 0x9146ff, + hoist: true, + mentionable: true, + permissions: ABDU, + purpose: + 'Der Kanal selbst. Steht ganz oben in der Mitgliederliste und traegt ' + + 'Administrator, darf also alles. Nur fuer Abdus eigenes Konto.', + }, + { + name: 'Mod', + color: 0x5865f2, + hoist: true, + mentionable: true, + permissions: MOD, + purpose: 'Raeumt auf: loeschen, stummschalten, im Zweifel rauswerfen.', + }, + { + name: 'Stammgast', + color: 0x4fd1c5, + hoist: true, + mentionable: false, + permissions: EVERYONE | bits('PRIORITY_SPEAKER'), + purpose: + 'Auszeichnung fuer Leute, die regelmaessig da sind. Bei dieser Kanalgroesse ' + + 'ist sichtbare Zugehoerigkeit der wirksamste Hebel - die Rolle faerbt den ' + + 'Namen und sortiert ihn in der Mitgliederliste nach oben.', + }, + { + name: 'Live-Ping', + color: 0x99aab5, + hoist: false, + mentionable: true, + permissions: EVERYONE, + purpose: + 'Wird bei Stream-Start angepingt - selbst vergeben, nicht automatisch. ' + + 'Ein Rundruf an alle bei jedem Stream ist der schnellste Weg, dass der ' + + 'Server stummgeschaltet wird.', + }, +]; + +// ---------------------------------------------------------------- Kanaele +// +// Zu den Namen: Discord schreibt Textkanaele klein und macht aus Leerzeichen +// Bindestriche. Emoji, das Trennzeichen U+2502 und Umlaute bleiben unveraendert +// stehen - nachgemessen am echten Server, nicht angenommen. + +export const CATEGORIES: CategoryPlan[] = [ + { key: 'info', name: '📌 Info', aliases: ['Info', 'Informationen'] }, + { key: 'community', name: '🎮 Community', aliases: ['Community', 'Textkanäle'] }, + { + key: 'intern', + name: '🔒 Intern', + aliases: ['Intern'], + // Fuer normale Mitglieder gibt es diese Kategorie nicht. + overwrites: [ + { role: '@everyone', deny: bits('VIEW_CHANNEL') }, + { role: 'Mod', allow: bits('VIEW_CHANNEL') }, + ], + }, +]; + +/** Nur lesen: schreiben darf hier niemand ausser Mods und Webhooks. */ +const READONLY: OverwritePlan[] = [ + { role: '@everyone', deny: bits('SEND_MESSAGES', 'CREATE_PUBLIC_THREADS') }, +]; + +/** Lesen, aber diskutieren nur in Threads - haelt die Ankuendigungen sauber. */ +const ANNOUNCE: OverwritePlan[] = [ + { + role: '@everyone', + deny: bits('SEND_MESSAGES'), + allow: bits('ADD_REACTIONS', 'CREATE_PUBLIC_THREADS', 'SEND_MESSAGES_IN_THREADS'), + }, +]; + +export const CHANNELS: ChannelPlan[] = [ + { + key: 'willkommen', + name: '👋│willkommen', + aliases: ['willkommen', 'willkommen-und-richtlinien'], + type: CH.TEXT, + category: 'info', + topic: 'Was das hier ist und die drei Regeln.', + overwrites: READONLY, + }, + { + key: 'ankuendigungen', + name: '📢│ankündigungen', + aliases: ['ankuendigungen', 'ankündigungen'], + type: CH.TEXT, + category: 'info', + topic: 'Stream-Start und die Zusammenfassung danach. Reden gerne in den Threads.', + overwrites: ANNOUNCE, + webhooks: [ + { name: 'Go-Live', slot: 'goLiveWebhook' }, + { name: 'Stream-Recap', slot: 'recapWebhook' }, + ], + }, + { + key: 'allgemein', + name: '💬│allgemein', + aliases: ['allgemein'], + type: CH.TEXT, + category: 'community', + topic: 'Der Hauptkanal. Im Zweifel hier.', + }, + { + key: 'clips', + name: '🎬│clips', + aliases: ['clips'], + type: CH.TEXT, + category: 'community', + topic: 'Gute Momente aus dem Stream. Der Clip-Knopf postet hier hin.', + webhooks: [{ name: 'Clips', slot: 'clipsWebhook' }], + }, + { + key: 'voice', + name: '🔊 Zusammen zocken', + aliases: ['zusammen-zocken', 'Zusammen zocken'], + type: CH.VOICE, + category: 'community', + }, + { + key: 'mod-chat', + name: '🔨│mod-chat', + aliases: ['mod-chat'], + type: CH.TEXT, + category: 'intern', + topic: 'Nur Mods.', + }, + { + key: 'technik-log', + name: '🤖│technik-log', + aliases: ['technik-log'], + type: CH.TEXT, + category: 'intern', + topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.', + }, +]; + +// ---------------------------------------------------------------- Server + +/** + * Servereinstellungen. + * - verification_level 2 verlangt ein Konto, das aelter als fuenf Minuten + * ist. Kostet echte Leute nichts und haelt Wegwerf-Konten draussen, was bei + * einem oeffentlich von Twitch aus verlinkten Server der Normalfall ist. + * - default_message_notifications 1 heisst: nur bei Erwaehnung. Sonst + * klingelt jede Nachricht bei jedem, und der Server wird stummgeschaltet. + */ +export const GUILD_SETTINGS = { + verification_level: 2, + explicit_content_filter: 2, + default_message_notifications: 1, + system_channel_flags: 4, // Einrichtungstipps unterdruecken, Beitritte zeigen +}; + +/** + * Wird einmalig in #willkommen gepostet, wenn der Kanal noch leer ist. + * {key} wird durch die Erwaehnung des Kanals mit diesem `key` ersetzt. + */ +export const WELCOME = `**Willkommen bei abdulive_** + +Das hier ist der Ort zwischen den Streams. Wer mitliest, gehört dazu. + +**Drei Regeln** +> **1.** Sei anständig. Keine Beleidigungen, kein Rassismus, kein Sexismus. +> **2.** Kein Spam, keine Werbung, keine fremden Streams im Chat. +> **3.** Wenn ein Mod etwas sagt, ist das erledigt. Diskussion per DM. + +**Wo was passiert** +> {allgemein} - alles andere +> {clips} - die guten Momente +> {ankuendigungen} - Stream-Start und die Zusammenfassung danach + +**Bescheid wissen, wenn der Stream startet** +Hol dir die Rolle **Live-Ping**. Dann wird nur angepingt, wer das auch möchte. + +Twitch: `; + +// ---------------------------------------------------------------- AutoMod + +/** + * Zwei Regeln, die Discord selbst durchsetzt - ohne Bot, auch nachts um drei. + * Mehr braucht es bei dieser Groesse nicht; Wortfilter kosten mehr Aerger + * durch Fehlalarme, als sie an Spam verhindern. + */ +export type AutomodPlan = { + name: string; + /** Frueher benutzte Namen, damit umbenannt statt doppelt angelegt wird. */ + aliases?: string[]; + trigger_type: number; + trigger_metadata: Record; +}; + +export const AUTOMOD: AutomodPlan[] = [ + { + name: 'Erwähnungs-Spam', + aliases: ['Erwaehnungs-Spam'], + trigger_type: 5, + trigger_metadata: { mention_total_limit: 5 }, + }, + { name: 'Spam-Erkennung', trigger_type: 3, trigger_metadata: {} }, +]; + +/** Was jemand zu sehen bekommt, dessen Nachricht AutoMod abgefangen hat. */ +export const AUTOMOD_BLOCK = 'Das hat die Spam-Erkennung abgefangen. Bei Fragen an die Mods.'; diff --git a/packages/twitch/src/secrets.ts b/packages/twitch/src/secrets.ts index ac52c94..23c9150 100644 --- a/packages/twitch/src/secrets.ts +++ b/packages/twitch/src/secrets.ts @@ -33,7 +33,15 @@ export type SecretStore = { apps: Partial>; tokens: Partial>; obs?: { password: string }; - discord?: { goLiveWebhook?: string; recapWebhook?: string }; + discord?: { + /** Bot-Token der Discord-App. Nur fuers Einrichten und spaeter fuer den Bot. */ + botToken?: string; + /** Server-ID ("Guild"), damit das Einrichten kein Argument braucht. */ + guildId?: string; + goLiveWebhook?: string; + recapWebhook?: string; + clipsWebhook?: string; + }; bluesky?: { handle: string; appPassword: string }; /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ update?: { token: string };