S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme

Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein
Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID,
Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben
Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume,
bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert.
Siehe ADR-0005.

Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und
die Ausfuehrung von TypeScript mit. Das Projekt hat damit null
Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die
Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der
Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type
Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001.

Enthalten:
- packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML
- packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort
- packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC
- apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung
- 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht)

Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst
sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 09:56:55 +02:00
1 parent 4ceb61f095
commit d07c18441b
82 files changed
+2775 -838

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
// Helix-Client mit eigenem Token-Bucket. Twitch erlaubt 800 Punkte pro Minute
// je Client-ID; wir drosseln selbst, statt uns auf 429 zu verlassen, und
// werten `Ratelimit-Remaining` aus dem Antwortkopf aus.
import { logger } from '#core/logger.ts';
import { retry, sleep } from '#core/retry.ts';
import { accessToken } from './oauth.ts';
import { readSecrets, type Role } from './secrets.ts';
const log = logger('helix');
const BASE = 'https://api.twitch.tv/helix';
class Bucket {
// Keine Parameter-Properties: Node fuehrt die .ts-Datei direkt aus und
// entfernt nur Typen; `constructor(private x)` waere zur Laufzeit weg.
private tokens: number;
private last = Date.now();
private capacity: number;
private perMinute: number;
constructor(capacity = 700, perMinute = 700) {
this.capacity = capacity;
this.perMinute = perMinute;
this.tokens = capacity;
}
async take(cost = 1): Promise<void> {
for (;;) {
const now = Date.now();
this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 60_000) * this.perMinute);
this.last = now;
if (this.tokens >= cost) { this.tokens -= cost; return; }
await sleep(Math.ceil(((cost - this.tokens) / this.perMinute) * 60_000) + 50);
}
}
/** Uebernimmt den echten Stand aus dem Antwortkopf. */
sync(remaining: number | null): void {
if (remaining !== null && remaining < this.tokens) this.tokens = remaining;
}
}
const bucket = new Bucket();
export type HelixStream = {
id: string; user_id: string; user_login: string; game_id: string; game_name: string;
title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string;
};
export type HelixUser = { id: string; login: string; display_name: string };
async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Promise<T> {
const app = readSecrets().apps[role];
if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`);
return await retry(async () => {
await bucket.take();
const token = await accessToken(role);
const res = await fetch(`${BASE}${endpoint}`, {
...init,
headers: {
'Client-Id': app.clientId,
Authorization: `Bearer ${token}`,
...(init.body ? { 'content-type': 'application/json' } : {}),
...init.headers,
},
});
const remaining = res.headers.get('ratelimit-remaining');
bucket.sync(remaining === null ? null : Number(remaining));
if (res.status === 429) {
const reset = Number(res.headers.get('ratelimit-reset') || 0) * 1000 - Date.now();
await sleep(Math.max(1000, Math.min(60_000, reset)));
throw new Error('Rate Limit erreicht');
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Helix ${endpoint}: HTTP ${res.status} ${text.slice(0, 200)}`);
}
if (res.status === 204) return undefined as T;
return await res.json() as T;
}, { attempts: 4, onRetry: (err, n, ms) => log.warn(`Wiederhole ${endpoint} (${n}) in ${ms} ms`, { err: String(err) }) });
}
export const helix = {
async userByLogin(login: string): Promise<HelixUser | null> {
const r = await call<{ data: HelixUser[] }>('broadcaster', `/users?login=${encodeURIComponent(login)}`);
return r.data[0] ?? null;
},
async streamByUserId(userId: string): Promise<HelixStream | null> {
const r = await call<{ data: HelixStream[] }>('broadcaster', `/streams?user_id=${userId}`);
return r.data[0] ?? null;
},
async chatters(broadcasterId: string, moderatorId: string): Promise<string[]> {
const out: string[] = [];
let cursor = '';
do {
const q = `/chat/chatters?broadcaster_id=${broadcasterId}&moderator_id=${moderatorId}&first=1000` +
(cursor ? `&after=${cursor}` : '');
const r = await call<{ data: Array<{ user_id: string }>; pagination?: { cursor?: string } }>(
'broadcaster', q);
for (const c of r.data) out.push(c.user_id);
cursor = r.pagination?.cursor ?? '';
} while (cursor);
return out;
},
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
const clip = r.data[0];
if (!clip) throw new Error('Twitch hat keinen Clip zurueckgegeben');
return clip;
},
};