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>
This commit is contained in:
1 parent
bc2c8f6cbe
commit
70928235b6
4 files changed
+324
-33
No files matched your search
@@ -4,7 +4,7 @@
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { retry, sleep } from '#core/retry.ts';
|
||||
import { accessToken } from './oauth.ts';
|
||||
import { accessToken, appToken } from './oauth.ts';
|
||||
import { readSecrets, type Role } from './secrets.ts';
|
||||
|
||||
const log = logger('helix');
|
||||
@@ -49,13 +49,33 @@ export type HelixStream = {
|
||||
|
||||
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];
|
||||
/**
|
||||
* `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();
|
||||
const token = await accessToken(role);
|
||||
// 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: {
|
||||
@@ -85,15 +105,26 @@ async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Pr
|
||||
|
||||
export const helix = {
|
||||
async userByLogin(login: string): Promise<HelixUser | null> {
|
||||
const r = await call<{ data: HelixUser[] }>('broadcaster', `/users?login=${encodeURIComponent(login)}`);
|
||||
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}`);
|
||||
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 = '';
|
||||
@@ -108,6 +139,48 @@ export const helix = {
|
||||
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' });
|
||||
|
||||
Reference in new issue
Block a user