docs: README und .env.example auf den Stand -- und Embeds im Stil des Bots

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>
This commit is contained in:
2026-08-11 20:38:36 +02:00
co-authored by Claude Opus 5
parent c9cd8eaa84
commit 51071fc292
4 changed files with 327 additions and 220 deletions
+106 -21
View File
@@ -9,9 +9,15 @@
// Berechtigungen, kein zweites Geheimnis, das ablaeuft. Wer ihn zurueckziehen
// will, loescht ihn in Discord.
//
// Der Dienst schickt HOECHSTENS EINMAL je Anlass. Eine Meldung, die stuendlich
// wiederkommt, wird nach dem dritten Mal weggeklickt -- und dann auch die, die
// zaehlt.
// 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'
@@ -22,8 +28,22 @@ export const MELDUNG_KEYS = {
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[]
@@ -35,17 +55,28 @@ export function meldungConfig(): MeldungConfig {
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'],
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(
text: string,
opt: { anlass?: string; einmalig?: string; farbe?: number } = {},
titel: string,
opt: {
text?: string
felder?: Feld[]
schwere?: Schwere
anlass?: string
einmalig?: string
tag?: string
} = {},
): Promise<boolean> {
try {
const webhook = getSetting(MELDUNG_KEYS.webhook)
@@ -59,6 +90,8 @@ export async function melde(
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' },
@@ -66,8 +99,14 @@ export async function melde(
username: 'd4rk_media',
embeds: [
{
description: text,
color: opt.farbe ?? 0x4ea3ff,
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(),
},
],
@@ -87,6 +126,9 @@ export async function melde(
}
}
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
@@ -99,30 +141,73 @@ export async function melde(
*/
export async function pruefeSicherung(): Promise<void> {
const roh = getSetting('sicherung.letzte')
if (!roh) {
await melde(
'⚠️ Es wurde noch **nie** eine Sicherung gemeldet. Läuft die geplante Aufgabe?',
{ anlass: 'sicherung', einmalig: 'nie', farbe: 0xd29922 },
)
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; fehler?: string }
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 letzte Sicherung ist **fehlgeschlagen**.\n${letzte.fehler ?? 'kein Grund gemeldet'}`,
{ anlass: 'sicherung', einmalig: `fehler:${letzte.at}`, farbe: 0xf85149 },
)
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 letzte Sicherung ist **${Math.floor(stunden)} Stunden** her. Läuft die geplante Aufgabe noch?`,
{ anlass: 'sicherung', einmalig: `alt:${letzte.at}`, farbe: 0xd29922 },
)
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',
})
}
+7 -5
View File
@@ -46,7 +46,7 @@ import {
type Vorlage,
} from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js'
import { MELDUNG_KEYS, melde, meldungConfig } from '../meldung.js'
import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import {
absolutePath,
checkPath,
@@ -1526,10 +1526,12 @@ dashRoutes.delete('/tokens/:id', (c) => {
* Ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall
* ausprobiert, ist ein Webhook, der dann nicht funktioniert. */
dashRoutes.post('/maintenance/melden', async (c) => {
const ok = await melde(
`✅ Testnachricht von d4rk_media — ausgelöst von **${c.get('user').username}**.`,
{ farbe: 0x3fb950 },
)
// Mit echten Zahlen statt "Hallo Welt": so sieht man nicht nur, DASS etwas
// ankommt, sondern auch, ob es lesbar ist.
const b = db
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size),0) AS bytes FROM media')
.get() as { files: number; bytes: number }
const ok = await testMeldung(c.get('user').username, b)
if (!ok) {
return c.json(
{ error: 'Nichts geschickt. Ist ein Webhook eingetragen, und stimmt die Adresse?' },