// 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 { 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( role: Role, endpoint: string, init: RequestInit = {}, opts: CallOpts = {} ): Promise { 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 { const r = await call<{ data: HelixUser[] }>( 'broadcaster', `/users?login=${encodeURIComponent(login)}`, {}, { appOk: true }); return r.data[0] ?? null; }, async streamByUserId(userId: string): Promise { 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 { 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 }, 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 { 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; }, };