Go-Live-Ping: Discord meldet den Streamstart

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>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:12:35 +02:00
1 parent e87879de9e
commit c4dc6ee04f
12 files changed
+1098 -18

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
-- Was das Toolkit nach draussen geschickt hat. Eine Zeile je Plattform und
-- Anlass.
--
-- Der Zweck ist nicht Statistik, sondern Verhinderung: Twitch schickt nach
-- einem Verbindungsabbruch ein zweites stream.online, und ohne diese Tabelle
-- bekommt der Discord-Server dann einen zweiten Rundruf. Ein Ping zu viel
-- kostet Abonnenten der Rolle, ein Ping zu wenig kostet nur einen Stream.
-- stream_id ist bewusst KEIN Fremdschluessel. Ein Post ist die Quittung fuer
-- etwas, das den Rechner verlassen hat; die muss auch dann schreibbar sein,
-- wenn es die Sitzung in `streams` nicht gibt - beim Probelauf von
-- `golive:test` etwa. Ein Fremdschluessel wuerde hier ausgerechnet das
-- Protokoll verhindern, das erklaeren soll, was passiert ist.
CREATE TABLE posts (
id TEXT PRIMARY KEY,
stream_id TEXT,
kind TEXT NOT NULL, -- golive | recap | clip
platform TEXT NOT NULL, -- discord | bluesky | mastodon | ...
mode TEXT NOT NULL, -- live | draft
created_at TEXT NOT NULL,
status TEXT NOT NULL, -- gesendet | fehlgeschlagen | entwurf
title TEXT,
category TEXT,
url TEXT, -- Nachricht auf der Zielplattform
detail TEXT -- Fehlertext, wenn es schiefging
);
-- Die Frage, die vor jedem Post gestellt wird: gab es das hier schon?
-- Nur gesendete Posts sperren; ein fehlgeschlagener Versuch darf wiederholt
-- werden, sonst kostet ein Netzwerkaussetzer die Ankuendigung des Streams.
CREATE UNIQUE INDEX idx_posts_einmal
ON posts(stream_id, kind, platform)
WHERE stream_id IS NOT NULL AND status = 'gesendet';
CREATE INDEX idx_posts_kind ON posts(kind, created_at);
+52
View File
@@ -19,6 +19,12 @@ export type ClipRow = {
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;
@@ -84,6 +90,21 @@ export function repos(db: Db) {
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,
@@ -160,6 +181,37 @@ export function repos(db: Db) {
.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[];