Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen seit der Discord-Einrichtung bereit, gepostet hat nie jemand. Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping anpingen. Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>` allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein eigener Test. Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm: - Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung. - Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem Verbindungsabbruch. - Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig. `stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein, wenn es die Sitzung nicht gibt - beim Probelauf etwa. Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das Ergebnis, bevor es der Server sieht. Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen anpingen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
// 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';
|
|
|
|
/** Spalten, die nach dem Anlegen eines Clips noch veraendert werden duerfen. */
|
|
const CLIP_COLUMNS = [
|
|
'twitch_clip_id', 'raw_path', 'vertical_path', 'subtitle_path',
|
|
'duration_s', 'title', 'transcript', 'status', 'score', 'uploaded_to',
|
|
] as const;
|
|
export type ClipColumn = (typeof CLIP_COLUMNS)[number];
|
|
|
|
export type ClipRow = {
|
|
id: string; twitch_clip_id: string | null; stream_id: string | null;
|
|
created_at: string; trigger: string; trigger_score: number | null;
|
|
source: string; raw_path: string | null; vertical_path: string | null;
|
|
status: string; score: number | null;
|
|
};
|
|
|
|
export type PostRow = {
|
|
id: string; stream_id: string | null; kind: string; platform: string;
|
|
mode: string; created_at: string; status: string;
|
|
title: string | null; category: string | null; url: string | null; detail: string | null;
|
|
};
|
|
|
|
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'
|
|
),
|
|
insertClip: db.prepare(`
|
|
INSERT INTO clips (id, stream_id, created_at, trigger, trigger_score, source, status)
|
|
VALUES (?,?,?,?,?,?,?)
|
|
`),
|
|
getClip: db.prepare('SELECT * FROM clips WHERE id = ?'),
|
|
clipsOfStream: db.prepare(
|
|
'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC'
|
|
),
|
|
insertPost: db.prepare(`
|
|
INSERT INTO posts
|
|
(id, stream_id, kind, platform, mode, created_at, status, title, category, url, detail)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
`),
|
|
postFor: db.prepare(`
|
|
SELECT * FROM posts
|
|
WHERE stream_id IS ? AND kind = ? AND platform = ? AND status = 'gesendet'
|
|
LIMIT 1
|
|
`),
|
|
lastPost: db.prepare(`
|
|
SELECT * FROM posts
|
|
WHERE kind = ? AND platform = ? AND status = 'gesendet'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`),
|
|
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 };
|
|
},
|
|
|
|
insertClip(c: {
|
|
id: string; streamId: string | null; trigger: string;
|
|
triggerScore: number | null; source: string; status: string;
|
|
}): void {
|
|
stmt.insertClip.run(c.id, c.streamId, new Date().toISOString(), c.trigger,
|
|
c.triggerScore, c.source, c.status);
|
|
},
|
|
|
|
/** Aktualisiert einzelne Spalten eines Clips. Nur bekannte Spalten sind erlaubt. */
|
|
updateClip(id: string, fields: Partial<Record<ClipColumn, string | number | null>>): void {
|
|
const keys = Object.keys(fields).filter((k): k is ClipColumn =>
|
|
(CLIP_COLUMNS as readonly string[]).includes(k));
|
|
if (!keys.length) return;
|
|
const set = keys.map((k) => `${k} = ?`).join(', ');
|
|
db.prepare(`UPDATE clips SET ${set} WHERE id = ?`)
|
|
.run(...keys.map((k) => fields[k] ?? null), id);
|
|
},
|
|
|
|
/**
|
|
* Haelt fest, was rausgegangen ist. Wirft nicht, wenn der eindeutige Index
|
|
* zuschlaegt - dann war jemand anders schneller, und genau das soll er.
|
|
*/
|
|
insertPost(p: {
|
|
streamId: string | null; kind: string; platform: string; mode: string;
|
|
status: string; title?: string | null; category?: string | null;
|
|
url?: string | null; detail?: string | null;
|
|
}): string | null {
|
|
const id = ulid();
|
|
try {
|
|
stmt.insertPost.run(id, p.streamId, p.kind, p.platform, p.mode,
|
|
new Date().toISOString(), p.status,
|
|
p.title ?? null, p.category ?? null, p.url ?? null, p.detail ?? null);
|
|
return id;
|
|
} catch (err) {
|
|
if (/UNIQUE/i.test((err as Error).message)) return null;
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
/** Gab es fuer diesen Stream schon einen gesendeten Post dieser Art? */
|
|
postFor(streamId: string | null, kind: string, platform: string) {
|
|
return stmt.postFor.get(streamId, kind, platform) as PostRow | undefined;
|
|
},
|
|
|
|
/** Der letzte gesendete Post dieser Art - fuer die Abklingzeit. */
|
|
lastPost(kind: string, platform: string) {
|
|
return stmt.lastPost.get(kind, platform) as PostRow | undefined;
|
|
},
|
|
|
|
getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; },
|
|
clipsOfStream(streamId: string | null) {
|
|
return stmt.clipsOfStream.all(streamId) as ClipRow[];
|
|
},
|
|
};
|
|
}
|
|
|
|
export type Repos = ReturnType<typeof repos>;
|