14 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5 e87879de9e Uebergabe auf den aktuellen Stand
Stand 2026-09-15. Der Discord-Server ist dazugekommen, der Rest ist
unveraendert: S2 wartet weiter auf ein laufendes OBS.

- Neuer Abschnitt "Der Discord-Server steht": was da ist, und dass die drei
  Webhooks fuer Broadcaster, Recap und Clipper bereitliegen. Wer diese
  Werkzeuge baut, muss nur noch posten.
- Neuer Abschnitt "Discord: was gemessen ist", nach dem Vorbild der
  Windows-Fallen. Vier Dinge, die nicht in der Doku stehen, in der man
  zuerst sucht: das Limit von zwei Umbenennungen je zehn Minuten, die
  Rangfolge-Regel hinter dem irrefuehrenden "Missing Permissions", die
  Schwelle fuer Onboarding und die Namensnormalisierung.
- Zwei Handgriffe fuer Abdu ergaenzt: sich selbst die Rolle geben, und den
  Einladungslink verteilen. An Letzterem haengt, ob der Server ueberhaupt
  jemanden bekommt.
- Ehrlich dazugeschrieben, was offen ist: der Knopf haengt am Daemon, und
  der Bot ist nie unter Last gelaufen.
- Der Hinweis auf die zwei Remotes, damit sie nicht auseinanderlaufen.
- 93 -> 162 Tests.

offene-punkte.md bekommt einen Discord-Abschnitt, die Roadmap den Hinweis,
dass die Discord-Seite von M3 schon steht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:05:03 +02:00
ZeasyAlexandClaude Opus 5 994ce4faf0 Discord: Knopf fuer die Live-Ping-Rolle statt Onboarding
Live-Ping nuetzt nur, wenn man sie sich selbst geben kann. Discords
Onboarding waere der vorgesehene Weg, laesst sich hier aber nicht
einschalten - gemessen, nicht vermutet:

  HTTP 400  Cannot enable onboarding, requirements are not met  (350000)
  sichtbar fuer @everyone: 5   davon beschreibbar: 3
  Discord verlangt:        7   davon beschreibbar: 5

Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser. Stattdessen ein Knopf unter dem Willkommenstext: ein Druck
holt die Rolle, der naechste gibt sie ab, die Antwort sieht nur, wer
geklickt hat.

Der Knopf braucht eine dauerhafte Verbindung, weil Discord Klicks ans
Gateway oder an eine oeffentliche HTTPS-Adresse liefert und der Stream-PC
keine hat:

- gateway.ts   WebSocket mit Herzschlag, Fortsetzen und Wiederverbinden.
               Bei 4004 und Verwandten wird aufgegeben statt im Kreis zu
               laufen - dagegen hilft nur eine Aenderung an der App.
               intents: 0, der Bot sieht weder Nachrichten noch Mitglieder.
- selfroles.ts Knopfleiste und Rollenwechsel.
- bot.ts       beides zusammen, laeuft im Ingest-Daemon mit. Ohne
               hinterlegtes Token entfaellt er wortlos.

Neuer Befehl `abdulive discord:bot` zum Ausprobieren; im Normalbetrieb
haengt der Bot im Daemon und meldet sich als discordConnected auf der
Statusseite.

Die Knopfleiste gehoert zum Soll-Zustand des Willkommenstextes: fehlt sie,
wird sie nachgereicht, ohne die Nachricht neu zu posten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 21:59:08 +02:00
ZeasyAlexandClaude Opus 5 3b744caf6a 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>
2026-09-15 20:43:21 +02:00
ZeasyAlexandClaude Opus 5 d231e1c722 Uebergabe auf den aktuellen Stand
Version 0.5.4, 93 Tests, acht ADRs. Neu darin:

- Auslieferung: was build-package.ts tut, wie die Veroeffentlichung
  entsteht, und dass die CI-Pipeline noch nie gelaufen ist
- Vier Windows-Fallen, die je einen Fehler verursacht haben und im
  Fehlerfall nicht nach ihrer Ursache aussehen: stderr als
  NativeCommandError, Rueckgabewert von & plus $LASTEXITCODE, localhost
  gegen 127.0.0.1, ffmpeg-Filterpfade
- Untertitel: die gemessenen Zahlen statt der alten Schaetzung
- Hinweis, dass das Repo oeffentlich ist

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:54:58 +02:00
ZeasyAlexandClaude Opus 5 7775c689be Handbuch als LaTeX, mit Bauskript
docs/handbuch/handbuch.tex plus bauen.ps1. Acht Seiten: was das Werkzeug
tut, Installation, Einrichtung Schritt fuer Schritt, OBS, Betrieb,
Updates, Sicherung, Deinstallation, Fehlersuche, Dateiablage.

Inhalte aus dem Quelltext gezogen, nicht geschaetzt: Abklingzeiten,
Tonspuren, Ausgabedateien, Routen der Einrichtungsseite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:12:26 +02:00
ZeasyAlexandClaude Opus 5 04da6ef802 Doku: Abschnitt zur Deinstallation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:05:39 +02:00
ZeasyAlexandClaude Opus 5 bc5d8ad07b Deinstallation: es gab keine
Wer das Toolkit testweise installiert hatte, konnte es nur von Hand wieder
abraeumen - zwei Aufgaben im Aufgabenplaner, ein laufender Prozess, zwei
Ordner. Das ist jetzt ein Skript.

Die Daten bleiben per Vorgabe stehen: in der Datenbank liegen Chatverlaeufe,
und die sind nach einer versehentlichen Deinstallation weg. -AuchDaten
raeumt auch die ab.

Getestet an einer laufenden Installation: Aufgaben weg, Prozess beendet,
Programm geloescht, Port 4792 frei.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:05:12 +02:00
ZeasyAlexandClaude Opus 5 2cdb4690e5 Doku: der Installer macht die drei Schritte selbst
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:20:16 +02:00
ZeasyAlexandClaude Opus 5 3f21f1c725 Installer meldete Fehler, wo keine waren
Zwei Fehler, beide in scripts/install.ps1:

1. Die Hilfsfunktion Ruf gab die Programmausgabe mit zurueck. `return
   $LASTEXITCODE` nach `& $Datei` liefert ein Array aus allen Ausgabe-
   zeilen plus Exit-Code - jeder Vergleich damit ist falsch. Deshalb galt
   ein erfolgreiches fetch-model als fehlgeschlagen und die check-env-
   Ausgabe verschwand. Jetzt ueber Out-Host.

2. Die Bereitschaftspruefung fragte http://localhost:4792/health ab. Der
   Daemon lauscht nur auf IPv4, Windows loest localhost aber zuerst nach
   ::1 auf: gemessen 2066 ms bis zum Rueckfall gegenueber 15 ms direkt.
   Bei einem Zeitlimit von 2 s lief die Pruefung jedes Mal ab, obwohl der
   Daemon laengst lief. Jetzt 127.0.0.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:18:15 +02:00
ZeasyAlexandClaude Opus 5 18421a1af0 Starter brachte den Daemon bei seiner ersten Logzeile um
Die Aufgabe scheiterte mit Code 1, abdulive.log gab es gar nicht. Ursache:

  & $node $main ingest 2>&1 | ForEach-Object { ... }

Windows PowerShell 5.1 verpackt jede stderr-Zeile eines nativen Programms
in einen ErrorRecord. Zusammen mit $ErrorActionPreference = Stop beendet
das sofort das ganze Skript - und der Daemon schreibt im Normalbetrieb
Warnungen auf stderr ("Kein Broadcaster-Token ..."). Er starb also an
seiner eigenen ersten Meldung, bevor irgendetwas ins Protokoll kam.

- launcher.ps1 startet jetzt ueber Start-Process mit getrennten
  Umleitungen fuer stdout und stderr, je Lauf rotiert
- install.ps1 ruft native Programme ueber eine Hilfsfunktion auf, die
  ErrorActionPreference kurz zurueckstellt
- NODE_OPTIONS=--disable-warning=ExperimentalWarning fehlte im Paket; im
  Container stand es in der Dockerfile

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:13:00 +02:00
ZeasyAlexandClaude Opus 5 56af15cf3f Installer macht alles: Modell, Selbsttest, Start, Browser
Vorher endete die Installation mit drei Befehlen zum Abtippen. Es gibt
keinen Grund dafuer - der Installer laedt jetzt das Sprachmodell, laeuft
den Selbsttest, startet den Daemon, wartet auf /health und oeffnet die
Einrichtungsseite.

Ein fehlgeschlagener Modell-Download bricht nichts ab: das Toolkit laeuft
auch ohne, es entstehen dann Clips ohne Untertitel. Schalter -OhneModell
und -KeinBrowser fuer den Fall, dass man das nicht will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:06:39 +02:00
ZeasyAlexandClaude Opus 5 92f14eaa62 Doku: Installation mit einem Befehl
Getestet wie Abdu es tippt, in frischer Umgebung: laedt, prueft die
Pruefsumme, installiert, legt die Aufgaben an. check-env danach ohne
harte Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:01:50 +02:00
ZeasyAlexandClaude Opus 5 55ee11cef8 Installer: Einzeiler funktioniert, seit das Repo oeffentlich ist
Die Vorgabe fuer -Quelle zeigt jetzt auf die gepruefte Veroeffentlichung.
Damit reicht ein Befehl:

  irm .../scripts/install.ps1 | iex

Die Pruefung auf HTML statt JSON bleibt drin - sie war der Grund, warum
der erste Versuch als PowerShell-Syntaxfehler endete statt als Fehlermeldung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:59:13 +02:00
ZeasyAlexandClaude Opus 5 08f7c21450 Veroeffentlichung ueber Gitea, BOM-Toleranz, Tests fuer den YAML-Leser
Das Repo ist jetzt oeffentlich, die Pakete haengen an der Veroeffentlichung
`latest`. Damit funktioniert `abdulive update` ohne Token.

- config.example.yaml traegt die gepruefte Update-Adresse
- .gitea/workflows/release.yml baut beim Tag und ersetzt `latest`
- Byte Order Mark: Notepad und `Set-Content -Encoding utf8` schreiben eines,
  und ohne Behandlung hiess der erste Schluessel in der config.yaml
  "\ufeffchannel" - die Einstellung waere stillschweigend ignoriert worden
- Der YAML-Leser hatte gar keine Tests; er liest die Konfiguration, an der
  alles haengt. Jetzt acht.

Der Updater ist einmal komplett durchgelaufen: 0.4.0 erkannt, 119 MB geladen,
Pruefsumme geprueft, bereitgelegt, von launcher.ps1 getauscht.

93 Tests, Typpruefung sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:43:43 +02:00
37 changed files with 4126 additions and 171 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
# Baut das Windows-Paket und haengt es an die Veroeffentlichung `latest`.
#
# Ausgeloest von einem Tag `v0.5.1` oder von Hand ueber die Actions-Seite.
# Der Updater auf Abdus Rechner fragt immer dieselbe Adresse ab:
# https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest/abdulive.json
# Deshalb wird `latest` bei jedem Lauf geloescht und neu angelegt.
name: Paket bauen und veroeffentlichen
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Versionsnummer, z. B. 0.5.1'
required: true
jobs:
paket:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Tests und Typpruefung
run: |
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
npm i --no-save typescript@5.9 @types/node
npx tsc --noEmit
- name: Version bestimmen
id: v
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "wert=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "wert=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
# Laedt Node, ffmpeg und whisper.cpp bei ihren Herausgebern und packt
# alles zusammen. Siehe scripts/build-package.ts und ADR-0007.
- name: Paket bauen
run: node scripts/build-package.ts --version "${{ steps.v.outputs.wert }}"
- name: Veroeffentlichung ersetzen
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
VERSION: ${{ steps.v.outputs.wert }}
run: |
set -eu
auth="Authorization: token $TOKEN"
# Vorhandene `latest` weg - sie soll immer den aktuellen Stand tragen
alt=$(curl -sf -H "$auth" "$API/releases/tags/latest" \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).id))}catch{}})' || true)
if [ -n "$alt" ]; then
curl -sf -X DELETE -H "$auth" "$API/releases/$alt" || true
fi
id=$(curl -sf -H "$auth" -H 'Content-Type: application/json' -X POST "$API/releases" \
-d "{\"tag_name\":\"latest\",\"target_commitish\":\"$GITHUB_SHA\",\"name\":\"abdulive $VERSION\",\"body\":\"Paket entpacken und darin install.ps1 ausfuehren.\"}" \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).id)))')
for f in dist/abdulive-"$VERSION"-win-x64.zip dist/abdulive.json; do
curl -sf -H "$auth" -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
-F "attachment=@$f" -o /dev/null
echo " hochgeladen: $(basename "$f")"
done
- name: Was drin ist
run: cat dist/abdulive.json
+7
View File
@@ -25,3 +25,10 @@ Thumbs.db
# Zwischenspeicher des Paketbaus und sein Ergebnis # Zwischenspeicher des Paketbaus und sein Ergebnis
.cache/ .cache/
dist/ dist/
# LaTeX-Zwischendateien
docs/handbuch/*.aux
docs/handbuch/*.log
docs/handbuch/*.out
docs/handbuch/*.toc
docs/handbuch/*.png
+10 -18
View File
@@ -43,6 +43,7 @@ Dazu der Dienst, der unter allen anderen liegt:
- [`docs/offene-punkte.md`](docs/offene-punkte.md) — was noch fehlt - [`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/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/`](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) - [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
## Auf dem Stream-PC installieren ## Auf dem Stream-PC installieren
@@ -51,30 +52,21 @@ Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts
zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)). zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)).
Das Paket herunterladen, entpacken, darin `install.ps1` ausführen. Ohne Ein Befehl, ohne Administratorrechte. Programm und Daten landen im
Administratorrechte; Programm und Daten landen im Benutzerprofil. Benutzerprofil.
```powershell ```powershell
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
.\abdulive\install.ps1
``` ```
> Einen `irm … | iex`-Einzeiler gibt es nicht: `git.zeasy.dev` verlangt Kein Netz auf dem Stream-PC? Dann das Paket von der Releases-Seite laden,
> instanzweit eine Anmeldung und antwortet auf **jede** Adresse mit einer entpacken und darin `install.ps1` ausführen.
> Umleitung auf `/user/login`. `iex` führt die Anmeldeseite dann als
> PowerShell aus. Wo die Pakete künftig liegen sollen, ist noch offen —
> siehe [`offene-punkte.md`](docs/offene-punkte.md).
Danach einmalig: Der Installer lädt danach das Sprachmodell, läuft den Selbsttest, startet
den Daemon und öffnet die Einrichtungsseite. Ein Befehl, und du bist da.
```powershell Dort die Twitch-Zugangsdaten eintragen. Sie werden verschlüsselt abgelegt,
abdulive fetch-model # Sprachmodell, ca. 465 MB nicht in einer Datei im Projekt
abdulive check-env # Selbsttest
Start-ScheduledTask -TaskName abdulive
```
Dann **http://localhost:4792/setup** öffnen und die Twitch-Zugangsdaten
eintragen. Sie werden verschlüsselt abgelegt, nicht in einer Datei im Projekt
([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)). ([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
Ausführlich: [`docs/05-setup.md`](docs/05-setup.md). Ausführlich: [`docs/05-setup.md`](docs/05-setup.md).
+115
View File
@@ -557,6 +557,116 @@ 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}`; 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.`);
}
async function discordBot(): Promise<void> {
const { createBot } = await import('#discord/bot.ts');
const bot = createBot();
if (!bot) {
throw new Error('Kein Discord-Bot hinterlegt. Erst einrichten: abdulive discord:setup');
}
const abort = new AbortController();
const stop = (sig: string): void => {
console.log(`\n${sig} empfangen, trenne die Verbindung.`);
abort.abort();
setTimeout(() => process.exit(0), 300);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
console.log('Discord-Bot laeuft. Beenden mit Strg+C.');
console.log(`${C.dim}Im Normalbetrieb laeuft er im Ingest-Daemon mit; dieser Befehl ` +
`ist zum Ausprobieren.${C.off}`);
await bot.run(abort.signal);
}
// ---------------------------------------------------------------- help // ---------------------------------------------------------------- help
function help(): void { function help(): void {
@@ -571,6 +681,9 @@ abdulive - Werkzeugkasten fuer Abdus Stream
sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt? sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt?
update [--force] Nach einer neuen Fassung sehen und sie bereitlegen update [--force] Nach einer neuen Fassung sehen und sie bereitlegen
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern) render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
help Diese Uebersicht help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup Einrichtung nach dem Start: http://localhost:4792/setup
@@ -601,6 +714,8 @@ try {
break; break;
} }
case 'render': await renderOne(args[0], args[1]); break; case 'render': await renderOne(args[0], args[1]); break;
case 'discord:setup': await discordSetup(args); break;
case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
console.error(`Unbekannter Befehl "${cmd}".`); console.error(`Unbekannter Befehl "${cmd}".`);
+10 -1
View File
@@ -5,7 +5,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { isNewer, looksLikeLogin } from './update.ts'; import { isNewer, looksLikeLogin, parseJsonLoose } from './update.ts';
test('vergleicht Versionen als Zahlen, nicht als Text', () => { test('vergleicht Versionen als Zahlen, nicht als Text', () => {
// Der Fall, der bei Zeichenkettenvergleich falsch herauskaeme // Der Fall, der bei Zeichenkettenvergleich falsch herauskaeme
@@ -42,3 +42,12 @@ test('haelt ein echtes Manifest nicht faelschlich fuer HTML', () => {
})); }));
assert.equal(looksLikeLogin(null, manifest), false); assert.equal(looksLikeLogin(null, manifest), false);
}); });
test('ein Byte Order Mark macht das Manifest nicht unlesbar', () => {
// Windows PowerShell 5.1 schreibt bei `Set-Content -Encoding utf8` eines,
// und Notepad ebenso. JSON.parse verschluckt sich daran.
const mitBom = '{"version":"0.5.0"}';
assert.throws(() => JSON.parse(mitBom));
assert.deepEqual(parseJsonLoose(mitBom), { version: '0.5.0' });
assert.deepEqual(parseJsonLoose('{"a":1}'), { a: 1 });
});
+8 -2
View File
@@ -56,10 +56,15 @@ export function looksLikeLogin(contentType: string | null, body: Buffer): boolea
head.includes('/user/login'); head.includes('/user/login');
} }
/** JSON.parse verschluckt sich an einem Byte Order Mark - Windows schreibt eines. */
export function parseJsonLoose(text: string): unknown {
return JSON.parse(text.replace(/^/, ''));
}
async function localVersion(): Promise<string> { async function localVersion(): Promise<string> {
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null); const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
if (!raw) return '0'; if (!raw) return '0';
return (JSON.parse(raw) as { version?: string }).version ?? '0'; return (parseJsonLoose(raw) as { version?: string }).version ?? '0';
} }
/** Adresse und - falls hinterlegt - der Zugriffstoken. */ /** Adresse und - falls hinterlegt - der Zugriffstoken. */
@@ -114,7 +119,8 @@ export async function checkForUpdate(opts: { force?: boolean } = {}): Promise<vo
console.log(`Quelle: ${url}`); console.log(`Quelle: ${url}`);
console.log(`Installiert: ${current}`); console.log(`Installiert: ${current}`);
const manifest = JSON.parse((await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest; const manifest = parseJsonLoose(
(await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest;
console.log(`Angeboten: ${manifest.version}`); console.log(`Angeboten: ${manifest.version}`);
if (!opts.force && !isNewer(manifest.version, current)) { if (!opts.force && !isNewer(manifest.version, current)) {
+6
View File
@@ -10,6 +10,7 @@ import { logger } from '#core/logger.ts';
import { sleep } from '#core/retry.ts'; import { sleep } from '#core/retry.ts';
import { openDb, closeDb } from '#db/index.ts'; import { openDb, closeDb } from '#db/index.ts';
import { repos } from '#db/repos.ts'; import { repos } from '#db/repos.ts';
import { createBot } from '#discord/bot.ts';
import { createObs } from '#obs/client.ts'; import { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts'; import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
@@ -116,6 +117,7 @@ export async function runIngest(): Promise<void> {
health: () => ({ health: () => ({
...state, ...state,
db: repo.stats(), db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false,
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(), clipsWaitingForFile: clipper.pendingCount(),
renderQueue: renderer.pending(), renderQueue: renderer.pending(),
@@ -170,6 +172,10 @@ export async function runIngest(): Promise<void> {
void irc.run(abort.signal); void irc.run(abort.signal);
void obs.run(abort.signal); void obs.run(abort.signal);
// ---- Discord (Rollen-Knoepfe). Fehlt die Einrichtung, entfaellt er.
const bot = createBot();
if (bot) void bot.run(abort.signal);
// ---- Zuschauerzahlen und Stream-Sessions // ---- Zuschauerzahlen und Stream-Sessions
void pollStream(cfg, repo, state, abort.signal); void pollStream(cfg, repo, state, abort.signal);
+1 -1
View File
@@ -29,7 +29,7 @@ ingest:
# Anmeldung (privates Gitea), gehoert der Zugriffstoken NICHT hierher, # Anmeldung (privates Gitea), gehoert der Zugriffstoken NICHT hierher,
# sondern auf die Einrichtungsseite - siehe ADR-0005. # sondern auf die Einrichtungsseite - siehe ADR-0005.
update: update:
url: "" url: "https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest"
obs: obs:
url: ws://127.0.0.1:4455 url: ws://127.0.0.1:4455
+2
View File
@@ -22,6 +22,8 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
- [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz` - [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz`
- [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu) - [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu)
- [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator - [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator
(Discord-Seite ist fertig: Server, Kanal und Webhook liegen bereit,
siehe [`projects/discord-community.md`](projects/discord-community.md))
- [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz - [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird **Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
+30 -18
View File
@@ -15,24 +15,13 @@ Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git.
## Installieren ## Installieren
Das Paket herunterladen, entpacken, darin `install.ps1` ausfuehren:
```powershell ```powershell
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
.\abdulive\install.ps1
``` ```
Liegt das Paket an einer Adresse **ohne** Anmeldung, holt der Installer es Alternativ das Paket von der Releases-Seite laden, entpacken und darin
selbst: `install.ps1` ausfuehren - dasselbe Skript, es merkt selbst, dass das
Paket schon danebenliegt.
```powershell
.\install.ps1 -Quelle https://.../abdulive
```
> Ein `irm … | iex`-Einzeiler funktioniert mit `git.zeasy.dev` nicht. Die
> Instanz verlangt fuer alles eine Anmeldung und antwortet mit einer Umleitung
> auf `/user/login`; `iex` fuehrt dann die Anmeldeseite als PowerShell aus.
> Wo die Pakete liegen sollen, ist noch zu entscheiden.
Der Installer laeuft **ohne Administratorrechte**: Der Installer laeuft **ohne Administratorrechte**:
@@ -80,15 +69,20 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung:
## Erste Einrichtung ## Erste Einrichtung
Der Installer erledigt das schon: Sprachmodell laden, Selbsttest, Daemon
starten, warten bis die Oberflaeche antwortet, Browser oeffnen.
Auf der Einrichtungsseite dann Client-ID und Secret der beiden Twitch-Apps
eintragen und je einmal auf „Mit Twitch verbinden" klicken.
Von Hand geht es auch:
```powershell ```powershell
abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout
abdulive fetch-model # Sprachmodell, ca. 465 MB abdulive fetch-model # Sprachmodell, ca. 465 MB
Start-ScheduledTask -TaskName abdulive Start-ScheduledTask -TaskName abdulive
``` ```
Dann **http://localhost:4792/setup** oeffnen: Client-ID und Secret der beiden
Twitch-Apps eintragen und je einmal auf „Mit Twitch verbinden" klicken.
## Layout einmessen ## Layout einmessen
```powershell ```powershell
@@ -133,6 +127,24 @@ Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst. Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst.
## Wieder entfernen
```powershell
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1"
```
Nimmt die beiden Aufgaben aus dem Aufgabenplaner, beendet den laufenden
Daemon und loescht den Programmordner. **Die Daten bleiben liegen** — in der
Datenbank stehen Chatverlaeufe, und die sind nach einer versehentlichen
Deinstallation nicht wiederherstellbar. Wer neu installiert, findet seine
Einrichtung wieder.
Wirklich alles weg, auch Datenbank, Clips und Sprachmodell:
```powershell
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1" -AuchDaten
```
## Backup ## Backup
```powershell ```powershell
+15
View File
@@ -0,0 +1,15 @@
# Baut das Handbuch nach handbuch.pdf.
#
# Braucht MiKTeX oder TeX Live mit xelatex. Zweimal, damit das
# Inhaltsverzeichnis stimmt.
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
foreach ($lauf in 1..2) {
xelatex -interaction=nonstopmode handbuch.tex | Out-Null
if ($LASTEXITCODE -ne 0) { throw "xelatex ist in Durchlauf $lauf gescheitert. Siehe handbuch.log" }
}
Remove-Item handbuch.aux, handbuch.log, handbuch.out, handbuch.toc -ErrorAction SilentlyContinue
Write-Host "handbuch.pdf gebaut ($([math]::Round((Get-Item handbuch.pdf).Length/1KB)) KB)"
Binary file not shown.
+449
View File
@@ -0,0 +1,449 @@
\documentclass[11pt,a4paper]{article}
\usepackage[ngerman]{babel}
\usepackage{fontspec}
\usepackage[a4paper,margin=2.4cm,headsep=14pt]{geometry}
\usepackage{xcolor}
\usepackage{titlesec}
\usepackage{enumitem}
\usepackage{booktabs}
\usepackage{tabularx}
\usepackage{fancyhdr}
\usepackage{fancyvrb}
\usepackage{mdframed}
\usepackage[hidelinks]{hyperref}
\setmainfont{Segoe UI}
\setmonofont{Consolas}[Scale=0.92]
\definecolor{lila}{HTML}{6B3FA0}
\definecolor{grau}{HTML}{555555}
\definecolor{hellgrau}{HTML}{F2F2F4}
\definecolor{rahmen}{HTML}{D8D8DE}
\titleformat{\section}{\Large\bfseries\color{lila}}{\thesection}{0.7em}{}
\titleformat{\subsection}{\large\bfseries}{\thesubsection}{0.6em}{}
\titlespacing*{\section}{0pt}{20pt}{8pt}
\titlespacing*{\subsection}{0pt}{14pt}{5pt}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small\color{grau}abdulive-toolkit}
\fancyhead[R]{\small\color{grau}\leftmark}
\fancyfoot[C]{\small\color{grau}\thepage}
\renewcommand{\headrulewidth}{0.4pt}
\renewcommand{\headrule}{\color{rahmen}\hrule height 0.4pt}
\renewcommand{\sectionmark}[1]{\markboth{#1}{}}
% Keine Einrueckung, dafuer Abstand zwischen den Absaetzen. Sonst wirkt jeder
% Absatz nach einer Liste oder Tabelle wie ein neuer Einzug.
\setlength{\parindent}{0pt}
\setlength{\parskip}{7pt}
\renewcommand{\arraystretch}{1.15}
\setlist[itemize]{leftmargin=1.3em,itemsep=2pt,topsep=4pt,parsep=0pt}
\setlist[enumerate]{leftmargin=1.6em,itemsep=3pt,topsep=4pt,parsep=0pt}
% Ohne commandchars: Backslashes in Windows-Pfaden bleiben woertlich. Mit
% commandchars wuerde aus \textbackslash ein Backslash plus Leerzeichen,
% und ein kopierter Pfad waere kaputt.
\DefineVerbatimEnvironment{code}{Verbatim}
{fontsize=\small,xleftmargin=8pt,frame=leftline,framerule=1.6pt,
rulecolor=\color{rahmen},framesep=8pt}
% Nur fuer Bloecke, die eine Einfaerbung brauchen.
\DefineVerbatimEnvironment{codef}{Verbatim}
{fontsize=\small,xleftmargin=8pt,frame=leftline,framerule=1.6pt,
rulecolor=\color{rahmen},framesep=8pt,commandchars=\\\{\}}
\newmdenv[backgroundcolor=hellgrau,linecolor=rahmen,linewidth=0.6pt,
innertopmargin=8pt,innerbottommargin=8pt,innerleftmargin=10pt,
innerrightmargin=10pt,skipabove=10pt,skipbelow=10pt,roundcorner=2pt]{kasten}
\newcommand{\pfad}[1]{\texttt{\small #1}}
\begin{document}
\thispagestyle{empty}
\vspace*{2.5cm}
{\Huge\bfseries\color{lila} abdulive-toolkit}
\vspace{6pt}
{\Large Handbuch}
\vspace{28pt}
{\large Aus einem Zuruf im Chat wird ein fertiges Hochformat-Video.}
\vspace{40pt}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
Version & 0.5.4 \\
Stand & 9. September 2026 \\
Gilt für & Windows 10 und 11, 64 Bit \\
Quelltext & \url{https://git.zeasy.dev/ZeasyAlex/abdulive} \\
\bottomrule
\end{tabularx}
\vfill
\noindent\small\color{grau}
Das Toolkit speichert Chatverläufe. Die Datenbank enthält damit
personenbezogene Daten und gehört weder ins Repository noch in eine
Cloud ohne Absprache.
\newpage
\tableofcontents
\newpage
\section{Was das Werkzeug tut}
Jemand tippt \texttt{!clip} in den Chat. Etwa eine Minute später liegt ein
fertiges 9:16-Video mit Untertiteln auf der Platte, bereit für TikTok, Reels
oder Shorts.
Dazwischen passiert Folgendes:
\begin{enumerate}
\item Das Toolkit liest den Chat mit und erkennt den Befehl.
\item Es weist OBS an, den Wiedergabepuffer zu speichern. OBS hat die
letzten 90 Sekunden ohnehin im Speicher, der Moment ist also schon
vorbei und trotzdem noch da.
\item OBS meldet den Dateipfad zurück. Das Toolkit wartet, bis die Datei
fertig geschrieben ist.
\item Aus den letzten 45 Sekunden wird ein Hochformat gebaut: Facecam oben,
Gameplay unten, beides ohne Verzerrung zugeschnitten.
\item Die Mikrofonspur wird transkribiert und als Untertitel ins Bild
gebrannt.
\end{enumerate}
\begin{kasten}
\textbf{Während des Streams wird nicht gerendert.} Das Encodieren nimmt
Rechenzeit, die der Stream braucht. Die Warteschlange arbeitet erst nach
Streamende. Wer das anders will, setzt \texttt{renderWhileLive: true}.
\end{kasten}
\subsection{Was dabei herauskommt}
Je Clip entsteht ein Ordner mit:
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\pfad{final.mp4} & das fertige Video mit eingebrannten Untertiteln \\
\pfad{vertical.mp4} & dasselbe ohne Untertitel \\
\pfad{thumb.jpg} & Vorschaubild \\
\pfad{subs.srt} & Untertitel als Textdatei, praktisch für die Beschreibung \\
\pfad{transcript.txt} & reiner Text des Gesagten \\
\bottomrule
\end{tabularx}
\subsection{Was es noch nicht tut}
Es lädt nichts hoch. Es entscheidet nicht, welcher Clip gut ist. Die
Sichtungsseite, auf der man Clips durchgeht und aussortiert, ist noch nicht
gebaut.
\section{Installation}
Vorausgesetzt wird Windows 10 oder 11 in 64 Bit und OBS Studio 30 oder neuer.
Sonst nichts. Node, ffmpeg, whisper.cpp und die Schriften sind im Paket
enthalten.
In PowerShell:
\begin{code}
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
\end{code}
Das Skript lädt das Paket, prüft die Prüfsumme, installiert, holt das
Sprachmodell (etwa 465 MB), startet den Dienst und öffnet die
Einrichtungsseite im Browser.
Es braucht keine Administratorrechte. Beim ersten Start warnt Windows
SmartScreen vor einem unbekannten Herausgeber, weil das Paket nicht signiert
ist. Einmal auf \emph{Weitere Informationen}, dann \emph{Trotzdem ausführen}.
\subsection{Wohin es installiert}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
Programm & \pfad{\%LOCALAPPDATA\%\textbackslash Programs\textbackslash abdulive} \\
Daten & \pfad{\%LOCALAPPDATA\%\textbackslash abdulive} \\
Autostart & Aufgabe \texttt{abdulive}, bei jeder Anmeldung \\
Updates & Aufgabe \texttt{abdulive-update}, täglich 5:00 Uhr \\
\bottomrule
\end{tabularx}
Programm und Daten liegen getrennt. Ein Update tauscht den Programmordner
komplett aus, die Datenbank bleibt davon unberührt.
\section{Einrichtung}
Die Einrichtungsseite liegt unter \url{http://localhost:4792/setup} und ist
nur vom eigenen Rechner erreichbar. Sie zeigt Zugangsdaten im Klartext, also
nicht während des Streams öffnen.
\subsection{Schritt 1: Zwei Twitch-Anwendungen anlegen}
Unter \url{https://dev.twitch.tv/console/apps} zwei Anwendungen erstellen. Bei
beiden als OAuth-Redirect-URL exakt eintragen:
\begin{code}
http://localhost:4792/callback
\end{code}
Die Anwendungen hängen nicht am Kanal. Sie können auch unter einem anderen
Konto angelegt werden.
\subsection{Schritt 2: Broadcaster-Konto verbinden}
Client-ID und Secret der ersten Anwendung auf der Einrichtungsseite eintragen,
speichern, dann auf \emph{Mit Twitch verbinden} klicken.
\begin{kasten}
\textbf{Das kann nur der Streamer selbst.} Twitch bindet die Berechtigungen an
das Konto, das sich anmeldet. Es gibt keine Rolle, mit der jemand anderes das
übernehmen kann. Moderator und Redakteur helfen hier nicht.
\end{kasten}
Damit bekommt das Toolkit Zugriff auf Clips, Titel und Kategorie, Abonnenten,
Follower, Raids, Kanalpunkte und Bits.
\subsection{Schritt 3: Bot-Konto verbinden}
Dasselbe mit der zweiten Anwendung, angemeldet als Bot-Konto. Das ist ein
eigenes, kostenloses Twitch-Konto, damit Antworten im Chat als Bot erkennbar
sind.
Ohne dieses Konto liest das Toolkit den Chat trotzdem mit. Es kann nur nicht
antworten.
\begin{kasten}
\textbf{Das Bot-Konto sollte Moderator im Kanal sein.} Der Streamer tippt
dafür einmal \texttt{/mod botname} in seinen Chat. Ohne Moderatorrechte gilt
ein Limit von 20 Nachrichten je 30 Sekunden statt 100, und im
Follower-only-Modus kommt der Bot gar nicht durch.
\end{kasten}
\subsection{Schritt 4: OBS-Passwort}
In OBS unter \emph{Werkzeuge} $\rightarrow$ \emph{WebSocket-Servereinstellungen}
den Server aktivieren, Port 4455. Das dort angezeigte Passwort auf der
Einrichtungsseite eintragen.
\subsection{Fertig?}
Unten auf der Seite steht \texttt{bereit} oder \texttt{unvollständig}.
Solange das Broadcaster-Konto fehlt, läuft das Toolkit im Lesemodus: Chat
wird mitgeschrieben, Zuschauerzahlen und Clips bleiben aus.
\section{OBS einrichten}
Ohne diese vier Punkte entstehen keine Clips oder sie sitzen schief.
\subsection{Wiedergabepuffer}
\emph{Einstellungen} $\rightarrow$ \emph{Ausgabe} $\rightarrow$
\emph{Wiedergabepuffer} aktivieren, 90 Sekunden.
Unter \emph{Einstellungen} $\rightarrow$ \emph{Hotkeys} eine Taste auf
\emph{Wiedergabepuffer speichern} legen. Das ist der Clip-Knopf für den
Streamer selbst. Ein eigener Windows-Hotkey wird bewusst nicht gebaut: OBS
meldet den gespeicherten Puffer ohnehin über WebSocket, das ist derselbe
Auslöser.
\subsection{Getrennte Tonspuren}
\emph{Einstellungen} $\rightarrow$ \emph{Ausgabe} $\rightarrow$
\emph{Aufnahme} $\rightarrow$ \emph{Audiospuren}, und im Audiomixer je Quelle
die Zuordnung prüfen:
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
Spur 1 & Mischung, kommt ins fertige Video \\
Spur 2 & Mikrofon, daraus entstehen die Untertitel \\
Spur 3 & Spielton \\
\bottomrule
\end{tabularx}
Das ist bei lauten Spielen der Unterschied zwischen brauchbarem und
unbrauchbarem Transkript.
\subsection{Bildaufteilung}
Der Zuschnitt nimmt feste Bereiche aus dem fertigen Streambild. Verrutscht die
Kamera um 50 Pixel, sitzt in jedem Short der Kopf schief. Deshalb liegt die
Position fest:
\begin{tabularx}{\textwidth}{@{}lll@{}}
\toprule
\textbf{Element} & \textbf{Position} & \textbf{Größe} \\
\midrule
Facecam & x 0, y 0 & 480 $\times$ 270 \\
Gameplay-Band & x 515 bis 1405 & 890 $\times$ 1080 \\
Chat, Alerts, Overlays & ab x 1405 & 515 px Spalte \\
\bottomrule
\end{tabularx}
Zwischen Kamera und Gameplay-Band liegen 35 Pixel Luft. Ein leicht
verrutschter Kamerarahmen schiebt so nicht sofort einen Streifen Gesicht ins
Spielbild.
Zum Ausrichten liegt eine Vorlage bei. Als Bildquelle in OBS laden, ganz nach
oben ziehen, Deckkraft über einen Farbkorrektur-Filter auf etwa 60 Prozent,
eigene Quellen einpassen, Vorlage wieder ausblenden.
Steht die Kamera in OBS als 4:3, vorher in den Kameraeinstellungen auf 16:9
umstellen. Ein 4:3-Bild in einen 16:9-Rahmen zu ziehen staucht das Gesicht.
\subsection{Fremder Text im Bild}
Was im Streambild steht, ist eingebrannt und lässt sich nachträglich nicht
entfernen. Untertitel des Spiels und Live-Caption-Overlays landen also im
Short, zusätzlich zu den eigenen Untertiteln.
\begin{itemize}
\item \textbf{Spieluntertitel:} abschalten. Sie laufen über die volle
Bildbreite, werden vom Zuschnitt angeschnitten und bringen im Short
nichts.
\item \textbf{Live-Captions:} verschieben statt abschalten. Für Zuschauer
ohne Ton sind sie wertvoll. Sie müssen nur aus dem Gameplay-Band
heraus, etwa nach unten links oder in die rechte Spalte.
\end{itemize}
Welche OBS-Quelle den Text überhaupt malt, sagt \texttt{abdulive sources}.
\section{Der laufende Betrieb}
\subsection{Clips auslösen}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\texttt{!clip} im Chat & jeder Zuschauer \\
OBS-Hotkey & der Streamer \\
Chat-Spitze & erkennt auffällige Reaktionen, standardmäßig aus \\
\bottomrule
\end{tabularx}
Abklingzeiten sind nicht optional. Ohne sie ist \texttt{!clip} nach zwei
Streams ein Spielzeug: 60 Sekunden global, 300 Sekunden je Zuschauer. Wer zu
früh dran ist, bekommt eine Antwort im Chat.
Die Spitzen-Erkennung läuft von Anfang an mit und schreibt jeden Kandidaten
samt Punktzahl mit, löst aber nichts aus. Sobald ein paar Streams
protokolliert sind, lässt sie sich an echten Daten kalibrieren und
scharfschalten.
\subsection{Befehle}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\texttt{abdulive check-env} & Selbsttest: Programme, Schriften, Modell, Verzeichnisse, OBS \\
\texttt{abdulive sources} & listet die OBS-Quellen und sagt, welche im 9:16-Ausschnitt landen \\
\texttt{abdulive calibrate} & prüft den Bildzuschnitt an einer echten Aufnahme \\
\texttt{abdulive render <datei>} & rendert eine Rohdatei nach, etwa nach einer Layoutänderung \\
\texttt{abdulive fetch-model} & lädt ein Sprachmodell \\
\texttt{abdulive update} & prüft auf eine neue Fassung \\
\bottomrule
\end{tabularx}
\subsection{Läuft es?}
\begin{codef}
Start-ScheduledTask -TaskName abdulive \textcolor{grau}{# starten}
Stop-ScheduledTask -TaskName abdulive \textcolor{grau}{# stoppen}
\end{codef}
Status und Zahlen liefert \url{http://127.0.0.1:4792/health}. Die Protokolle
liegen unter \pfad{\%LOCALAPPDATA\%\textbackslash abdulive\textbackslash logs}.
\begin{kasten}
Bei einer Adresse im Browser immer \texttt{127.0.0.1} statt
\texttt{localhost} verwenden, wenn etwas hängt. Der Dienst lauscht nur auf
IPv4, und Windows probiert bei \texttt{localhost} zuerst IPv6. Das kostet
zwei Sekunden.
\end{kasten}
\section{Updates}
Die Aufgabe \texttt{abdulive-update} sieht täglich um 5:00 Uhr nach. Findet
sie eine neue Fassung, lädt sie das Paket, prüft die Prüfsumme und legt es
bereit.
\textbf{Eingespielt wird beim nächsten Start.} Mitten im Stream wird nichts
ausgetauscht. Schlägt der Tausch fehl, läuft die alte Fassung weiter.
Sofort übernehmen:
\begin{code}
Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
\end{code}
\section{Sicherung}
\begin{code}
& "$env:LOCALAPPDATA\Programs\abdulive\backup.ps1"
\end{code}
Schreibt eine in sich stimmige Kopie der Datenbank und hält 30 Generationen.
Die Datenbank wird im laufenden Betrieb beschrieben, ein einfaches Kopieren
ergäbe eine unbrauchbare Sicherung.
\section{Deinstallation}
\begin{code}
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1"
\end{code}
Nimmt die beiden Aufgaben, beendet den Dienst und löscht den Programmordner.
Die Daten bleiben stehen, weil in der Datenbank Chatverläufe liegen. Wer neu
installiert, findet seine Einrichtung wieder.
Wirklich alles weg, auch Datenbank, Clips und Sprachmodell:
\begin{code}
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1" -AuchDaten
\end{code}
\section{Wenn etwas nicht geht}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\textbf{Symptom} & \textbf{Ursache und Abhilfe} \\
\midrule
Keine Clips & OBS-WebSocket aus oder Passwort fehlt. \texttt{check-env}
zeigt es unter \emph{OBS}. \\
Clips ohne Untertitel & Sprachmodell fehlt. \texttt{abdulive fetch-model}. \\
Untertitel voller Unsinn & Transkribiert wird die falsche Tonspur. Mikrofon
gehört auf Spur 2. \\
Kopf sitzt schief & OBS-Layout weicht von der Vorgabe ab.
\texttt{abdulive calibrate}. \\
Fremder Text im Short & Eine OBS-Quelle liegt im Gameplay-Band.
\texttt{abdulive sources}. \\
Chat wird nur gelesen & Broadcaster-Konto nicht verbunden. \\
Oberfläche antwortet nicht & Protokolle unter
\pfad{\%LOCALAPPDATA\%\textbackslash abdulive\textbackslash logs} ansehen. \\
\bottomrule
\end{tabularx}
\section{Wo was liegt}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\pfad{config\textbackslash config.yaml} & Einstellungen. Keine Geheimnisse. \\
\pfad{config\textbackslash layout.yaml} & Bildaufteilung für den Zuschnitt \\
\pfad{db\textbackslash} & Datenbank und verschlüsselte Zugangsdaten \\
\pfad{clips\textbackslash} & fertige Videos \\
\pfad{work\textbackslash} & Rohdateien der Verarbeitung \\
\pfad{models\textbackslash} & Sprachmodelle \\
\pfad{logs\textbackslash} & Protokolle \\
\bottomrule
\end{tabularx}
Alles unterhalb von \pfad{\%LOCALAPPDATA\%\textbackslash abdulive}.
\begin{kasten}
\textbf{Der Arbeitsordner wird noch nicht aufgeräumt.} Rohdateien sammeln
sich unbegrenzt an, mehrere hundert MB je Clip. Bis eine Aufbewahrungsregel
gebaut ist, gelegentlich selbst nachsehen.
\end{kasten}
\end{document}
+234 -65
View File
@@ -1,126 +1,295 @@
# Übergabe # Übergabe
Stand **2026-09-09**. Wer hier weitermacht, sollte diese Seite zuerst lesen und Stand **2026-09-15**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
danach [`07-mvp-plan.md`](07-mvp-plan.md). liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
## Wo das Projekt steht ## Wo das Projekt steht
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut. Die Kette vom Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut. Die Kette vom
Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist sie Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
überall dort, wo es ohne Abdus Stream-PC ging. überall dort, wo es ohne Abdus Stream-PC ging.
| Scheibe | Stand | Nachweis | | Scheibe | Stand | Nachweis |
|---|---|---| |---|---|---|
| S0 Toolchain + Auslieferung | fertig | `check-env` nativ ohne harte Fehler, Paket gebaut und installiert | | S0 Toolchain + Auslieferung | fertig | Paket gebaut und installiert, `check-env` ohne harte Fehler, Updater einmal komplett durchgelaufen |
| S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt | | S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt |
| S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS | | S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS |
| S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme | | S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme |
| S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip | | S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip, Ränder nachgemessen |
| S5 Onboarding + Sichtung | offen | — | | S5 Onboarding + Sichtung | offen | |
| S6 Erstchatter-Begrüßung | offen | — | | S6 Erstchatter-Begrüßung | offen | |
79 Tests, alle grün. Vollständige Liste dessen, was noch fehlt: 162 Tests, alle grün. Was noch fehlt, steht vollständig in
[`offene-punkte.md`](offene-punkte.md). [`offene-punkte.md`](offene-punkte.md).
## Der Discord-Server steht
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
Community-Server ist eingerichtet, und zwar als Code statt von Hand. Der
Bauplan liegt in [`packages/discord/src/plan.ts`](../packages/discord/src/plan.ts),
aufgetragen wird er mit `abdulive discord:setup`. Wiederholbar: gefunden wird
über Namen und frühere Namen, danach wird angeglichen. Der zweite Lauf ändert
nichts, gelöscht wird nie.
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
[Go-Live](projects/go-live-broadcaster.md), [Recap](projects/stream-recap.md)
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
Sieben Kanäle, vier Rollen, zwei AutoMod-Regeln. Der Entwurf und seine
Begründung stehen in [`projects/discord-community.md`](projects/discord-community.md);
die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser
sind als zwanzig leere.
Ein Teil davon läuft dauerhaft: der Knopf unter dem Willkommenstext, mit dem
man sich die Rolle `Live-Ping` selbst gibt. Er hängt als Gateway-Verbindung im
Ingest-Daemon mit drin und meldet sich als `discordConnected` auf der
Statusseite. **Läuft der Daemon nicht, tut der Knopf nichts** - der Klick
läuft dann in Discords Zeitüberschreitung.
## Was jemand wissen muss, bevor er etwas anfasst ## Was jemand wissen muss, bevor er etwas anfasst
**Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite, **Keine Abhängigkeiten, kein Build.** Node 24 bringt SQLite, WebSocket,
WebSocket, `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` läuft
läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`, direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax, also keine `enum`,
keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly` keine Parameter-Properties im Konstruktor, keine `namespace`.
in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen. `erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
echten Fehler gefangen.
**Alles läuft unter einem Dach**, in einem Prozess. Schwere **Alles läuft in einem Prozess.** Schwere Arbeit (ffmpeg, whisper) läuft als
Arbeit (ffmpeg, whisper) läuft als Kindprozess und darf abstürzen. Der Ingest Kindprozess und darf abstürzen. Der Ingest darf nie sterben. Was er nicht
darf nie sterben — was er nicht mitschreibt, ist für immer weg. mitschreibt, ist für immer weg.
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie liegen **Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr — die Datenbank liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
wird gesichert und weitergereicht, die Geheimnisse nicht. Datenbank wird gesichert und weitergereicht, die Geheimnisse nicht.
**Rohdateien gehören in den Arbeitsordner**, nicht neben den OBS-Puffer. Das **Das Repo ist seit 2026-09-09 öffentlich.** Damit funktioniert der Einzeiler
war unter Docker ein Zeitproblem (Encodieren über die WSL2-Brücke kostete ein zur Installation, und der Updater braucht keinen Token. Es heißt aber auch:
Vielfaches) und ist nativ nur noch Ordnung — der Wiedergabepuffer-Ordner nichts Vertrauliches mehr hineinschreiben, auch nicht in die Doku.
gehört OBS.
Die Begründungen stehen in [`decisions/`](decisions/), sieben ADRs. Wer eine **Es gibt zwei Remotes**, beide mit demselben Inhalt: `origin`
davon umwerfen will, sollte sie vorher lesen; jede hat einen Grund, der beim (`ZeasyAlex/abdulive`) und `discord` (`ZeasyAlex/abdulive_-discord`). Wer
Bauen aufgetaucht ist. ADR-0004 (Docker) ist durch ADR-0007 ersetzt — die pusht, pusht auf beide, sonst laufen sie auseinander.
Analyse darin bleibt trotzdem lesenswert.
Die Begründungen stehen in [`decisions/`](decisions/), acht ADRs. Wer eine
davon umwerfen will, liest sie vorher. ADR-0004 (Docker) ist durch ADR-0007
ersetzt; die Analyse darin bleibt lesenswert, weil zwei der vier
beschriebenen Fallen mit dem Container weggefallen sind statt gelöst worden
zu sein.
## Loslegen ## Loslegen
**Auf Abdus Rechner** läuft eine native Windows-Anwendung — kein Docker, kein Auf Abdus Rechner, eine native Windows-Anwendung:
WSL2, nichts zu übersetzen ([ADR-0007](decisions/ADR-0007-native-windows.md)):
```powershell ```powershell
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
.\abdulive\install.ps1
abdulive fetch-model
abdulive check-env
``` ```
Einen Einzeiler zum Nachladen gibt es nicht — `git.zeasy.dev` ist komplett Der Installer lädt das Paket, prüft die Prüfsumme, holt das Sprachmodell,
angemeldet-only. **Wo die Pakete liegen sollen, ist die naechste offene läuft den Selbsttest, startet den Dienst und öffnet die Einrichtungsseite.
Entscheidung**, und ohne sie funktioniert auch `abdulive update` nicht. Ohne Administratorrechte. Ausführlich in [`05-setup.md`](05-setup.md).
Ausführlich in [`05-setup.md`](05-setup.md). Zum Entwickeln reicht ein Checkout und Node 24 oder neuer:
**Zum Entwickeln** reicht ein Checkout und Node 24 oder neuer:
```powershell ```powershell
node apps/cli/main.ts check-env node apps/cli/main.ts check-env
node --test "packages/**/*.test.ts" "apps/**/*.test.ts" node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
node scripts/build-package.ts --version 0.5.0 # baut dist/*.zip node scripts/build-package.ts --version 0.5.5 # baut dist/
``` ```
Typprüfung (das Einzige, wofür npm gebraucht wird): Typprüfung, das Einzige wofür npm gebraucht wird:
```powershell ```powershell
npm i typescript@5.9 @types/node npm i typescript@5.9 @types/node
npx tsc --noEmit npx tsc --noEmit
``` ```
## Ausliefern
`build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern,
packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
`abdulive update` auf Abdus Rechner ab.
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
ersetzt, damit die Adresse gleich bleibt.
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Die Pipeline
ist noch nie gelaufen**, bisher wurde von Hand veröffentlicht. Der Runner
(`ubuntu-latest`) steht.
## Windows-Fallen, die schon zugeschlagen haben
Alle vier haben Zeit gekostet, und keine sieht im Fehlerfall nach ihrer
Ursache aus. Wer an den PowerShell-Skripten arbeitet, sollte sie kennen.
**stderr beendet das Skript.** Windows PowerShell 5.1 verpackt jede
stderr-Zeile eines nativen Programms in einen `ErrorRecord`. Zusammen mit
`$ErrorActionPreference = 'Stop'` bricht das sofort alles ab. Der Daemon
schreibt im Normalbetrieb Warnungen auf stderr, der Starter hat ihn also bei
seiner ersten Logzeile umgebracht: Aufgabe meldete Code 1, das Protokoll war
leer. Deshalb läuft der Start über `Start-Process` mit getrennten
Umleitungen, und `install.ps1` ruft native Programme über eine Hilfsfunktion
auf, die `ErrorActionPreference` kurz zurückstellt.
**`& $programm; return $LASTEXITCODE` gibt auch die Ausgabe zurück.** Die
Funktion liefert dann ein Array aus allen Ausgabezeilen plus Exit-Code, und
jeder Vergleich damit ist falsch, ohne dass es auffällt. Ein erfolgreiches
`fetch-model` galt so als fehlgeschlagen. Ausgabe mit `| Out-Host`
durchreichen.
**`localhost` kostet zwei Sekunden.** Der Dienst lauscht nur auf IPv4,
Windows probiert bei `localhost` zuerst `::1`. Gemessen: 2066 ms bis zum
Rückfall gegenüber 15 ms bei `127.0.0.1`. Eine Bereitschaftsprüfung mit zwei
Sekunden Zeitlimit läuft dadurch immer ab, obwohl der Dienst längst läuft.
**Windows-Pfade im ffmpeg-Filtergraphen brauchen drei Schritte.** An echtem
ffmpeg 8.1 durchprobiert:
| Form | Ergebnis |
|---|---|
| `ass=filename=C:/x/s.ass` | `No option name near '/x/s.ass'` |
| `ass=filename=C\:/x/s.ass` | ebenso |
| `ass=filename='C:/x/s.ass'` | ebenso |
| `ass=filename='C\:/x/s.ass'` | läuft |
Vorwärtsschrägstriche, escapter Doppelpunkt **und** Anführungszeichen. Je
zwei davon reichen nicht. `filterPath()` in `packages/media/src/ffmpeg.ts`
macht das, mit Tests.
## Untertitel: was gemessen ist
**Die Schrift muss mitgeliefert werden.** Im Container kam DejaVu Sans aus
dem Betriebssystem, Windows hat sie nicht. Fehlt `fontsdir`, setzt libass
wortlos eine Ersatzschrift ein. Gemessen: dieselbe Zeile wird 720 statt 826
Pixel breit, ohne jede Fehlermeldung. Die Dateien liegen unter
`assets/fonts/`.
**Die Zeilenlänge ist eingemessen, nicht geschätzt.** Vorher stand dort fest
`maxChars: 30`. Das sind bei Schriftgröße 76 rund 1085 px, nutzbar sind 940.
Zusammen mit `WrapStyle: 2`, bei dem libass gar nicht umbricht, lief der Text
links und rechts aus dem Bild. Jetzt wird die Grenze aus Breite und
Schriftgröße abgeleitet, Versalien zählen breiter, und einzelne zu lange
Wörter bekommen eine kleinere Schrift, weil libass innerhalb eines Wortes
nicht umbricht. Deutsche Komposita sind regelmäßig breiter als das Bild.
Wer die Schrift wechselt, muss `CHAR_WIDTH_RATIO` in `subtitles.ts` neu
einmessen. Sonst bricht die Zeile wieder an der falschen Stelle.
## Discord: was gemessen ist
Vier Dinge, die nicht in der Dokumentation standen, in der man zuerst sucht.
Alle am echten Server nachgeprüft.
**Kanalnamen lassen sich nur zweimal in zehn Minuten ändern.** Danach verlangt
Discord eine Pause, gemessen wurden 529 Sekunden. Ein Einrichtungslauf, der
brav auf ein 429 wartet, steht dann neun Minuten. Der Client wartet deshalb
höchstens eine Minute, meldet den Kanal als übersprungen und macht weiter.
**Eine Rolle kann nur Rollen unterhalb der eigenen verschieben** - auch mit
Administrator. Discord hängt die Rolle eines frisch eingeladenen Bots ganz
unten ein, also gibt es dort keinen Platz. Der erste Lauf quittierte das mit
`Missing Permissions`, was nach einem fehlenden Recht aussieht und keines ist.
`rolePositions()` rechnet den Platz jetzt aus und sagt, was zu tun ist.
**Discords Onboarding braucht einen größeren Server.**
```
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
sichtbar für @everyone: 5 davon beschreibbar: 3
Discord verlangt: 7 davon beschreibbar: 5
```
Zwei Kanäle anzulegen, damit eine Schwelle passt, hätte den Server leerer
gemacht statt besser. Daher der Knopf.
**Kanalnamen werden normalisiert, aber nicht so weit wie erwartet.**
Textkanäle werden kleingeschrieben und Leerzeichen zu Bindestrichen; Emoji,
Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
`normalizeName()` bildet das ab. Ohne diesen Abgleich geht bei jedem Lauf
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
oben.
## Die drei Dinge, die Abdu tun muss ## Die drei Dinge, die Abdu tun muss
1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps 1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps
(dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`) (dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`)
kann auch jemand anders anlegen — sie hängen nicht am Kanal. Was **nur Abdu** kann auch jemand anders anlegen, sie hängen nicht am Kanal. Was nur Abdu
kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner schon verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner laufen,
laufen; die Seite hört nur auf `127.0.0.1`. die Seite hört nur auf `127.0.0.1`.
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s, 2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren. Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
Ausrichtungsvorlage in OBS über die Szene legen, Quellen einpassen, wieder `abdulive calibrate` zur Kontrolle.
ausblenden. Danach `calibrate` zur Kontrolle.
Ohne Punkt 1 liest das Toolkit den Chat anonym mit und kann nichts senden. Ohne Punkt 1 liest das Toolkit den Chat anonym mit und kann nichts senden.
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief. Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
(`/mod <botname>`). Ohne das gilt ein Limit von 20 Chat-Nachrichten je 30
Sekunden statt 100, und im Follower-only-Modus kommt der Bot gar nicht durch.
Und zwei für Discord, beide nicht über die API machbar:
1. **Sich selbst die Rolle `Abdu` geben.** Das Skript legt Rollen an, aber es
hängt sie niemandem um.
2. **Einladungslink** ohne Ablaufdatum erzeugen und ins Twitch-Panel, in die
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
niemand erwähnt, bleibt leer. Das ist der eine Handgriff, an dem hängt, ob
der Server überhaupt jemanden bekommt.
## Wo ich weitermachen würde ## Wo ich weitermachen würde
**S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne Einrichtungs- **S2 an einem laufenden OBS abnehmen.** Das ist die einzige ungeprüfte Stelle
Assistenten und Sichtungsseite für Abdu nicht bedienbar ist. Die Kette in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
funktioniert, aber sie hat noch keine Oberfläche, die jemand ohne Erklärung gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
benutzt. Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts malt. Das war vorher nicht zu erraten.
eingerichtet" bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.*
Davor, wenn Gelegenheit ist: **S2 an einem laufenden OBS abnehmen**. Das ist **Discord braucht jetzt nichts mehr.** Der Server steht, die Webhooks liegen
die einzige ungeprüfte Stelle in der ganzen Kette, und alles Weitere hängt bereit. Wer als Nächstes `apps/broadcaster` oder `apps/recap` baut, findet
daran. alles vor und muss nur noch posten. Das wäre auch ein guter Nebenweg, wenn der
Stream-PC gerade nicht verfügbar ist, denn anders als S2 hängt daran nichts.
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
hat keine Oberfläche, auf der man Clips durchgeht und aussortiert.
Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts eingerichtet"
bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.* Installer und
Handbuch decken davon inzwischen ein gutes Stück ab.
## Womit ich nicht zufrieden bin ## Womit ich nicht zufrieden bin
Die Untertitel sind brauchbar, nicht gut. `ggml-small` verhört sich hörbar **Das Aufräumen im Arbeitsordner fehlt komplett.** Rohdateien sammeln sich
(„Danke" wurde „Dank es"), die Schrift ist unauffällig, und das fortlaufende unbegrenzt an, mehrere hundert MB je Clip. Das ist die Lücke, die als Erstes
Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs konkrete weh tun wird.
Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
Und: Das Aufräumen im Arbeitsordner fehlt komplett. Rohdateien sammeln sich **Die Untertitel sind brauchbar, nicht gut.** `ggml-small` verhört sich
unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird. hörbar („Danke" wurde „Dank es"), DejaVu Sans ist unauffällig, und das
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
Der Updater ist gebaut und in Teilen geprüft — Manifest, Download, Prüfsumme **Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen.
und Bereitlegen laufen. Der eigentliche Tausch durch `launcher.ps1` ist noch
nie im Feld passiert. Vor dem ersten echten Update einmal künstlich **Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
durchspielen. Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
bekommt, nicht mehr.
**Der Discord-Knopf hängt am Daemon.** Läuft der Ingest nicht, passiert beim
Klick nichts, und die klickende Person sieht nur „Interaktion fehlgeschlagen".
Das ist vertretbar, solange der Daemon ohnehin durchläuft, aber es ist eine
stille Abhängigkeit zwischen zwei Dingen, die sonst nichts miteinander zu tun
haben.
**Der Discord-Bot ist nie unter Last gelaufen.** Getestet sind die Bausteine
und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die
Verbindung nachgewiesen und der Rollenwechsel einmal in beide Richtungen
durchgespielt. Was bei gleichzeitigen Klicks oder nach Tagen Laufzeit
passiert, ist offen.
**Es gibt keine Einstellung für den OBS-Wiedergabepuffer-Ordner.** Im
Container kam der Pfad über `OBS_REPLAY_DIR`, nativ fehlt der Weg. Für die
Clip-Kette ist das egal, weil OBS den Pfad über WebSocket meldet. Aber
`calibrate` findet so keine Aufnahmen, und `check-env` warnt zu Recht.
+19 -21
View File
@@ -68,38 +68,36 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
## Auslieferung ## Auslieferung
- [ ] **Wo liegen die Pakete?** Das ist die Entscheidung, die als Naechstes
ansteht — ohne sie funktioniert `abdulive update` gar nicht.
`git.zeasy.dev` verlangt **instanzweit** eine Anmeldung: `/explore`,
die API und jede Rohdatei antworten mit 303 auf `/user/login`. Ein
`irm … | iex`-Einzeiler bekommt dadurch die Anmeldeseite und versucht,
sie als PowerShell auszufuehren. Drei Wege:
*(a)* eine Ablage ohne Anmeldung, etwa `dl.zeasy.software` — am
einfachsten, und das Paket enthaelt ohnehin den vollstaendigen Quelltext
als lesbare `.ts`-Dateien;
*(b)* Gitea-Release plus Lese-Token auf Abdus Rechner — der Token darf
dann aber das ganze private Repo lesen;
*(c)* kein Updater, Alexander spielt neue Fassungen per Fernwartung ein.
Code und Installer sind auf *(a)* und *(b)* vorbereitet: `update.url` in
der `config.yaml`, Token im verschluesselten Speicher.
- [ ] **Code-Signing.** Das Paket ist nicht signiert, SmartScreen warnt bei - [ ] **Code-Signing.** Das Paket ist nicht signiert, SmartScreen warnt bei
jeder Installation vor einem unbekannten Herausgeber. Ein jeder Installation vor einem unbekannten Herausgeber. Ein
OV-Zertifikat kostet rund 200 EUR im Jahr, ein EV-Zertifikat mehr und OV-Zertifikat kostet rund 200 EUR im Jahr, ein EV-Zertifikat mehr und
braucht Hardware. Solange nur Abdu installiert, reicht ein Klick auf braucht Hardware. Solange nur Abdu installiert, reicht ein Klick auf
"Trotzdem ausfuehren" - sobald jemand Drittes das Paket bekommt, nicht "Trotzdem ausfuehren" - sobald jemand Drittes das Paket bekommt, nicht
mehr. mehr.
- [ ] **CI auf git.zeasy.dev.** `scripts/build-package.ts` laeuft bisher von - [ ] **CI einmal wirklich laufen lassen.** `.gitea/workflows/release.yml`
Hand. Als Forgejo-Action beim Tag gebaut und als Veroeffentlichung ist geschrieben, der Runner (`ubuntu-latest`) steht — ausgefuehrt wurde
hochgeladen, findet `abdulive update` sie von allein. sie aber noch nie. Beim ersten Tag zusehen, ob sie durchlaeuft.
- [ ] **Update noch nie im Feld gelaufen.** Bereitlegen und Pruefsumme sind
getestet, der Tausch durch `launcher.ps1` nur gelesen. Vor dem ersten
echten Update einmal kuenstlich durchspielen.
- [ ] **Hardware-Encoding.** Der mitgelieferte ffmpeg kann QSV, AMF und NVENC - [ ] **Hardware-Encoding.** Der mitgelieferte ffmpeg kann QSV, AMF und NVENC
(`--enable-libvpl`, `--enable-amf`, `--enable-ffnvcodec`) - aus WSL2 (`--enable-libvpl`, `--enable-amf`, `--enable-ffnvcodec`) - aus WSL2
heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf
`libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt. `libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt.
## Discord
- [ ] **Den Bot unter Last sehen.** Gleichzeitige Klicks auf den Rollen-Knopf
sind nie vorgekommen, ebenso wenig eine Laufzeit über Tage. Die
Gateway-Verbindung kann fortsetzen und wiederverbinden, aber beides ist
nur gegen einen nachgebauten Server geprüft.
- [ ] **Onboarding einschalten, sobald der Server groß genug ist.** Discord
verlangt sieben Vorgabekanäle mit fünf beschreibbaren, wir haben fünf und
drei. Die Frage liegt fertig auf dem Server, nur ausgeschaltet. Dann
könnte der Knopf entfallen und mit ihm die Gateway-Verbindung.
- [ ] **Twitch-Konto unter Integrationen verbinden**, damit Subs automatisch
eine Rolle bekommen.
- [ ] **Stammgast automatisch vergeben.** Bisher von Hand. Die Daten dafür
liegen längst in der Datenbank, es fehlt die Brücke zwischen
Twitch-Konto und Discord-Konto.
## Nach dem MVP ## Nach dem MVP
- [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden - [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden
+1
View File
@@ -15,3 +15,4 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` | | 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` | | 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` | | 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
| — | [Discord-Community](discord-community.md) | `packages/discord` |
+282
View File
@@ -0,0 +1,282 @@
# 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.
### Der Knopf fuer Live-Ping
`Live-Ping` nuetzt nur, wenn man sie sich selbst geben kann. Discord bietet
dafuer **Onboarding** an, aber das laesst sich hier nicht einschalten:
```
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
sichtbar fuer @everyone: 5 davon beschreibbar: 3
Discord verlangt: 7 davon beschreibbar: 5
```
Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser - genau das, wogegen der ganze Entwurf gebaut ist. Also
stattdessen ein **Knopf unter dem Willkommenstext**. Ein Druck holt die Rolle,
der naechste gibt sie wieder ab, und die Antwort sieht nur, wer geklickt hat.
Der Knopf braucht einen laufenden Bot: Discord liefert Klicks entweder ans
Gateway oder an eine oeffentlich erreichbare HTTPS-Adresse, und der Stream-PC
hat keine. Die Gateway-Verbindung haengt deshalb im Ingest-Daemon mit drin
([`gateway.ts`](../../packages/discord/src/gateway.ts)) und meldet sich als
`discordConnected` auf der Statusseite. Zum Ausprobieren einzeln:
```powershell
abdulive discord:bot
```
Die Verbindung fordert **keine** Absichten an (`intents: 0`). Interaktionen
kommen trotzdem an, aber Nachrichteninhalte und Mitgliederlisten sieht der Bot
nicht - was man nicht anfordert, kann man auch nicht versehentlich mitlesen.
Welche Knoepfe es gibt, steht in `SELF_ROLES` in `plan.ts`. Die `custom_id`
steht dauerhaft in der Nachricht und darf sich nie aendern, sonst tun alte
Knoepfe nichts mehr.
**`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
Drei 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. **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.
3. **Twitch-Konto verbinden** (Servereinstellungen > Integrationen). Danach
bekommen Subs automatisch eine Rolle.
Onboarding steht bewusst nicht in der Liste - warum, steht oben unter
[Der Knopf fuer Live-Ping](#der-knopf-fuer-live-ping).
## 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.
- Der Knopf unter dem Willkommenstext vergibt `Live-Ping` und nimmt sie beim
zweiten Druck wieder weg; die Antwort sieht nur, wer geklickt hat.
+1
View File
@@ -10,6 +10,7 @@
"imports": { "imports": {
"#shared/*": "./packages/shared/src/*", "#shared/*": "./packages/shared/src/*",
"#core/*": "./packages/core/src/*", "#core/*": "./packages/core/src/*",
"#discord/*": "./packages/discord/src/*",
"#db/*": "./packages/db/src/*", "#db/*": "./packages/db/src/*",
"#twitch/*": "./packages/twitch/src/*", "#twitch/*": "./packages/twitch/src/*",
"#obs/*": "./packages/obs/src/*", "#obs/*": "./packages/obs/src/*",
+55
View File
@@ -0,0 +1,55 @@
// Der YAML-Leser ist selbst geschrieben (ADR-0001: keine Abhaengigkeiten) und
// liest die Konfiguration, an der alles haengt - hatte aber bis hierher keinen
// einzigen Test. Geprueft wird genau der Sprachumfang, den config/*.yaml
// benutzt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseYaml } from './yaml.ts';
test('verschachtelte Maps ueber Einrueckung', () => {
assert.deepEqual(parseYaml('channel:\n login: abdulive_\n language: de\n'),
{ channel: { login: 'abdulive_', language: 'de' } });
});
test('erkennt Zahlen, Wahrheitswerte und Leeres', () => {
const y = parseYaml([
'a: 42', 'b: -7', 'c: 3.5', 'd: true', 'e: false', 'f:', 'g: ~',
].join('\n'));
assert.deepEqual(y, { a: 42, b: -7, c: 3.5, d: true, e: false, f: null, g: null });
});
test('Anfuehrungszeichen bleiben Text', () => {
// Wichtig fuer update.url - sonst wuerde aus "0.5" eine Zahl
const y = parseYaml('update:\n url: "https://git.zeasy.dev/x"\n v: "0.5"\n');
assert.deepEqual(y, { update: { url: 'https://git.zeasy.dev/x', v: '0.5' } });
});
test('Inline-Maps, wie sie im Layout stehen', () => {
const y = parseYaml('crop: { x: 0, y: 0, w: 480, h: 270 }\n');
assert.deepEqual(y, { crop: { x: 0, y: 0, w: 480, h: 270 } });
});
test('Listen aus Skalaren', () => {
const y = parseYaml('chapterScenes: [Gameplay, "Just Chatting"]\n');
assert.deepEqual(y, { chapterScenes: ['Gameplay', 'Just Chatting'] });
});
test('Kommentare und Leerzeilen stoeren nicht', () => {
const y = parseYaml('# Kopfzeile\n\nobs:\n # dazwischen\n url: ws://127.0.0.1:4455\n');
assert.deepEqual(y, { obs: { url: 'ws://127.0.0.1:4455' } });
});
test('Zeilenenden nach Windows-Art', () => {
// config.yaml wird auf dem Stream-PC bearbeitet
assert.deepEqual(parseYaml('a:\r\n b: 1\r\n'), { a: { b: 1 } });
});
test('ein Byte Order Mark verstellt nicht den ersten Schluessel', () => {
// Notepad und `Set-Content -Encoding utf8` unter Windows PowerShell 5.1
// schreiben eines. Ohne Behandlung hiesse der erste Schluessel "channel",
// und die Einstellung wuerde stillschweigend ignoriert - ohne Fehlermeldung.
const y = parseYaml('\uFEFFchannel:\n login: abdulive_\n');
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
});
+5 -1
View File
@@ -51,7 +51,11 @@ function stripComment(line: string): string {
type Frame = { indent: number; target: YamlMap; listKey?: string }; type Frame = { indent: number; target: YamlMap; listKey?: string };
export function parseYaml(text: string): YamlMap { export function parseYaml(text: string): YamlMap {
const lines = text.split(/\r?\n/); // Byte Order Mark abschneiden. Notepad und `Set-Content -Encoding utf8`
// unter Windows PowerShell 5.1 schreiben eines an den Dateianfang; ohne
// diese Zeile heisst der erste Schluessel dann "channel" und die
// Einstellung wird stillschweigend ignoriert.
const lines = text.replace(/^/, '').split(/\r?\n/);
const root: YamlMap = {}; const root: YamlMap = {};
const stack: Frame[] = [{ indent: -1, target: root }]; const stack: Frame[] = [{ indent: -1, target: root }];
let i = 0; let i = 0;
+36
View File
@@ -0,0 +1,36 @@
# @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 |
| `src/gateway.ts` | WebSocket-Verbindung fuer Klicks auf Knoepfe |
| `src/selfroles.ts` | Der Knopf, der `Live-Ping` gibt und wieder nimmt |
| `src/bot.ts` | Beides zusammen; laeuft im Ingest-Daemon mit |
Einrichten geschieht einmalig ueber `apply.ts`, der Betrieb laeuft ueber
`bot.ts`. Beide lesen denselben Bauplan.
Aufruf ueber die CLI:
```powershell
abdulive discord:setup --server <ID> --vorschau # zeigt nur, was passieren wuerde
abdulive discord:setup --server <ID> # wendet es an
```
Und der laufende Teil, der die Knoepfe bedient:
```powershell
abdulive discord:bot # zum Ausprobieren; sonst im Ingest-Daemon
```
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)
+170
View File
@@ -0,0 +1,170 @@
// 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); }
put<T>(path: string, body?: unknown, reason?: string): Promise<T> { return this.request<T>('PUT', path, body, reason); }
del<T>(path: string, reason?: string): Promise<T> { return this.request<T>('DELETE', path, undefined, reason); }
/** Fuer die Gateway-Verbindung; verlaesst diese Klasse sonst nie. */
get botToken(): string { return this.token; }
}
/**
* 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>;
};
/** Zusaetzlich das, was der Bot im Betrieb braucht: Rollen setzen und loesen. */
export type RestFull = Rest & {
put<T>(path: string, body?: unknown, reason?: string): Promise<T>;
del<T>(path: string, 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;
+479
View File
@@ -0,0 +1,479 @@
// 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 }; components?: unknown[] }>>();
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 },
components: ((b as Record<string, unknown>)['components'] ?? []) as unknown[],
};
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) {
const src = body as Record<string, unknown>;
m.content = String(src['content'] ?? '');
m.components = (src['components'] ?? []) as unknown[];
}
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');
});
test('der Willkommenstext bekommt die Knopfleiste', async () => {
const api = new FakeDiscord();
await run(api);
const post = api.messages.get(live(api, 'willkommen')!.id)![0]!;
const reihen = post.components as Array<{ type: number; components: Array<{ custom_id: string }> }>;
assert.equal(reihen.length, 1);
assert.equal(reihen[0]!.components[0]!.custom_id, 'rolle:live-ping');
});
test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => {
// So sieht der Willkommenstext aus, der vor den Knoepfen gepostet wurde.
const api = new FakeDiscord();
await run(api);
const kanal = live(api, 'willkommen')!.id;
api.messages.get(kanal)![0]!.components = [];
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Willkommenstext');
assert.equal(step?.change, 'angepasst');
assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1);
assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten');
});
+527
View File
@@ -0,0 +1,527 @@
// 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 { selfRoleComponents } from './selfroles.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 components = selfRoleComponents();
const payload = {
content: body,
components,
allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden
};
type Msg = { id: string; content: string; author?: { id: string }; components?: unknown[] };
const seen = await api.get<Msg[]>(`/channels/${channel.id}/messages?limit=50`);
const own = seen.find((m) => m.author?.id === botUserId);
if (own) {
// Auch die Knopfleiste gehoert zum Soll-Zustand: fehlt sie, weil die
// Nachricht aus einer Zeit vor den Knoepfen stammt, wird sie nachgereicht.
const knoepfe = (own.components ?? []).length;
if (own.content === body && knoepfe === components.length) {
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 };
}
+44
View File
@@ -0,0 +1,44 @@
// Der laufende Teil der Discord-Anbindung: haelt die Gateway-Verbindung und
// beantwortet Klicks auf die Rollen-Knoepfe.
//
// Laeuft im Ingest-Daemon mit. Ohne hinterlegtes Bot-Token passiert nichts -
// das ist kein Fehler, sondern der Zustand vor der Einrichtung.
import { logger } from '#core/logger.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { Discord } from './api.ts';
import { createGateway } from './gateway.ts';
import { createRoleButtons } from './selfroles.ts';
const log = logger('discord:bot');
export type Bot = {
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
/**
* Gibt null zurueck, wenn Token oder Server-ID fehlen. Der Aufrufer soll dann
* einfach ohne Discord weiterlaufen.
*/
export function createBot(): Bot | null {
const cfg = readSecrets().discord;
if (!cfg?.botToken || !cfg.guildId) {
log.info('Kein Discord-Bot hinterlegt - Rollen-Knoepfe bleiben aus. ' +
'Einrichten mit: abdulive discord:setup');
return null;
}
const api = new Discord(cfg.botToken);
const buttons = createRoleButtons(api, cfg.guildId);
const gateway = createGateway(
api.botToken,
(path) => api.get<{ url: string }>(path),
{
onInteraction: (i) => buttons.handle(i),
onReady: ({ user }) => log.info(`Rollen-Knoepfe sind scharf (${user.username})`),
},
);
return { run: (signal) => gateway.run(signal), isConnected: () => gateway.isConnected() };
}
+281
View File
@@ -0,0 +1,281 @@
// Discord-Gateway ueber WebSocket. Nur so viel, wie ein Knopf braucht:
// verbinden, am Leben halten, Interaktionen durchreichen.
//
// Warum ueberhaupt eine dauerhafte Verbindung: Discord liefert Klicks auf
// einen Knopf entweder ans Gateway oder an eine oeffentlich erreichbare
// HTTPS-Adresse. Der Stream-PC hat keine, also Gateway. Die Verbindung haengt
// im Ingest-Daemon mit drin und kostet dort praktisch nichts.
//
// Absichten (Intents) stehen auf 0: Interaktionen kommen immer an, ohne
// Berechtigung fuer Nachrichteninhalte oder Mitgliederlisten. Was wir nicht
// anfordern, koennen wir auch nicht versehentlich mitlesen.
import { logger } from '#core/logger.ts';
import { forever } from '#core/retry.ts';
const log = logger('discord:gateway');
// Betriebsarten des Gateways
const OP = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
RESUME: 6,
RECONNECT: 7,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
} as const;
/**
* Schliessgruende, bei denen ein neuer Versuch nichts bringt: falsches Token,
* falsche Absichten, falsche Fassung. Dagegen hilft nur eine Aenderung an der
* App, und bis dahin waere jeder Wiederverbindungsversuch nur Laerm.
*/
const FATAL_CLOSE = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
export type Payload = { op: number; d?: unknown; s?: number | null; t?: string | null };
/** Nur die Felder einer Interaktion, die hier gebraucht werden. */
export type Interaction = {
id: string;
token: string;
type: number;
application_id: string;
guild_id?: string;
channel_id?: string;
data?: { custom_id?: string; component_type?: number };
member?: { user: { id: string; username: string }; roles: string[] };
user?: { id: string; username: string };
};
export type GatewayHandlers = {
onReady?: (info: { user: { id: string; username: string }; sessionId: string }) => void;
onInteraction?: (interaction: Interaction) => Promise<void> | void;
onClosed?: (code: number, reason: string) => void;
};
export type GatewayClient = {
/** Haelt die Verbindung; kehrt erst bei `signal.abort` oder einem harten Fehler zurueck. */
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
/** Was ueber eine Trennung hinweg erhalten bleibt, damit fortgesetzt statt neu begonnen wird. */
type Session = { id: string; resumeUrl: string; seq: number | null };
export function createGateway(
token: string,
restUrl: (path: string) => Promise<{ url: string }>,
handlers: GatewayHandlers,
): GatewayClient {
let session: Session | null = null;
let connected = false;
return {
isConnected: () => connected,
run: async (outer) => {
// Bei einem harten Fehler brechen wir die Schleife selbst ab, statt
// Discord alle paar Sekunden erneut mit demselben Token zu behelligen.
const own = new AbortController();
const signal = AbortSignal.any([outer, own.signal]);
await forever(
async (sig) => {
try {
await connectOnce(token, restUrl, handlers, sig, {
get: () => session,
set: (s) => { session = s; },
setConnected: (v) => { connected = v; },
giveUp: () => own.abort(),
});
} finally {
connected = false;
}
},
{
signal,
baseMs: 2000,
maxMs: 120_000,
onWait: (ms, n) => {
if (own.signal.aborted) return;
log.warn(`Gateway getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`);
},
},
);
},
};
function connectOnce(
botToken: string,
getGateway: (path: string) => Promise<{ url: string }>,
h: GatewayHandlers,
signal: AbortSignal,
store: {
get: () => Session | null;
set: (s: Session | null) => void;
setConnected: (v: boolean) => void;
/** Bricht die Wiederverbindungsschleife ab - nur bei harten Fehlern. */
giveUp: () => void;
},
): Promise<void> {
return new Promise<void>((_resolve, reject) => {
let ws: WebSocket | null = null;
let beat: ReturnType<typeof setInterval> | null = null;
let firstBeat: ReturnType<typeof setTimeout> | null = null;
let acked = true;
let seq: number | null = store.get()?.seq ?? null;
let done = false;
const finish = (why: string): void => {
if (done) return;
done = true;
if (beat) clearInterval(beat);
if (firstBeat) clearTimeout(firstBeat);
store.setConnected(false);
try { ws?.close(); } catch { /* schon zu */ }
reject(new Error(why));
};
signal.addEventListener('abort', () => finish('abgebrochen'), { once: true });
const send = (payload: Payload): void => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
};
const heartbeat = (): void => {
if (!acked) {
// Discord hat den letzten Schlag nicht bestaetigt. Die Verbindung
// sieht offen aus, ist es aber nicht mehr - abreissen und neu.
log.warn('Kein Herzschlag bestaetigt, Verbindung gilt als tot');
finish('Herzschlag ohne Antwort');
return;
}
acked = false;
send({ op: OP.HEARTBEAT, d: seq });
};
void (async () => {
let url: string;
const resume = store.get();
try {
url = resume?.resumeUrl ?? (await getGateway('/gateway/bot')).url;
} catch (err) {
finish(`Gateway-Adresse nicht abrufbar: ${(err as Error).message}`);
return;
}
if (done) return;
ws = new WebSocket(`${url}?v=10&encoding=json`);
ws.addEventListener('close', (ev) => {
const code = (ev as CloseEvent).code;
const reason = (ev as CloseEvent).reason || '';
h.onClosed?.(code, reason);
if (FATAL_CLOSE.has(code)) {
// Nicht fortsetzbar und nicht behebbar: Token oder Absichten.
log.error(`Gateway hat endgueltig geschlossen (${code} ${reason}). ` +
'Bot-Token pruefen und den Dienst neu starten.');
store.set(null);
store.giveUp();
} else if (code === 4007 || code === 4009 || code === 1000 || code === 1001) {
// Sitzung ist hin, aber ein neuer Anlauf lohnt.
store.set(null);
}
finish(`geschlossen (${code})`);
});
ws.addEventListener('error', () => finish('Verbindungsfehler'));
ws.addEventListener('message', (ev) => {
let msg: Payload;
try {
msg = JSON.parse(String((ev as MessageEvent).data)) as Payload;
} catch {
return;
}
if (typeof msg.s === 'number') seq = msg.s;
switch (msg.op) {
case OP.HELLO: {
const interval = (msg.d as { heartbeat_interval: number }).heartbeat_interval;
// Der erste Schlag kommt versetzt, damit nicht alle Verbindungen
// der Welt im selben Moment klopfen - so schreibt es Discord vor.
firstBeat = setTimeout(() => {
heartbeat();
beat = setInterval(heartbeat, interval);
}, Math.floor(interval * Math.random()));
const s = store.get();
if (s) {
send({ op: OP.RESUME, d: { token: botToken, session_id: s.id, seq: s.seq } });
} else {
send({
op: OP.IDENTIFY,
d: {
token: botToken,
intents: 0, // nur Interaktionen, nichts mitlesen
properties: { os: process.platform, browser: 'abdulive-toolkit', device: 'abdulive-toolkit' },
},
});
}
return;
}
case OP.HEARTBEAT:
// Discord darf jederzeit einen Schlag anfordern.
send({ op: OP.HEARTBEAT, d: seq });
return;
case OP.HEARTBEAT_ACK:
acked = true;
return;
case OP.RECONNECT:
log.info('Gateway bittet um Wiederverbindung');
finish('Wiederverbindung angefordert');
return;
case OP.INVALID_SESSION:
// d = true heisst: die Sitzung laesst sich noch fortsetzen.
if (msg.d !== true) store.set(null);
finish('Sitzung ungueltig');
return;
case OP.DISPATCH:
onDispatch(msg);
return;
default:
return;
}
});
function onDispatch(msg: Payload): void {
if (msg.t === 'READY') {
const d = msg.d as {
session_id: string; resume_gateway_url: string;
user: { id: string; username: string };
};
store.set({ id: d.session_id, resumeUrl: d.resume_gateway_url, seq });
store.setConnected(true);
log.info(`Gateway verbunden als ${d.user.username}`);
h.onReady?.({ user: d.user, sessionId: d.session_id });
return;
}
if (msg.t === 'RESUMED') {
store.setConnected(true);
log.info('Gateway-Sitzung fortgesetzt');
return;
}
if (msg.t === 'INTERACTION_CREATE') {
const s = store.get();
if (s) store.set({ ...s, seq });
void Promise.resolve(h.onInteraction?.(msg.d as Interaction))
.catch((err: unknown) => log.error(`Interaktion nicht verarbeitet: ${String(err)}`));
}
}
})();
});
}
}
+75
View File
@@ -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;
}
+324
View File
@@ -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');
});
+343
View File
@@ -0,0 +1,343 @@
// 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**
Knopf drücken. Angepingt wird nur, wer das auch möchte, und ein zweiter Druck
nimmt die Rolle wieder weg.
Twitch: <https://twitch.tv/abdulive_>`;
// ---------------------------------------------------------- Selbstbedienung
export type SelfRolePlan = {
/** Landet im Knopf und kommt bei jedem Klick zurueck. Nie aendern. */
customId: string;
/** Name der Rolle aus ROLES. */
role: string;
label: string;
emoji: string;
/** Antwort beim Holen und beim Abgeben - sieht nur, wer geklickt hat. */
onText: string;
offText: string;
};
/**
* Rollen, die man sich per Knopf selbst gibt.
*
* Discords Onboarding kann das auch, verlangt aber sieben Vorgabekanaele mit
* fuenf beschreibbaren. Der Server hat fuenf und drei. Zwei Kanaele anzulegen,
* damit eine Schwelle passt, macht den Server leerer statt besser - also ein
* Knopf unter dem Willkommenstext. Siehe docs/projects/discord-community.md.
*/
export const SELF_ROLES: SelfRolePlan[] = [
{
customId: 'rolle:live-ping',
role: 'Live-Ping',
label: 'Sag mir Bescheid, wenn der Stream startet',
emoji: '🔴',
onText: 'Passt. Du wirst in den Ankündigungen angepingt, wenn der Stream startet. ' +
'Nochmal drücken nimmt es wieder weg.',
offText: 'Erledigt, du wirst nicht mehr angepingt.',
},
];
// ---------------------------------------------------------------- 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.';
+182
View File
@@ -0,0 +1,182 @@
// Der Knopf vergibt Rollen an echte Leute. Was hier schiefgeht, merkt man
// daran, dass jemand keinen Ping bekommt oder einen, den er abbestellt hat -
// also spaet und vermittelt. Deshalb hier direkt geprueft.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES } from './plan.ts';
import { createRoleButtons, selfRoleComponents } from './selfroles.ts';
const GUILD = '100';
const LIVE_PING = '777';
const USER = '42';
type Call = { method: string; path: string; body?: unknown };
/** Rest-Client im Arbeitsspeicher. Merkt sich, was gerufen wurde. */
function fakeRest(opts: { roles?: Array<{ id: string; name: string }>; failRole?: boolean } = {}) {
const calls: Call[] = [];
const roles = opts.roles ?? [
{ id: GUILD, name: '@everyone' },
{ id: LIVE_PING, name: 'Live-Ping' },
];
const api: RestFull = {
async get<T>(path: string): Promise<T> {
calls.push({ method: 'GET', path });
if (path === `/guilds/${GUILD}/roles`) return roles as T;
throw new Error(`GET ${path} nicht nachgebaut`);
},
async post<T>(path: string, body: unknown): Promise<T> {
calls.push({ method: 'POST', path, body });
return undefined as T;
},
async patch<T>(path: string, body: unknown): Promise<T> {
calls.push({ method: 'PATCH', path, body });
return undefined as T;
},
async put<T>(path: string): Promise<T> {
calls.push({ method: 'PUT', path });
if (opts.failRole) throw new Error('Missing Permissions');
return undefined as T;
},
async del<T>(path: string): Promise<T> {
calls.push({ method: 'DELETE', path });
if (opts.failRole) throw new Error('Missing Permissions');
return undefined as T;
},
};
return { api, calls };
}
const klick = (customId: string, roles: string[] = []): Interaction => ({
id: 'i1',
token: 'tok',
type: 3, // Klick auf eine Komponente
application_id: 'app',
guild_id: GUILD,
data: { custom_id: customId, component_type: 2 },
member: { user: { id: USER, username: 'jemand' }, roles },
});
const antwort = (calls: Call[]): string => {
const c = calls.find((x) => x.path.startsWith('/interactions/'));
return String((c?.body as { data?: { content?: string } })?.data?.content ?? '');
};
// ---------------------------------------------------------------- Knopf
test('die Knopfleiste bildet den Bauplan ab', () => {
const rows = selfRoleComponents();
assert.equal(rows.length, 1, 'eine Reihe');
const buttons = rows[0]!['components'] as Array<Record<string, unknown>>;
assert.equal(buttons.length, SELF_ROLES.length);
assert.equal(buttons[0]!['custom_id'], SELF_ROLES[0]!.customId);
assert.equal(buttons[0]!['type'], 2, 'Typ 2 ist ein Knopf');
});
test('die custom_id traegt keine Nutzerdaten', () => {
// Sie steht dauerhaft in der Nachricht und geht an jeden, der sie sieht.
for (const r of SELF_ROLES) {
assert.match(r.customId, /^[a-z:-]+$/, `${r.customId} sollte ein fester Bezeichner sein`);
assert.ok(r.customId.length <= 100, 'Discord erlaubt hoechstens 100 Zeichen');
}
});
// ---------------------------------------------------------------- Vergeben
test('wer die Rolle nicht hat, bekommt sie', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
const put = calls.find((c) => c.method === 'PUT');
assert.equal(put?.path, `/guilds/${GUILD}/members/${USER}/roles/${LIVE_PING}`);
assert.equal(antwort(calls), SELF_ROLES[0]!.onText);
});
test('wer sie hat, wird sie los - derselbe Knopf', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping', [LIVE_PING]));
const del = calls.find((c) => c.method === 'DELETE');
assert.equal(del?.path, `/guilds/${GUILD}/members/${USER}/roles/${LIVE_PING}`);
assert.equal(calls.some((c) => c.method === 'PUT'), false, 'nicht gleichzeitig setzen');
assert.equal(antwort(calls), SELF_ROLES[0]!.offText);
});
test('die Antwort sieht nur, wer geklickt hat', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
const c = calls.find((x) => x.path.startsWith('/interactions/'))?.body as
{ type: number; data: { flags: number } };
assert.equal(c.type, 4, 'Antwort mit Text');
assert.equal(c.data.flags, 64, 'ohne flags 64 liest der ganze Kanal mit');
});
// ---------------------------------------------------------------- Abwehr
test('fremde Knoepfe werden ignoriert', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('irgendwas-anderes'));
assert.deepEqual(calls, [], 'kein Aufruf, keine Antwort');
});
test('andere Interaktionsarten werden ignoriert', async () => {
const { api, calls } = fakeRest();
const i = { ...klick('rolle:live-ping'), type: 2 }; // Slash-Befehl
await createRoleButtons(api, GUILD).handle(i);
assert.deepEqual(calls, []);
});
test('ein Klick aus der Direktnachricht vergibt keine Rolle', async () => {
const { api, calls } = fakeRest();
const ohneMitglied: Interaction = { ...klick('rolle:live-ping') };
delete ohneMitglied.member;
await createRoleButtons(api, GUILD).handle(ohneMitglied);
assert.equal(calls.some((c) => c.method === 'PUT' || c.method === 'DELETE'), false);
assert.match(antwort(calls), /nur im Server/);
});
test('fehlt die Rolle auf dem Server, wird das gesagt statt still zu scheitern', async () => {
const { api, calls } = fakeRest({ roles: [{ id: GUILD, name: '@everyone' }] });
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
assert.equal(calls.some((c) => c.method === 'PUT'), false);
assert.match(antwort(calls), /Live-Ping/);
assert.match(antwort(calls), /Mod/);
});
test('auf einen Klick kommt auch dann eine Antwort, wenn Discord nein sagt', async () => {
// Ohne Antwort steht bei der Person nur "Interaktion fehlgeschlagen".
const { api, calls } = fakeRest({ failRole: true });
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
assert.match(antwort(calls), /nicht geklappt/);
});
// ---------------------------------------------------------------- Zwischenspeicher
test('die Rollenliste wird nur einmal geholt', async () => {
const { api, calls } = fakeRest();
const buttons = createRoleButtons(api, GUILD);
await buttons.handle(klick('rolle:live-ping'));
await buttons.handle(klick('rolle:live-ping', [LIVE_PING]));
const holt = calls.filter((c) => c.path === `/guilds/${GUILD}/roles`);
assert.equal(holt.length, 1, 'die zweite Anfrage darf aus dem Speicher kommen');
});
test('eine nachtraeglich angelegte Rolle wird beim zweiten Blick gefunden', async () => {
const roles = [{ id: GUILD, name: '@everyone' }];
const { api, calls } = fakeRest({ roles });
const buttons = createRoleButtons(api, GUILD);
await buttons.handle(klick('rolle:live-ping')); // fehlt noch
roles.push({ id: LIVE_PING, name: 'Live-Ping' }); // jemand legt sie an
await buttons.handle(klick('rolle:live-ping'));
assert.equal(calls.some((c) => c.method === 'PUT'), true, 'beim zweiten Mal muss es klappen');
});
+110
View File
@@ -0,0 +1,110 @@
// Rollen, die man sich selbst per Knopf gibt.
//
// Discords eigenes Onboarding kann das auch, verlangt dafuer aber sieben
// Vorgabekanaele, von denen fuenf beschreibbar sind. Der Server hat fuenf und
// drei - nachgemessen, nicht geraten (Fehlercode 350000). Zwei Kanaele nur
// anzulegen, damit eine Schwelle passt, macht den Server schlechter. Also ein
// Knopf unter dem Willkommenstext.
import { logger } from '#core/logger.ts';
import type { ApiRole, RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
const log = logger('discord:rollen');
/** Interaktionsart: Klick auf eine Komponente. */
const MESSAGE_COMPONENT = 3;
/** Antwortart: sichtbare Antwort, hier zusaetzlich nur fuer den Klickenden. */
const CHANNEL_MESSAGE = 4;
const EPHEMERAL = 64;
/**
* Die Knopfleiste unter dem Willkommenstext.
* Style 2 ist der graue Knopf - er soll einladen, nicht schreien.
*/
export function selfRoleComponents(): Array<Record<string, unknown>> {
if (SELF_ROLES.length === 0) return [];
return [{
type: 1, // Reihe
components: SELF_ROLES.map((r) => ({
type: 2, // Knopf
style: 2,
label: r.label,
custom_id: r.customId,
emoji: { name: r.emoji },
})),
}];
}
export type RoleButtons = {
handle: (interaction: Interaction) => Promise<void>;
};
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
// Rollennamen einmal aufloesen und merken. Kommt ein Name nicht vor, wird
// einmal neu geladen - eine Rolle kann nach dem Start angelegt worden sein.
let cache: Map<string, string> | null = null;
async function roleId(name: string, force = false): Promise<string | undefined> {
if (!cache || force) {
const roles = await api.get<ApiRole[]>(`/guilds/${guildId}/roles`);
cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id]));
}
const hit = cache.get(name.toLowerCase());
if (hit || force) return hit;
return roleId(name, true);
}
async function reply(interaction: Interaction, content: string): Promise<void> {
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
type: CHANNEL_MESSAGE,
data: { content, flags: EPHEMERAL },
});
}
return {
async handle(interaction) {
if (interaction.type !== MESSAGE_COMPONENT) return;
const customId = interaction.data?.custom_id;
const plan: SelfRolePlan | undefined = SELF_ROLES.find((r) => r.customId === customId);
if (!plan) return;
const member = interaction.member;
if (!member) {
// Ausserhalb eines Servers gibt es keine Rollen zu vergeben.
await reply(interaction, 'Das geht nur im Server selbst.');
return;
}
try {
const id = await roleId(plan.role);
if (!id) {
await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`);
log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`);
return;
}
const user = member.user.id;
const path = `/guilds/${guildId}/members/${user}/roles/${id}`;
const hatSie = member.roles.includes(id);
if (hatSie) {
await api.del(path, 'Rolle per Knopf abgegeben');
await reply(interaction, plan.offText);
log.info(`${member.user.username} hat ${plan.role} abgegeben`);
} else {
await api.put(path, undefined, 'Rolle per Knopf geholt');
await reply(interaction, plan.onText);
log.info(`${member.user.username} hat ${plan.role} geholt`);
}
} catch (err) {
log.error(`Rollenwechsel fehlgeschlagen: ${(err as Error).message}`);
// Auf einen Klick gehoert eine Antwort, auch wenn es schiefging -
// sonst steht bei der Person nur "Interaktion fehlgeschlagen".
await reply(interaction, 'Hat nicht geklappt. Versuch es gleich noch einmal.')
.catch(() => undefined);
}
},
};
}
+9 -1
View File
@@ -33,7 +33,15 @@ export type SecretStore = {
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>; apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
tokens: Partial<Record<Role, TokenSet>>; tokens: Partial<Record<Role, TokenSet>>;
obs?: { password: string }; 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 }; bluesky?: { handle: string; appPassword: string };
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
update?: { token: string }; update?: { token: string };
+2
View File
@@ -8,6 +8,8 @@ Paketbau, Installation und Start.
|---|---|---| |---|---|---|
| `build-package.ts` | Entwicklungsrechner / CI | Baut `dist/abdulive-<version>-win-x64.zip` samt Updater-Manifest | | `build-package.ts` | Entwicklungsrechner / CI | Baut `dist/abdulive-<version>-win-x64.zip` samt Updater-Manifest |
| `install.ps1` | Stream-PC | Installiert Programm, Daten und Autostart. Ohne Administratorrechte | | `install.ps1` | Stream-PC | Installiert Programm, Daten und Autostart. Ohne Administratorrechte |
| `uninstall.ps1` | Stream-PC | Entfernt Programm und Autostart. `-AuchDaten` loescht auch Datenbank, Clips und Modell |
| `uninstall.ps1` | Stream-PC | Entfernt Programm und Autostart. `-AuchDaten` loescht auch Datenbank, Clips und Modell |
| `launcher.ps1` | Stream-PC | Vom Aufgabenplaner gestartet: Update anwenden, dann Daemon starten | | `launcher.ps1` | Stream-PC | Vom Aufgabenplaner gestartet: Update anwenden, dann Daemon starten |
| `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS | | `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS |
| `auth` | Stream-PC | OAuth-Flow fuer Broadcaster- und Bot-Token | | `auth` | Stream-PC | OAuth-Flow fuer Broadcaster- und Bot-Token |
+11 -2
View File
@@ -168,7 +168,7 @@ async function main(): Promise<void> {
for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) { for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) {
files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) }); files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) });
} }
for (const f of ['launcher.ps1', 'install.ps1', 'backup.ps1']) { for (const f of ['launcher.ps1', 'install.ps1', 'uninstall.ps1', 'backup.ps1']) {
files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) }); files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) });
} }
console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`); console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`);
@@ -184,9 +184,18 @@ async function main(): Promise<void> {
name: 'version.json', name: 'version.json',
data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'), data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'),
}); });
// NODE_OPTIONS setzen: node:sqlite meldet sich sonst bei jedem Aufruf mit
// einer ExperimentalWarning auf stderr. Im Container stand das in der
// Dockerfile - beim Umzug ging es verloren, und unter PowerShell wird aus
// stderr schnell ein Abbruch (siehe launcher.ps1).
files.push({ files.push({
name: 'abdulive.cmd', name: 'abdulive.cmd',
data: Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*\r\n'), data: Buffer.from([
'@echo off',
'set "NODE_OPTIONS=--disable-warning=ExperimentalWarning"',
'"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*',
'',
].join('\r\n')),
}); });
// --- packen // --- packen
+102 -35
View File
@@ -1,16 +1,15 @@
# Installiert abdulive auf dem Stream-PC. # Installiert abdulive auf dem Stream-PC.
# #
# Der uebliche Weg, ein Befehl:
# irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
#
# Oder aus einem entpackten Paket heraus, wenn das Netz nicht mitspielt:
# .\install.ps1 # .\install.ps1
# #
# Aus einem entpackten Paket heraus ausfuehren - install.ps1 liegt darin neben # Der Einzeiler funktioniert nur, weil das Repo oeffentlich ist. Solange es
# node.exe. # privat war, antwortete git.zeasy.dev auf jede Adresse mit einer Umleitung
# # auf /user/login - `iex` fuehrte dann die Anmeldeseite als PowerShell aus.
# Einen `irm ... | iex`-Einzeiler gibt es bewusst nicht: git.zeasy.dev verlangt # Deshalb prueft das Skript unten, ob wirklich JSON zurueckkommt.
# instanzweit eine Anmeldung und antwortet auf jede Adresse mit einer Umleitung
# auf /user/login. `iex` fuehrt die Anmeldeseite dann als PowerShell aus, mit
# einer Fehlermeldung, aus der niemand die Ursache ablesen kann. Wer eine
# Ablage ohne Anmeldung hat, gibt sie mit -Quelle an; dann laedt das Skript
# das Paket selbst.
# #
# Ohne Administratorrechte, mit Absicht: Programm und Daten liegen im Profil # Ohne Administratorrechte, mit Absicht: Programm und Daten liegen im Profil
# des Benutzers, damit auch Updates ohne Rueckfrage durchlaufen. Siehe # des Benutzers, damit auch Updates ohne Rueckfrage durchlaufen. Siehe
@@ -18,17 +17,44 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
# Nur noetig, wenn nicht aus einem entpackten Paket heraus installiert # Wird nur gebraucht, wenn nicht aus einem entpackten Paket heraus
# wird. Dort muessen abdulive.json und die ZIP-Datei ohne Anmeldung # installiert wird. Dort liegen abdulive.json und die ZIP-Datei.
# erreichbar sein. [string]$Quelle = 'https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest',
[string]$Quelle,
[string]$Ziel = (Join-Path $env:LOCALAPPDATA 'Programs\abdulive'), [string]$Ziel = (Join-Path $env:LOCALAPPDATA 'Programs\abdulive'),
[switch]$KeinAutostart [switch]$KeinAutostart,
# Das Sprachmodell ist 465 MB. Ohne es entstehen Clips ohne Untertitel.
[switch]$OhneModell,
[switch]$KeinBrowser
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$daten = Join-Path $env:LOCALAPPDATA 'abdulive' $daten = Join-Path $env:LOCALAPPDATA 'abdulive'
# Experimentelle Warnungen abschalten - node:sqlite meldet sich sonst bei
# jedem Aufruf auf stderr.
$env:NODE_OPTIONS = '--disable-warning=ExperimentalWarning'
function Ruf {
<#
Ein natives Programm aufrufen, ohne dass seine stderr-Ausgabe das
Skript beendet. Windows PowerShell 5.1 verpackt jede stderr-Zeile in
einen ErrorRecord; zusammen mit ErrorActionPreference = 'Stop' bricht
dann alles ab, sobald das Programm auch nur eine Warnung schreibt.
#>
param([string]$Datei, [string[]]$Argumente)
$alt = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
# Out-Host, damit die Ausgabe sichtbar bleibt, aber nicht zum
# Rueckgabewert wird. Ohne das kommt statt des Exit-Codes ein Array
# aus allen Ausgabezeilen zurueck - und jeder Vergleich damit ist
# falsch, ohne dass es auffaellt.
& $Datei @Argumente | Out-Host
return $LASTEXITCODE
}
finally { $ErrorActionPreference = $alt }
}
function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan } function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
function Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray } function Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray }
@@ -49,16 +75,6 @@ $ausPaket = $hier -and (Test-Path (Join-Path $hier 'node.exe'))
if ($ausPaket) { if ($ausPaket) {
Schritt "Installiere aus dem entpackten Paket ($hier)" Schritt "Installiere aus dem entpackten Paket ($hier)"
$quellordner = $hier $quellordner = $hier
} elseif (-not $Quelle) {
Write-Host ''
Write-Host 'Dieses Skript liegt nicht neben einem Paket, und -Quelle ist nicht gesetzt.' -ForegroundColor Red
Write-Host ''
Write-Host 'Entweder:'
Write-Host ' - das Paket herunterladen, entpacken und darin install.ps1 ausfuehren'
Write-Host ' - oder eine Ablage angeben, unter der abdulive.json ohne Anmeldung liegt:'
Write-Host ' .\install.ps1 -Quelle https://.../abdulive'
Write-Host ''
exit 1
} else { } else {
Schritt 'Lade das aktuelle Paket' Schritt 'Lade das aktuelle Paket'
$tmp = Join-Path $env:TEMP ("abdulive-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $tmp = Join-Path $env:TEMP ("abdulive-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
@@ -117,7 +133,7 @@ Get-Process node -ErrorAction SilentlyContinue |
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json', foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json',
'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'backup.ps1')) { 'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'uninstall.ps1', 'backup.ps1')) {
$von = Join-Path $quellordner $item $von = Join-Path $quellordner $item
if (-not (Test-Path $von)) { continue } if (-not (Test-Path $von)) { continue }
$nach = Join-Path $Ziel $item $nach = Join-Path $Ziel $item
@@ -170,18 +186,69 @@ if (-not $KeinAutostart) {
Hinweis 'Aufgabe "abdulive-update" sieht taeglich um 05:00 nach.' Hinweis 'Aufgabe "abdulive-update" sieht taeglich um 05:00 nach.'
} }
# ---------------------------------------------------------------- Einrichten
$cmd = Join-Path $Ziel 'abdulive.cmd'
$version = (Get-Content (Join-Path $Ziel 'version.json') -Raw | ConvertFrom-Json).version
if (-not $OhneModell) {
Schritt 'Sprachmodell laden (einmalig, ca. 465 MB)'
# Nicht abbrechen, wenn das schiefgeht: das Toolkit laeuft auch ohne
# Modell, es entstehen dann nur Clips ohne Untertitel.
$code = Ruf $cmd @('fetch-model')
if ($code -ne 0) {
Hinweis 'Fehlgeschlagen. Spaeter nachholen mit: abdulive fetch-model'
}
}
Schritt 'Selbsttest'
$selbsttest = Ruf $cmd @('check-env')
if (-not $KeinAutostart) {
Schritt 'Starten'
Start-ScheduledTask -TaskName 'abdulive'
# Warten, bis die Weboberflaeche antwortet - sonst oeffnet der Browser
# ins Leere. Der Daemon braucht einen Moment fuer Datenbank und erste
# Verbindungen.
#
# 127.0.0.1 und nicht localhost: der Daemon lauscht nur auf IPv4, und
# Windows loest localhost zuerst nach ::1 auf. Gemessen 2066 ms bis zum
# Rueckfall gegenueber 15 ms direkt - die Pruefung lief jedes Mal in ihr
# eigenes Zeitlimit.
$bereit = $false
foreach ($i in 1..30) {
Start-Sleep -Seconds 1
try {
$null = Invoke-WebRequest 'http://127.0.0.1:4792/health' -UseBasicParsing -TimeoutSec 2
$bereit = $true
break
} catch { }
}
if ($bereit) {
Hinweis 'Laeuft, die Oberflaeche antwortet.'
if (-not $KeinBrowser) { Start-Process 'http://localhost:4792/setup' }
} else {
Hinweis 'Antwortet nach 30 s noch nicht. Protokoll:'
Hinweis " $env:LOCALAPPDATA\abdulive\logs\abdulive.log"
}
}
# ---------------------------------------------------------------- Fertig # ---------------------------------------------------------------- Fertig
$version = (Get-Content (Join-Path $Ziel 'version.json') -Raw | ConvertFrom-Json).version
Write-Host '' Write-Host ''
Write-Host "abdulive $version ist installiert." -ForegroundColor Green if ($selbsttest -eq 0) {
Write-Host "abdulive $version laeuft." -ForegroundColor Green
} else {
Write-Host "abdulive $version ist installiert, der Selbsttest meldet aber Fehler." -ForegroundColor Yellow
}
Write-Host '' Write-Host ''
Write-Host 'Naechste Schritte:' Write-Host 'Im Browser ist jetzt die Einrichtungsseite offen:'
Write-Host " 1. Sprachmodell laden (einmalig, ca. 465 MB):" Write-Host ' http://localhost:4792/setup'
Write-Host " & '$Ziel\abdulive.cmd' fetch-model" Write-Host ''
Write-Host " 2. Umgebung pruefen:" Write-Host 'Dort eintragen: die beiden Twitch-Apps und das OBS-Passwort.'
Write-Host " & '$Ziel\abdulive.cmd' check-env" Write-Host 'Danach ist alles beisammen.'
Write-Host ' 3. Starten und einrichten:' Write-Host ''
Write-Host ' Start-ScheduledTask -TaskName abdulive' Write-Host 'Wieder loswerden:' -ForegroundColor DarkGray
Write-Host ' dann http://localhost:4792/setup im Browser oeffnen' Write-Host " & '$Ziel\uninstall.ps1'" -ForegroundColor DarkGray
Write-Host '' Write-Host ''
+27 -6
View File
@@ -36,7 +36,7 @@ if (Test-Path $pending) {
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json', foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json',
'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'backup.ps1')) { 'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'uninstall.ps1', 'backup.ps1')) {
$new = Join-Path $pending $item $new = Join-Path $pending $item
if (-not (Test-Path $new)) { continue } if (-not (Test-Path $new)) { continue }
$old = Join-Path $root $item $old = Join-Path $root $item
@@ -59,9 +59,30 @@ $node = Join-Path $root 'node.exe'
$main = Join-Path $root 'app\apps\cli\main.ts' $main = Join-Path $root 'app\apps\cli\main.ts'
if (-not (Test-Path $node)) { Write-Log "node.exe fehlt in $root"; exit 1 } if (-not (Test-Path $node)) { Write-Log "node.exe fehlt in $root"; exit 1 }
Write-Log 'Starte den Daemon.' # Experimentelle Warnungen abschalten. node:sqlite meldet sich sonst bei
& $node $main ingest 2>&1 | ForEach-Object { # jedem Start auf stderr - im Container stand das in der Dockerfile.
Add-Content -Path (Join-Path $log 'abdulive.log') -Value $_ -Encoding utf8 $env:NODE_OPTIONS = '--disable-warning=ExperimentalWarning'
$aus = Join-Path $log 'abdulive.log'
$fehler = Join-Path $log 'abdulive-fehler.log'
# Vorherigen Lauf beiseitelegen statt anzuhaengen: Start-Process kann nicht
# anhaengen, und ein Protokoll pro Lauf ist beim Suchen ohnehin angenehmer.
foreach ($f in @($aus, $fehler)) {
if (Test-Path $f) { Move-Item $f "$f.1" -Force }
} }
Write-Log "Daemon beendet (Code $LASTEXITCODE)."
exit $LASTEXITCODE Write-Log 'Starte den Daemon.'
# NICHT `& $node ... 2>&1 | ...` verwenden. Windows PowerShell 5.1 verpackt
# jede stderr-Zeile eines nativen Programms in einen ErrorRecord, und mit
# $ErrorActionPreference = 'Stop' beendet das sofort das ganze Skript. Der
# Daemon schreibt aber im Normalbetrieb Warnungen auf stderr ("Kein
# Broadcaster-Token ..."), und genau daran ist er beim ersten Versuch
# gestorben - noch bevor eine einzige Zeile im Protokoll stand.
$p = Start-Process -FilePath $node -ArgumentList @($main, 'ingest') `
-NoNewWindow -PassThru -Wait `
-RedirectStandardOutput $aus -RedirectStandardError $fehler
Write-Log "Daemon beendet (Code $($p.ExitCode))."
exit $p.ExitCode
+78
View File
@@ -0,0 +1,78 @@
# Entfernt abdulive wieder.
#
# .\uninstall.ps1 Programm und Autostart weg, Daten bleiben
# .\uninstall.ps1 -AuchDaten alles weg, auch Datenbank und Clips
#
# Die Daten bleiben absichtlich per Vorgabe stehen: in der Datenbank liegen
# Chatverlaeufe, und die sind nach einer versehentlichen Deinstallation nicht
# wiederherstellbar. Wer neu installiert, findet seine Einrichtung wieder.
[CmdletBinding()]
param(
[string]$Ziel = (Join-Path $env:LOCALAPPDATA 'Programs\abdulive'),
[switch]$AuchDaten
)
$ErrorActionPreference = 'Stop'
$daten = Join-Path $env:LOCALAPPDATA 'abdulive'
function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
function Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray }
Schritt 'Autostart entfernen'
$aufgaben = Get-ScheduledTask -TaskName 'abdulive', 'abdulive-update' -ErrorAction SilentlyContinue
if ($aufgaben) {
foreach ($a in $aufgaben) {
try { Stop-ScheduledTask -TaskName $a.TaskName -ErrorAction SilentlyContinue } catch { }
Unregister-ScheduledTask -TaskName $a.TaskName -Confirm:$false
Hinweis "Aufgabe `"$($a.TaskName)`" entfernt."
}
} else {
Hinweis 'Keine Aufgaben gefunden.'
}
Schritt 'Laufenden Daemon beenden'
$prozesse = Get-Process node -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and $_.Path.StartsWith($Ziel, 'OrdinalIgnoreCase') }
if ($prozesse) {
foreach ($p in $prozesse) {
Stop-Process -Id $p.Id -Force
Hinweis "node.exe (PID $($p.Id)) beendet."
}
# Windows gibt die Dateisperren nicht sofort frei
Start-Sleep -Milliseconds 800
} else {
Hinweis 'Lief nicht.'
}
Schritt 'Programm entfernen'
if (Test-Path $Ziel) {
Remove-Item -Recurse -Force $Ziel
Hinweis $Ziel
} else {
Hinweis 'War nicht installiert.'
}
if ($AuchDaten) {
Schritt 'Daten entfernen'
if (Test-Path $daten) {
Remove-Item -Recurse -Force $daten
Hinweis "$daten (Datenbank, Clips, Sprachmodell, Protokolle)"
} else {
Hinweis 'Nichts da.'
}
} elseif (Test-Path $daten) {
$mb = [math]::Round((Get-ChildItem $daten -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object Length -Sum).Sum / 1MB)
Write-Host ''
Write-Host "Die Daten bleiben liegen ($mb MB):" -ForegroundColor Yellow
Write-Host " $daten"
Write-Host ' Darin: Datenbank mit Chatverlaeufen, Clips, Sprachmodell, Einrichtung.'
Write-Host ''
Write-Host 'Auch die weg:'
Write-Host " Remove-Item -Recurse -Force '$daten'"
}
Write-Host ''
Write-Host 'abdulive ist entfernt.' -ForegroundColor Green
Write-Host ''