Hub: der Dienst laeuft zentral statt auf dem Stream-PC

Zwei Dinge, die Reichweite bringen sollen, hingen bisher daran, dass jemand
vor seinem Rechner sitzt: der Discord-Rollen-Knopf und die Go-Live-
Ankuendigung. Beide brauchen nur Internet.

Der Schnitt verlaeuft entlang einer Frage: braucht die Aufgabe eine Datei
oder ein Programm, das nur auf dem Stream-PC existiert?

  Hub (LXC)                    Ingest (Abdus PC)
  ---------                    -----------------
  Chat, Zuschauerzahlen        OBS-Wiedergabepuffer
  Discord: Knopf und Go-Live   !clip, Rendern, Untertitel
  Overlay-Seiten fuer OBS

Beide schreiben in ihre eigene Datenbank. Eine gemeinsame waere eine
Netzwerkfreigabe fuer SQLite - der zuverlaessigste bekannte Weg zu einer
beschaedigten Datei.

Das kostet ADR-0005 seine Hauptbegruendung: "bindet nur auf 127.0.0.1" gilt
fuer den Hub nicht mehr. An die Stelle treten ein Passwort mit signiertem
Sitzungs-Cookie fuer alles, was ein Mensch bedient, und ein zufaelliger
Schluessel in der Adresse fuer die Browser-Sources. Der Schluessel ist kein
schoenes Verfahren - er steht in jedem OBS-Profil -, aber eine Browser-Source
kann kein Formular ausfuellen und keinen Login durchhalten.

`publicUrl` haengt der OAuth-Rueckruf daran. Twitch vergleicht die
Redirect-URI zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext
aus wie ein falsches Client-Secret.

Die Routen der Einrichtungsseite sind aus ingest/server.ts herausgeloest,
weil sie jetzt an zwei Servern haengen.

Begruendung und Konsequenzen in ADR-0008.

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:58 +02:00
1 parent e18336c57f
commit 3b606e36fb
8 files changed
+985 -10

No files matched your search

+185 -1
View File
@@ -646,6 +646,177 @@ Was jetzt noch von Hand gehoert:
verbinden, dann bekommen Subs automatisch eine Rolle.`); verbinden, dann bekommen Subs automatisch eine Rolle.`);
} }
// ---------------------------------------------------------------- overlay
/**
* Overlay-Server allein starten. Zum Ansehen und Einrichten, ohne dass der
* ganze Hub laeuft. Mit --demo laeuft erfundener Betrieb: anders koennte man
* eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet.
*/
async function overlayOnly(args: string[]): Promise<void> {
const cfg = loadConfig();
const { startOverlayServer } = await import('../overlay-server/src/server.ts');
const { createBus } = await import('../overlay-server/src/bus.ts');
const bus = createBus();
const abort = new AbortController();
process.on('SIGINT', () => { abort.abort(); process.exit(0); });
startOverlayServer({
port: cfg.overlay.port,
channel: cfg.channel.login,
publicUrl: cfg.publicUrl,
bus,
});
if (args.includes('--demo')) {
const { startDemo } = await import('../overlay-server/src/demo.ts');
startDemo(bus, abort.signal);
}
const wo = cfg.publicUrl || `http://127.0.0.1:${cfg.overlay.port}`;
console.log(`
Uebersicht mit allen Adressen fuer OBS: ${wo}/`);
console.log(`${C.dim}Beenden mit Strg+C.${C.off}`);
await new Promise(() => { /* laeuft bis Strg+C */ });
}
/** Passwort fuer die Weboberflaeche setzen. Nur hier, nie ueber das Netz. */
async function webPasswort(args: string[]): Promise<void> {
const { setzePasswort } = await import('../overlay-server/src/auth.ts');
const pw = args[0] || process.env.ABDULIVE_WEB_PASSWORT ||
await promptHidden('Neues Passwort (Eingabe wird nicht angezeigt): ');
setzePasswort(pw.trim());
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
}
// ---------------------------------------------------------------- golive
/**
* Probelauf der Go-Live-Ankuendigung.
*
* Ohne Argument geht der Post in den internen Kanal und pingt niemanden -
* damit laesst sich das Aussehen pruefen, ohne den Server aufzuwecken. Mit
* `--echt` geht er dorthin, wo er im Betrieb hinginge, mit Ping. Das ist der
* einzige Weg, vor dem ersten Stream zu sehen, ob der Ping wirklich ankommt;
* eine Erwaehnung ohne `allowed_mentions` sieht naemlich genauso aus wie eine
* mit, benachrichtigt aber niemanden.
*/
async function goLiveTest(args: string[]): Promise<void> {
const echt = args.includes('--echt');
const cfg = loadConfig();
const { createAnnouncer } = await import('../broadcaster/src/golive.ts');
const { repos } = await import('#db/repos.ts');
const { helix } = await import('#twitch/helix.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const discord = readSecrets().discord;
const ziel = echt ? discord?.goLiveWebhook : discord?.technikWebhook;
if (!ziel) {
throw new Error(echt
? 'Kein Go-Live-Webhook hinterlegt. Einmal `abdulive discord:setup` laufen lassen.'
: 'Kein Webhook fuer den internen Kanal. Einmal `abdulive discord:setup` laufen lassen ' +
'- der Technik-Webhook ist seit Fassung 0.6 im Bauplan.');
}
// Wenn Abdu gerade live ist, wird der echte Stream angekuendigt. Sonst ein
// erfundener - das prueft denselben Weg, nur mit anderem Text.
let stream = {
id: `probe-${Date.now()}`,
title: 'Probelauf der Go-Live-Ankuendigung',
category: 'Just Chatting',
login: cfg.channel.login,
startedAt: new Date().toISOString(),
} as import('../broadcaster/src/golive.ts').StreamInfo;
try {
const user = await helix.userByLogin(cfg.channel.login);
const live = user ? await helix.streamByUserId(user.id) : null;
if (live) {
stream = {
id: `probe-${live.id}-${Date.now()}`,
title: live.title,
category: live.game_name,
login: cfg.channel.login,
startedAt: live.started_at,
thumbnailUrl: live.thumbnail_url,
};
console.log(`${C.dim}Stream laeuft gerade - der Probelauf nimmt die echten Daten.${C.off}`);
} else {
console.log(`${C.dim}Kein laufender Stream - der Probelauf nimmt erfundene Daten.${C.off}`);
}
} catch (err) {
// Haeufigster Fall ist nicht "Netz weg", sondern "noch keine Twitch-App
// hinterlegt". Der Satz soll das sagen, sonst sucht man am falschen Ende.
console.log(`${C.warn}Twitch nicht abgefragt:${C.off} ${(err as Error).message}`);
console.log(`${C.dim}Der Probelauf nimmt erfundene Daten - der Weg zum Webhook ` +
`wird trotzdem vollstaendig geprueft.${C.off}`);
}
// OBS nur dazunehmen, wenn es laeuft: sonst wartet der Screenshot ins Leere.
let obs: Awaited<ReturnType<typeof withObs>> | null = null;
if (cfg.broadcaster.obsScreenshot && await tcpProbe(OBS_HOST, OBS_PORT, 1500)) {
obs = await withObs();
}
const announcer = createAnnouncer({
cfg: {
enabled: true,
mode: echt ? 'live' : 'draft',
delaySeconds: 0,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: Boolean(obs),
},
repo: repos(openDb()),
...(obs ? { obs: obs.client } : {}),
// Ein Probelauf geht durch die Abklingzeit hindurch, sonst koennte man ihn
// nach einem echten Stream sechs Stunden lang nicht wiederholen.
refresh: async (s) => s,
});
// Als eigene Art protokollieren: sonst sperrt der Probelauf ueber die
// Abklingzeit die naechste echte Ankuendigung.
const result = await announcer.announceNow(stream, { force: true, art: 'golive-test' });
obs?.stop();
if (result.status === 'gesendet') {
const wohin = result.mode === 'draft' ? 'interner Kanal' : 'Ankuendigungen';
const ping = result.pinged ? `mit Ping an "${cfg.broadcaster.pingRole}"` : 'ohne Ping';
console.log(`
${C.ok}Post ist raus${C.off} (${wohin}, ${ping}).`);
if (result.url) console.log(` ${result.url}`);
if (echt && !result.pinged) {
console.log(`
${C.warn}Kein Ping gesetzt.${C.off} Pruefen: gibt es die Rolle ` +
`"${cfg.broadcaster.pingRole}" auf dem Server, und ist ein Bot-Token hinterlegt?`);
}
if (!echt) console.log(`
${C.dim}Mit --echt geht derselbe Post in die Ankuendigungen, ` +
`mit Ping.${C.off}`);
} else {
console.log(`
${C.bad}${result.status}${C.off}: ${result.reason}`);
process.exitCode = 1;
}
}
/** OBS kurz verbinden, damit ein Screenshot moeglich ist. */
async function withObs() {
const { createObs } = await import('#obs/client.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const abort = new AbortController();
const client = createObs(`ws://${OBS_HOST}:${OBS_PORT}`,
() => readSecrets().obs?.password, {});
void client.run(abort.signal);
// Kurz auf die Identifikation warten; ohne sie gibt es keinen Screenshot,
// und das ist kein Grund zum Abbruch.
for (let i = 0; i < 30 && !client.isConnected(); i++) {
await new Promise((r) => setTimeout(r, 100));
}
return { client, stop: () => abort.abort() };
}
async function discordBot(): Promise<void> { async function discordBot(): Promise<void> {
const { createBot } = await import('#discord/bot.ts'); const { createBot } = await import('#discord/bot.ts');
const bot = createBot(); const bot = createBot();
@@ -673,7 +844,10 @@ function help(): void {
console.log(` console.log(`
abdulive - Werkzeugkasten fuer Abdus Stream abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite) hub Zentraler Dienst: Chat, Discord, Go-Live, Overlay
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
check-env Umgebung pruefen check-env Umgebung pruefen
db:migrate Migrationen anwenden db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
@@ -684,6 +858,8 @@ abdulive - Werkzeugkasten fuer Abdus Stream
discord:setup [--server ID] [--vorschau] discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten Discord-Server nach docs/projects/discord-community.md einrichten
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit) discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
Kanal ohne Ping, mit --echt in die Ankuendigungen
help Diese Uebersicht help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup Einrichtung nach dem Start: http://localhost:4792/setup
@@ -715,6 +891,14 @@ try {
} }
case 'render': await renderOne(args[0], args[1]); break; case 'render': await renderOne(args[0], args[1]); break;
case 'discord:setup': await discordSetup(args); break; case 'discord:setup': await discordSetup(args); break;
case 'golive:test': await goLiveTest(args); break;
case 'hub': {
const { runHub } = await import('../hub/src/index.ts');
await runHub();
break;
}
case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break;
case 'discord:bot': await discordBot(); break; case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
+481
View File
@@ -0,0 +1,481 @@
// Der zentrale Dienst. Laeuft in einem LXC, nicht auf Abdus Rechner.
//
// Was hier drin ist, braucht nur Internet: Twitch-Chat, Twitch-API, Discord
// und die Overlay-Seiten. Das laeuft damit auch dann, wenn der Stream-PC aus
// ist - der Rollen-Knopf auf Discord antwortet nachts um drei, und die
// Go-Live-Ankuendigung haengt nicht daran, dass jemand den Daemon gestartet
// hat.
//
// Was NICHT hier drin ist und auch nicht hierher kann: OBS, der
// Wiedergabepuffer, ffmpeg und whisper. Die haengen an den Dateien auf dem
// Stream-PC und bleiben in `abdulive ingest`.
//
// Hub (LXC) Stream-PC (abdulive ingest)
// --------- --------------------------
// Chat lesen und senden OBS-Wiedergabepuffer
// Zuschauerzahlen, Sitzungen !clip, Rendern, Untertitel
// Discord: Knopf und Go-Live
// Overlay-Seiten fuer OBS
//
// Beide schreiben in ihre eigene Datenbank. Das ist bewusst: eine gemeinsame
// waere eine Netzwerkfreigabe fuer SQLite, und das ist der zuverlaessigste
// Weg zu einer beschaedigten Datei.
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 { createBot } from '#discord/bot.ts';
import { helix } from '#twitch/helix.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { createEventSub } from '#twitch/eventsub.ts';
import { createIrc, type UserNotice } from '#twitch/irc.ts';
import { streamWechsel } from '#twitch/stream-state.ts';
import {
ausEventSub, createDrossel, createWarteschlange, entscheiden,
} from './rewards.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createSpikeDetector } from '../../ingest/src/spike.ts';
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
import { startOverlayServer } from '../../overlay-server/src/server.ts';
import { ulid } from '#shared/events.ts';
const log = logger('hub');
export async function runHub(): Promise<void> {
const cfg = loadConfig();
const db = openDb();
const repo = repos(db);
const spikes = createSpikeDetector(cfg.ingest.spike);
const bus = createBus();
const state = {
startedAt: new Date().toISOString(),
streamId: null as string | null,
broadcasterId: null as string | null,
lastSeenLive: 0,
viewers: 0,
title: '',
category: '',
startedLiveAt: null as string | null,
chatConnected: false,
chatAnonymous: true,
messages: 0,
spikeCandidates: 0,
eventsub: false,
lastError: null as string | null,
};
const abort = new AbortController();
const stop = (signal: string): void => {
log.info(`${signal} empfangen, fahre herunter`);
abort.abort();
setTimeout(() => { closeDb(); process.exit(0); }, 500);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
// ---- Go-Live-Ankuendigung. Ohne OBS gibt es kein eigenes Vorschaubild;
// dann nimmt der Post Twitchs Vorschau, und die ist meist gut genug.
const announcer = createAnnouncer({
cfg: {
enabled: cfg.broadcaster.enabled,
mode: cfg.broadcaster.mode,
delaySeconds: cfg.broadcaster.delaySecondsAfterOnline,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: false,
},
repo,
});
// ---- Kanalpunkte
const warteschlange = createWarteschlange();
const drossel = createDrossel();
/** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */
const erstatten = async (rewardId: string, redemptionId: string): Promise<void> => {
if (!state.broadcasterId) return;
if (!helix.hasScope('channel:manage:redemptions')) {
log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' +
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
return;
}
try {
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
} catch (err) {
log.warn(`Punkte nicht erstattet: ${(err as Error).message}`);
}
};
const vorlesen = (id: string, name: string, text: string): void => {
bus.publish({ kind: 'tts', id, name, text });
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
};
const behandleEinloesung = (roh: Record<string, unknown>): void => {
const e = ausEventSub(roh);
if (!e) { log.warn('Einloesung ohne verwertbare Felder - ignoriert'); return; }
repo.event('points.redeemed', {
reward: e.rewardTitle, kosten: e.kosten, login: e.userName, text: e.text,
}, state.streamId, e.userId, e.ts);
const ent = entscheiden(e, cfg.rewards, drossel.zaehlen(e.userId));
drossel.merken(e.userId);
switch (ent.art) {
case 'unbekannt':
log.debug(`Belohnung "${e.rewardTitle}" hat keine hinterlegte Aktion`);
break;
case 'abgelehnt':
log.info(`Einloesung von ${e.userName} abgelehnt: ${ent.grund}`);
void erstatten(e.rewardId, e.id);
break;
case 'ausfuehren':
// Immer dieselbe Einblendung: sie sagt, wer was eingeloest hat. Die
// Aktion entscheidet nur, was zusaetzlich passiert. Frueher stand
// hier die Abo-Einblendung - eine Belohnung als "Neues Abo" ins Bild
// zu schreiben ist eine Falschmeldung, die in der Aufzeichnung bleibt.
bus.publish({
kind: 'alert', type: 'reward', id: ulid(), name: e.userName,
reward: e.rewardTitle,
...(e.kosten ? { amount: e.kosten } : {}),
...(e.text ? { message: e.text } : {}),
});
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
break;
case 'vorlesen':
vorlesen(ulid(), e.userName, ent.text);
break;
case 'warteschlange':
warteschlange.einreihen({
id: ulid(), name: e.userName, text: ent.text,
rewardId: e.rewardId, redemptionId: e.id, seit: new Date().toISOString(),
});
log.info(`${e.userName} wartet auf Freigabe zum Vorlesen`);
break;
}
};
// ---- EventSub. Nur sinnvoll mit verbundenem Konto: der WebSocket-Weg
// verlangt fuer das Abonnement dasselbe Nutzer-Token.
const eventsub = createEventSub({
onSession: async (sessionId) => {
const id = state.broadcasterId || (await helix.userByLogin(cfg.channel.login))?.id;
if (!id) throw new Error('Broadcaster-ID unbekannt');
state.broadcasterId = id;
await helix.subscribe({
type: 'channel.channel_points_custom_reward_redemption.add',
version: '1',
condition: { broadcaster_user_id: id },
}, sessionId);
log.info('Abonniert: Kanalpunkt-Einloesungen');
},
onNotification: (typ, event) => {
if (typ === 'channel.channel_points_custom_reward_redemption.add') {
behandleEinloesung(event);
}
},
onRevocation: (typ) => log.error(
`Twitch hat "${typ}" entzogen - meist zurueckgezogene Freigabe. Neu verbinden unter /setup.`),
onConnected: () => { state.eventsub = true; },
onClosed: () => { state.eventsub = false; },
});
// ---- Discord (Rollen-Knoepfe)
const bot = createBot();
if (bot) void bot.run(abort.signal);
// ---- Chat
const irc = createIrc(cfg.channel.login, {
onConnected: (anonymous) => {
state.chatConnected = true;
state.chatAnonymous = anonymous;
},
onMessage: (msg) => {
try {
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);
bus.publish({
kind: 'chat',
id: msg.id,
userId: msg.userId,
login: msg.login,
name: msg.displayName,
text: msg.text,
color: msg.color,
emotes: msg.emotes,
gifs: msg.gifs,
first: msg.isFirst,
mod: msg.isMod,
sub: msg.isSub,
broadcaster: msg.isBroadcaster,
bits: msg.bits,
});
if (msg.isFirst) {
repo.event('chat.first_message', { login: msg.login }, state.streamId, msg.userId, msg.ts);
bus.publish({ kind: 'alert', type: 'first', id: ulid(), name: msg.displayName });
log.info(`Erstchatter: ${msg.displayName}`);
}
if (msg.bits > 0) {
repo.event('cheer', { bits: msg.bits, login: msg.login }, state.streamId, msg.userId, msg.ts);
bus.publish({
kind: 'alert', type: 'cheer', id: ulid(),
name: msg.displayName, amount: msg.bits, message: msg.text,
});
}
const spike = spikes.push(msg);
if (spike) {
state.spikeCandidates++;
repo.event('chat.spike', spike, state.streamId, null, msg.ts);
bus.publish({ kind: 'hype', score: spike.score, rate: spike.rate });
log.info(`Spitze im Chat: Punktzahl ${spike.score} (${spike.rate} Nachrichten ` +
`von ${spike.chatters} Leuten, Basislinie ${spike.baseline})`);
}
} catch (err) {
state.lastError = (err as Error).message;
log.error(`Nachricht nicht verarbeitet: ${(err as Error).message}`);
}
},
onUserNotice: (notice) => {
const alert = toAlert(notice);
if (!alert) return;
repo.event(eventTypeFor(notice), notice, state.streamId);
bus.publish(alert);
log.info(notice.systemMessage || `${notice.kind} von ${notice.displayName}`);
},
onClear: (what) => bus.publish({ kind: 'clear', ...what }),
});
void irc.run(abort.signal);
// ---- Die eine oeffentliche Tuer
const server = startOverlayServer({
port: cfg.overlay.port,
channel: cfg.channel.login,
publicUrl: cfg.publicUrl,
bus,
setup: {
channel: cfg.channel.login,
port: cfg.overlay.port,
health: () => ({
...state,
rolle: 'hub',
db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false,
goLive: announcer.lastResult() ?? 'noch nichts',
overlayClients: bus.clients(),
ttsWartend: warteschlange.liste().length,
spikeArmed: spikes.isArmed(),
note: state.chatAnonymous
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
: undefined,
}),
},
tts: {
wartend: () => warteschlange.liste()
.map((w) => ({ id: w.id, name: w.name, text: w.text, seit: w.seit })),
freigeben: (id) => {
const w = warteschlange.entnehmen(id);
if (!w) return false;
vorlesen(w.id, w.name, w.text);
return true;
},
ablehnen: (id) => {
const w = warteschlange.entnehmen(id);
if (!w) return false;
log.info(`Vorlesen abgelehnt (${w.name})`);
void erstatten(w.rewardId, w.redemptionId);
return true;
},
alleAblehnen: () => {
const alle = warteschlange.leeren();
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
return alle.length;
},
still: () => {
bus.publish({ kind: 'still' });
const alle = warteschlange.leeren();
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
},
},
});
void server;
// ---- Zuschauerzahlen und Stream-Sitzungen
void pollStream(cfg, repo, state, bus, abort.signal, (stream) => announcer.announce(stream));
// ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token
// wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als
// Protokollrauschen erzeugen.
if (cfg.rewards.enabled && readSecrets().tokens.broadcaster) {
void eventsub.run(abort.signal);
} else if (cfg.rewards.enabled) {
log.warn('Kanalpunkt-Aktionen sind an, aber kein Konto verbunden - ' +
'EventSub bleibt aus. Verbinden unter /setup.');
}
log.info(`Hub laeuft fuer #${cfg.channel.login}`);
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
}
// ---------------------------------------------------------------- Ereignisse
/** USERNOTICE in eine Einblendung uebersetzen. Unbekanntes faellt weg. */
export function toAlert(n: UserNotice): AlertEvent | null {
const gemeinsam = {
kind: 'alert' as const,
id: ulid(),
name: n.displayName,
...(n.months !== undefined ? { months: n.months } : {}),
...(n.tier ? { tier: n.tier } : {}),
...(n.text ? { message: n.text } : {}),
};
switch (n.kind) {
case 'sub': case 'resub':
return { ...gemeinsam, type: n.kind };
case 'subgift':
return { ...gemeinsam, type: 'subgift', ...(n.recipient ? { recipient: n.recipient } : {}) };
case 'massgift':
return { ...gemeinsam, type: 'massgift', ...(n.count ? { amount: n.count } : {}) };
case 'raid':
return { ...gemeinsam, type: 'raid', ...(n.count ? { amount: n.count } : {}) };
default:
// Ankuendigungen und alles Unbekannte bekommen keine Einblendung. Eine
// Einblendung fuer etwas, das man nicht kennt, sieht kaputt aus.
return null;
}
}
function eventTypeFor(n: UserNotice): 'subscribe' | 'subgift' | 'raid.in' | 'chat.message' {
if (n.kind === 'subgift' || n.kind === 'massgift') return 'subgift';
if (n.kind === 'raid') return 'raid.in';
if (n.kind === 'sub' || n.kind === 'resub') return 'subscribe';
return 'chat.message';
}
// ---------------------------------------------------------------- Abfrage
type State = {
streamId: string | null; broadcasterId: string | null; lastSeenLive: number;
viewers: number; title: string; category: string; startedLiveAt: string | null;
lastError: string | null;
};
async function pollStream(
cfg: ReturnType<typeof loadConfig>,
repo: ReturnType<typeof repos>,
state: State,
bus: Bus,
signal: AbortSignal,
onOnline: (stream: StreamInfo) => void
): Promise<void> {
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
let warnedMissingAuth = false;
const sendState = (live: boolean): void => {
bus.publish({
kind: 'stream', live,
title: state.title, category: state.category,
viewers: state.viewers, startedAt: state.startedLiveAt,
});
};
// Ein laufender Stream wird beim Start uebernommen, damit ein Neustart
// mitten im Stream keine zweite Sitzung anlegt - und keinen zweiten Ping.
const open = repo.latestOpenStream();
if (open) {
state.streamId = open.id;
state.startedLiveAt = open.started_at;
state.lastSeenLive = Date.now();
log.info(`Laufende Sitzung uebernommen: ${open.id} seit ${open.started_at}`);
}
while (!signal.aborted) {
try {
// Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
// mit einem App-Token. Auf das verbundene Konto wartet hier niemand.
if (!helix.canReadStream()) {
if (!warnedMissingAuth) {
log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen, Sitzungen und die ' +
'Go-Live-Ankuendigung bleiben aus. Einrichtung unter /setup.');
warnedMissingAuth = true;
}
} else {
warnedMissingAuth = false;
const userId = state.broadcasterId || cfg.channel.userId ||
(await helix.userByLogin(cfg.channel.login))?.id || '';
if (userId) state.broadcasterId = userId;
const live = userId ? await helix.streamByUserId(userId) : null;
const now = Date.now();
const wechsel = streamWechsel({
live: live ? { id: live.id, startedAt: live.started_at } : null,
laufendeId: state.streamId,
zuletztLive: state.lastSeenLive,
jetzt: now,
luckeMs: gapMs,
});
if (wechsel.art === 'gestartet') {
state.streamId = wechsel.id;
repo.openStream(wechsel.id, wechsel.startedAt);
repo.event('stream.online',
{ title: live!.title, category: live!.game_name }, wechsel.id);
log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` +
`gestartet ${wechsel.startedAt}`);
onOnline({
id: wechsel.id,
title: live!.title,
category: live!.game_name,
login: cfg.channel.login,
startedAt: wechsel.startedAt,
thumbnailUrl: live!.thumbnail_url,
});
} else if (wechsel.art === 'fortsetzung') {
log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` +
`von ${state.streamId} - keine zweite Ankuendigung`);
} else if (wechsel.art === 'beendet') {
repo.closeStream(wechsel.id, new Date().toISOString());
repo.event('stream.offline', {}, wechsel.id);
log.info(`Stream offline: ${wechsel.id}`);
state.streamId = null;
state.viewers = 0;
state.startedLiveAt = null;
sendState(false);
}
if (live) {
state.title = live.title;
state.category = live.game_name;
state.startedLiveAt = live.started_at;
state.lastSeenLive = now;
state.viewers = live.viewer_count;
if (state.streamId) {
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
}
sendState(true);
}
}
} 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; }
}
}
+31 -5
View File
@@ -59,18 +59,45 @@ async function handle(
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
): Promise<void> { ): Promise<void> {
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`); const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
// Auf dem Stream-PC ist die Einrichtung das Einzige, was dieser Server
// kann - also faengt die Wurzel dorthin. Im Hub steht auf / die
// Overlay-Uebersicht, deshalb liegt die Weiche hier und nicht in
// `setupRoutes`.
if (url.pathname === '/') {
res.writeHead(303, { location: '/setup' });
res.end();
return;
}
if (await setupRoutes(req, res, deps, url)) return;
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Nicht gefunden');
}
/**
* Die Routen der Einrichtung, ohne 404 am Ende.
*
* Ausgelagert, weil sie seit dem zentralen Betrieb an zwei Servern haengen:
* hier auf dem Stream-PC und im Overlay-Server des Hubs. Der Rueckgabewert
* sagt, ob die Anfrage beantwortet wurde - wer sie einbindet, macht sonst
* selbst weiter.
*/
export async function setupRoutes(
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps, url: URL
): Promise<boolean> {
const html = (body: string, code = 200) => { const html = (body: string, code = 200) => {
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
res.end(body); res.end(body);
return true;
}; };
const back = (kind: 'ok' | 'err', text: string) => { const back = (kind: 'ok' | 'err', text: string) => {
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` }); res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
res.end(); res.end();
return true;
}; };
// ---- Einrichtungsseite // ---- Einrichtungsseite
if (url.pathname === '/' || url.pathname === '/setup') { if (url.pathname === '/setup') {
const ok = url.searchParams.get('ok'); const ok = url.searchParams.get('ok');
const err = url.searchParams.get('err'); const err = url.searchParams.get('err');
return html(setupPage(secretStatus(), { return html(setupPage(secretStatus(), {
@@ -119,7 +146,7 @@ async function handle(
try { try {
res.writeHead(303, { location: authorizeUrl(role, deps.port) }); res.writeHead(303, { location: authorizeUrl(role, deps.port) });
res.end(); res.end();
return; return true;
} catch (err) { } catch (err) {
return back('err', (err as Error).message); return back('err', (err as Error).message);
} }
@@ -144,9 +171,8 @@ async function handle(
if (url.pathname === '/health') { if (url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(deps.health(), null, 2)); res.end(JSON.stringify(deps.health(), null, 2));
return; return true;
} }
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); return false;
res.end('Nicht gefunden');
} }
@@ -0,0 +1,100 @@
# ADR-0008: Geteilter Betrieb - Hub im LXC, Ingest am Stream-PC
Datum: 2026-09-17
Status: akzeptiert
Berührt: [ADR-0005](ADR-0005-zugangsdaten.md), [ADR-0006](ADR-0006-ein-dach.md),
[ADR-0007](ADR-0007-native-windows.md)
## Kontext
Bis hierher lief alles in einem Prozess auf Abdus Rechner (ADR-0007), und
alles band an `127.0.0.1` (ADR-0005). Das war richtig, solange jede Aufgabe
etwas mit OBS zu tun hatte.
Zwei neue Aufgaben haben damit aber nichts zu tun:
- **Der Rollen-Knopf auf Discord.** Er hängt an einer Gateway-Verbindung im
Ingest-Daemon. Ist Abdus Rechner aus, klickt jemand auf den Knopf und
bekommt "Interaktion fehlgeschlagen". Ein Knopf, der nur läuft, während
der Besitzer am Rechner sitzt, ist kein Knopf.
- **Die Go-Live-Ankündigung.** Sie braucht Twitch und Discord, sonst nichts.
Dazu kam das Overlay. Eine Browser-Source in OBS holt ihre Seite über HTTP;
ob die von `127.0.0.1` kommt oder aus dem Netz, ist ihr gleich.
## Entscheidung
Das Toolkit läuft ab jetzt an zwei Stellen, mit einem klaren Schnitt:
| | Hub (LXC 141, `abdu.dimension47.de`) | Ingest (Abdus PC) |
|---|---|---|
| Befehl | `abdulive hub` | `abdulive ingest` |
| Twitch-Chat lesen und senden | ja | ja |
| Zuschauerzahlen, Stream-Sitzungen | ja | ja |
| Discord: Rollen-Knopf, Go-Live | **ja** | nein |
| Overlay-Seiten ausliefern | **ja** | nein |
| OBS, Wiedergabepuffer, `!clip` | nein | **ja** |
| Rendern, Untertitel | nein | **ja** |
Der Schnitt verläuft entlang einer einzigen Frage: **braucht die Aufgabe eine
Datei oder ein Programm, das nur auf dem Stream-PC existiert?** Alles, was mit
Nein zu beantworten ist, gehört in den Hub.
Der Hub ist damit der einzige Teil, der von außen erreichbar ist. Er bindet
auf `0.0.0.0`, hinter dem vorhandenen Nginx Proxy Manager, und bringt Overlay,
Einrichtungsseite und OAuth-Rückruf unter einer Adresse zusammen.
## Begründung
**Warum nicht alles zentral.** Der Wiedergabepuffer von OBS liegt als Datei
auf Abdus Platte, ffmpeg und whisper arbeiten darauf, und ein Clip ist
mehrere hundert Megabyte. Das über das Netz zu schieben wäre teurer als es
bringt, und obs-websocket gehört ohnehin nicht ins Internet.
**Warum nicht alles lokal bleiben.** Genau die zwei Dinge, die Reichweite
bringen sollen - der Ping und der Knopf - sind die, die durchlaufen müssen.
Sie an den Stream-PC zu hängen heißt, sie an die Verfügbarkeit einer Person zu
hängen.
**Warum zwei Datenbanken und nicht eine.** Beide Seiten schreiben in ihre
eigene SQLite-Datei. Eine gemeinsame wäre eine Netzwerkfreigabe für SQLite,
und das ist der zuverlässigste bekannte Weg zu einer beschädigten Datei. Was
zusammengeführt werden muss, wird später über Ereignisse zusammengeführt, nicht
über geteilten Dateizugriff.
**Warum der Bruch mit ADR-0005 vertretbar ist.** Dort war die Begründung: was
nur auf `127.0.0.1` hört, ist gegen alles geschützt, was nicht vor dem Rechner
sitzt. Diese Eigenschaft geht verloren, und der Schutz wandert auf zwei Dinge:
- Passwort mit Sitzungs-Cookie für Übersicht, Einrichtung und Status.
- Ein zufälliger Schlüssel in der Adresse jeder Browser-Source. Er darf nur
lesen, nie schreiben, und lässt sich auf der Übersichtsseite neu würfeln.
Der Schlüssel in der URL ist kein schönes Verfahren. Er steht in jedem
OBS-Profil und in jedem Screenshot der Einstellungen. Er ist trotzdem der
richtige: eine Browser-Source kann kein Formular ausfüllen, keinen Login
durchhalten und keinen zweiten Faktor bedienen. Jedes Verfahren, das mehr
verlangt, wird spätestens beim ersten Ausfall mitten im Stream abgeschaltet.
**Warum die Zugangsdaten trotzdem verschlüsselt neben der Datenbank liegen.**
Daran ändert sich nichts. Der Hub hat seinen eigenen Speicher, mit eigenem
Schlüssel, und die Twitch-Apps brauchen jetzt eine zweite Redirect-URI
(`https://abdu.dimension47.de/callback`) neben der bisherigen.
## Konsequenzen
- Die Twitch-App braucht eine zweite Redirect-URI. Twitch vergleicht
zeichengenau; ein fehlender Schrägstrich sieht im Fehlertext aus wie ein
falsches Client-Secret.
- Der Discord-Bot darf nur an **einer** Stelle laufen. Laufen Hub und Ingest
beide mit hinterlegtem Bot-Token, antworten zwei Prozesse auf denselben
Klick. Auf dem Stream-PC bleibt das Discord-Token deshalb leer.
- `abdulive ingest` verliert nichts, macht aber weniger. Der Go-Live-Post ist
dort weiterhin eingebaut - wer nur lokal fährt, hat damit alles an einer
Stelle, und dort gibt es zusätzlich das OBS-Vorschaubild.
- Der Hub kennt OBS nicht und kann deshalb keinen Screenshot für die
Ankündigung machen. Er nimmt Twitchs Vorschaubild. Das ist kurz nach dem
Start manchmal noch schwarz - der Preis dafür, dass der Post auch dann
rausgeht, wenn der Daemon am Stream-PC nicht läuft.
- ADR-0006 ("ein Dach") gilt weiter, aber jetzt je Seite: der Hub hat ein
Dach, der Stream-PC hat eins.
+1
View File
@@ -9,6 +9,7 @@
| [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert | | [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert |
| [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert | | [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert |
| [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert | | [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert |
| [0008](ADR-0008-geteilter-betrieb.md) | Geteilter Betrieb: Hub im LXC, Ingest am Stream-PC | akzeptiert |
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext / Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
Entscheidung / Begruendung / Konsequenzen. Entscheidung / Begruendung / Konsequenzen.
+71 -1
View File
@@ -45,6 +45,37 @@ export type Config = {
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number }; ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
whisper: { model: string; language: string; threads: number }; whisper: { model: string; language: string; threads: number };
}; };
broadcaster: {
enabled: boolean;
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
mode: 'live' | 'draft';
delaySecondsAfterOnline: number;
cooldownHours: number;
/** Rollenname aus dem Discord-Bauplan. Leer heisst: Post ohne Ping. */
pingRole: string;
obsScreenshot: boolean;
};
/** Kanalpunkt-Belohnungen und was sie ausloesen. Siehe apps/hub/src/rewards.ts. */
rewards: {
enabled: boolean;
belohnungen: Array<{
titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
freigabe?: boolean; maxZeichen?: number;
}>;
tts: {
freigabe: boolean; maxZeichen: number;
blockliste: string[]; proPersonProStunde: number;
};
};
overlay: { port: number; enabled: boolean };
/**
* Oeffentliche Adresse des Dienstes, ohne Schraegstrich am Ende. Nur noetig,
* wenn er nicht auf demselben Rechner wie der Browser laeuft - dann haengt
* daran der OAuth-Rueckruf von Twitch, der exakt zur eingetragenen
* Redirect-URI passen muss.
*/
publicUrl: string;
ingestServer: { port: number }; ingestServer: { port: number };
dashboard: { port: number }; dashboard: { port: number };
raw: YamlMap; raw: YamlMap;
@@ -81,6 +112,40 @@ const DEFAULTS = {
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 }, ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 }, whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
}, },
broadcaster: {
enabled: true,
// Vorgabe ist `live`: der Sinn der Sache ist der Ping. Wer erst zusehen
// will, stellt `draft` ein - dann landet dieselbe Nachricht ohne Ping im
// internen Kanal. Zum Ausprobieren gibt es `abdulive golive:test`.
mode: 'live' as const,
// Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
// postet, kuendigt regelmaessig den Titel des letzten Streams an.
delaySecondsAfterOnline: 60,
cooldownHours: 6,
pingRole: 'Live-Ping',
obsScreenshot: true,
},
rewards: {
// Aus, bis jemand bewusst Belohnungen eintraegt. Eine Aktion, die
// niemand erwartet, ist im Stream schlimmer als gar keine.
enabled: false,
belohnungen: [] as Array<{
titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
freigabe?: boolean; maxZeichen?: number;
}>,
tts: {
// Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim
// Zugucken von allein an und sind damit kaum eine Huerde; ohne diesen
// Schalter hat man eine Vorlesefunktion fuer Fremde im eigenen Stream.
freigabe: true,
maxZeichen: 200,
blockliste: [] as string[],
proPersonProStunde: 3,
},
},
overlay: { port: 4791, enabled: true },
publicUrl: '',
ingestServer: { port: 4790 }, ingestServer: { port: 4790 },
dashboard: { port: 4792 }, dashboard: { port: 4792 },
}; };
@@ -105,7 +170,12 @@ export function loadConfig(): Config {
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
raw = parseYaml(fs.readFileSync(file, 'utf8')); raw = parseYaml(fs.readFileSync(file, 'utf8'));
} }
cached = { ...merge(DEFAULTS, raw), raw } as Config; const merged = merge(DEFAULTS, raw) as Omit<Config, 'raw'>;
// Die Umgebung gewinnt: im Container steht die oeffentliche Adresse in der
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
cached = { ...merged, raw } as Config;
return cached; return cached;
} }
+64 -3
View File
@@ -1,7 +1,8 @@
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben // OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
// Server wie die Einrichtungsseite (Port 4792) - siehe ADR-0005. // Server wie die Einrichtungsseite - siehe ADR-0005.
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { loadConfig } from '#core/config.ts';
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts'; import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
@@ -19,6 +20,10 @@ export const SCOPES: Record<Role, string[]> = {
'moderator:read:chatters', 'moderator:read:chatters',
'channel:manage:raids', 'channel:manage:raids',
'channel:read:redemptions', 'channel:read:redemptions',
// Zum Zurueckgeben der Punkte, wenn eine Aktion scheitert oder abgelehnt
// wird. Wer Punkte nimmt und nichts liefert, verliert schneller Vertrauen
// als durch jeden Fehler im Bild.
'channel:manage:redemptions',
'bits:read', 'bits:read',
], ],
bot: ['chat:read', 'chat:edit'], bot: ['chat:read', 'chat:edit'],
@@ -27,9 +32,19 @@ export const SCOPES: Record<Role, string[]> = {
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */ /** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
const pending = new Map<string, { role: Role; created: number }>(); const pending = new Map<string, { role: Role; created: number }>();
/**
* Muss exakt so in der Twitch-App hinterlegt sein - Twitch vergleicht
* zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext aus wie
* ein falsches Client-Secret.
*
* Laeuft der Dienst zentral, steht in `publicUrl` die oeffentliche Adresse.
* Dann ist der Rueckruf `https://.../callback`, und Twitch verlangt dort
* ohnehin HTTPS: `http://localhost` ist die einzige Ausnahme, die Twitch
* ohne Zertifikat akzeptiert.
*/
export function redirectUri(port: number): string { export function redirectUri(port: number): string {
// Muss exakt so in der Twitch-App hinterlegt sein. const base = loadConfig().publicUrl;
return `http://localhost:${port}/callback`; return base ? `${base}/callback` : `http://localhost:${port}/callback`;
} }
export function authorizeUrl(role: Role, port: number): string { export function authorizeUrl(role: Role, port: number): string {
@@ -86,6 +101,52 @@ export async function handleCallback(code: string, state: string, port: number):
return role; return role;
} }
/**
* App-Token (client_credentials). Gehoert keinem Nutzer und braucht keine
* Freigabe - es beweist nur, dass die Anfrage von dieser App kommt.
*
* Damit laufen die oeffentlichen Endpunkte: `GET /users` und `GET /streams`.
* Genau die beiden traegt die Stream-Erkennung, und deshalb muss sie nicht
* darauf warten, dass der Broadcaster sein Konto verbindet. Alles, was ueber
* einen Kanal etwas *tut* oder Privates liest - Clips anlegen, Chatter
* auflisten, Abos sehen - verlangt weiterhin ein Nutzer-Token.
*
* Gemerkt wird nur im Arbeitsspeicher: das Token gilt rund zwei Monate, und
* nach einem Neustart ist es eine einzige Anfrage.
*/
const appTokens = new Map<Role, { token: string; bis: number }>();
export async function appToken(role: Role): Promise<string> {
const gemerkt = appTokens.get(role);
// Fuenf Minuten Puffer, damit kein Aufruf mitten im Ablauf landet
if (gemerkt && gemerkt.bis - Date.now() > 300_000) return gemerkt.token;
const app = readSecrets().apps[role];
if (!app?.clientId || !app.clientSecret) {
throw new Error(`Fuer "${role}" fehlen Client-ID und Secret - Einrichtung unter /setup.`);
}
const res = await fetch(TOKEN, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: app.clientId,
client_secret: app.clientSecret,
grant_type: 'client_credentials',
}),
});
if (!res.ok) {
throw new Error(`App-Token fuer "${role}" nicht erhalten (HTTP ${res.status}). ` +
'Stimmen Client-ID und Secret?');
}
const body = await res.json() as { access_token: string; expires_in: number };
appTokens.set(role, { token: body.access_token, bis: Date.now() + body.expires_in * 1000 });
log.info(`App-Token fuer ${role} geholt (gilt ${Math.round(body.expires_in / 86400)} Tage)`);
return body.access_token;
}
/** Vergisst gemerkte App-Tokens. Fuer Tests und nach einem App-Wechsel. */
export function forgetAppTokens(): void { appTokens.clear(); }
export async function validate(accessToken: string) { export async function validate(accessToken: string) {
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } }); const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`); if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Den Hub auf den LXC ausrollen.
#
# Warum ein Skript und kein `git pull` im Container: der Container hat keinen
# Zugang zum Repo, und er soll auch keinen bekommen. Ausgerollt wird, was
# hier im Arbeitsverzeichnis liegt - damit ist immer nachvollziehbar, welcher
# Stand laeuft, auch wenn gerade etwas nicht gepusht ist.
#
# Aufruf: scripts/deploy-hub.sh
# Umgebung (mit Vorgaben):
# PVE_HOST=192.168.2.20 Proxmox-Host
# CT=141 Container-ID
set -euo pipefail
PVE_HOST="${PVE_HOST:-192.168.2.20}"
CT="${CT:-141}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STAND="$(date -u +%Y%m%d-%H%M%S)"
echo "Packe den Arbeitsstand ..."
cd "$REPO"
# node_modules bleibt draussen: das Projekt hat keine Laufzeit-Abhaengigkeiten,
# TypeScript wird nur fuer die Typpruefung gebraucht und nicht im Betrieb.
tar czf "/tmp/abdulive-${STAND}.tgz" \
--exclude='./node_modules' \
--exclude='./.git' \
--exclude='./dist' \
--exclude='./docs/handbuch/build' \
apps packages config scripts package.json tsconfig.json
echo "Schiebe nach ${PVE_HOST} -> CT ${CT} ..."
scp -q "/tmp/abdulive-${STAND}.tgz" "root@${PVE_HOST}:/tmp/abdulive.tgz"
ssh "root@${PVE_HOST}" "pct push ${CT} /tmp/abdulive.tgz /tmp/abdulive.tgz && rm -f /tmp/abdulive.tgz"
rm -f "/tmp/abdulive-${STAND}.tgz"
echo "Entpacke und starte neu ..."
ssh "root@${PVE_HOST}" "pct exec ${CT} -- bash -lc '
set -e
# In einen frischen Ordner entpacken und erst danach umhaengen: bricht das
# Entpacken ab, laeuft der alte Stand unveraendert weiter.
rm -rf /opt/abdulive.neu
mkdir -p /opt/abdulive.neu
tar xzf /tmp/abdulive.tgz -C /opt/abdulive.neu --no-same-owner
rm -f /tmp/abdulive.tgz
rm -rf /opt/abdulive.alt
[ -d /opt/abdulive ] && mv /opt/abdulive /opt/abdulive.alt || true
mv /opt/abdulive.neu /opt/abdulive
systemctl restart abdulive-hub 2>/dev/null || true
'"
echo "Fertig. Stand ${STAND}."
ssh "root@${PVE_HOST}" "pct exec ${CT} -- systemctl is-active abdulive-hub" || true