// Erkennung von Chat-Spitzen. // // Wichtig: bei zehn Zuschauern ist die Nachrichtenrate so niedrig, dass drei // gleichzeitige "lol" bereits eine Verdreifachung sind. Der Detektor laeuft // deshalb von Anfang an mit und protokolliert JEDEN Kandidaten samt Punktzahl, // loest aber nichts aus, solange `armed` false ist. Die Schwelle wird spaeter // an echtem Material kalibriert, statt sie zu raten. // Siehe docs/projects/00-ingest.md. import type { ChatMessage, SpikeEvent } from '#shared/events.ts'; export type SpikeConfig = { windowSeconds: number; baselineMinutes: number; factor: number; minDelta: number; cooldownSeconds: number; armed: boolean; laughEmotes: string[]; }; type Entry = { ts: number; userId: string; emoteHits: number; clipMention: number; shout: number }; export function createSpikeDetector(cfg: SpikeConfig) { const window: Entry[] = []; const rateHistory: Array<{ ts: number; rate: number }> = []; const laugh = new Set(cfg.laughEmotes.map((e) => e.toLowerCase())); let lastSpike = 0; function isShout(text: string): number { const letters = text.replace(/[^\p{L}]/gu, ''); if (letters.length < 4) return 0; const upper = (text.match(/\p{Lu}/gu) ?? []).length; const excl = /[!?]{2,}/.test(text) ? 1 : 0; return (upper / letters.length > 0.6 ? 1 : 0) + excl; } function score(msg: ChatMessage): Entry { let emoteHits = msg.emotes.reduce( (n, e) => n + (laugh.has(e.name.toLowerCase()) ? e.count : 0), 0); // Emotes ohne Twitch-Tag (BTTV/7TV) stehen nur als Text in der Nachricht for (const word of msg.text.split(/\s+/)) { if (laugh.has(word.toLowerCase())) emoteHits++; } return { ts: Date.parse(msg.ts), userId: msg.userId, emoteHits, clipMention: /\bclip\b/i.test(msg.text) ? 1 : 0, shout: isShout(msg.text), }; } /** Gibt einen Kandidaten zurueck, wenn das Fenster auffaellig ist - sonst null. */ function push(msg: ChatMessage): SpikeEvent | null { const entry = score(msg); window.push(entry); const now = entry.ts; const windowStart = now - cfg.windowSeconds * 1000; while (window.length && window[0]!.ts < windowStart) window.shift(); const rate = window.length; rateHistory.push({ ts: now, rate }); const baseStart = now - cfg.baselineMinutes * 60_000; while (rateHistory.length && rateHistory[0]!.ts < baseStart) rateHistory.shift(); // Median statt Mittelwert: ein einzelner Ausbruch soll die Basislinie // nicht mit anheben und damit den naechsten Ausbruch verstecken. const sorted = rateHistory.map((r) => r.rate).sort((a, b) => a - b); const baseline = sorted.length ? sorted[Math.floor(sorted.length / 2)]! : 0; const threshold = Math.max(baseline * cfg.factor, baseline + cfg.minDelta); const chatters = new Set(window.map((e) => e.userId)).size; const emoteHits = window.reduce((n, e) => n + e.emoteHits, 0); const clipMentions = window.reduce((n, e) => n + e.clipMention, 0); const shouts = window.reduce((n, e) => n + e.shout, 0); const overThreshold = rate >= threshold && rate >= cfg.minDelta; const cooled = now - lastSpike > cfg.cooldownSeconds * 1000; if (!overThreshold || !cooled) return null; lastSpike = now; // Mehrere Personen wiegen schwerer als viele Nachrichten einer Person - // sonst erzeugt ein einzelner Vielschreiber laufend Kandidaten. const value = (baseline > 0 ? rate / baseline : rate) * 1.0 + chatters * 1.5 + emoteHits * 0.8 + shouts * 0.5 + clipMentions * 3.0; return { score: Math.round(value * 100) / 100, rate, baseline, chatters, emoteHits, clipMentions, windowFrom: new Date(windowStart).toISOString(), windowTo: new Date(now).toISOString(), overThreshold: cfg.armed, }; } return { push, isArmed: () => cfg.armed }; }