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:
1 parent
4ceb61f095
commit
d07c18441b
82 files changed
+2775
-838
No files matched your search
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user