S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme
Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID, Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume, bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert. Siehe ADR-0005. Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und die Ausfuehrung von TypeScript mit. Das Projekt hat damit null Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001. Enthalten: - packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML - packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort - packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC - apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung - 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht) Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
4ceb61f095
commit
d07c18441b
82 files changed
+2775
-838
No files matched your search
@@ -0,0 +1,109 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in new issue
Block a user