Compare commits
14
Commits
latest
...
e87879de9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87879de9e | ||
|
|
994ce4faf0 | ||
|
|
3b744caf6a | ||
|
|
d231e1c722 | ||
|
|
7775c689be | ||
|
|
04da6ef802 | ||
|
|
bc5d8ad07b | ||
|
|
2cdb4690e5 | ||
|
|
3f21f1c725 | ||
|
|
18421a1af0 | ||
|
|
56af15cf3f | ||
|
|
92f14eaa62 | ||
|
|
55ee11cef8 | ||
|
|
08f7c21450 |
No files matched your search
@@ -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
|
||||
@@ -25,3 +25,10 @@ Thumbs.db
|
||||
# Zwischenspeicher des Paketbaus und sein Ergebnis
|
||||
.cache/
|
||||
dist/
|
||||
|
||||
# LaTeX-Zwischendateien
|
||||
docs/handbuch/*.aux
|
||||
docs/handbuch/*.log
|
||||
docs/handbuch/*.out
|
||||
docs/handbuch/*.toc
|
||||
docs/handbuch/*.png
|
||||
@@ -43,6 +43,7 @@ Dazu der Dienst, der unter allen anderen liegt:
|
||||
- [`docs/offene-punkte.md`](docs/offene-punkte.md) — was noch fehlt
|
||||
- [`docs/obs-layout.md`](docs/obs-layout.md) — die Layout-Vorgabe für OBS
|
||||
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
|
||||
- [`docs/projects/discord-community.md`](docs/projects/discord-community.md) — Aufbau des Community-Servers
|
||||
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
|
||||
|
||||
## Auf dem Stream-PC installieren
|
||||
@@ -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
|
||||
zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)).
|
||||
|
||||
Das Paket herunterladen, entpacken, darin `install.ps1` ausführen. Ohne
|
||||
Administratorrechte; Programm und Daten landen im Benutzerprofil.
|
||||
Ein Befehl, ohne Administratorrechte. Programm und Daten landen im
|
||||
Benutzerprofil.
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
|
||||
.\abdulive\install.ps1
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
> Einen `irm … | iex`-Einzeiler gibt es nicht: `git.zeasy.dev` verlangt
|
||||
> instanzweit eine Anmeldung und antwortet auf **jede** Adresse mit einer
|
||||
> 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).
|
||||
Kein Netz auf dem Stream-PC? Dann das Paket von der Releases-Seite laden,
|
||||
entpacken und darin `install.ps1` ausführen.
|
||||
|
||||
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
|
||||
abdulive fetch-model # Sprachmodell, ca. 465 MB
|
||||
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
|
||||
Dort die Twitch-Zugangsdaten eintragen. Sie werden verschlüsselt abgelegt,
|
||||
nicht in einer Datei im Projekt
|
||||
([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
|
||||
|
||||
Ausführlich: [`docs/05-setup.md`](docs/05-setup.md).
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
|
||||
function help(): void {
|
||||
@@ -571,6 +681,9 @@ abdulive - Werkzeugkasten fuer Abdus Stream
|
||||
sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt?
|
||||
update [--force] Nach einer neuen Fassung sehen und sie bereitlegen
|
||||
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
|
||||
discord:setup [--server ID] [--vorschau]
|
||||
Discord-Server nach docs/projects/discord-community.md einrichten
|
||||
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
||||
help Diese Uebersicht
|
||||
|
||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||
@@ -601,6 +714,8 @@ try {
|
||||
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;
|
||||
default:
|
||||
console.error(`Unbekannter Befehl "${cmd}".`);
|
||||
|
||||
+10
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
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', () => {
|
||||
// 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);
|
||||
});
|
||||
|
||||
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
@@ -56,10 +56,15 @@ export function looksLikeLogin(contentType: string | null, body: Buffer): boolea
|
||||
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> {
|
||||
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
|
||||
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. */
|
||||
@@ -114,7 +119,8 @@ export async function checkForUpdate(opts: { force?: boolean } = {}): Promise<vo
|
||||
console.log(`Quelle: ${url}`);
|
||||
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}`);
|
||||
|
||||
if (!opts.force && !isNewer(manifest.version, current)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { logger } from '#core/logger.ts';
|
||||
import { sleep } from '#core/retry.ts';
|
||||
import { openDb, closeDb } from '#db/index.ts';
|
||||
import { repos } from '#db/repos.ts';
|
||||
import { createBot } from '#discord/bot.ts';
|
||||
import { createObs } from '#obs/client.ts';
|
||||
import { isWindows } from '#core/platform.ts';
|
||||
import { helix } from '#twitch/helix.ts';
|
||||
@@ -116,6 +117,7 @@ export async function runIngest(): Promise<void> {
|
||||
health: () => ({
|
||||
...state,
|
||||
db: repo.stats(),
|
||||
discordConnected: bot ? bot.isConnected() : false,
|
||||
spikeArmed: spikes.isArmed(),
|
||||
clipsWaitingForFile: clipper.pendingCount(),
|
||||
renderQueue: renderer.pending(),
|
||||
@@ -170,6 +172,10 @@ export async function runIngest(): Promise<void> {
|
||||
void irc.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
|
||||
void pollStream(cfg, repo, state, abort.signal);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ ingest:
|
||||
# Anmeldung (privates Gitea), gehoert der Zugriffstoken NICHT hierher,
|
||||
# sondern auf die Einrichtungsseite - siehe ADR-0005.
|
||||
update:
|
||||
url: ""
|
||||
url: "https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest"
|
||||
|
||||
obs:
|
||||
url: ws://127.0.0.1:4455
|
||||
|
||||
@@ -22,6 +22,8 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
|
||||
- [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz`
|
||||
- [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu)
|
||||
- [ ] `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
|
||||
|
||||
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
|
||||
|
||||
+30
-18
@@ -15,24 +15,13 @@ Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git.
|
||||
|
||||
## Installieren
|
||||
|
||||
Das Paket herunterladen, entpacken, darin `install.ps1` ausfuehren:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
|
||||
.\abdulive\install.ps1
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Liegt das Paket an einer Adresse **ohne** Anmeldung, holt der Installer es
|
||||
selbst:
|
||||
|
||||
```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.
|
||||
Alternativ das Paket von der Releases-Seite laden, entpacken und darin
|
||||
`install.ps1` ausfuehren - dasselbe Skript, es merkt selbst, dass das
|
||||
Paket schon danebenliegt.
|
||||
|
||||
Der Installer laeuft **ohne Administratorrechte**:
|
||||
|
||||
@@ -80,15 +69,20 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung:
|
||||
|
||||
## 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
|
||||
abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout
|
||||
abdulive fetch-model # Sprachmodell, ca. 465 MB
|
||||
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
|
||||
|
||||
```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.
|
||||
|
||||
## 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
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -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.
@@ -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
@@ -1,126 +1,295 @@
|
||||
# Übergabe
|
||||
|
||||
Stand **2026-09-09**. Wer hier weitermacht, sollte diese Seite zuerst lesen und
|
||||
danach [`07-mvp-plan.md`](07-mvp-plan.md).
|
||||
Stand **2026-09-15**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
| S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS |
|
||||
| S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme |
|
||||
| S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip |
|
||||
| S5 Onboarding + Sichtung | offen | — |
|
||||
| S6 Erstchatter-Begrüßung | offen | — |
|
||||
| S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip, Ränder nachgemessen |
|
||||
| S5 Onboarding + Sichtung | 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).
|
||||
|
||||
## 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
|
||||
|
||||
**Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite,
|
||||
WebSocket, `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts`
|
||||
läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`,
|
||||
keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly`
|
||||
in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen.
|
||||
**Keine Abhängigkeiten, kein Build.** Node 24 bringt SQLite, WebSocket,
|
||||
`fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` läuft
|
||||
direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax, also keine `enum`,
|
||||
keine Parameter-Properties im Konstruktor, keine `namespace`.
|
||||
`erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
|
||||
echten Fehler gefangen.
|
||||
|
||||
**Alles läuft unter einem Dach**, in einem Prozess. Schwere
|
||||
Arbeit (ffmpeg, whisper) läuft als Kindprozess und darf abstürzen. Der Ingest
|
||||
darf nie sterben — was er nicht mitschreibt, ist für immer weg.
|
||||
**Alles läuft in einem Prozess.** Schwere Arbeit (ffmpeg, whisper) läuft als
|
||||
Kindprozess und darf abstürzen. Der Ingest darf nie sterben. Was er nicht
|
||||
mitschreibt, ist für immer weg.
|
||||
|
||||
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie liegen
|
||||
verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr — die Datenbank
|
||||
wird gesichert und weitergereicht, die Geheimnisse nicht.
|
||||
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
|
||||
liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
|
||||
Datenbank wird gesichert und weitergereicht, die Geheimnisse nicht.
|
||||
|
||||
**Rohdateien gehören in den Arbeitsordner**, nicht neben den OBS-Puffer. Das
|
||||
war unter Docker ein Zeitproblem (Encodieren über die WSL2-Brücke kostete ein
|
||||
Vielfaches) und ist nativ nur noch Ordnung — der Wiedergabepuffer-Ordner
|
||||
gehört OBS.
|
||||
**Das Repo ist seit 2026-09-09 öffentlich.** Damit funktioniert der Einzeiler
|
||||
zur Installation, und der Updater braucht keinen Token. Es heißt aber auch:
|
||||
nichts Vertrauliches mehr hineinschreiben, auch nicht in die Doku.
|
||||
|
||||
Die Begründungen stehen in [`decisions/`](decisions/), sieben ADRs. Wer eine
|
||||
davon umwerfen will, sollte sie vorher lesen; jede hat einen Grund, der beim
|
||||
Bauen aufgetaucht ist. ADR-0004 (Docker) ist durch ADR-0007 ersetzt — die
|
||||
Analyse darin bleibt trotzdem lesenswert.
|
||||
**Es gibt zwei Remotes**, beide mit demselben Inhalt: `origin`
|
||||
(`ZeasyAlex/abdulive`) und `discord` (`ZeasyAlex/abdulive_-discord`). Wer
|
||||
pusht, pusht auf beide, sonst laufen sie auseinander.
|
||||
|
||||
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
|
||||
|
||||
**Auf Abdus Rechner** läuft eine native Windows-Anwendung — kein Docker, kein
|
||||
WSL2, nichts zu übersetzen ([ADR-0007](decisions/ADR-0007-native-windows.md)):
|
||||
Auf Abdus Rechner, eine native Windows-Anwendung:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
|
||||
.\abdulive\install.ps1
|
||||
abdulive fetch-model
|
||||
abdulive check-env
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Einen Einzeiler zum Nachladen gibt es nicht — `git.zeasy.dev` ist komplett
|
||||
angemeldet-only. **Wo die Pakete liegen sollen, ist die naechste offene
|
||||
Entscheidung**, und ohne sie funktioniert auch `abdulive update` nicht.
|
||||
Der Installer lädt das Paket, prüft die Prüfsumme, holt das Sprachmodell,
|
||||
läuft den Selbsttest, startet den Dienst und öffnet die Einrichtungsseite.
|
||||
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
|
||||
node apps/cli/main.ts check-env
|
||||
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
|
||||
npm i typescript@5.9 @types/node
|
||||
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
|
||||
|
||||
1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps
|
||||
(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
|
||||
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner schon
|
||||
laufen; die Seite hört nur auf `127.0.0.1`.
|
||||
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner laufen,
|
||||
die Seite hört nur auf `127.0.0.1`.
|
||||
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
|
||||
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die
|
||||
Ausrichtungsvorlage in OBS über die Szene legen, Quellen einpassen, wieder
|
||||
ausblenden. Danach `calibrate` zur Kontrolle.
|
||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
||||
`abdulive calibrate` zur Kontrolle.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
**S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne Einrichtungs-
|
||||
Assistenten und Sichtungsseite für Abdu nicht bedienbar ist. Die Kette
|
||||
funktioniert, aber sie hat noch keine Oberfläche, die jemand ohne Erklärung
|
||||
benutzt. Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts
|
||||
eingerichtet" bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.*
|
||||
**S2 an einem laufenden OBS abnehmen.** Das ist die einzige ungeprüfte Stelle
|
||||
in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
|
||||
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
|
||||
malt. Das war vorher nicht zu erraten.
|
||||
|
||||
Davor, wenn Gelegenheit ist: **S2 an einem laufenden OBS abnehmen**. Das ist
|
||||
die einzige ungeprüfte Stelle in der ganzen Kette, und alles Weitere hängt
|
||||
daran.
|
||||
**Discord braucht jetzt nichts mehr.** Der Server steht, die Webhooks liegen
|
||||
bereit. Wer als Nächstes `apps/broadcaster` oder `apps/recap` baut, findet
|
||||
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
|
||||
|
||||
Die Untertitel sind brauchbar, nicht gut. `ggml-small` verhört sich hörbar
|
||||
(„Danke" wurde „Dank es"), die Schrift 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).
|
||||
**Das Aufräumen im Arbeitsordner fehlt komplett.** Rohdateien sammeln sich
|
||||
unbegrenzt an, mehrere hundert MB je Clip. Das ist die Lücke, die als Erstes
|
||||
weh tun wird.
|
||||
|
||||
Und: Das Aufräumen im Arbeitsordner fehlt komplett. Rohdateien sammeln sich
|
||||
unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird.
|
||||
**Die Untertitel sind brauchbar, nicht gut.** `ggml-small` verhört sich
|
||||
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
|
||||
und Bereitlegen laufen. Der eigentliche Tausch durch `launcher.ps1` ist noch
|
||||
nie im Feld passiert. Vor dem ersten echten Update einmal künstlich
|
||||
durchspielen.
|
||||
**Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen.
|
||||
|
||||
**Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
|
||||
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
@@ -68,38 +68,36 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
|
||||
## 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
|
||||
jeder Installation vor einem unbekannten Herausgeber. Ein
|
||||
OV-Zertifikat kostet rund 200 EUR im Jahr, ein EV-Zertifikat mehr und
|
||||
braucht Hardware. Solange nur Abdu installiert, reicht ein Klick auf
|
||||
"Trotzdem ausfuehren" - sobald jemand Drittes das Paket bekommt, nicht
|
||||
mehr.
|
||||
- [ ] **CI auf git.zeasy.dev.** `scripts/build-package.ts` laeuft bisher von
|
||||
Hand. Als Forgejo-Action beim Tag gebaut und als Veroeffentlichung
|
||||
hochgeladen, findet `abdulive update` sie von allein.
|
||||
- [ ] **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.
|
||||
- [ ] **CI einmal wirklich laufen lassen.** `.gitea/workflows/release.yml`
|
||||
ist geschrieben, der Runner (`ubuntu-latest`) steht — ausgefuehrt wurde
|
||||
sie aber noch nie. Beim ersten Tag zusehen, ob sie durchlaeuft.
|
||||
- [ ] **Hardware-Encoding.** Der mitgelieferte ffmpeg kann QSV, AMF und NVENC
|
||||
(`--enable-libvpl`, `--enable-amf`, `--enable-ffnvcodec`) - aus WSL2
|
||||
heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf
|
||||
`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
|
||||
|
||||
- [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden
|
||||
|
||||
@@ -15,3 +15,4 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
|
||||
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
|
||||
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
|
||||
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
|
||||
| — | [Discord-Community](discord-community.md) | `packages/discord` |
|
||||
@@ -0,0 +1,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.
|
||||
@@ -10,6 +10,7 @@
|
||||
"imports": {
|
||||
"#shared/*": "./packages/shared/src/*",
|
||||
"#core/*": "./packages/core/src/*",
|
||||
"#discord/*": "./packages/discord/src/*",
|
||||
"#db/*": "./packages/db/src/*",
|
||||
"#twitch/*": "./packages/twitch/src/*",
|
||||
"#obs/*": "./packages/obs/src/*",
|
||||
|
||||
@@ -0,0 +1,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));
|
||||
});
|
||||
@@ -51,7 +51,11 @@ function stripComment(line: string): string {
|
||||
type Frame = { indent: number; target: YamlMap; listKey?: string };
|
||||
|
||||
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 stack: Frame[] = [{ indent: -1, target: root }];
|
||||
let i = 0;
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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)}`));
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Discord-Rechtebits. Die Werte sind ein 64-Bit-Feld und werden in der API
|
||||
// als Dezimalstring uebertragen - deshalb BigInt und nicht number.
|
||||
//
|
||||
// Kein `enum`: Node fuehrt die .ts-Dateien direkt aus und entfernt nur Typen,
|
||||
// ein enum waere zur Laufzeit weg (ADR-0001, `erasableSyntaxOnly`).
|
||||
|
||||
export const P = {
|
||||
CREATE_INSTANT_INVITE: 1n << 0n,
|
||||
KICK_MEMBERS: 1n << 1n,
|
||||
BAN_MEMBERS: 1n << 2n,
|
||||
ADMINISTRATOR: 1n << 3n,
|
||||
MANAGE_CHANNELS: 1n << 4n,
|
||||
MANAGE_GUILD: 1n << 5n,
|
||||
ADD_REACTIONS: 1n << 6n,
|
||||
VIEW_AUDIT_LOG: 1n << 7n,
|
||||
PRIORITY_SPEAKER: 1n << 8n,
|
||||
STREAM: 1n << 9n,
|
||||
VIEW_CHANNEL: 1n << 10n,
|
||||
SEND_MESSAGES: 1n << 11n,
|
||||
SEND_TTS_MESSAGES: 1n << 12n,
|
||||
MANAGE_MESSAGES: 1n << 13n,
|
||||
EMBED_LINKS: 1n << 14n,
|
||||
ATTACH_FILES: 1n << 15n,
|
||||
READ_MESSAGE_HISTORY: 1n << 16n,
|
||||
MENTION_EVERYONE: 1n << 17n,
|
||||
USE_EXTERNAL_EMOJIS: 1n << 18n,
|
||||
VIEW_GUILD_INSIGHTS: 1n << 19n,
|
||||
CONNECT: 1n << 20n,
|
||||
SPEAK: 1n << 21n,
|
||||
MUTE_MEMBERS: 1n << 22n,
|
||||
DEAFEN_MEMBERS: 1n << 23n,
|
||||
MOVE_MEMBERS: 1n << 24n,
|
||||
USE_VAD: 1n << 25n,
|
||||
CHANGE_NICKNAME: 1n << 26n,
|
||||
MANAGE_NICKNAMES: 1n << 27n,
|
||||
MANAGE_ROLES: 1n << 28n,
|
||||
MANAGE_WEBHOOKS: 1n << 29n,
|
||||
MANAGE_GUILD_EXPRESSIONS: 1n << 30n,
|
||||
USE_APPLICATION_COMMANDS: 1n << 31n,
|
||||
REQUEST_TO_SPEAK: 1n << 32n,
|
||||
MANAGE_EVENTS: 1n << 33n,
|
||||
MANAGE_THREADS: 1n << 34n,
|
||||
CREATE_PUBLIC_THREADS: 1n << 35n,
|
||||
CREATE_PRIVATE_THREADS: 1n << 36n,
|
||||
USE_EXTERNAL_STICKERS: 1n << 37n,
|
||||
SEND_MESSAGES_IN_THREADS: 1n << 38n,
|
||||
USE_EMBEDDED_ACTIVITIES: 1n << 39n,
|
||||
MODERATE_MEMBERS: 1n << 40n,
|
||||
USE_SOUNDBOARD: 1n << 42n,
|
||||
CREATE_EVENTS: 1n << 44n,
|
||||
USE_EXTERNAL_SOUNDS: 1n << 45n,
|
||||
SEND_VOICE_MESSAGES: 1n << 46n,
|
||||
SEND_POLLS: 1n << 49n,
|
||||
} as const;
|
||||
|
||||
export type PermName = keyof typeof P;
|
||||
|
||||
/** Vereinigt mehrere Rechte zu einem Bitfeld. */
|
||||
export function bits(...names: PermName[]): bigint {
|
||||
let out = 0n;
|
||||
for (const n of names) out |= P[n];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Wie die API es erwartet: Dezimalstring. */
|
||||
export const str = (v: bigint): string => v.toString();
|
||||
|
||||
/** Namen eines Bitfelds - nur fuer die Vorschau und Fehlermeldungen. */
|
||||
export function names(value: bigint): PermName[] {
|
||||
const out: PermName[] = [];
|
||||
for (const [name, bit] of Object.entries(P) as Array<[PermName, bigint]>) {
|
||||
if ((value & bit) === bit) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Der Bauplan wird gegen einen echten Server gefahren, auf dem danach Leute
|
||||
// sitzen. Ein vertipptes Recht faellt dort nicht auf - es faellt auf, wenn
|
||||
// jemand es ausnutzt. Deshalb pruefen diese Tests vor allem das, was nicht
|
||||
// drinstehen darf.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { P, bits, names, str } from './permissions.ts';
|
||||
import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, WELCOME } from './plan.ts';
|
||||
import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts';
|
||||
import { CH } from './api.ts';
|
||||
|
||||
const role = (name: string) => {
|
||||
const r = ROLES.find((x) => x.name === name);
|
||||
assert.ok(r, `Rolle ${name} fehlt im Bauplan`);
|
||||
return r;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Bits
|
||||
|
||||
test('Rechtebits entsprechen den Werten der Discord-API', () => {
|
||||
assert.equal(P.VIEW_CHANNEL, 1024n);
|
||||
assert.equal(P.SEND_MESSAGES, 2048n);
|
||||
assert.equal(P.ADMINISTRATOR, 8n);
|
||||
assert.equal(P.MENTION_EVERYONE, 131072n);
|
||||
// Jenseits von 2^31 - hier wuerde eine number-Rechnung still falsch werden.
|
||||
assert.equal(P.CREATE_PUBLIC_THREADS, 34359738368n);
|
||||
assert.equal(P.MODERATE_MEMBERS, 1099511627776n);
|
||||
assert.equal(P.SEND_POLLS, 562949953421312n);
|
||||
});
|
||||
|
||||
test('bits vereinigt, str liefert den Dezimalstring der API', () => {
|
||||
assert.equal(bits('VIEW_CHANNEL', 'SEND_MESSAGES'), 3072n);
|
||||
assert.equal(str(bits('VIEW_CHANNEL', 'SEND_MESSAGES')), '3072');
|
||||
assert.equal(bits(), 0n);
|
||||
// Doppelt genannt bleibt einfach gesetzt.
|
||||
assert.equal(bits('VIEW_CHANNEL', 'VIEW_CHANNEL'), 1024n);
|
||||
});
|
||||
|
||||
test('names liest ein Bitfeld wieder auf', () => {
|
||||
assert.deepEqual(names(bits('KICK_MEMBERS', 'BAN_MEMBERS')), ['KICK_MEMBERS', 'BAN_MEMBERS']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rollen
|
||||
|
||||
test('@everyone bekommt kein Recht, mit dem sich der Server sprengen laesst', () => {
|
||||
for (const verboten of [
|
||||
'ADMINISTRATOR', 'MENTION_EVERYONE', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS',
|
||||
'MANAGE_MESSAGES', 'MANAGE_WEBHOOKS', 'KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_TTS_MESSAGES',
|
||||
] as const) {
|
||||
assert.equal(EVERYONE & P[verboten], 0n, `@everyone darf nicht ${verboten}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('@everyone kann das Noetige: lesen, schreiben, einladen', () => {
|
||||
for (const noetig of ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY', 'CREATE_INSTANT_INVITE'] as const) {
|
||||
assert.equal(EVERYONE & P[noetig], P[noetig], `@everyone braucht ${noetig}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Mod darf moderieren, aber nicht umbauen', () => {
|
||||
const mod = role('Mod').permissions;
|
||||
for (const darf of ['KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', 'MANAGE_MESSAGES'] as const) {
|
||||
assert.equal(mod & P[darf], P[darf], `Mod braucht ${darf}`);
|
||||
}
|
||||
for (const nicht of ['ADMINISTRATOR', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_WEBHOOKS'] as const) {
|
||||
assert.equal(mod & P[nicht], 0n, `Mod darf nicht ${nicht}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Administrator traegt genau eine Rolle, und das ist Abdu', () => {
|
||||
const mitAdmin = ROLES.filter((r) => (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR);
|
||||
assert.deepEqual(mitAdmin.map((r) => r.name), ['Abdu']);
|
||||
});
|
||||
|
||||
test('Abdu steht ganz oben in der Rangfolge', () => {
|
||||
// Die Reihenfolge in ROLES ist die Rangfolge auf dem Server.
|
||||
assert.equal(ROLES[0]?.name, 'Abdu');
|
||||
});
|
||||
|
||||
test('Stammgast ist Auszeichnung, keine Macht', () => {
|
||||
const s = role('Stammgast');
|
||||
assert.equal(s.hoist, true, 'muss in der Mitgliederliste sichtbar sein - das ist der ganze Zweck');
|
||||
// Ueber @everyone hinaus nur Kosmetik und Sprachkanal-Vorrang.
|
||||
assert.equal(s.permissions & ~(EVERYONE | P.PRIORITY_SPEAKER), 0n);
|
||||
});
|
||||
|
||||
test('Live-Ping ist erwaehnbar, sonst nichts', () => {
|
||||
const l = role('Live-Ping');
|
||||
assert.equal(l.mentionable, true, 'sonst kann die Go-Live-Meldung sie nicht anpingen');
|
||||
assert.equal(l.permissions & ~EVERYONE, 0n);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanaele
|
||||
|
||||
test('Kanalnamen sind eindeutig und ueberleben Discords Normalisierung', () => {
|
||||
const seen = new Set<string>();
|
||||
for (const c of CHANNELS) {
|
||||
// Discord schreibt Textkanaele klein. Steht im Plan ein Grossbuchstabe,
|
||||
// weicht der gespeicherte Name ab und wir benennen bei jedem Lauf erneut um.
|
||||
if (c.type === CH.TEXT) {
|
||||
assert.equal(c.name, normalizeName(c.name, c.type),
|
||||
`${c.name}: Discord wuerde den Namen veraendern`);
|
||||
}
|
||||
assert.equal(seen.has(c.name), false, `${c.name} kommt doppelt vor`);
|
||||
seen.add(c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Eintrag hat einen eindeutigen key', () => {
|
||||
const keys = [...CATEGORIES.map((c) => c.key), ...CHANNELS.map((c) => c.key)];
|
||||
assert.equal(new Set(keys).size, keys.length, 'keys muessen eindeutig sein');
|
||||
});
|
||||
|
||||
test('der alte Name steht als alias drin, sonst entsteht ein zweiter Kanal', () => {
|
||||
// Beim Umstellen auf Emoji-Namen war genau das die Gefahr.
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.aliases?.length, `${c.key} hat keinen frueheren Namen hinterlegt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Kanal haengt in einer Kategorie, die es gibt', () => {
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.category, `${c.name} hat keine Kategorie`);
|
||||
assert.ok(CATEGORIES.some((k) => k.key === c.category),
|
||||
`${c.name}: Kategorie ${c.category} ist unbekannt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Rechte-Ausnahmen verweisen nur auf Rollen aus dem Bauplan', () => {
|
||||
const bekannt = new Set(['@everyone', ...ROLES.map((r) => r.name)]);
|
||||
const alle = [...CHANNELS.flatMap((c) => c.overwrites ?? []), ...CATEGORIES.flatMap((c) => c.overwrites ?? [])];
|
||||
for (const o of alle) assert.equal(bekannt.has(o.role), true, `unbekannte Rolle: ${o.role}`);
|
||||
});
|
||||
|
||||
test('die interne Kategorie ist fuer Mitglieder unsichtbar, fuer Mods nicht', () => {
|
||||
const ow = CATEGORIES.find((c) => c.key === 'intern')?.overwrites;
|
||||
assert.ok(ow);
|
||||
const jeder = ow.find((o) => o.role === '@everyone');
|
||||
const mod = ow.find((o) => o.role === 'Mod');
|
||||
assert.equal(jeder?.deny! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
assert.equal(mod?.allow! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
});
|
||||
|
||||
test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
|
||||
for (const key of ['mod-chat', 'technik-log']) {
|
||||
const c = CHANNELS.find((x) => x.key === key);
|
||||
assert.equal(c?.category, 'intern', `${key} waere sonst oeffentlich`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Webhook-Platz kommt genau einmal vor', () => {
|
||||
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
|
||||
assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']);
|
||||
});
|
||||
|
||||
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
|
||||
// Go-Live und Recap muessen obenauf bleiben; in #clips darf jeder posten.
|
||||
const ank = CHANNELS.find((c) => c.key === 'ankuendigungen');
|
||||
const deny = ank?.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
|
||||
assert.equal(deny & P.SEND_MESSAGES, P.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => {
|
||||
const keys = new Set(CHANNELS.map((c) => c.key));
|
||||
for (const [, key] of WELCOME.matchAll(/\{(\w[\w-]*)\}/g)) {
|
||||
assert.equal(keys.has(key!), true, `${key} aus dem Willkommenstext gibt es nicht`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Merge
|
||||
|
||||
const ID = (name: string): string | undefined =>
|
||||
({ '@everyone': '1', Mod: '2', Stammgast: '3' })[name];
|
||||
|
||||
test('fremde Rechte-Ausnahmen bleiben stehen', () => {
|
||||
// "99" hat jemand von Hand gesetzt. Das Skript darf das nicht wegraeumen.
|
||||
const vorher = [{ id: '99', type: 0 as const, allow: '8', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.ok(out);
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(out.find((o) => o.id === '99'), vorher[0]);
|
||||
assert.deepEqual(out.find((o) => o.id === '2'), { id: '2', type: 0, allow: '1024', deny: '0' });
|
||||
});
|
||||
|
||||
test('nichts zu tun heisst null - dann faellt das PATCH weg', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '2048' }];
|
||||
assert.equal(mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID), null);
|
||||
});
|
||||
|
||||
test('abweichende Rechte werden ueberschrieben, nicht ergaenzt', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(out, [{ id: '1', type: 0, allow: '0', deny: '2048' }]);
|
||||
});
|
||||
|
||||
test('eine Ausnahme fuer ein Mitglied wird nicht mit einer Rolle verwechselt', () => {
|
||||
// type 1 ist ein einzelnes Mitglied. Gleiche ID, anderer Typ - darf nicht
|
||||
// ueberschrieben werden.
|
||||
const vorher = [{ id: '2', type: 1 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out?.[0], vorher[0]);
|
||||
});
|
||||
|
||||
test('Rollen, die es noch nicht gibt, werden uebergangen statt zu raten', () => {
|
||||
assert.equal(mergeOverwrites([], [{ role: 'Gibtsnicht', allow: 1024n }], ID), null);
|
||||
});
|
||||
|
||||
test('die Vorlage wird nicht verbogen', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '0' }];
|
||||
const kopie = structuredClone(vorher);
|
||||
mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(vorher, kopie, 'mergeOverwrites darf die Eingabe nicht veraendern');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Namen
|
||||
|
||||
test('normalizeName bildet ab, was Discord mit dem Namen macht', () => {
|
||||
// Am echten Server nachgemessen: kleinschreiben, Leerzeichen zu Bindestrich,
|
||||
// Emoji, Umlaute und U+2502 bleiben unveraendert.
|
||||
assert.equal(normalizeName('Aü Test Kanal', CH.TEXT), 'aü-test-kanal');
|
||||
assert.equal(normalizeName('📢│ankündigungen', CH.TEXT), '📢│ankündigungen');
|
||||
// Sprachkanaele und Kategorien behalten Grossbuchstaben und Leerzeichen.
|
||||
assert.equal(normalizeName('🔊 Zusammen zocken', CH.VOICE), '🔊 Zusammen zocken');
|
||||
assert.equal(normalizeName('📌 Info', CH.CATEGORY), '📌 Info');
|
||||
});
|
||||
|
||||
test('findChannel erkennt den Kanal am frueheren Namen', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: 'sonstwas', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '1');
|
||||
});
|
||||
|
||||
test('findChannel bevorzugt den neuen Namen vor dem alten', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: '💬│allgemein', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '2');
|
||||
});
|
||||
|
||||
test('findChannel verwechselt Sprach- und Textkanal nicht', () => {
|
||||
const vorhanden = [{ id: '1', name: 'clips', type: CH.VOICE, position: 0, parent_id: null }];
|
||||
assert.equal(findChannel(vorhanden, { name: '🎬│clips', aliases: ['clips'], type: CH.TEXT }), undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('rolePositions setzt die Rollen unter die Bot-Rolle', () => {
|
||||
// Bot auf Platz 5, drei Rollen -> 4, 3, 2. Platz 1 bleibt frei, 0 ist @everyone.
|
||||
assert.deepEqual(rolePositions(3, 5), { positions: [4, 3, 2] });
|
||||
});
|
||||
|
||||
test('rolePositions nutzt den Platz genau aus', () => {
|
||||
assert.deepEqual(rolePositions(3, 4), { positions: [3, 2, 1] });
|
||||
});
|
||||
|
||||
test('steht die Bot-Rolle zu weit unten, sagt rolePositions was zu tun ist', () => {
|
||||
// Der Normalfall direkt nach dem Einladen: Bot auf Platz 1, kein Platz darunter.
|
||||
const out = rolePositions(3, 1);
|
||||
assert.ok('problem' in out);
|
||||
assert.match(out.problem, /Platz fuer 0 von 3/);
|
||||
assert.match(out.problem, /nach oben ziehen/);
|
||||
});
|
||||
|
||||
test('rolePositions verlangt nie einen Platz ueber der Bot-Rolle', () => {
|
||||
// Genau das hat Discord mit "Missing Permissions" quittiert.
|
||||
for (let botTop = 1; botTop <= 8; botTop++) {
|
||||
const out = rolePositions(3, botTop);
|
||||
if ('problem' in out) continue;
|
||||
for (const p of out.positions) {
|
||||
assert.ok(p < botTop, `Platz ${p} liegt nicht unter der Bot-Rolle (${botTop})`);
|
||||
assert.ok(p > 0, `Platz ${p} wuerde @everyone verdraengen`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Deutsch
|
||||
|
||||
test('was Mitglieder lesen, steht in richtigem Deutsch', () => {
|
||||
// Im Quelltext ist ae/oe/ue Konvention. In allem, was auf Discord sichtbar
|
||||
// ist, waere es ein Tippfehler - "Sei anstaendig" liest sich wie einer.
|
||||
const sichtbar = [
|
||||
WELCOME,
|
||||
...CHANNELS.map((c) => c.name),
|
||||
...CHANNELS.map((c) => c.topic ?? ''),
|
||||
...CATEGORIES.map((c) => c.name),
|
||||
...ROLES.map((r) => r.name),
|
||||
...AUTOMOD.map((r) => r.name),
|
||||
AUTOMOD_BLOCK,
|
||||
].join('\n');
|
||||
|
||||
// Wortstaemme, die im Deutschen praktisch immer einen Umlaut tragen.
|
||||
for (const falsch of ['ae', 'oe', 'ue']) {
|
||||
const treffer = sichtbar.match(new RegExp('\\w*' + falsch + '\\w*', 'g')) ?? [];
|
||||
// "Neuer", "Feuer" und Aehnliches sind echte Vorkommen - nur die bekannten
|
||||
// Umschreibungen aus diesem Projekt duerfen nicht auftauchen.
|
||||
for (const wort of treffer) {
|
||||
assert.equal(
|
||||
/gehoert|anstaendig|Probelaeufe|Erwaehnung|aendert|laeuft|zusaetzlich|Aenderung/i.test(wort),
|
||||
false,
|
||||
`"${wort}" gehoert in richtiges Deutsch geschrieben`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Abdu traegt Administrator, sonst niemand', () => {
|
||||
for (const r of ROLES) {
|
||||
const admin = (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR;
|
||||
assert.equal(admin, r.name === 'Abdu', `${r.name}: Administrator=${admin}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('die AutoMod-Regel kennt ihren frueheren Namen', () => {
|
||||
const regel = AUTOMOD.find((r) => r.name === 'Erwähnungs-Spam');
|
||||
assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'),
|
||||
'sonst steht die Regel nach der Umbenennung zweimal da');
|
||||
});
|
||||
@@ -0,0 +1,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.';
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -33,7 +33,15 @@ export type SecretStore = {
|
||||
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
|
||||
tokens: Partial<Record<Role, TokenSet>>;
|
||||
obs?: { password: string };
|
||||
discord?: { goLiveWebhook?: string; recapWebhook?: string };
|
||||
discord?: {
|
||||
/** Bot-Token der Discord-App. Nur fuers Einrichten und spaeter fuer den Bot. */
|
||||
botToken?: string;
|
||||
/** Server-ID ("Guild"), damit das Einrichten kein Argument braucht. */
|
||||
guildId?: string;
|
||||
goLiveWebhook?: string;
|
||||
recapWebhook?: string;
|
||||
clipsWebhook?: string;
|
||||
};
|
||||
bluesky?: { handle: string; appPassword: string };
|
||||
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
||||
update?: { token: string };
|
||||
|
||||
@@ -8,6 +8,8 @@ Paketbau, Installation und Start.
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS |
|
||||
| `auth` | Stream-PC | OAuth-Flow fuer Broadcaster- und Bot-Token |
|
||||
|
||||
@@ -168,7 +168,7 @@ async function main(): Promise<void> {
|
||||
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)) });
|
||||
}
|
||||
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)) });
|
||||
}
|
||||
console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`);
|
||||
@@ -184,9 +184,18 @@ async function main(): Promise<void> {
|
||||
name: 'version.json',
|
||||
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({
|
||||
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
|
||||
|
||||
+102
-35
@@ -1,16 +1,15 @@
|
||||
# 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
|
||||
#
|
||||
# Aus einem entpackten Paket heraus ausfuehren - install.ps1 liegt darin neben
|
||||
# node.exe.
|
||||
#
|
||||
# Einen `irm ... | iex`-Einzeiler gibt es bewusst nicht: git.zeasy.dev verlangt
|
||||
# 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.
|
||||
# Der Einzeiler funktioniert nur, weil das Repo oeffentlich ist. Solange es
|
||||
# privat war, antwortete git.zeasy.dev auf jede Adresse mit einer Umleitung
|
||||
# auf /user/login - `iex` fuehrte dann die Anmeldeseite als PowerShell aus.
|
||||
# Deshalb prueft das Skript unten, ob wirklich JSON zurueckkommt.
|
||||
#
|
||||
# Ohne Administratorrechte, mit Absicht: Programm und Daten liegen im Profil
|
||||
# des Benutzers, damit auch Updates ohne Rueckfrage durchlaufen. Siehe
|
||||
@@ -18,17 +17,44 @@
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Nur noetig, wenn nicht aus einem entpackten Paket heraus installiert
|
||||
# wird. Dort muessen abdulive.json und die ZIP-Datei ohne Anmeldung
|
||||
# erreichbar sein.
|
||||
[string]$Quelle,
|
||||
# Wird nur gebraucht, wenn nicht aus einem entpackten Paket heraus
|
||||
# installiert wird. Dort liegen abdulive.json und die ZIP-Datei.
|
||||
[string]$Quelle = 'https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest',
|
||||
[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'
|
||||
$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 Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray }
|
||||
|
||||
@@ -49,16 +75,6 @@ $ausPaket = $hier -and (Test-Path (Join-Path $hier 'node.exe'))
|
||||
if ($ausPaket) {
|
||||
Schritt "Installiere aus dem entpackten Paket ($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 {
|
||||
Schritt 'Lade das aktuelle Paket'
|
||||
$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
|
||||
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
|
||||
if (-not (Test-Path $von)) { continue }
|
||||
$nach = Join-Path $Ziel $item
|
||||
@@ -170,18 +186,69 @@ if (-not $KeinAutostart) {
|
||||
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
|
||||
|
||||
$version = (Get-Content (Join-Path $Ziel 'version.json') -Raw | ConvertFrom-Json).version
|
||||
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 'Naechste Schritte:'
|
||||
Write-Host " 1. Sprachmodell laden (einmalig, ca. 465 MB):"
|
||||
Write-Host " & '$Ziel\abdulive.cmd' fetch-model"
|
||||
Write-Host " 2. Umgebung pruefen:"
|
||||
Write-Host " & '$Ziel\abdulive.cmd' check-env"
|
||||
Write-Host ' 3. Starten und einrichten:'
|
||||
Write-Host ' Start-ScheduledTask -TaskName abdulive'
|
||||
Write-Host ' dann http://localhost:4792/setup im Browser oeffnen'
|
||||
Write-Host 'Im Browser ist jetzt die Einrichtungsseite offen:'
|
||||
Write-Host ' http://localhost:4792/setup'
|
||||
Write-Host ''
|
||||
Write-Host 'Dort eintragen: die beiden Twitch-Apps und das OBS-Passwort.'
|
||||
Write-Host 'Danach ist alles beisammen.'
|
||||
Write-Host ''
|
||||
Write-Host 'Wieder loswerden:' -ForegroundColor DarkGray
|
||||
Write-Host " & '$Ziel\uninstall.ps1'" -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
+27
-6
@@ -36,7 +36,7 @@ if (Test-Path $pending) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
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
|
||||
if (-not (Test-Path $new)) { continue }
|
||||
$old = Join-Path $root $item
|
||||
@@ -59,9 +59,30 @@ $node = Join-Path $root 'node.exe'
|
||||
$main = Join-Path $root 'app\apps\cli\main.ts'
|
||||
if (-not (Test-Path $node)) { Write-Log "node.exe fehlt in $root"; exit 1 }
|
||||
|
||||
Write-Log 'Starte den Daemon.'
|
||||
& $node $main ingest 2>&1 | ForEach-Object {
|
||||
Add-Content -Path (Join-Path $log 'abdulive.log') -Value $_ -Encoding utf8
|
||||
# Experimentelle Warnungen abschalten. node:sqlite meldet sich sonst bei
|
||||
# jedem Start auf stderr - im Container stand das in der Dockerfile.
|
||||
$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
|
||||
@@ -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 ''
|
||||
Reference in new issue
Block a user