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:
1 parent
e87879de9e
commit
c4dc6ee04f
12 files changed
+1098
-18
No files matched your search
@@ -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);
|
||||
@@ -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[];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user