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[];
+13 -1
View File
@@ -150,8 +150,20 @@ test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
});
test('jeder Webhook-Platz kommt genau einmal vor', () => {
// Zweimal derselbe Platz hiesse: der zweite Kanal ueberschreibt die URL des
// ersten im Zugangsdaten-Speicher, und einer der beiden postet ins Leere.
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']);
assert.deepEqual([...slots].sort(),
['clipsWebhook', 'goLiveWebhook', 'recapWebhook', 'technikWebhook']);
assert.equal(new Set(slots).size, slots.length);
});
test('der Entwurfsmodus postet in einen Kanal, den Mitglieder nicht sehen', () => {
// `broadcaster.mode: draft` schickt dieselbe Nachricht wie im Betrieb,
// nur ohne Ping. Laege der Kanal oeffentlich, waere jeder Probelauf eine
// falsche Ankuendigung im Server.
const kanal = CHANNELS.find((c) => (c.webhooks ?? []).some((w) => w.slot === 'technikWebhook'));
assert.equal(kanal?.category, 'intern');
});
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
+5 -1
View File
@@ -21,7 +21,7 @@ import { CH } from './api.ts';
// und der erste Eindruck eines Servers ist der Text darin.
/** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */
export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook';
export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook' | 'technikWebhook';
export type RolePlan = {
name: string;
@@ -236,6 +236,10 @@ export const CHANNELS: ChannelPlan[] = [
type: CH.TEXT,
category: 'intern',
topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.',
// Der Entwurfsmodus des Go-Live-Posts landet hier: dieselbe Nachricht,
// die sonst in die Ankuendigungen ginge, nur ohne Ping und nur fuer Mods.
// So sieht man das Ergebnis, bevor es der Server sieht.
webhooks: [{ name: 'Technik', slot: 'technikWebhook' }],
},
];
+54
View File
@@ -0,0 +1,54 @@
// Rollennamen zu Rollen-IDs. Der Bauplan verweist auf Namen, Discord auf IDs.
//
// Bewusst eigenstaendig: die Knopfleiste braucht das, und der Go-Live-Ping
// braucht es auch - der muss `<@&ID>` in die Nachricht schreiben, weil ein
// Webhook eine Rolle nicht ueber ihren Namen anpingen kann.
import type { ApiRole, Rest } from './api.ts';
export type RoleResolver = {
/**
* Gross-/Kleinschreibung spielt keine Rolle. Ein Fehlschlag laedt die Liste
* einmal neu, bevor er `undefined` zurueckgibt: eine Rolle kann nach dem
* Start des Daemons angelegt worden sein, und ein Neustart ist eine
* schlechte Antwort auf eine neue Rolle.
*/
id: (name: string) => Promise<string | undefined>;
/** Vergisst die gemerkte Liste - fuer Tests und nach einem Umbau. */
forget: () => void;
};
/**
* `reloadAfterMs` bremst das Nachladen nach einem Fehlschlag. Vorgabe ist 0:
* jeder Fehlschlag laedt neu, denn ein Klick kommt von einem Menschen und ist
* selten. Wer im Hintergrund fragt - der Go-Live-Ping etwa - setzt eine
* Bremse, sonst fragt ein Tippfehler im Bauplan im Sekundentakt bei Discord
* nach, und zwar genau dann, wenn es dort keine Antwort gibt.
*/
export function createRoleResolver(
api: Rest, guildId: string, reloadAfterMs = 0
): RoleResolver {
let cache = new Map<string, string>();
let loaded = false;
let lastLoad = 0;
async function load(): Promise<void> {
const roles = await api.get<ApiRole[]>(`/guilds/${guildId}/roles`);
cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id]));
loaded = true;
lastLoad = Date.now();
}
return {
async id(name) {
const key = name.toLowerCase();
if (!loaded) await load();
const hit = cache.get(key);
if (hit) return hit;
if (reloadAfterMs > 0 && Date.now() - lastLoad < reloadAfterMs) return undefined;
await load();
return cache.get(key);
},
forget() { loaded = false; cache = new Map(); },
};
}
+6 -15
View File
@@ -7,9 +7,10 @@
// Knopf unter dem Willkommenstext.
import { logger } from '#core/logger.ts';
import type { ApiRole, RestFull } from './api.ts';
import type { RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
import { createRoleResolver } from './roles.ts';
const log = logger('discord:rollen');
@@ -42,19 +43,9 @@ export type RoleButtons = {
};
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
// Rollennamen einmal aufloesen und merken. Kommt ein Name nicht vor, wird
// einmal neu geladen - eine Rolle kann nach dem Start angelegt worden sein.
let cache: Map<string, string> | null = null;
async function roleId(name: string, force = false): Promise<string | undefined> {
if (!cache || force) {
const roles = await api.get<ApiRole[]>(`/guilds/${guildId}/roles`);
cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id]));
}
const hit = cache.get(name.toLowerCase());
if (hit || force) return hit;
return roleId(name, true);
}
// Ohne Bremse: ein Klick kommt von einem Menschen, und wenn eine Rolle
// gerade erst angelegt wurde, soll der naechste Klick sie finden.
const roles = createRoleResolver(api, guildId);
async function reply(interaction: Interaction, content: string): Promise<void> {
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
@@ -78,7 +69,7 @@ export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
}
try {
const id = await roleId(plan.role);
const id = await roles.id(plan.role);
if (!id) {
await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`);
log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`);
+159
View File
@@ -0,0 +1,159 @@
// Posten ueber einen Webhook. Kein Bot-Token, kein Gateway - die URL allein
// genuegt, und genau deshalb legt `discord:setup` sie an.
//
// Eigener Client statt `Discord` aus api.ts: Webhooks haben eigene
// Drosselungs-Eimer, brauchen keinen Authorization-Kopf, und sie koennen als
// Einzige eine Datei anhaengen. Gemeinsam ist nur der Umgang mit 429.
import { logger } from '#core/logger.ts';
import { retry, sleep } from '#core/retry.ts';
const log = logger('discord:webhook');
/**
* Laenger warten wir nicht. Ein Go-Live-Post, der eine Viertelstunde spaeter
* kommt, ist schlimmer als gar keiner - dann steht in den Ankuendigungen
* "jetzt live", waehrend der Stream laeuft und keiner mehr kommt.
*/
const MAX_WAIT_MS = 30_000;
export type Embed = {
title?: string;
description?: string;
url?: string;
color?: number;
timestamp?: string;
footer?: { text: string; icon_url?: string };
image?: { url: string };
thumbnail?: { url: string };
author?: { name: string; url?: string; icon_url?: string };
fields?: Array<{ name: string; value: string; inline?: boolean }>;
};
export type WebhookMessage = {
content?: string;
embeds?: Embed[];
username?: string;
avatar_url?: string;
/**
* Ohne dieses Feld entscheidet Discord selbst, was aus `@everyone` und
* `<@&...>` im Text wird. Wir entscheiden das immer selbst - ein Rundruf
* aus Versehen ist der einzige Fehler hier, den man nicht zuruecknehmen kann.
*/
allowed_mentions?: { parse: string[]; roles?: string[]; users?: string[] };
/** Komponenten braucht der Go-Live-Post nicht, der Willkommenstext schon. */
components?: Array<Record<string, unknown>>;
};
export type Attachment = { name: string; type: string; data: Uint8Array };
/** Was Discord von einer gesendeten Nachricht zurueckmeldet. */
export type PostedMessage = { id: string; channel_id: string };
/**
* Baut eine Erwaehnung, die auch wirklich pingt.
*
* `<@&ID>` allein reicht nicht: ohne `allowed_mentions.roles` faerbt Discord
* die Erwaehnung zwar ein, schickt aber keine Benachrichtigung. Das ist der
* Fehler, den man erst bemerkt, wenn drei Streams lang niemand gekommen ist.
*/
export type Ping = {
/** Was in den Text gehoert. Leer, wenn es nichts zu pingen gibt. */
mention: string;
allowed_mentions: { parse: string[]; roles?: string[] };
};
export function rolePing(roleId: string | undefined): Ping {
if (!roleId) return { mention: '', allowed_mentions: { parse: [] } };
return { mention: `<@&${roleId}>`, allowed_mentions: { parse: [], roles: [roleId] } };
}
function isWebhookUrl(url: string): boolean {
return /^https:\/\/(canary\.|ptb\.)?discord\.com\/api\/webhooks\/\d+\/[\w-]+/.test(url);
}
/**
* Schickt die Nachricht. Mit `?wait=true`, damit Discord die angelegte
* Nachricht zurueckgibt - ohne das kommt ein 204 ohne Inhalt, und wir koennten
* hinterher nicht sagen, wo der Post steht.
*/
export async function postWebhook(
url: string,
message: WebhookMessage,
files: Attachment[] = []
): Promise<PostedMessage> {
if (!isWebhookUrl(url)) {
// Die URL kommt aus dem verschluesselten Speicher. Steht dort etwas
// anderes, ist das ein Einrichtungsfehler und kein Netzwerkproblem.
throw new Error('Das ist keine Discord-Webhook-URL.');
}
const target = `${url}${url.includes('?') ? '&' : '?'}wait=true`;
return await retry(async () => {
let body: BodyInit;
let headers: Record<string, string> = {};
if (files.length === 0) {
body = JSON.stringify(message);
headers = { 'content-type': 'application/json' };
} else {
// Mit Anhang verlangt Discord multipart: die Nachricht als
// `payload_json`, die Dateien als `files[n]`. Die Namen in
// `attachment://...` eines Embeds muessen exakt den Dateinamen treffen.
const form = new FormData();
form.set('payload_json', JSON.stringify(message));
files.forEach((f, i) => {
form.set(`files[${i}]`, new File([f.data as BlobPart], f.name, { type: f.type }));
});
body = form;
}
const res = await fetch(target, {
method: 'POST',
headers: { 'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive)', ...headers },
body,
});
if (res.status === 429) {
const info = await res.json().catch(() => ({})) as { retry_after?: number };
const waitMs = Math.max(1000, (info.retry_after ?? 1) * 1000 + 100);
if (waitMs > MAX_WAIT_MS) {
throw Object.assign(new Error(
`Discord verlangt ${Math.round(waitMs / 1000)} s Pause - Post faellt aus`
), { final: true });
}
log.warn(`Rate Limit am Webhook, warte ${Math.round(waitMs)} ms`);
await sleep(waitMs);
throw new Error('Rate Limit erreicht');
}
if (!res.ok) {
const text = (await res.text()).slice(0, 300);
const err = Object.assign(
new Error(`Webhook: HTTP ${res.status} ${text}`),
// 401/403/404 heisst: Webhook geloescht oder Kanal weg. Wiederholen
// hilft nicht, und der Text soll sagen, was zu tun ist.
res.status === 401 || res.status === 403 || res.status === 404
? { final: true } : {}
);
if (res.status === 404) {
err.message += ' - der Webhook existiert nicht mehr. Neu anlegen mit: abdulive discord:setup';
}
throw err;
}
return await res.json() as PostedMessage;
}, {
attempts: 4,
onRetry: (err, n, ms) => {
if ((err as { final?: boolean }).final) throw err;
log.warn(`Wiederhole den Webhook-Post (${n}) in ${ms} ms`, { err: String(err) });
},
});
}
/** Link auf eine gepostete Nachricht - fuers Protokoll und die Statusseite. */
export function messageUrl(guildId: string, m: PostedMessage): string {
return `https://discord.com/channels/${guildId}/${m.channel_id}/${m.id}`;
}
+18
View File
@@ -41,6 +41,24 @@ export type SecretStore = {
goLiveWebhook?: string;
recapWebhook?: string;
clipsWebhook?: string;
/** Interner Kanal: Entwuerfe und Fehlermeldungen, nur fuer Mods sichtbar. */
technikWebhook?: string;
};
/**
* Zugang zur Weboberflaeche. Erst noetig, seit der Dienst nicht mehr auf
* 127.0.0.1 haengt - vorher war "wer am Rechner sitzt, darf" die Regel.
*/
web?: {
passwordHash?: string;
passwordSalt?: string;
/** Steht in der Adresse jeder Browser-Source. Darf nur lesen. */
overlayKey?: string;
/**
* Signiert die Sitzungs-Cookies. Liegt hier und nicht im Arbeitsspeicher,
* damit ein Neustart niemanden abmeldet - der Dienst startet bei jedem
* Ausrollen neu.
*/
sessionKey?: string;
};
bluesky?: { handle: string; appPassword: string };
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */