Discord-Community-Server: Bauplan und Einrichtungsbefehl
Der Server ist kein Tool aus der Neunerliste, aber Broadcaster, Recap und Clipper posten dorthin. Deshalb steht die Struktur als Code im Repo und nicht nur in einer Oberflaeche. packages/discord: - permissions.ts Rechtebits als BigInt, das Feld ist 64 Bit breit - api.ts REST-Client: Warteschlange, Rate Limits, lesbare Fehler - plan.ts Rollen, Kanaele, Rechte, AutoMod, Willkommenstext - apply.ts traegt den Bauplan auf einen bestehenden Server auf Neuer Befehl `abdulive discord:setup [--server ID] [--vorschau]`. Zugeschnitten auf 5 bis 30 gleichzeitige Zuschauer: fuenf sichtbare Kanaele statt zwanzig, und eine selbst vergebene Rolle Live-Ping statt eines Rundrufs an alle bei jedem Stream. Wiederholbar statt einmalig: gefunden wird ueber Namen und frueheren Namen, danach wird angeglichen. Der zweite Lauf aendert nichts. Geloescht wird nie, und von Hand gesetzte Rechte-Ausnahmen bleiben stehen. Drei Dinge, die am echten Server nachgemessen und nicht angenommen wurden: - Discord schreibt Textkanaele klein und macht aus Leerzeichen Bindestriche; Emoji, Umlaute und U+2502 bleiben. normalizeName() bildet das ab, sonst geht bei jedem Lauf dieselbe Umbenennung erneut raus. - Kanalnamen lassen sich nur zweimal je zehn Minuten aendern, danach verlangt Discord bis zu neun Minuten Pause. Der Lauf wartet hoechstens eine Minute und meldet den Rest. - Eine Rolle kann nur Rollen unterhalb der eigenen verschieben, auch mit Administrator. rolePositions() rechnet den Platz unter der Bot-Rolle aus, statt blind 3, 2, 1 zu verlangen. Zugangsdaten laufen ueber den verschluesselten Speicher (ADR-0005): der Bot-Token wird verdeckt abgefragt, die drei Webhook-URLs werden nie ausgegeben und nie geloggt. Im Quelltext bleibt es bei ae/oe/ue, alles was ein Mitglied auf Discord liest steht in richtigem Deutsch. Ein Test haelt das fest. Begruendung, Fallstricke und die Schritte von Hand: docs/projects/discord-community.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d231e1c722
commit
3b744caf6a
13 files changed
+2210
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -557,6 +557,95 @@ async function listSources(profileName = 'default'): Promise<void> {
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
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 <ID>\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<string, string> = {
|
||||
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 <datei> [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}".`);
|
||||
|
||||
@@ -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` |
|
||||
@@ -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 <https://discord.com/developers/applications>:
|
||||
„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 <SERVER-ID> --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 <SERVER-ID>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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/*",
|
||||
|
||||
@@ -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 <ID> --vorschau # zeigt nur, was passieren wuerde
|
||||
abdulive discord:setup --server <ID> # 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)
|
||||
@@ -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<unknown> = 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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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<T>(method: string, path: string, body?: unknown, reason?: string): Promise<T> {
|
||||
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<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
|
||||
post<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('POST', path, body, reason); }
|
||||
patch<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('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<T>(path: string): Promise<T>;
|
||||
post<T>(path: string, body: unknown, reason?: string): Promise<T>;
|
||||
patch<T>(path: string, body: unknown, reason?: string): Promise<T>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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;
|
||||
@@ -0,0 +1,450 @@
|
||||
// Der ganze Durchlauf gegen einen nachgebauten Server. Kein Netz, kein Token.
|
||||
//
|
||||
// Die Frage, die hier beantwortet wird, ist nicht "kompiliert es", sondern:
|
||||
// Was passiert beim zweiten Lauf? Ein Einrichtungsskript, das beim zweiten Mal
|
||||
// alles doppelt anlegt, richtet mehr Schaden an, als es Arbeit spart.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts';
|
||||
import { applyPlan, type Step } from './apply.ts';
|
||||
import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts';
|
||||
import { str } from './permissions.ts';
|
||||
|
||||
const GUILD = '100';
|
||||
|
||||
/** Ein Discord-Server im Arbeitsspeicher. Kennt nur die Routen, die wir nutzen. */
|
||||
class FakeDiscord implements Rest {
|
||||
guild: ApiGuild;
|
||||
channels: ApiChannel[] = [];
|
||||
webhooks: ApiWebhook[] = [];
|
||||
automod: Array<{ id: string; name: string; actions?: unknown[] }> = [];
|
||||
messages = new Map<string, Array<{ id: string; content: string; author: { id: string } }>>();
|
||||
calls: string[] = [];
|
||||
private next = 1000;
|
||||
|
||||
/** Platz der Bot-Rolle. Discord haengt sie beim Einladen auf 1. */
|
||||
botRolePosition: number;
|
||||
readonly botUserId = '5000';
|
||||
readonly botRoleId = '5001';
|
||||
|
||||
constructor(opts: { botRolePosition?: number } = {}) {
|
||||
this.botRolePosition = opts.botRolePosition ?? 8;
|
||||
this.guild = {
|
||||
id: GUILD, name: 'abdulive', owner_id: '1', features: [],
|
||||
roles: [
|
||||
{
|
||||
id: GUILD, name: '@everyone', permissions: '0', position: 0,
|
||||
color: 0, hoist: false, mentionable: false, managed: false,
|
||||
},
|
||||
{
|
||||
id: this.botRoleId, name: 'abdulive_ Bot', permissions: '8',
|
||||
position: this.botRolePosition, color: 0, hoist: false,
|
||||
mentionable: false, managed: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private id(): string { return String(this.next++); }
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
this.calls.push(`GET ${path}`);
|
||||
if (path === '/users/@me') return { id: this.botUserId, username: 'abdulive_ Bot', bot: true } as T;
|
||||
if (path === `/guilds/${GUILD}/members/${this.botUserId}`) return { roles: [this.botRoleId] } as T;
|
||||
if (path === `/guilds/${GUILD}/roles`) return structuredClone(this.guild.roles) as T;
|
||||
if (path === `/guilds/${GUILD}`) return structuredClone(this.guild) as T;
|
||||
if (path === `/guilds/${GUILD}/channels`) return structuredClone(this.channels) as T;
|
||||
if (path === `/guilds/${GUILD}/auto-moderation/rules`) return structuredClone(this.automod) as T;
|
||||
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
|
||||
if (wh) return this.webhooks.filter((w) => w.channel_id === wh[1]) as T;
|
||||
const msg = /^\/channels\/(\d+)\/messages\?limit=(\d+)$/.exec(path);
|
||||
if (msg) return structuredClone(this.messages.get(msg[1]!) ?? []).slice(0, Number(msg[2])) as T;
|
||||
throw new Error(`GET ${path} nicht nachgebaut`);
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: unknown): Promise<T> {
|
||||
this.calls.push(`POST ${path}`);
|
||||
const b = body as Record<string, never>;
|
||||
if (path === `/guilds/${GUILD}/roles`) {
|
||||
const role = { id: this.id(), position: 1, managed: false, ...b } as unknown as ApiRole;
|
||||
this.guild.roles.push(role);
|
||||
return structuredClone(role) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/channels`) {
|
||||
const ch = {
|
||||
id: this.id(), position: this.channels.length, parent_id: null,
|
||||
permission_overwrites: [], ...b,
|
||||
} as unknown as ApiChannel;
|
||||
this.channels.push(ch);
|
||||
return structuredClone(ch) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/auto-moderation/rules`) {
|
||||
const src = body as Record<string, unknown>;
|
||||
const rule = { id: this.id(), name: String(src['name']), actions: src['actions'] as unknown[] };
|
||||
this.automod.push(rule);
|
||||
return structuredClone(rule) as T;
|
||||
}
|
||||
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
|
||||
if (wh) {
|
||||
const made: ApiWebhook = { id: this.id(), name: String(b['name']), channel_id: wh[1]!, token: 'tok-' + this.id() };
|
||||
this.webhooks.push(made);
|
||||
return structuredClone(made) as T;
|
||||
}
|
||||
const msg = /^\/channels\/(\d+)\/messages$/.exec(path);
|
||||
if (msg) {
|
||||
const made = {
|
||||
id: this.id(),
|
||||
content: String((b as Record<string, unknown>)['content'] ?? ''),
|
||||
author: { id: this.botUserId },
|
||||
};
|
||||
const list = this.messages.get(msg[1]!) ?? [];
|
||||
list.push(made);
|
||||
this.messages.set(msg[1]!, list);
|
||||
return structuredClone(made) as T;
|
||||
}
|
||||
throw new Error(`POST ${path} nicht nachgebaut`);
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body: unknown): Promise<T> {
|
||||
this.calls.push(`PATCH ${path}`);
|
||||
if (path === `/guilds/${GUILD}`) return {} as T;
|
||||
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
|
||||
for (const e of body as Array<{ id: string; position: number }>) {
|
||||
const r = this.guild.roles.find((x) => x.id === e.id);
|
||||
if (!r) continue;
|
||||
if (e.position >= this.botRolePosition) throw new Error('Missing Permissions');
|
||||
r.position = e.position;
|
||||
}
|
||||
return structuredClone(this.guild.roles) as T;
|
||||
}
|
||||
const role = /^\/guilds\/\d+\/roles\/(\d+)$/.exec(path);
|
||||
if (role) {
|
||||
const r = this.guild.roles.find((x) => x.id === role[1]);
|
||||
if (r) Object.assign(r, body);
|
||||
return structuredClone(r) as T;
|
||||
}
|
||||
const rule = /^\/guilds\/\d+\/auto-moderation\/rules\/(\d+)$/.exec(path);
|
||||
if (rule) {
|
||||
const r = this.automod.find((x) => x.id === rule[1]);
|
||||
if (r) Object.assign(r, body);
|
||||
return structuredClone(r) as T;
|
||||
}
|
||||
const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path);
|
||||
if (msg) {
|
||||
const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]);
|
||||
if (m) m.content = String((body as Record<string, unknown>)['content'] ?? '');
|
||||
return structuredClone(m) as T;
|
||||
}
|
||||
const ch = /^\/channels\/(\d+)$/.exec(path);
|
||||
if (ch) {
|
||||
const c = this.channels.find((x) => x.id === ch[1]);
|
||||
if (c) Object.assign(c, body);
|
||||
return structuredClone(c) as T;
|
||||
}
|
||||
throw new Error(`PATCH ${path} nicht nachgebaut`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (api: Rest, dryRun = false) =>
|
||||
applyPlan(api, GUILD, { dryRun, saveSecrets: false });
|
||||
|
||||
/** Der Kanal aus dem Bauplan, so wie er im nachgebauten Server gelandet ist. */
|
||||
const live = (api: FakeDiscord, key: string): ApiChannel | undefined => {
|
||||
const plan = CHANNELS.find((c) => c.key === key);
|
||||
assert.ok(plan, `key ${key} gibt es im Bauplan nicht`);
|
||||
return api.channels.find((c) => c.name === plan.name && c.type === plan.type);
|
||||
};
|
||||
|
||||
const katName = (key: string): string => {
|
||||
const k = CATEGORIES.find((c) => c.key === key);
|
||||
assert.ok(k);
|
||||
return k.name;
|
||||
};
|
||||
|
||||
const counts = (steps: Step[]): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const s of steps) out[s.change] = (out[s.change] ?? 0) + 1;
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Erstlauf
|
||||
|
||||
test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const res = await run(api);
|
||||
|
||||
assert.equal(res.guild, 'abdulive');
|
||||
for (const r of ROLES) {
|
||||
assert.ok(api.guild.roles.find((x) => x.name === r.name), `Rolle ${r.name} fehlt`);
|
||||
}
|
||||
assert.equal(api.channels.filter((c) => c.type === CH.CATEGORY).length, 3);
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
|
||||
}
|
||||
assert.equal(res.webhooks, 3);
|
||||
assert.equal(api.automod.length, 2);
|
||||
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
||||
});
|
||||
|
||||
test('@everyone bekommt die Grundrechte aus dem Bauplan', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const everyone = api.guild.roles.find((r) => r.id === GUILD);
|
||||
assert.equal(everyone?.permissions, str(EVERYONE));
|
||||
});
|
||||
|
||||
test('Kanaele haengen unter der richtigen Kategorie', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const kat = (key: string) =>
|
||||
api.channels.find((c) => c.name === katName(key) && c.type === CH.CATEGORY)?.id;
|
||||
for (const plan of CHANNELS) {
|
||||
const c = live(api, plan.key);
|
||||
assert.equal(c?.parent_id, kat(plan.category!), `${plan.name} haengt falsch`);
|
||||
}
|
||||
});
|
||||
|
||||
test('der interne Bereich ist fuer @everyone gesperrt und fuer Mod offen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const intern = api.channels.find((c) => c.name === katName('intern'));
|
||||
const mod = api.guild.roles.find((r) => r.name === 'Mod');
|
||||
const ow = intern?.permission_overwrites ?? [];
|
||||
const jeder = ow.find((o) => o.id === GUILD);
|
||||
assert.ok(jeder && (BigInt(jeder.deny) & 1024n) === 1024n, 'VIEW_CHANNEL muss fuer @everyone gesperrt sein');
|
||||
const fuerMod = ow.find((o) => o.id === mod?.id);
|
||||
assert.ok(fuerMod && (BigInt(fuerMod.allow) & 1024n) === 1024n, 'Mod muss hineinsehen koennen');
|
||||
});
|
||||
|
||||
test('die Begruessung wird gepostet und die Kanalnamen sind aufgeloest', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const willkommen = live(api, 'willkommen');
|
||||
const posts = api.messages.get(willkommen!.id) ?? [];
|
||||
assert.equal(posts.length, 1);
|
||||
assert.match(posts[0]!.content, /<#\d+> - alles andere/, 'die Kanalnamen muessen aufgeloest sein');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Zweitlauf
|
||||
|
||||
test('der zweite Lauf aendert nichts', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const nachErstlauf = {
|
||||
rollen: api.guild.roles.length,
|
||||
kanaele: api.channels.length,
|
||||
webhooks: api.webhooks.length,
|
||||
automod: api.automod.length,
|
||||
};
|
||||
|
||||
api.calls = [];
|
||||
const res = await run(api);
|
||||
|
||||
assert.deepEqual({
|
||||
rollen: api.guild.roles.length,
|
||||
kanaele: api.channels.length,
|
||||
webhooks: api.webhooks.length,
|
||||
automod: api.automod.length,
|
||||
}, nachErstlauf, 'beim zweiten Lauf wurde etwas doppelt angelegt');
|
||||
|
||||
// Kein POST ausser den Lesevorgaengen - nichts Neues, nichts Geaendertes.
|
||||
const schreibend = api.calls.filter((c) => c.startsWith('POST') || (c.startsWith('PATCH') && !c.endsWith('/roles')));
|
||||
assert.deepEqual(schreibend.filter((c) => c !== `PATCH /guilds/${GUILD}`), [],
|
||||
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
||||
|
||||
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
|
||||
assert.equal(res.webhooks, 3, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
|
||||
});
|
||||
|
||||
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
await run(api);
|
||||
const willkommen = live(api, 'willkommen');
|
||||
assert.equal((api.messages.get(willkommen!.id) ?? []).length, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Vorschau
|
||||
|
||||
test('die Vorschau schreibt nichts', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const res = await run(api, true);
|
||||
|
||||
assert.equal(api.guild.roles.length, 2, 'nur @everyone und die Bot-Rolle, die es schon gab');
|
||||
assert.equal(api.channels.length, 0);
|
||||
assert.equal(api.webhooks.length, 0);
|
||||
assert.equal(api.automod.length, 0);
|
||||
assert.deepEqual(api.calls.filter((c) => !c.startsWith('GET')), []);
|
||||
assert.ok((counts(res.steps)['erstellt'] ?? 0) > 10, 'die Vorschau muss zeigen, was entstehen wuerde');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Bestand
|
||||
|
||||
test('von Hand angelegte Kanaele und Rechte bleiben stehen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({
|
||||
id: '900', name: 'memes', type: CH.TEXT, position: 0, parent_id: null,
|
||||
permission_overwrites: [{ id: '777', type: 1, allow: '2048', deny: '0' }],
|
||||
});
|
||||
api.channels.push({
|
||||
id: '901', name: 'allgemein', type: CH.TEXT, position: 1, parent_id: null,
|
||||
permission_overwrites: [{ id: '777', type: 1, allow: '0', deny: '2048' }],
|
||||
});
|
||||
|
||||
await run(api);
|
||||
|
||||
assert.ok(api.channels.find((c) => c.name === 'memes'), 'ein fremder Kanal darf nicht verschwinden');
|
||||
const allgemein = api.channels.find((c) => c.id === '901');
|
||||
assert.equal(live(api, 'allgemein')?.id, '901', 'der bestehende Kanal muss umbenannt werden');
|
||||
assert.equal(api.channels.filter((c) => c.name.includes('allgemein')).length, 1, 'nicht doppelt anlegen');
|
||||
const fremd = (allgemein?.permission_overwrites ?? []).find((o: ApiOverwrite) => o.id === '777');
|
||||
assert.deepEqual(fremd, { id: '777', type: 1, allow: '0', deny: '2048' },
|
||||
'eine von Hand gesetzte Ausnahme darf nicht wegfallen');
|
||||
});
|
||||
|
||||
test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api); // legt alles an
|
||||
// Jemand loescht den Go-Live-Webhook und baut ihn in der Oberflaeche nach -
|
||||
// dann fehlt uns der Token, und das muss auffallen statt still zu scheitern.
|
||||
const i = api.webhooks.findIndex((w) => w.name === 'Go-Live');
|
||||
delete api.webhooks[i]!.token;
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.match(step?.note ?? '', /nicht auslesbar/);
|
||||
assert.equal(res.webhooks, 2);
|
||||
});
|
||||
|
||||
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
// Die Twitch-Anbindung legt eigene Rollen an. Die gehoeren Discord.
|
||||
api.guild.roles.push({
|
||||
id: '800', name: 'Stammgast', permissions: '0', position: 2,
|
||||
color: 0, hoist: false, mentionable: false, managed: true,
|
||||
});
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.kind === 'Rolle' && s.name === 'Stammgast');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.equal(api.guild.roles.find((r) => r.id === '800')?.permissions, '0', 'unveraendert geblieben');
|
||||
});
|
||||
|
||||
test('scheitert die Rangfolge, laeuft der Rest trotzdem durch', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const echt = api.patch.bind(api);
|
||||
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions');
|
||||
return echt<T>(path, body);
|
||||
};
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.name === 'Rangfolge');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.equal(api.channels.length, 3 + CHANNELS.length, 'die Kanaele muessen trotzdem entstehen');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('die Rollen landen unter der Bot-Rolle, in der Reihenfolge des Bauplans', async () => {
|
||||
const api = new FakeDiscord({ botRolePosition: 8 });
|
||||
await run(api);
|
||||
|
||||
const pos = (name: string) => api.guild.roles.find((r) => r.name === name)?.position;
|
||||
assert.deepEqual(ROLES.map((r) => pos(r.name)), [7, 6, 5, 4]);
|
||||
for (const r of ROLES) {
|
||||
assert.ok(pos(r.name)! < api.botRolePosition, `${r.name} steht ueber der Bot-Rolle`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async () => {
|
||||
// Der reale Fall direkt nach dem Einladen. Discord haengt die Bot-Rolle auf
|
||||
// Platz 1, darunter ist kein Platz - frueher gab das ein Missing Permissions.
|
||||
const api = new FakeDiscord({ botRolePosition: 1 });
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name === 'Rangfolge');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.match(step?.note ?? '', /nach oben ziehen/);
|
||||
// Alles andere muss trotzdem stehen.
|
||||
assert.equal(api.channels.length, 3 + CHANNELS.length);
|
||||
assert.equal(res.webhooks, 3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Umbenennen
|
||||
|
||||
test('ein Kanal unter altem Namen wird umbenannt, nicht verdoppelt', async () => {
|
||||
const api = new FakeDiscord();
|
||||
// So sah der Server nach dem ersten Lauf ohne Emoji aus.
|
||||
const alt = new Map<string, string>();
|
||||
CHANNELS.forEach((plan, i) => {
|
||||
const id = String(900 + i); // der Fake erwartet Ziffern-IDs
|
||||
alt.set(plan.key, id);
|
||||
api.channels.push({
|
||||
id, name: plan.aliases![0]!, type: plan.type,
|
||||
position: 0, parent_id: null, permission_overwrites: [],
|
||||
});
|
||||
});
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
for (const plan of CHANNELS) {
|
||||
assert.equal(live(api, plan.key)?.id, alt.get(plan.key), `${plan.key} wurde neu angelegt statt umbenannt`);
|
||||
}
|
||||
assert.equal(api.channels.filter((c) => c.type !== CH.CATEGORY).length, CHANNELS.length,
|
||||
'es darf kein Kanal dazugekommen sein');
|
||||
assert.ok(res.steps.some((s) => s.name.includes('->')), 'die Umbenennung muss im Bericht stehen');
|
||||
});
|
||||
|
||||
test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', async () => {
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({
|
||||
id: '901', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
const echt = api.patch.bind(api);
|
||||
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause');
|
||||
return echt<T>(path, body);
|
||||
};
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name.includes('allgemein'));
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.match(step?.note ?? '', /530 s/);
|
||||
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
|
||||
assert.equal(res.webhooks, 3);
|
||||
assert.equal(api.automod.length, 2);
|
||||
});
|
||||
|
||||
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const regel = api.automod[0]!;
|
||||
(regel.actions as Array<{ type: number; metadata: { custom_message: string } }>)[0]!
|
||||
.metadata.custom_message = 'alter Text';
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.kind === 'AutoMod' && s.name === regel.name);
|
||||
assert.equal(step?.change, 'angepasst');
|
||||
assert.match(step?.note ?? '', /actions/);
|
||||
assert.equal(api.automod.length, 2, 'die Regel darf nicht neu angelegt werden');
|
||||
});
|
||||
|
||||
test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const regel = api.automod[0]!;
|
||||
const actions = regel.actions as Array<{ type: number; metadata: Record<string, string> }>;
|
||||
actions[0]!.metadata['custom_message'] = 'alter Text';
|
||||
actions[1]!.metadata['channel_id'] = '424242'; // jemand hat umgestellt
|
||||
|
||||
await run(api);
|
||||
|
||||
const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2);
|
||||
assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden');
|
||||
});
|
||||
@@ -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 = <T extends { name: string }>(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<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
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<ApiRole>(`/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<string, string>,
|
||||
steps: Step[], dry: boolean,
|
||||
): Promise<void> {
|
||||
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<ApiRole[]>(`/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<ApiChannel | null> {
|
||||
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<ApiChannel>(`/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<string, unknown> = {};
|
||||
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<WebhookSlot, string>,
|
||||
): Promise<void> {
|
||||
if (!plan.webhooks?.length) return;
|
||||
const existing = await api.get<ApiWebhook[]>(`/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<ApiWebhook>(`/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<Record<string, unknown>> {
|
||||
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<void> {
|
||||
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<Rule[]>(`/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<string, unknown> = {};
|
||||
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<string, string>,
|
||||
steps: Step[], dry: boolean,
|
||||
): Promise<void> {
|
||||
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<Msg[]>(`/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<ApplyResult> {
|
||||
const dry = opts.dryRun ?? false;
|
||||
const steps: Step[] = [];
|
||||
|
||||
const guild = await api.get<ApiGuild>(`/guilds/${guildId}`);
|
||||
const me = await api.get<ApiUser>('/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<ApiChannel[]>(`/guilds/${guild.id}/channels`);
|
||||
const categoryIds = new Map<string, string>();
|
||||
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<WebhookSlot, string>();
|
||||
const made = new Map<string, ApiChannel>();
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Der Bauplan wird gegen einen echten Server gefahren, auf dem danach Leute
|
||||
// sitzen. Ein vertipptes Recht faellt dort nicht auf - es faellt auf, wenn
|
||||
// jemand es ausnutzt. Deshalb pruefen diese Tests vor allem das, was nicht
|
||||
// drinstehen darf.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { P, bits, names, str } from './permissions.ts';
|
||||
import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, WELCOME } from './plan.ts';
|
||||
import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts';
|
||||
import { CH } from './api.ts';
|
||||
|
||||
const role = (name: string) => {
|
||||
const r = ROLES.find((x) => x.name === name);
|
||||
assert.ok(r, `Rolle ${name} fehlt im Bauplan`);
|
||||
return r;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Bits
|
||||
|
||||
test('Rechtebits entsprechen den Werten der Discord-API', () => {
|
||||
assert.equal(P.VIEW_CHANNEL, 1024n);
|
||||
assert.equal(P.SEND_MESSAGES, 2048n);
|
||||
assert.equal(P.ADMINISTRATOR, 8n);
|
||||
assert.equal(P.MENTION_EVERYONE, 131072n);
|
||||
// Jenseits von 2^31 - hier wuerde eine number-Rechnung still falsch werden.
|
||||
assert.equal(P.CREATE_PUBLIC_THREADS, 34359738368n);
|
||||
assert.equal(P.MODERATE_MEMBERS, 1099511627776n);
|
||||
assert.equal(P.SEND_POLLS, 562949953421312n);
|
||||
});
|
||||
|
||||
test('bits vereinigt, str liefert den Dezimalstring der API', () => {
|
||||
assert.equal(bits('VIEW_CHANNEL', 'SEND_MESSAGES'), 3072n);
|
||||
assert.equal(str(bits('VIEW_CHANNEL', 'SEND_MESSAGES')), '3072');
|
||||
assert.equal(bits(), 0n);
|
||||
// Doppelt genannt bleibt einfach gesetzt.
|
||||
assert.equal(bits('VIEW_CHANNEL', 'VIEW_CHANNEL'), 1024n);
|
||||
});
|
||||
|
||||
test('names liest ein Bitfeld wieder auf', () => {
|
||||
assert.deepEqual(names(bits('KICK_MEMBERS', 'BAN_MEMBERS')), ['KICK_MEMBERS', 'BAN_MEMBERS']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rollen
|
||||
|
||||
test('@everyone bekommt kein Recht, mit dem sich der Server sprengen laesst', () => {
|
||||
for (const verboten of [
|
||||
'ADMINISTRATOR', 'MENTION_EVERYONE', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS',
|
||||
'MANAGE_MESSAGES', 'MANAGE_WEBHOOKS', 'KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_TTS_MESSAGES',
|
||||
] as const) {
|
||||
assert.equal(EVERYONE & P[verboten], 0n, `@everyone darf nicht ${verboten}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('@everyone kann das Noetige: lesen, schreiben, einladen', () => {
|
||||
for (const noetig of ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY', 'CREATE_INSTANT_INVITE'] as const) {
|
||||
assert.equal(EVERYONE & P[noetig], P[noetig], `@everyone braucht ${noetig}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Mod darf moderieren, aber nicht umbauen', () => {
|
||||
const mod = role('Mod').permissions;
|
||||
for (const darf of ['KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', 'MANAGE_MESSAGES'] as const) {
|
||||
assert.equal(mod & P[darf], P[darf], `Mod braucht ${darf}`);
|
||||
}
|
||||
for (const nicht of ['ADMINISTRATOR', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_WEBHOOKS'] as const) {
|
||||
assert.equal(mod & P[nicht], 0n, `Mod darf nicht ${nicht}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Administrator traegt genau eine Rolle, und das ist Abdu', () => {
|
||||
const mitAdmin = ROLES.filter((r) => (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR);
|
||||
assert.deepEqual(mitAdmin.map((r) => r.name), ['Abdu']);
|
||||
});
|
||||
|
||||
test('Abdu steht ganz oben in der Rangfolge', () => {
|
||||
// Die Reihenfolge in ROLES ist die Rangfolge auf dem Server.
|
||||
assert.equal(ROLES[0]?.name, 'Abdu');
|
||||
});
|
||||
|
||||
test('Stammgast ist Auszeichnung, keine Macht', () => {
|
||||
const s = role('Stammgast');
|
||||
assert.equal(s.hoist, true, 'muss in der Mitgliederliste sichtbar sein - das ist der ganze Zweck');
|
||||
// Ueber @everyone hinaus nur Kosmetik und Sprachkanal-Vorrang.
|
||||
assert.equal(s.permissions & ~(EVERYONE | P.PRIORITY_SPEAKER), 0n);
|
||||
});
|
||||
|
||||
test('Live-Ping ist erwaehnbar, sonst nichts', () => {
|
||||
const l = role('Live-Ping');
|
||||
assert.equal(l.mentionable, true, 'sonst kann die Go-Live-Meldung sie nicht anpingen');
|
||||
assert.equal(l.permissions & ~EVERYONE, 0n);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanaele
|
||||
|
||||
test('Kanalnamen sind eindeutig und ueberleben Discords Normalisierung', () => {
|
||||
const seen = new Set<string>();
|
||||
for (const c of CHANNELS) {
|
||||
// Discord schreibt Textkanaele klein. Steht im Plan ein Grossbuchstabe,
|
||||
// weicht der gespeicherte Name ab und wir benennen bei jedem Lauf erneut um.
|
||||
if (c.type === CH.TEXT) {
|
||||
assert.equal(c.name, normalizeName(c.name, c.type),
|
||||
`${c.name}: Discord wuerde den Namen veraendern`);
|
||||
}
|
||||
assert.equal(seen.has(c.name), false, `${c.name} kommt doppelt vor`);
|
||||
seen.add(c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Eintrag hat einen eindeutigen key', () => {
|
||||
const keys = [...CATEGORIES.map((c) => c.key), ...CHANNELS.map((c) => c.key)];
|
||||
assert.equal(new Set(keys).size, keys.length, 'keys muessen eindeutig sein');
|
||||
});
|
||||
|
||||
test('der alte Name steht als alias drin, sonst entsteht ein zweiter Kanal', () => {
|
||||
// Beim Umstellen auf Emoji-Namen war genau das die Gefahr.
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.aliases?.length, `${c.key} hat keinen frueheren Namen hinterlegt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Kanal haengt in einer Kategorie, die es gibt', () => {
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.category, `${c.name} hat keine Kategorie`);
|
||||
assert.ok(CATEGORIES.some((k) => k.key === c.category),
|
||||
`${c.name}: Kategorie ${c.category} ist unbekannt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Rechte-Ausnahmen verweisen nur auf Rollen aus dem Bauplan', () => {
|
||||
const bekannt = new Set(['@everyone', ...ROLES.map((r) => r.name)]);
|
||||
const alle = [...CHANNELS.flatMap((c) => c.overwrites ?? []), ...CATEGORIES.flatMap((c) => c.overwrites ?? [])];
|
||||
for (const o of alle) assert.equal(bekannt.has(o.role), true, `unbekannte Rolle: ${o.role}`);
|
||||
});
|
||||
|
||||
test('die interne Kategorie ist fuer Mitglieder unsichtbar, fuer Mods nicht', () => {
|
||||
const ow = CATEGORIES.find((c) => c.key === 'intern')?.overwrites;
|
||||
assert.ok(ow);
|
||||
const jeder = ow.find((o) => o.role === '@everyone');
|
||||
const mod = ow.find((o) => o.role === 'Mod');
|
||||
assert.equal(jeder?.deny! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
assert.equal(mod?.allow! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
});
|
||||
|
||||
test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
|
||||
for (const key of ['mod-chat', 'technik-log']) {
|
||||
const c = CHANNELS.find((x) => x.key === key);
|
||||
assert.equal(c?.category, 'intern', `${key} waere sonst oeffentlich`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Webhook-Platz kommt genau einmal vor', () => {
|
||||
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
|
||||
assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']);
|
||||
});
|
||||
|
||||
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
|
||||
// Go-Live und Recap muessen obenauf bleiben; in #clips darf jeder posten.
|
||||
const ank = CHANNELS.find((c) => c.key === 'ankuendigungen');
|
||||
const deny = ank?.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
|
||||
assert.equal(deny & P.SEND_MESSAGES, P.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => {
|
||||
const keys = new Set(CHANNELS.map((c) => c.key));
|
||||
for (const [, key] of WELCOME.matchAll(/\{(\w[\w-]*)\}/g)) {
|
||||
assert.equal(keys.has(key!), true, `${key} aus dem Willkommenstext gibt es nicht`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Merge
|
||||
|
||||
const ID = (name: string): string | undefined =>
|
||||
({ '@everyone': '1', Mod: '2', Stammgast: '3' })[name];
|
||||
|
||||
test('fremde Rechte-Ausnahmen bleiben stehen', () => {
|
||||
// "99" hat jemand von Hand gesetzt. Das Skript darf das nicht wegraeumen.
|
||||
const vorher = [{ id: '99', type: 0 as const, allow: '8', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.ok(out);
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(out.find((o) => o.id === '99'), vorher[0]);
|
||||
assert.deepEqual(out.find((o) => o.id === '2'), { id: '2', type: 0, allow: '1024', deny: '0' });
|
||||
});
|
||||
|
||||
test('nichts zu tun heisst null - dann faellt das PATCH weg', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '2048' }];
|
||||
assert.equal(mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID), null);
|
||||
});
|
||||
|
||||
test('abweichende Rechte werden ueberschrieben, nicht ergaenzt', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(out, [{ id: '1', type: 0, allow: '0', deny: '2048' }]);
|
||||
});
|
||||
|
||||
test('eine Ausnahme fuer ein Mitglied wird nicht mit einer Rolle verwechselt', () => {
|
||||
// type 1 ist ein einzelnes Mitglied. Gleiche ID, anderer Typ - darf nicht
|
||||
// ueberschrieben werden.
|
||||
const vorher = [{ id: '2', type: 1 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out?.[0], vorher[0]);
|
||||
});
|
||||
|
||||
test('Rollen, die es noch nicht gibt, werden uebergangen statt zu raten', () => {
|
||||
assert.equal(mergeOverwrites([], [{ role: 'Gibtsnicht', allow: 1024n }], ID), null);
|
||||
});
|
||||
|
||||
test('die Vorlage wird nicht verbogen', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '0' }];
|
||||
const kopie = structuredClone(vorher);
|
||||
mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(vorher, kopie, 'mergeOverwrites darf die Eingabe nicht veraendern');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Namen
|
||||
|
||||
test('normalizeName bildet ab, was Discord mit dem Namen macht', () => {
|
||||
// Am echten Server nachgemessen: kleinschreiben, Leerzeichen zu Bindestrich,
|
||||
// Emoji, Umlaute und U+2502 bleiben unveraendert.
|
||||
assert.equal(normalizeName('Aü Test Kanal', CH.TEXT), 'aü-test-kanal');
|
||||
assert.equal(normalizeName('📢│ankündigungen', CH.TEXT), '📢│ankündigungen');
|
||||
// Sprachkanaele und Kategorien behalten Grossbuchstaben und Leerzeichen.
|
||||
assert.equal(normalizeName('🔊 Zusammen zocken', CH.VOICE), '🔊 Zusammen zocken');
|
||||
assert.equal(normalizeName('📌 Info', CH.CATEGORY), '📌 Info');
|
||||
});
|
||||
|
||||
test('findChannel erkennt den Kanal am frueheren Namen', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: 'sonstwas', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '1');
|
||||
});
|
||||
|
||||
test('findChannel bevorzugt den neuen Namen vor dem alten', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: '💬│allgemein', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '2');
|
||||
});
|
||||
|
||||
test('findChannel verwechselt Sprach- und Textkanal nicht', () => {
|
||||
const vorhanden = [{ id: '1', name: 'clips', type: CH.VOICE, position: 0, parent_id: null }];
|
||||
assert.equal(findChannel(vorhanden, { name: '🎬│clips', aliases: ['clips'], type: CH.TEXT }), undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('rolePositions setzt die Rollen unter die Bot-Rolle', () => {
|
||||
// Bot auf Platz 5, drei Rollen -> 4, 3, 2. Platz 1 bleibt frei, 0 ist @everyone.
|
||||
assert.deepEqual(rolePositions(3, 5), { positions: [4, 3, 2] });
|
||||
});
|
||||
|
||||
test('rolePositions nutzt den Platz genau aus', () => {
|
||||
assert.deepEqual(rolePositions(3, 4), { positions: [3, 2, 1] });
|
||||
});
|
||||
|
||||
test('steht die Bot-Rolle zu weit unten, sagt rolePositions was zu tun ist', () => {
|
||||
// Der Normalfall direkt nach dem Einladen: Bot auf Platz 1, kein Platz darunter.
|
||||
const out = rolePositions(3, 1);
|
||||
assert.ok('problem' in out);
|
||||
assert.match(out.problem, /Platz fuer 0 von 3/);
|
||||
assert.match(out.problem, /nach oben ziehen/);
|
||||
});
|
||||
|
||||
test('rolePositions verlangt nie einen Platz ueber der Bot-Rolle', () => {
|
||||
// Genau das hat Discord mit "Missing Permissions" quittiert.
|
||||
for (let botTop = 1; botTop <= 8; botTop++) {
|
||||
const out = rolePositions(3, botTop);
|
||||
if ('problem' in out) continue;
|
||||
for (const p of out.positions) {
|
||||
assert.ok(p < botTop, `Platz ${p} liegt nicht unter der Bot-Rolle (${botTop})`);
|
||||
assert.ok(p > 0, `Platz ${p} wuerde @everyone verdraengen`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Deutsch
|
||||
|
||||
test('was Mitglieder lesen, steht in richtigem Deutsch', () => {
|
||||
// Im Quelltext ist ae/oe/ue Konvention. In allem, was auf Discord sichtbar
|
||||
// ist, waere es ein Tippfehler - "Sei anstaendig" liest sich wie einer.
|
||||
const sichtbar = [
|
||||
WELCOME,
|
||||
...CHANNELS.map((c) => c.name),
|
||||
...CHANNELS.map((c) => c.topic ?? ''),
|
||||
...CATEGORIES.map((c) => c.name),
|
||||
...ROLES.map((r) => r.name),
|
||||
...AUTOMOD.map((r) => r.name),
|
||||
AUTOMOD_BLOCK,
|
||||
].join('\n');
|
||||
|
||||
// Wortstaemme, die im Deutschen praktisch immer einen Umlaut tragen.
|
||||
for (const falsch of ['ae', 'oe', 'ue']) {
|
||||
const treffer = sichtbar.match(new RegExp('\\w*' + falsch + '\\w*', 'g')) ?? [];
|
||||
// "Neuer", "Feuer" und Aehnliches sind echte Vorkommen - nur die bekannten
|
||||
// Umschreibungen aus diesem Projekt duerfen nicht auftauchen.
|
||||
for (const wort of treffer) {
|
||||
assert.equal(
|
||||
/gehoert|anstaendig|Probelaeufe|Erwaehnung|aendert|laeuft|zusaetzlich|Aenderung/i.test(wort),
|
||||
false,
|
||||
`"${wort}" gehoert in richtiges Deutsch geschrieben`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Abdu traegt Administrator, sonst niemand', () => {
|
||||
for (const r of ROLES) {
|
||||
const admin = (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR;
|
||||
assert.equal(admin, r.name === 'Abdu', `${r.name}: Administrator=${admin}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('die AutoMod-Regel kennt ihren frueheren Namen', () => {
|
||||
const regel = AUTOMOD.find((r) => r.name === 'Erwähnungs-Spam');
|
||||
assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'),
|
||||
'sonst steht die Regel nach der Umbenennung zweimal da');
|
||||
});
|
||||
@@ -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: <https://twitch.tv/abdulive_>`;
|
||||
|
||||
// ---------------------------------------------------------------- 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<string, unknown>;
|
||||
};
|
||||
|
||||
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.';
|
||||
@@ -33,7 +33,15 @@ export type SecretStore = {
|
||||
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
|
||||
tokens: Partial<Record<Role, TokenSet>>;
|
||||
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 };
|
||||
|
||||
Reference in new issue
Block a user