DIE README BESCHRIEB NOCH DEN VORGAENGER: "Fivemanage Lite mit MySQL und MinIO", Ausprobieren ueber docker-compose.local.yml, vier Geheimnisse, davon drei fuer Dienste, die es nicht mehr gibt. Jetzt beschreibt sie, was da ist: ein Container, SQLite, Dateien auf einer Platte -- Starten, was der Dienst kann, die eine Regel und wie sie im Code durchgesetzt wird, Betrieb (Sicherung, Wiki, Zurueckspielen), was bewusst fehlt, und die zwei Dinge, die Zeit gekostet haben. Und die .env.example nannte DB_PASSWORD, MINIO_ROOT_PASSWORD und API_TOKEN_HMAC_SECRET -- drei Werte fuer Dienste, die seit Wochen weg sind, und KEINEN der acht, die das Compose wirklich liest. Nachgezaehlt: jetzt sind alle acht genannt, zwei als Pflicht, sechs als Optional mit dem Grund dahinter. EMBEDS IM STIL DES BOTS. Auf den Hinweis hin in d4rkbot/src/embeds.js nachgesehen: dort gibt es eine zentrale Embed-Fabrik mit Markenfarbe (#f5c518), Fusszeile "D4RKST3R // <TAG>" und Zeitstempel. Zwei Dienste desselben Hauses sollen in einem Kanal nicht wie zwei Fremde aussehen -- also uebernommen statt neu erfunden, samt Feldern statt Fliesstext und Discords Zeitmarken (<t:...:R>), die "vor 3 Stunden" in der Zeitzone des Lesers anzeigen. EINE ABWEICHUNG, mit Absicht: der Bot faerbt alles in der Markenfarbe, hier faerbt die SCHWERE. Eine Warnung, die aussieht wie jede andere Nachricht, ist eine Warnung, die man ueberliest -- und diese Meldungen gibt es nur, weil jemand sie sehen soll. Die Testnachricht schickt jetzt echte Zahlen mit. So sieht man nicht nur, DASS etwas ankommt, sondern auch, ob es lesbar ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
// Meldungen nach Discord.
|
|
//
|
|
// WOZU. Die Sicherung laeuft nachts um halb fuenf, und wenn sie aufhoert zu
|
|
// laufen, merkt es niemand -- das Panel zeigt es nur dem, der hinschaut. Genau
|
|
// dafuer ist das hier: nicht fuer Jubelmeldungen, sondern fuer die zwei, drei
|
|
// Faelle, in denen jemand etwas tun muss.
|
|
//
|
|
// Ein WEBHOOK und keine Bot-Anbindung: eine Adresse, kein Token, keine
|
|
// Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen
|
|
// will, loescht ihn in Discord.
|
|
//
|
|
// DER AUSSEHEN FOLGT DEM d4rkbot. Dessen src/embeds.js hat eine zentrale
|
|
// Embed-Fabrik: Markenfarbe, Fusszeile "D4RKST3R // <TAG>", Zeitstempel. Zwei
|
|
// Dienste desselben Hauses sollen in einem Kanal nicht wie zwei Fremde
|
|
// aussehen, also uebernehmen wir das Muster statt ein eigenes zu erfinden.
|
|
//
|
|
// EINE ABWEICHUNG, mit Absicht: der Bot faerbt alles in der Markenfarbe, hier
|
|
// faerbt die SCHWERE. Eine Warnung, die aussieht wie jede andere Nachricht,
|
|
// ist eine Warnung, die man ueberliest -- und diese Meldungen gibt es nur,
|
|
// weil jemand sie sehen soll.
|
|
|
|
import { getSetting, setSetting } from './settings.js'
|
|
import { now } from './db.js'
|
|
|
|
export const MELDUNG_KEYS = {
|
|
webhook: 'discord.webhook',
|
|
/** Was gemeldet wird: Komma-Liste aus 'sicherung', 'verwaiste'. */
|
|
anlaesse: 'discord.anlaesse',
|
|
/** Womit zuletzt gemeldet wurde -- gegen Wiederholungen. */
|
|
zuletzt: 'discord.zuletzt',
|
|
/** Der Name in der Fusszeile. Wie beim Bot einstellbar, mit demselben
|
|
* Standard. */
|
|
marke: 'marke.name',
|
|
} as const
|
|
|
|
/** Dieselben Werte wie im d4rkbot (src/runtime-settings.js). */
|
|
const MARKE_STANDARD = 'D4RKST3R'
|
|
const FARBEN = {
|
|
marke: 0xf5c518,
|
|
gut: 0x3fb950,
|
|
warnung: 0xd29922,
|
|
schlecht: 0xf85149,
|
|
} as const
|
|
|
|
export type Schwere = keyof typeof FARBEN
|
|
|
|
export type MeldungConfig = {
|
|
hatWebhook: boolean
|
|
anlaesse: string[]
|
|
}
|
|
|
|
export function meldungConfig(): MeldungConfig {
|
|
const roh = getSetting(MELDUNG_KEYS.anlaesse)
|
|
return {
|
|
hatWebhook: Boolean(getSetting(MELDUNG_KEYS.webhook)),
|
|
// Ohne Eintrag gilt: alles, was es gibt. Wer einen Webhook eintraegt,
|
|
// will Meldungen -- sonst haette er es gelassen.
|
|
anlaesse: roh
|
|
? roh.split(',').map((a) => a.trim()).filter(Boolean)
|
|
: ['sicherung', 'verwaiste'],
|
|
}
|
|
}
|
|
|
|
type Feld = { name: string; value: string; inline?: boolean }
|
|
|
|
/** Eine Nachricht schicken. Wirft nie.
|
|
*
|
|
* `einmalig` ist der Schluessel gegen Wiederholungen: dieselbe Zeichenkette
|
|
* wird nur einmal gemeldet, bis sich der Zustand aendert. */
|
|
export async function melde(
|
|
titel: string,
|
|
opt: {
|
|
text?: string
|
|
felder?: Feld[]
|
|
schwere?: Schwere
|
|
anlass?: string
|
|
einmalig?: string
|
|
tag?: string
|
|
} = {},
|
|
): Promise<boolean> {
|
|
try {
|
|
const webhook = getSetting(MELDUNG_KEYS.webhook)
|
|
if (!webhook) return false
|
|
|
|
if (opt.anlass && !meldungConfig().anlaesse.includes(opt.anlass)) return false
|
|
|
|
if (opt.einmalig) {
|
|
const zuletzt = getSetting(MELDUNG_KEYS.zuletzt)
|
|
if (zuletzt === opt.einmalig) return false
|
|
setSetting(MELDUNG_KEYS.zuletzt, opt.einmalig)
|
|
}
|
|
|
|
const marke = getSetting(MELDUNG_KEYS.marke) || MARKE_STANDARD
|
|
|
|
const antwort = await fetch(webhook, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username: 'd4rk_media',
|
|
embeds: [
|
|
{
|
|
title: titel,
|
|
description: opt.text,
|
|
color: FARBEN[opt.schwere ?? 'marke'],
|
|
// Felder statt Fliesstext, wo es Zahlen sind: "228
|
|
// Dateien / 11,5 MB / vor 3 Stunden" liest sich in
|
|
// einer Zeile schlechter als nebeneinander.
|
|
fields: opt.felder,
|
|
footer: { text: `${marke} // ${opt.tag ?? 'MEDIA'}` },
|
|
timestamp: new Date(now()).toISOString(),
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
// Discord antwortet auf einen Webhook mit 204.
|
|
if (!antwort.ok) {
|
|
console.error('[discord] Webhook antwortete mit', antwort.status)
|
|
return false
|
|
}
|
|
return true
|
|
} catch (err) {
|
|
// Eine Meldung, die den Dienst mitreisst, waere die Buchhaltung, die
|
|
// das Geschaeft verhindert.
|
|
console.error('[discord] Meldung fehlgeschlagen:', err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const groesse = (n: number) =>
|
|
n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
|
|
/** Wacht ueber die Sicherung.
|
|
*
|
|
* Der eigentliche Punkt der ganzen Datei. Geprueft wird stuendlich, gemeldet
|
|
* hoechstens einmal je Zustand:
|
|
*
|
|
* - der letzte Lauf ist gescheitert
|
|
* - der letzte Lauf ist laenger als 26 Stunden her (taeglich um 04:30 plus
|
|
* Luft; 24 waeren zu knapp, 48 zu spaet)
|
|
* - es gab noch nie einen
|
|
*/
|
|
export async function pruefeSicherung(): Promise<void> {
|
|
const roh = getSetting('sicherung.letzte')
|
|
|
|
if (!roh) {
|
|
await melde('Noch keine Sicherung gemeldet', {
|
|
text: 'Es wurde noch **nie** eine Sicherung eingetragen. Läuft die geplante Aufgabe?',
|
|
schwere: 'warnung',
|
|
anlass: 'sicherung',
|
|
einmalig: 'nie',
|
|
tag: 'SICHERUNG',
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
const letzte = JSON.parse(roh) as {
|
|
at: number
|
|
ok: boolean
|
|
dateien?: number
|
|
groesse?: number
|
|
hoch?: boolean
|
|
fehler?: string
|
|
}
|
|
const stunden = (now() - letzte.at) / 3_600_000
|
|
const wann = `<t:${Math.floor(letzte.at / 1000)}:R>`
|
|
|
|
if (!letzte.ok) {
|
|
await melde('Die Sicherung ist fehlgeschlagen', {
|
|
text: letzte.fehler ?? 'Es wurde kein Grund gemeldet.',
|
|
felder: [
|
|
{ name: 'Wann', value: wann, inline: true },
|
|
{
|
|
name: 'Zuletzt gut',
|
|
value: letzte.dateien ? `${letzte.dateien} Dateien` : '—',
|
|
inline: true,
|
|
},
|
|
],
|
|
schwere: 'schlecht',
|
|
anlass: 'sicherung',
|
|
einmalig: `fehler:${letzte.at}`,
|
|
tag: 'SICHERUNG',
|
|
})
|
|
} else if (stunden > 26) {
|
|
await melde('Die Sicherung ist überfällig', {
|
|
text: 'Läuft die geplante Aufgabe noch? Erwartet wird sie täglich um 04:30.',
|
|
felder: [
|
|
{ name: 'Letzter Lauf', value: wann, inline: true },
|
|
{ name: 'Das ist', value: `${Math.floor(stunden)} Stunden her`, inline: true },
|
|
],
|
|
schwere: 'warnung',
|
|
anlass: 'sicherung',
|
|
einmalig: `alt:${letzte.at}`,
|
|
tag: 'SICHERUNG',
|
|
})
|
|
}
|
|
} catch {
|
|
/* kaputter Eintrag -- beim naechsten Lauf wieder */
|
|
}
|
|
}
|
|
|
|
/** Die Testnachricht. Zeigt bewusst, WIE eine echte aussieht — sonst prueft
|
|
* man nur, ob irgendetwas ankommt, und nicht, ob es lesbar ist. */
|
|
export async function testMeldung(wer: string, bestand: { files: number; bytes: number }) {
|
|
return melde('Testnachricht', {
|
|
text: `Ausgelöst von **${wer}**. So sieht eine Meldung von hier aus.`,
|
|
felder: [
|
|
{ name: 'Bestand', value: `${bestand.files} Dateien`, inline: true },
|
|
{ name: 'Belegt', value: groesse(bestand.bytes), inline: true },
|
|
],
|
|
schwere: 'gut',
|
|
})
|
|
}
|