Files
abdulive/apps/hub/src/rewards.ts
T
ZeasyAlexandClaude Opus 5 24f0528ff6 Kanalpunkte: EventSub, Aktionen und Vorlesen mit Freigabe
EventSub ueber WebSocket wurde in ADR-0003 beschlossen und nie gebaut. Jetzt
gebaut, weil Kanalpunkt-Einloesungen ueber IRC nur ankommen, wenn die
Belohnung eine Texteingabe verlangt - und selbst dann ohne Einloese-ID. Ohne
die ID lassen sich Punkte nicht zurueckgeben, wenn eine Aktion scheitert.

Zwei Eigenheiten des Protokolls, die im Code stehen:

- Ein Abonnement muss innerhalb von 10 Sekunden nach dem Willkommensgruss
  stehen, sonst schliesst Twitch wieder.
- Bei `session_reconnect` wird erst die neue Verbindung aufgebaut und dann die
  alte geschlossen. Andersherum faellt genau dazwischen eine Einloesung unter
  den Tisch.

Belohnungen werden ueber ihren Titel zugeordnet, wahlweise ueber die stabile
ID; liegt beides vor, gewinnt die ID. Eine unbekannte Belohnung ist kein
Fehler - es darf welche geben, die das Toolkit nichts angehen.

Zum Vorlesen, und warum es eine Warteschlange hat: chat-control.md schliesst
TTS fuer Ausbaustufe 1 aus, und der Grund gilt. Kanalpunkte sammeln sich beim
Zugucken von allein an und sind damit kaum eine Huerde; ohne Freigabe hat man
eine Vorlesefunktion fuer Fremde im eigenen Stream. Vorgabe ist deshalb die
Freigabe von Hand, dazu Laengengrenze, Wortfilter, Drossel je Person und
Stunde und ein Panikschalter. Zu langer Text wird abgelehnt statt gekuerzt -
ein halb vorgelesener Satz ist schlimmer als gar keiner, weil niemand weiss,
ob der Rest Absicht war.

Gesprochen wird ueber `speechSynthesis`, im Browser eingebaut: kein
Cloud-Dienst, keine Kosten, kein Zuschauertext, der das Haus verlaesst. Ob
OBS' Browser-Quelle das kann, ist offen - CEF wird teils ohne Sprachausgabe
gebaut. Die Seite prueft es beim Start und blendet einen roten Hinweis ein,
statt still zu bleiben.

Kanalpunkte haben eine eigene Einblendung. Zwischendurch stand hier die des
Abos, womit eine eingeloeste Belohnung als "Neues Abo" im Bild gelandet waere -
eine Falschmeldung, die in der Aufzeichnung bleibt. Ein Test haelt das fest.

`rewards.enabled` ist aus, bis jemand bewusst Belohnungen eintraegt. Eine
Aktion, die niemand erwartet, ist im Stream schlimmer als gar keine.

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

204 lines
7.1 KiB
TypeScript

// Was bei einer Kanalpunkt-Einloesung passiert.
//
// Der Weg: EventSub meldet die Einloesung -> hier wird sie einer Aktion aus
// der Konfiguration zugeordnet -> die Aktion laeuft, oder sie landet in der
// Warteschlange und wartet auf Abdus Klick.
//
// Zur Zuordnung: Belohnungen werden ueber ihren Titel gefunden, wahlweise
// ueber die ID. Der Titel ist lesbar und laesst sich in der Konfiguration
// wiedererkennen, kann sich aber aendern; die ID ist stabil, aber
// nichtssagend. Deshalb beides, und die ID gewinnt.
//
// Warum TTS eine Warteschlange hat, steht in docs/projects/chat-control.md:
// Kanalpunkte sammeln sich beim Zugucken von allein an und sind damit kaum
// eine Huerde. Wer beliebigen Fremdtext ungeprueft vorlesen laesst, hat eine
// Vorlesefunktion fuer Fremde im eigenen Stream.
import { logger } from '#core/logger.ts';
const log = logger('rewards');
/** Eine Einloesung, so weit sie hier gebraucht wird. */
export type Einloesung = {
id: string;
rewardId: string;
rewardTitle: string;
userId: string;
userName: string;
/** Der eingegebene Text. Leer, wenn die Belohnung keinen verlangt. */
text: string;
kosten: number;
ts: string;
};
export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
export type BelohnungsPlan = {
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
titel?: string;
/** Stabile Belohnungs-ID. Gewinnt gegen den Titel. */
id?: string;
aktion: AktionsArt;
/** Nur bei `tts`: ohne Freigabe wird sofort vorgelesen. */
freigabe?: boolean;
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
maxZeichen?: number;
};
export type RewardsConfig = {
enabled: boolean;
belohnungen: BelohnungsPlan[];
tts: {
/** Vorgabe fuer `freigabe`, wenn eine Belohnung nichts anderes sagt. */
freigabe: boolean;
maxZeichen: number;
/** Woerter, die den Text ohne Nachfrage ablehnen. */
blockliste: string[];
/** So viele Einloesungen je Person und Stunde. */
proPersonProStunde: number;
};
};
/** Was mit einer Einloesung geschehen soll. Rein, damit pruefbar. */
export type Entscheidung =
| { art: 'ausfuehren'; aktion: Exclude<AktionsArt, 'tts' | 'nichts'>; plan: BelohnungsPlan }
| { art: 'vorlesen'; text: string }
| { art: 'warteschlange'; text: string }
| { art: 'abgelehnt'; grund: string }
| { art: 'unbekannt' };
/** Findet den Plan zu einer Einloesung. ID gewinnt, sonst der Titel. */
export function planFuer(
e: Pick<Einloesung, 'rewardId' | 'rewardTitle'>, plaene: BelohnungsPlan[],
): BelohnungsPlan | undefined {
return plaene.find((p) => p.id && p.id === e.rewardId)
?? plaene.find((p) => p.titel && p.titel.toLowerCase() === e.rewardTitle.toLowerCase());
}
export function entscheiden(
e: Einloesung,
cfg: RewardsConfig,
/** Wie oft diese Person in der letzten Stunde schon eingeloest hat. */
bisherigeDieseStunde = 0,
): Entscheidung {
if (!cfg.enabled) return { art: 'abgelehnt', grund: 'Belohnungs-Aktionen sind ausgeschaltet' };
const plan = planFuer(e, cfg.belohnungen);
// Unbekannte Belohnung heisst nicht "Fehler": Abdu darf Belohnungen anlegen,
// die mit dem Toolkit nichts zu tun haben. Sie werden protokolliert und
// sonst in Ruhe gelassen.
if (!plan) return { art: 'unbekannt' };
if (plan.aktion === 'nichts') return { art: 'unbekannt' };
if (plan.aktion !== 'tts') {
return { art: 'ausfuehren', aktion: plan.aktion, plan };
}
// ---- ab hier nur noch TTS
const text = e.text.trim();
if (!text) return { art: 'abgelehnt', grund: 'kein Text eingegeben' };
const max = plan.maxZeichen ?? cfg.tts.maxZeichen;
if (text.length > max) {
// Abschneiden waere schlimmer: dann wird ein halber Satz vorgelesen, und
// niemand weiss, ob der Rest Absicht war.
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
}
const klein = text.toLowerCase();
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
}
const freigabe = plan.freigabe ?? cfg.tts.freigabe;
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
}
// ---------------------------------------------------------------- Warteschlange
export type Wartend = {
id: string;
name: string;
text: string;
/** Fuer die Erstattung, falls abgelehnt wird. */
rewardId: string;
redemptionId: string;
seit: string;
};
export type Warteschlange = {
einreihen: (w: Wartend) => void;
/** Gibt den Eintrag zurueck, damit der Aufrufer ihn vorlesen lassen kann. */
entnehmen: (id: string) => Wartend | undefined;
liste: () => Wartend[];
leeren: () => Wartend[];
};
/**
* Wartende Nachrichten. Bewusst im Arbeitsspeicher: was beim Neustart
* verloren geht, war ohnehin nicht dringend, und eine Tabelle mit fremdem
* Text, die niemand aufraeumt, will hier niemand.
*/
export function createWarteschlange(maximal = 25): Warteschlange {
const wartend: Wartend[] = [];
return {
einreihen(w) {
wartend.push(w);
// Laeuft sie voll, faellt das Aelteste raus. Eine Warteschlange, die
// bei einem Ansturm auf 300 waechst, arbeitet Abdu nie ab.
while (wartend.length > maximal) {
const weg = wartend.shift();
if (weg) log.warn(`Warteschlange voll, aelteste Nachricht verworfen (${weg.name})`);
}
},
entnehmen(id) {
const i = wartend.findIndex((w) => w.id === id);
return i === -1 ? undefined : wartend.splice(i, 1)[0];
},
liste: () => [...wartend],
leeren: () => wartend.splice(0, wartend.length),
};
}
/**
* Zaehlt Einloesungen je Person in einem gleitenden Fenster.
* Braucht keine Datenbank - nach einem Neustart faengt das Fenster neu an,
* und das ist bei einer Stunde Fensterbreite egal.
*/
export function createDrossel(fensterMs = 3_600_000) {
const gesehen = new Map<string, number[]>();
return {
zaehlen(userId: string, jetzt = Date.now()): number {
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
gesehen.set(userId, bisher);
return bisher.length;
},
merken(userId: string, jetzt = Date.now()): void {
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
bisher.push(jetzt);
gesehen.set(userId, bisher);
},
};
}
/** Das EventSub-Ereignis in unsere Form bringen. */
export function ausEventSub(event: Record<string, unknown>): Einloesung | null {
const reward = event['reward'] as { id?: string; title?: string; cost?: number } | undefined;
const id = event['id'];
const userId = event['user_id'];
if (typeof id !== 'string' || typeof userId !== 'string' || !reward?.id) return null;
return {
id,
rewardId: reward.id,
rewardTitle: reward.title ?? '',
userId,
userName: String(event['user_name'] ?? event['user_login'] ?? ''),
text: String(event['user_input'] ?? ''),
kosten: Number(reward.cost ?? 0),
ts: String(event['redeemed_at'] ?? new Date().toISOString()),
};
}