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
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@abdulive/ingest",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
+176
-2
@@ -1,2 +1,176 @@
|
||||
// @abdulive/ingest — siehe docs/projects/00-ingest.md
|
||||
export {};
|
||||
// Ingest-Daemon. Liest Chat und Zuschauerzahlen, schreibt beides in die
|
||||
// Datenbank und haelt die Einrichtungsseite bereit.
|
||||
//
|
||||
// Stand S1: IRC + Helix-Polling. EventSub und OBS kommen in S2.
|
||||
// Siehe docs/projects/00-ingest.md.
|
||||
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { sleep } from '#core/retry.ts';
|
||||
import { openDb, closeDb } from '#db/index.ts';
|
||||
import { repos } from '#db/repos.ts';
|
||||
import { helix } from '#twitch/helix.ts';
|
||||
import { createIrc } from '#twitch/irc.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { createSpikeDetector } from './spike.ts';
|
||||
import { startServer } from './server.ts';
|
||||
|
||||
const log = logger('ingest');
|
||||
|
||||
export async function runIngest(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const db = openDb();
|
||||
const repo = repos(db);
|
||||
const spikes = createSpikeDetector(cfg.ingest.spike);
|
||||
|
||||
const state = {
|
||||
startedAt: new Date().toISOString(),
|
||||
streamId: null as string | null,
|
||||
lastSeenLive: 0,
|
||||
viewers: 0,
|
||||
chatConnected: false,
|
||||
chatAnonymous: true,
|
||||
messages: 0,
|
||||
spikeCandidates: 0,
|
||||
lastError: null as string | null,
|
||||
};
|
||||
|
||||
const abort = new AbortController();
|
||||
const stop = (signal: string) => {
|
||||
log.info(`${signal} empfangen, fahre herunter`);
|
||||
abort.abort();
|
||||
setTimeout(() => { closeDb(); process.exit(0); }, 500);
|
||||
};
|
||||
process.on('SIGINT', () => stop('SIGINT'));
|
||||
process.on('SIGTERM', () => stop('SIGTERM'));
|
||||
|
||||
startServer({
|
||||
channel: cfg.channel.login,
|
||||
port: cfg.dashboard.port,
|
||||
health: () => ({
|
||||
...state,
|
||||
db: repo.stats(),
|
||||
spikeArmed: spikes.isArmed(),
|
||||
note: state.chatAnonymous
|
||||
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
// ---- Chat
|
||||
const irc = createIrc(cfg.channel.login, {
|
||||
onConnected: (anonymous) => {
|
||||
state.chatConnected = true;
|
||||
state.chatAnonymous = anonymous;
|
||||
},
|
||||
onMessage: (msg) => {
|
||||
try {
|
||||
const { streamsChatted } = repo.recordMessage(msg, state.streamId);
|
||||
state.messages++;
|
||||
repo.event('chat.message', {
|
||||
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
|
||||
}, state.streamId, msg.userId, msg.ts);
|
||||
|
||||
if (msg.isFirst) {
|
||||
repo.event('chat.first_message', { login: msg.login }, state.streamId, msg.userId, msg.ts);
|
||||
log.info(`Erstchatter: ${msg.displayName}`);
|
||||
} else if (streamsChatted === 1) {
|
||||
log.debug(`Erster Stream fuer ${msg.displayName}`);
|
||||
}
|
||||
|
||||
const spike = spikes.push(msg);
|
||||
if (spike) {
|
||||
state.spikeCandidates++;
|
||||
repo.event('chat.spike', spike, state.streamId, null, msg.ts);
|
||||
log.info(`Spitze im Chat: Punktzahl ${spike.score} ` +
|
||||
`(${spike.rate} Nachrichten von ${spike.chatters} Leuten, Basislinie ${spike.baseline})` +
|
||||
(spike.overThreshold ? '' : ' - nur protokolliert, Ausloesung ist aus'));
|
||||
}
|
||||
} catch (err) {
|
||||
state.lastError = (err as Error).message;
|
||||
log.error(`Nachricht nicht verarbeitet: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
void irc.run(abort.signal);
|
||||
|
||||
// ---- Zuschauerzahlen und Stream-Sessions
|
||||
void pollStream(cfg, repo, state, abort.signal);
|
||||
|
||||
log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
|
||||
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
||||
}
|
||||
|
||||
type State = {
|
||||
streamId: string | null; lastSeenLive: number; viewers: number; lastError: string | null;
|
||||
};
|
||||
|
||||
async function pollStream(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
repo: ReturnType<typeof repos>,
|
||||
state: State,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
|
||||
let warnedMissingAuth = false;
|
||||
|
||||
// Ein laufender Stream wird beim Start uebernommen, damit ein Neustart
|
||||
// mitten im Stream keine zweite Sitzung anlegt.
|
||||
const open = repo.latestOpenStream();
|
||||
if (open) {
|
||||
state.streamId = open.id;
|
||||
state.lastSeenLive = Date.now();
|
||||
log.info(`Laufende Sitzung uebernommen: ${open.id} seit ${open.started_at}`);
|
||||
}
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
if (!readSecrets().tokens.broadcaster) {
|
||||
if (!warnedMissingAuth) {
|
||||
log.warn('Kein Broadcaster-Token - Zuschauerzahlen und Stream-Sitzungen bleiben aus. ' +
|
||||
'Einrichtung unter /setup.');
|
||||
warnedMissingAuth = true;
|
||||
}
|
||||
} else {
|
||||
warnedMissingAuth = false;
|
||||
const userId = cfg.channel.userId ||
|
||||
(await helix.userByLogin(cfg.channel.login))?.id || '';
|
||||
const live = userId ? await helix.streamByUserId(userId) : null;
|
||||
const now = Date.now();
|
||||
|
||||
if (live) {
|
||||
if (state.streamId !== live.id) {
|
||||
// Kurze Unterbrechungen gelten als dieselbe Sitzung, sonst
|
||||
// zerfaellt ein Stream bei jedem Aussetzer in Fragmente.
|
||||
const wasRecent = now - state.lastSeenLive < gapMs;
|
||||
if (state.streamId && wasRecent) {
|
||||
log.info(`Neue Twitch-Stream-ID ${live.id}, behandle als Fortsetzung von ${state.streamId}`);
|
||||
} else {
|
||||
state.streamId = live.id;
|
||||
repo.openStream(live.id, live.started_at);
|
||||
repo.event('stream.online',
|
||||
{ title: live.title, category: live.game_name }, live.id);
|
||||
log.info(`Stream online: "${live.title}" (${live.game_name})`);
|
||||
}
|
||||
}
|
||||
state.lastSeenLive = now;
|
||||
state.viewers = live.viewer_count;
|
||||
if (state.streamId) {
|
||||
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
||||
}
|
||||
} else if (state.streamId && now - state.lastSeenLive > gapMs) {
|
||||
const ended = new Date().toISOString();
|
||||
repo.closeStream(state.streamId, ended);
|
||||
repo.event('stream.offline', {}, state.streamId);
|
||||
log.info(`Stream offline: ${state.streamId}`);
|
||||
state.streamId = null;
|
||||
state.viewers = 0;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.lastError = (err as Error).message;
|
||||
log.error(`Abfrage fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
try { await sleep(cfg.ingest.viewerPollSeconds * 1000, signal); } catch { return; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Ein HTTP-Server fuer Einrichtung, OAuth-Rueckruf und Statusabfrage.
|
||||
// Bindet ausschliesslich auf 127.0.0.1 - siehe ADR-0005.
|
||||
// Ab S5 kommt hier die Sichtungsseite fuer Clip-Kandidaten dazu.
|
||||
|
||||
import http from 'node:http';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { authorizeUrl, handleCallback, redirectUri } from '#twitch/oauth.ts';
|
||||
import { secretStatus, updateSecrets, type Role } from '#twitch/secrets.ts';
|
||||
import { setupPage } from './setup-page.ts';
|
||||
|
||||
const log = logger('web');
|
||||
|
||||
export type ServerDeps = {
|
||||
channel: string;
|
||||
port: number;
|
||||
health: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > 64 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isRole(v: string | null): v is Role {
|
||||
return v === 'broadcaster' || v === 'bot';
|
||||
}
|
||||
|
||||
export function startServer(deps: ServerDeps): http.Server {
|
||||
const server = http.createServer((req, res) => {
|
||||
void handle(req, res, deps).catch((err: Error) => {
|
||||
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Interner Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
// Vorgabe ist die Loopback-Adresse. Im Container muss das 0.0.0.0 sein -
|
||||
// dort ist 127.0.0.1 die Container-Loopback, die Dockers Portweiterleitung
|
||||
// nie erreicht; die Abschottung kommt dann daher, dass Compose den Port
|
||||
// ausschliesslich auf 127.0.0.1 des Hosts veroeffentlicht. Das Image setzt
|
||||
// BIND_HOST entsprechend. Siehe ADR-0005.
|
||||
const host = process.env.BIND_HOST || '127.0.0.1';
|
||||
server.listen(deps.port, host, () => {
|
||||
log.info(`Einrichtung und Status: http://localhost:${deps.port}/setup (gebunden an ${host})`);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
|
||||
const html = (body: string, code = 200) => {
|
||||
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
||||
res.end(body);
|
||||
};
|
||||
const back = (kind: 'ok' | 'err', text: string) => {
|
||||
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
|
||||
res.end();
|
||||
};
|
||||
|
||||
// ---- Einrichtungsseite
|
||||
if (url.pathname === '/' || url.pathname === '/setup') {
|
||||
const ok = url.searchParams.get('ok');
|
||||
const err = url.searchParams.get('err');
|
||||
return html(setupPage(secretStatus(), {
|
||||
channel: deps.channel,
|
||||
redirectUri: redirectUri(deps.port),
|
||||
...(ok ? { notice: { kind: 'ok' as const, text: ok } }
|
||||
: err ? { notice: { kind: 'err' as const, text: err } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Client-ID und Secret speichern
|
||||
if (url.pathname === '/setup/app' && req.method === 'POST') {
|
||||
const form = await readBody(req);
|
||||
const role = form.get('role');
|
||||
const clientId = (form.get('clientId') ?? '').trim();
|
||||
const clientSecret = (form.get('clientSecret') ?? '').trim();
|
||||
if (!isRole(role)) return back('err', 'Unbekannte Rolle');
|
||||
if (!clientId) return back('err', 'Client-ID fehlt');
|
||||
|
||||
updateSecrets((s) => {
|
||||
const existing = s.apps[role];
|
||||
// Leeres Secret-Feld heisst "unveraendert lassen", nicht "loeschen"
|
||||
const secret = clientSecret || existing?.clientSecret;
|
||||
if (!secret) throw new Error('Client-Secret fehlt');
|
||||
s.apps[role] = { clientId, clientSecret: secret };
|
||||
// Bei gewechselter App sind die alten Tokens wertlos
|
||||
if (existing && existing.clientId !== clientId) delete s.tokens[role];
|
||||
});
|
||||
log.info(`Client-Daten fuer ${role} gespeichert`);
|
||||
return back('ok', 'Gespeichert.');
|
||||
}
|
||||
|
||||
// ---- OBS-Passwort
|
||||
if (url.pathname === '/setup/obs' && req.method === 'POST') {
|
||||
const password = ((await readBody(req)).get('password') ?? '').trim();
|
||||
if (!password) return back('err', 'Kein Passwort eingegeben - unveraendert gelassen.');
|
||||
updateSecrets((s) => { s.obs = { password }; });
|
||||
log.info('OBS-Passwort gespeichert');
|
||||
return back('ok', 'OBS-Passwort gespeichert.');
|
||||
}
|
||||
|
||||
// ---- OAuth starten
|
||||
if (url.pathname === '/setup/connect') {
|
||||
const role = url.searchParams.get('role');
|
||||
if (!isRole(role)) return back('err', 'Unbekannte Rolle');
|
||||
try {
|
||||
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
|
||||
res.end();
|
||||
return;
|
||||
} catch (err) {
|
||||
return back('err', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OAuth-Rueckruf
|
||||
if (url.pathname === '/callback') {
|
||||
const error = url.searchParams.get('error_description') ?? url.searchParams.get('error');
|
||||
if (error) return back('err', `Twitch hat abgelehnt: ${error}`);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
if (!code || !state) return back('err', 'Antwort von Twitch war unvollstaendig');
|
||||
try {
|
||||
const role = await handleCallback(code, state, deps.port);
|
||||
return back('ok', `${role === 'bot' ? 'Bot' : 'Broadcaster'}-Konto verbunden.`);
|
||||
} catch (err) {
|
||||
return back('err', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status fuer Skripte und Dashboard
|
||||
if (url.pathname === '/health') {
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(deps.health(), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Nicht gefunden');
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Einrichtungsseite. Bewusst eine einzelne HTML-Datei ohne Framework und ohne
|
||||
// Build - sie wird ein paar Mal im Leben des Projekts benutzt.
|
||||
//
|
||||
// Regeln aus ADR-0005:
|
||||
// - gespeicherte Geheimnisse werden nie zurueckgegeben, nur "gesetzt"
|
||||
// - oben steht eine Warnung, die Seite nicht im Stream zu oeffnen
|
||||
// - der Server bindet nur auf 127.0.0.1
|
||||
|
||||
import type { SecretStatus } from '#twitch/secrets.ts';
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
||||
}
|
||||
|
||||
const STYLE = `
|
||||
:root{--bg:#14151a;--card:#1d1f27;--line:#2e3140;--fg:#e7e9ee;--dim:#9aa0b0;
|
||||
--ok:#4ec98a;--warn:#e3b341;--accent:#9147ff;--danger:#f0616d}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:32px 20px 80px}
|
||||
h1{font-size:22px;margin:0 0 4px}
|
||||
h2{font-size:16px;margin:0 0 14px;font-weight:600}
|
||||
p.lead{color:var(--dim);margin:0 0 28px}
|
||||
.warn{background:#3a2418;border:1px solid #6b3d1f;border-radius:10px;
|
||||
padding:12px 14px;margin:0 0 26px;color:#f5c99b}
|
||||
.warn b{color:#ffd7ae}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
|
||||
padding:18px 20px;margin:0 0 16px}
|
||||
.row{display:flex;align-items:center;justify-content:space-between;gap:16px;
|
||||
padding:9px 0;border-top:1px solid var(--line)}
|
||||
.row:first-of-type{border-top:0}
|
||||
label{display:block;color:var(--dim);font-size:13px;margin:12px 0 5px}
|
||||
input{width:100%;padding:9px 11px;border-radius:8px;border:1px solid var(--line);
|
||||
background:#111219;color:var(--fg);font:inherit;font-size:14px}
|
||||
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
||||
button{border:0;border-radius:8px;padding:9px 16px;font:inherit;font-weight:600;
|
||||
cursor:pointer;background:var(--accent);color:#fff}
|
||||
button.ghost{background:#2b2e3a;color:var(--fg)}
|
||||
a.btn{display:inline-block;text-decoration:none;border-radius:8px;padding:9px 16px;
|
||||
font-weight:600;background:var(--accent);color:#fff}
|
||||
a.btn.ghost{background:#2b2e3a;color:var(--fg)}
|
||||
.pill{font-size:12px;padding:3px 9px;border-radius:99px;white-space:nowrap}
|
||||
.pill.on{background:#14331f;color:var(--ok)}
|
||||
.pill.off{background:#332020;color:var(--danger)}
|
||||
.pill.wait{background:#33290f;color:var(--warn)}
|
||||
code{background:#111219;padding:2px 6px;border-radius:5px;font-size:13px}
|
||||
.hint{color:var(--dim);font-size:13px;margin:8px 0 0}
|
||||
.done{color:var(--ok)}
|
||||
.msg{border-radius:10px;padding:11px 14px;margin:0 0 20px}
|
||||
.msg.ok{background:#14331f;color:#8ce0b0}
|
||||
.msg.err{background:#33191c;color:#ffb3ba}
|
||||
form{margin:0}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}
|
||||
@media(max-width:560px){.grid{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
||||
export function setupPage(
|
||||
status: SecretStatus,
|
||||
opts: { channel: string; redirectUri: string; notice?: { kind: 'ok' | 'err'; text: string } }
|
||||
): string {
|
||||
const app = (role: 'broadcaster' | 'bot', title: string, note: string) => {
|
||||
const a = status.apps[role];
|
||||
const t = status.tokens[role];
|
||||
return `
|
||||
<div class="card">
|
||||
<h2>${esc(title)}</h2>
|
||||
<p class="hint" style="margin-top:-8px">${note}</p>
|
||||
|
||||
<form method="post" action="/setup/app">
|
||||
<input type="hidden" name="role" value="${role}">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="${role}-id">Client-ID</label>
|
||||
<input id="${role}-id" name="clientId" value="${esc(a.clientId)}"
|
||||
placeholder="z. B. gp762nuuoqcoxypju8c569th9wz7q5" autocomplete="off" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="${role}-secret">Client-Secret ${a.hasSecret ? '<span class="done">gespeichert</span>' : ''}</label>
|
||||
<input id="${role}-secret" name="clientSecret" type="password"
|
||||
placeholder="${a.hasSecret ? '••••••••• leer lassen = unveraendert' : 'aus der Twitch-Konsole'}"
|
||||
autocomplete="new-password" ${a.hasSecret ? '' : 'required'}>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:14px"><button type="submit">Speichern</button></div>
|
||||
</form>
|
||||
|
||||
<div class="row" style="margin-top:16px">
|
||||
<div>
|
||||
<strong>Twitch-Konto</strong><br>
|
||||
<span class="hint">${t.connected
|
||||
? `verbunden als <code>${esc(t.login ?? '')}</code>, ${t.scopes} Scopes`
|
||||
: 'noch nicht verbunden'}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span class="pill ${t.connected ? 'on' : 'off'}">${t.connected ? 'verbunden' : 'offen'}</span>
|
||||
${a.clientId && a.hasSecret
|
||||
? `<a class="btn ${t.connected ? 'ghost' : ''}" href="/setup/connect?role=${role}">${
|
||||
t.connected ? 'Neu verbinden' : 'Mit Twitch verbinden'}</a>`
|
||||
: '<span class="hint">erst Client-Daten speichern</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Einrichtung - abdulive-toolkit</title><style>${STYLE}</style></head>
|
||||
<body><div class="wrap">
|
||||
|
||||
<h1>Einrichtung</h1>
|
||||
<p class="lead">Kanal <code>${esc(opts.channel)}</code></p>
|
||||
|
||||
<div class="warn">
|
||||
<b>Diese Seite zeigt Zugangsdaten.</b> Nicht oeffnen, waehrend gesendet wird.
|
||||
Gespeicherte Geheimnisse werden nie wieder angezeigt, aber was hier
|
||||
eingetippt wird, steht im Klartext im Eingabefeld.
|
||||
</div>
|
||||
|
||||
${opts.notice ? `<div class="msg ${opts.notice.kind}">${esc(opts.notice.text)}</div>` : ''}
|
||||
|
||||
<div class="card">
|
||||
<h2>Bevor es losgeht</h2>
|
||||
<p class="hint" style="margin-top:-6px">
|
||||
Zwei Anwendungen anlegen unter
|
||||
<a href="https://dev.twitch.tv/console/apps" target="_blank" rel="noreferrer"
|
||||
style="color:#b18cff">dev.twitch.tv/console/apps</a>.
|
||||
Bei beiden als OAuth-Redirect-URL exakt eintragen:
|
||||
</p>
|
||||
<p style="margin:10px 0 0"><code>${esc(opts.redirectUri)}</code></p>
|
||||
</div>
|
||||
|
||||
${app('broadcaster', '1. Broadcaster-Konto',
|
||||
'Fuer Clips, Titel, Raids und Zuschauerzahlen. Das ist dein eigener Kanal.')}
|
||||
${app('bot', '2. Bot-Konto',
|
||||
'Nur fuer Chat-Nachrichten. Ein eigenes Twitch-Konto, damit Bot-Antworten als Bot erkennbar sind. ' +
|
||||
'Ohne das liest das Toolkit den Chat trotzdem mit - es kann nur nicht antworten.')}
|
||||
|
||||
<div class="card">
|
||||
<h2>3. OBS</h2>
|
||||
<p class="hint" style="margin-top:-8px">
|
||||
Werkzeuge → WebSocket-Servereinstellungen → Server aktivieren, Port 4455.
|
||||
</p>
|
||||
<form method="post" action="/setup/obs">
|
||||
<label for="obs">Passwort ${status.obs ? '<span class="done">gespeichert</span>' : ''}</label>
|
||||
<input id="obs" name="password" type="password"
|
||||
placeholder="${status.obs ? '••••••••• leer lassen = unveraendert' : 'aus den OBS-Einstellungen'}"
|
||||
autocomplete="new-password">
|
||||
<div style="margin-top:14px"><button type="submit" class="ghost">Speichern</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div><strong>Status</strong><br><span class="hint">${status.ready
|
||||
? 'Alles Noetige ist hinterlegt. Der Ingest laeuft.'
|
||||
: 'Solange das Broadcaster-Konto nicht verbunden ist, liest das Toolkit den Chat anonym mit.'}</span></div>
|
||||
<span class="pill ${status.ready ? 'on' : 'wait'}">${status.ready ? 'bereit' : 'unvollstaendig'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { ChatMessage } from '#shared/events.ts';
|
||||
import { createSpikeDetector, type SpikeConfig } from './spike.ts';
|
||||
|
||||
const CFG: SpikeConfig = {
|
||||
windowSeconds: 15,
|
||||
baselineMinutes: 10,
|
||||
factor: 3,
|
||||
minDelta: 4,
|
||||
cooldownSeconds: 60,
|
||||
armed: false,
|
||||
laughEmotes: ['KEKW', 'LUL'],
|
||||
};
|
||||
|
||||
let clock = Date.parse('2026-09-09T20:00:00.000Z');
|
||||
|
||||
function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
|
||||
clock += advanceMs;
|
||||
return {
|
||||
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
|
||||
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [],
|
||||
isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
|
||||
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('ruhiger Chat erzeugt keinen Kandidaten', () => {
|
||||
clock = Date.parse('2026-09-09T20:00:00.000Z');
|
||||
const d = createSpikeDetector(CFG);
|
||||
let hits = 0;
|
||||
// Eine Nachricht alle 20 s - das Fenster ist nie voller als eine
|
||||
for (let i = 0; i < 30; i++) if (d.push(msg('hi', String(i % 4), 20_000))) hits++;
|
||||
assert.equal(hits, 0);
|
||||
});
|
||||
|
||||
test('ploetzlicher Ausbruch von mehreren Leuten wird erkannt', () => {
|
||||
clock = Date.parse('2026-09-09T20:00:00.000Z');
|
||||
const d = createSpikeDetector(CFG);
|
||||
for (let i = 0; i < 30; i++) d.push(msg('ruhig', String(i % 3), 20_000));
|
||||
|
||||
let spike = null;
|
||||
for (let i = 0; i < 8 && !spike; i++) spike = d.push(msg('KEKW', `x${i}`, 300));
|
||||
assert.ok(spike, 'Ausbruch haette einen Kandidaten liefern muessen');
|
||||
assert.ok(spike.chatters >= 4, `zu wenig verschiedene Chatter: ${spike.chatters}`);
|
||||
assert.ok(spike.emoteHits > 0, 'Lach-Emotes wurden nicht gezaehlt');
|
||||
assert.equal(spike.overThreshold, false, 'armed=false darf nicht ausloesen');
|
||||
});
|
||||
|
||||
test('Vielschreiber allein erzeugt weniger Punkte als eine Gruppe', () => {
|
||||
const run = (users: string[]) => {
|
||||
clock = Date.parse('2026-09-09T20:00:00.000Z');
|
||||
const d = createSpikeDetector(CFG);
|
||||
for (let i = 0; i < 30; i++) d.push(msg('ruhig', 'base', 20_000));
|
||||
let spike = null;
|
||||
for (let i = 0; i < 10 && !spike; i++) spike = d.push(msg('hahaha', users[i % users.length]!, 300));
|
||||
return spike;
|
||||
};
|
||||
const solo = run(['einer']);
|
||||
const gruppe = run(['a', 'b', 'c', 'd', 'e']);
|
||||
assert.ok(solo && gruppe);
|
||||
assert.ok(gruppe.score > solo.score,
|
||||
`Gruppe (${gruppe.score}) muss hoeher liegen als Einzelperson (${solo.score})`);
|
||||
});
|
||||
|
||||
test('das Wort clip zaehlt stark', () => {
|
||||
clock = Date.parse('2026-09-09T20:00:00.000Z');
|
||||
const d = createSpikeDetector(CFG);
|
||||
for (let i = 0; i < 30; i++) d.push(msg('ruhig', String(i % 3), 20_000));
|
||||
let spike = null;
|
||||
for (let i = 0; i < 8 && !spike; i++) spike = d.push(msg('das war ein CLIP wert', `y${i}`, 300));
|
||||
assert.ok(spike);
|
||||
assert.ok(spike.clipMentions >= 4, `clip-Erwaehnungen: ${spike.clipMentions}`);
|
||||
});
|
||||
|
||||
test('Abklingzeit verhindert Kandidaten im Sekundentakt', () => {
|
||||
clock = Date.parse('2026-09-09T20:00:00.000Z');
|
||||
const d = createSpikeDetector(CFG);
|
||||
for (let i = 0; i < 30; i++) d.push(msg('ruhig', String(i % 3), 20_000));
|
||||
let hits = 0;
|
||||
for (let i = 0; i < 60; i++) if (d.push(msg('KEKW', `z${i}`, 500))) hits++;
|
||||
// 60 Nachrichten in 30 s: ohne Abklingzeit waeren das viele Kandidaten
|
||||
assert.ok(hits <= 1, `Abklingzeit greift nicht, ${hits} Kandidaten`);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user