// 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; 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, 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(); 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): 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()), }; }