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>
192 lines
7.2 KiB
TypeScript
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;
|
|
},
|
|
};
|