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:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 09:56:55 +02:00
1 parent 4ceb61f095
commit d07c18441b
82 files changed
+2775 -838

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
// Alle SQL-Anweisungen des Projekts stehen hier. Kein anderer Ort schreibt
// SQL - das haelt das Schema an einer Stelle nachvollziehbar.
import type { Db } from './index.ts';
import type { ChatMessage, EventType, StreamEvent } from '#shared/events.ts';
import { ulid } from '#shared/events.ts';
export type StreamRow = {
id: string; started_at: string; ended_at: string | null;
peak_viewers: number; unique_chatters: number;
};
export function repos(db: Db) {
const stmt = {
insertEvent: db.prepare(
'INSERT OR IGNORE INTO events (id, ts, type, stream_id, user_id, payload) VALUES (?,?,?,?,?,?)'
),
upsertUser: db.prepare(`
INSERT INTO users (id, login, display_name, first_seen_at, last_seen_at, message_count)
VALUES (?,?,?,?,?,0)
ON CONFLICT(id) DO UPDATE SET
login = excluded.login,
display_name = excluded.display_name,
last_seen_at = excluded.last_seen_at
`),
bumpUserMessage: db.prepare(
'UPDATE users SET message_count = message_count + 1, last_seen_at = ? WHERE id = ?'
),
getUser: db.prepare('SELECT * FROM users WHERE id = ?'),
insertChat: db.prepare(`
INSERT OR IGNORE INTO chat_messages
(id, stream_id, ts, user_id, text, emotes, is_first, is_command, bits)
VALUES (?,?,?,?,?,?,?,?,?)
`),
openStream: db.prepare(
'INSERT OR IGNORE INTO streams (id, started_at) VALUES (?,?)'
),
closeStream: db.prepare('UPDATE streams SET ended_at = ? WHERE id = ? AND ended_at IS NULL'),
getStream: db.prepare('SELECT * FROM streams WHERE id = ?'),
latestOpenStream: db.prepare(
'SELECT * FROM streams WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1'
),
sample: db.prepare(
'INSERT OR REPLACE INTO viewer_samples (stream_id, ts, viewers) VALUES (?,?,?)'
),
bumpPeak: db.prepare('UPDATE streams SET peak_viewers = ? WHERE id = ? AND peak_viewers < ?'),
touchUserStream: db.prepare(`
INSERT INTO user_streams (user_id, stream_id, first_ts, last_ts, messages)
VALUES (?,?,?,?,?)
ON CONFLICT(user_id, stream_id) DO UPDATE SET
last_ts = excluded.last_ts,
messages = user_streams.messages + excluded.messages
`),
countUserStreams: db.prepare(
'SELECT COUNT(*) AS n FROM user_streams WHERE user_id = ?'
),
recountChatters: db.prepare(`
UPDATE streams SET unique_chatters =
(SELECT COUNT(*) FROM user_streams WHERE stream_id = ? AND messages > 0)
WHERE id = ?
`),
recentChat: db.prepare(
'SELECT ts, text, user_id FROM chat_messages WHERE ts >= ? ORDER BY ts'
),
stats: db.prepare(`
SELECT
(SELECT COUNT(*) FROM chat_messages) AS messages,
(SELECT COUNT(*) FROM users) AS users,
(SELECT COUNT(*) FROM streams) AS streams,
(SELECT COUNT(*) FROM events) AS events
`),
};
return {
event(type: EventType, payload: unknown, streamId: string | null = null,
userId: string | null = null, ts = new Date().toISOString()): StreamEvent {
const ev: StreamEvent = { id: ulid(), ts, type, streamId, userId, payload };
stmt.insertEvent.run(ev.id, ev.ts, ev.type, ev.streamId, ev.userId, JSON.stringify(payload));
return ev;
},
/** Schreibt Nutzer, Nachricht und Anwesenheit in einer Transaktion. */
recordMessage(msg: ChatMessage, streamId: string | null): { streamsChatted: number } {
db.exec('BEGIN');
try {
stmt.upsertUser.run(msg.userId, msg.login, msg.displayName, msg.ts, msg.ts);
stmt.bumpUserMessage.run(msg.ts, msg.userId);
stmt.insertChat.run(
msg.id, streamId, msg.ts, msg.userId, msg.text,
JSON.stringify(msg.emotes), msg.isFirst ? 1 : 0, msg.isCommand ? 1 : 0, msg.bits
);
if (streamId) {
stmt.touchUserStream.run(msg.userId, streamId, msg.ts, msg.ts, 1);
stmt.recountChatters.run(streamId, streamId);
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
const row = stmt.countUserStreams.get(msg.userId) as { n: number } | undefined;
return { streamsChatted: row?.n ?? 0 };
},
openStream(id: string, startedAt: string): void { stmt.openStream.run(id, startedAt); },
closeStream(id: string, endedAt: string): void { stmt.closeStream.run(endedAt, id); },
getStream(id: string) { return stmt.getStream.get(id) as StreamRow | undefined; },
latestOpenStream() { return stmt.latestOpenStream.get() as StreamRow | undefined; },
sample(streamId: string, ts: string, viewers: number): void {
stmt.sample.run(streamId, ts, viewers);
stmt.bumpPeak.run(viewers, streamId, viewers);
},
recentChat(sinceIso: string) {
return stmt.recentChat.all(sinceIso) as Array<{ ts: string; text: string; user_id: string }>;
},
stats() {
return stmt.stats.get() as { messages: number; users: number; streams: number; events: number };
},
};
}
export type Repos = ReturnType<typeof repos>;