Files
abdulive/packages/twitch/src/helix.ts
T
ZeasyAlexandClaude Opus 5 70928235b6 Stream-Erkennung: eine geprüfte Funktion statt zweimal ausgeschrieben
Wann ist ein Stream ein *neuer* Stream? Die Entscheidung stand doppelt in Hub
und Ingest und war ungetestet - dabei haengt genau an ihr, ob der Go-Live-Ping
zum richtigen Zeitpunkt und nur einmal rausgeht.

Jetzt rein in stream-state.ts, mit zehn Tests. Darunter die Faelle, die im
Betrieb selten sind und dann mitten im Stream zuschlagen:

- Neue Twitch-ID nach kurzem Aussetzer ist eine Fortsetzung, kein zweiter
  Ping. Twitch vergibt bei einem Verbindungsabbruch eine neue ID.
- Eine einzelne leere Antwort beendet den Stream nicht. Wer daraufhin die
  Sitzung schliesst, hat beim naechsten Abruf einen "neuen" Stream.
- Ein Durchlauf ueber 90 Minuten mit zwei Aussetzern, im Minutentakt
  abgefragt, ergibt genau einen Start und genau ein Ende.

Dazu App-Token fuer Helix: `GET /users` und `GET /streams` kommen ohne
Nutzer-Token aus. Das ist der Unterschied zwischen "laeuft" und "wartet auf
einen Klick" - ohne diese Unterscheidung stand der ganze Dienst still, bis
der Broadcaster sein Konto verbindet. Endpunkte, die etwas tun oder Privates
lesen, verlangen weiterhin ein Nutzer-Token, und `appOk` sagt je Aufruf, was
gilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:28 +02:00

192 lines
7.2 KiB
TypeScript

// 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, appToken } 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 };
/**
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
*
* Das ist kein Detail, sondern der Unterschied zwischen "laeuft" und "wartet
* auf einen Klick": Stream-Erkennung und damit die Go-Live-Ankuendigung
* brauchen nur `GET /users` und `GET /streams`, und beide sind oeffentlich.
* Ohne diese Unterscheidung stuende der ganze Dienst still, bis der
* Broadcaster sein Konto verbindet.
*/
type CallOpts = { appOk?: boolean };
async function call<T>(
role: Role, endpoint: string, init: RequestInit = {}, opts: CallOpts = {}
): Promise<T> {
const secrets = readSecrets();
const app = secrets.apps[role];
if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`);
const hatNutzerToken = Boolean(secrets.tokens[role]);
if (!hatNutzerToken && !opts.appOk) {
throw new Error(`"${role}" ist nicht verbunden - Einrichtung unter /setup.`);
}
return await retry(async () => {
await bucket.take();
// Das Nutzer-Token gewinnt, wenn es da ist: es sieht mehr (Abos, Chatter)
// und zaehlt auf ein eigenes Kontingent.
const token = hatNutzerToken ? await accessToken(role) : await appToken(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)}`, {}, { appOk: true });
return r.data[0] ?? null;
},
async streamByUserId(userId: string): Promise<HelixStream | null> {
const r = await call<{ data: HelixStream[] }>(
'broadcaster', `/streams?user_id=${userId}`, {}, { appOk: true });
return r.data[0] ?? null;
},
/**
* Reicht das Hinterlegte, um den Stream-Zustand abzufragen? Client-ID und
* Secret genuegen dafuer - ein verbundenes Konto ist nicht noetig.
*/
canReadStream(): boolean {
const app = readSecrets().apps.broadcaster;
return Boolean(app?.clientId && app.clientSecret);
},
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;
},
/**
* Abonnement fuer die WebSocket-Sitzung anlegen.
*
* Zwingend mit dem Nutzer-Token: Twitch verlangt fuer den WebSocket-Weg
* dasselbe Token, mit dem die Sitzung geoeffnet wurde. Ein App-Token wird
* hier abgelehnt, und die Fehlermeldung sagt nicht, warum.
*/
async subscribe(
sub: { type: string; version: string; condition: Record<string, string> },
sessionId: string,
): Promise<{ id: string }> {
const r = await call<{ data: Array<{ id: string }> }>('broadcaster', '/eventsub/subscriptions', {
method: 'POST',
body: JSON.stringify({ ...sub, transport: { method: 'websocket', session_id: sessionId } }),
});
const angelegt = r.data[0];
if (!angelegt) throw new Error(`Abonnement ${sub.type} wurde nicht angelegt`);
return angelegt;
},
/**
* Eine Einloesung abschliessen oder die Punkte zurueckgeben.
*
* Braucht `channel:manage:redemptions`; `channel:read:redemptions` allein
* genuegt nicht. Fehlt der Scope, meldet Twitch 401 - der Aufrufer faengt
* das ab und macht weiter, statt die Aktion daran scheitern zu lassen.
*/
async setRedemptionStatus(
broadcasterId: string, rewardId: string, redemptionId: string,
status: 'FULFILLED' | 'CANCELED',
): Promise<void> {
await call('broadcaster',
`/channel_points/custom_rewards/redemptions?broadcaster_id=${broadcasterId}` +
`&reward_id=${encodeURIComponent(rewardId)}&id=${encodeURIComponent(redemptionId)}`,
{ method: 'PATCH', body: JSON.stringify({ status }) });
},
/** Hat das verbundene Konto diesen Scope freigegeben? */
hasScope(scope: string): boolean {
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
},
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;
},
};