Files
d4rkbot/src/tuning.js
T
D4rkst3randClaude Fable 5 137452da11
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Linked Roles: Rollen, die an geprueften Werten haengen
Neues Modul, standardmaessig aus. Ein Mitglied klickt in Discord auf "Rolle
holen", landet auf /linked-roles, bestaetigt den OAuth-Dialog — und der Bot
schiebt ihm drei Kennzahlen zu, die Discord dann gegen die Rollen-Einstellung
prueft:

  level        Zahl >= eingestelltem Wert
  dabei_seit   Datum <= "vor X Tagen", also Mitglied seit mindestens X Tagen
  playtester   Wahrheitswert

Der Bot vergibt dabei keine Rollen. Er liefert nur die Zahlen; was daraus
wird, entscheidet die Rollen-Einstellung im Server. Die Daten kommen aus dem,
was ohnehin da ist: Level-Tabelle, Playtester-Liste, Discords Beitrittsdatum.

Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind —
role_connections.write hat der normale Login nicht.

Die Tokens werden gespeichert, und das ist keine Bequemlichkeit: ohne
Refresh-Token bliebe jede Rolle auf dem Stand des Verknuepfungs-Tages stehen,
ein Level-Up kaeme nie an. Discords eigene Anleitung sagt dasselbe. Der
Umfang ist eng (nur identify + role_connections.write), beim Widerruf oder
Entkoppeln fliegt der Datensatz sofort raus. Steht so auch als Kommentar an
der Tabelle.

Abgeglichen wird alle 6 Stunden, einstellbar. Dafuer kann everyTuned jetzt
auch Stunden — und wirft bei einer unbekannten Einheit, statt den Abstand
still um Faktor 60 oder 3600 danebenzulegen.

Beim Bauen gefunden: moduleEnabled fehlte im Import von client.js. Der Bot
waere beim ClientReady mit ReferenceError gestorben.

Geprueft: alle drei Kennzahlen gegen Discords Formatgrenzen (Schluessel-Regex,
Laengen, gueltige Typ-Codes), Modul- und Stellwert-Register, die
Stunden-Umrechnung (6 h = 21600000 ms), Speichern und Loeschen einer
Verknuepfung, dazu 186 SQL-Abfragen und alle Routen auf Rechtepruefung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 05:57:57 +02:00

251 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Stellwerte: alle Zahlen, die vorher fest im Code standen — XP-Beträge,
// Wartezeiten, Prüf-Intervalle, Obergrenzen.
//
// `module` verbindet einen Wert mit seiner Funktion — die Modul-Seite zeigt ihn
// dann dort, wo er hingehört, statt nur in der flachen Werte-Liste. Ein Wert
// darf zu mehreren gehören (die Thread-Archivdauer gilt für Tickets UND
// Modmail); dann steht dort eine Liste.
//
// Wie bei den Modulen und Vorlagen lebt der Standard im Code und die
// Datenbank enthält nur Abweichungen. `min`/`max` sind keine Kosmetik: sie
// verhindern, dass ein Vertipper den Bot in eine Prüfschleife im
// Sekundentakt schickt oder das Level-System unbrauchbar macht.
import { getSetting, setSetting } from './db.js';
export const TUNING_GROUPS = [
{ id: 'level', label: 'Level & Aktivität' },
{ id: 'zeiten', label: 'Wann der Bot postet' },
{ id: 'pruefung', label: 'Prüf-Intervalle' },
{ id: 'grenzen', label: 'Obergrenzen' },
];
export const TUNING = [
/* ── Level & Aktivität ────────────────────────────── */
{
id: 'xp_min', module: 'levels', group: 'level', label: 'XP pro Nachricht — mindestens',
unit: 'XP', default: 15, min: 1, max: 500,
},
{
id: 'xp_max', module: 'levels', group: 'level', label: 'XP pro Nachricht — höchstens',
hint: 'Der Bot würfelt zwischen beiden Werten.',
unit: 'XP', default: 25, min: 1, max: 500,
},
{
id: 'xp_cooldown', module: 'levels', group: 'level', label: 'Wartezeit zwischen zwei XP-Gutschriften',
hint: 'Verhindert, dass Vielschreiber das Level-System leerlaufen lassen.',
unit: 'Sekunden', default: 60, min: 5, max: 3600,
},
{
id: 'trigger_cooldown', module: 'triggers', group: 'level', label: 'Wartezeit für Auto-Antworten',
hint: 'Pro Schlüsselwort und Kanal — sonst antwortet der Bot sich in Grund und Boden.',
unit: 'Sekunden', default: 30, min: 5, max: 3600,
},
/* ── Wann der Bot postet ──────────────────────────── */
{
id: 'birthday_hour', module: 'birthdays', group: 'zeiten', label: 'Geburtstags-Gratulation ab',
hint: 'Uhrzeit in Europe/Berlin. Gratuliert wird einmal am Tag.',
unit: 'Uhr', default: 9, min: 0, max: 23,
},
{
id: 'recap_weekday', module: 'weekly_recap', group: 'zeiten', label: 'Wochen-Rückblick am',
hint: '0 = Sonntag, 1 = Montag … 6 = Samstag.',
unit: 'Wochentag', default: 0, min: 0, max: 6,
},
{
id: 'recap_hour', module: 'weekly_recap', group: 'zeiten', label: 'Wochen-Rückblick ab',
unit: 'Uhr', default: 20, min: 0, max: 23,
},
/* ── Prüf-Intervalle ──────────────────────────────── */
{
id: 'monitor_interval', module: 'server_monitor', group: 'pruefung', label: 'Game-Server prüfen alle',
unit: 'Minuten', default: 2, min: 1, max: 120,
},
{
id: 'monitor_timeout', module: 'server_monitor', group: 'pruefung', label: 'Game-Server — Antwort abwarten',
unit: 'Sekunden', default: 8, min: 2, max: 60,
},
{
id: 'monitor_fails', module: 'server_monitor', group: 'pruefung', label: 'Game-Server — Alarm nach',
hint: 'Fehlversuchen in Folge. 1 meldet jeden Aussetzer, höher meldet nur echte Ausfälle.',
unit: 'Fehlversuchen', default: 2, min: 1, max: 10,
},
{
id: 'watchdog_interval', module: 'watchdog', group: 'pruefung', label: 'Adressen prüfen alle',
unit: 'Minuten', default: 2, min: 1, max: 120,
},
{
id: 'watchdog_timeout', module: 'watchdog', group: 'pruefung', label: 'Adressen — Antwort abwarten',
unit: 'Sekunden', default: 10, min: 2, max: 60,
},
{
id: 'watchdog_fails', module: 'watchdog', group: 'pruefung', label: 'Adressen — Alarm nach',
unit: 'Fehlversuchen', default: 2, min: 1, max: 10,
},
{
id: 'social_interval', module: 'social', group: 'pruefung', label: 'Twitch & YouTube prüfen alle',
hint: 'Zu kurz bringt nichts — YouTube-Feeds aktualisieren sich ohnehin nur alle paar Minuten.',
unit: 'Minuten', default: 5, min: 1, max: 180,
},
{
id: 'birthday_interval', module: 'birthdays', group: 'pruefung', label: 'Geburtstage prüfen alle',
unit: 'Minuten', default: 15, min: 1, max: 120,
},
/* ── Obergrenzen ──────────────────────────────────── */
{
id: 'raid_joins', module: 'anti_raid', group: 'grenzen', label: 'Raid-Verdacht ab',
hint: 'So viele Beitritte im Zeitfenster lösen die Sperre aus. Zu niedrig, und ein Stream-Shoutout reicht schon.',
unit: 'Beitritte', default: 8, min: 3, max: 100,
},
{
id: 'raid_window', module: 'anti_raid', group: 'grenzen', label: 'Raid — Zeitfenster',
unit: 'Sekunden', default: 20, min: 5, max: 600,
},
{
id: 'linked_sync', module: 'linked_roles', group: 'pruefung', label: 'Verknüpfte Rollen abgleichen alle',
hint: 'Discord fragt die Werte nicht selbst nach — ohne Abgleich bliebe eine Rolle auf dem Stand des Verknüpfungs-Tages.',
unit: 'Stunden', default: 6, min: 1, max: 168,
},
{
id: 'raid_lockdown', module: 'anti_raid', group: 'zeiten', label: 'Raid-Sperre dauert',
hint: 'Danach hebt der Bot sie von selbst wieder auf.',
unit: 'Minuten', default: 15, min: 1, max: 720,
},
{
id: 'ticket_transcript_max', module: 'tickets', group: 'grenzen', label: 'Nachrichten im Ticket-Protokoll',
hint: 'Ältere fallen weg. Hoch gesetzt dauert das Schließen länger.',
unit: 'Nachrichten', default: 500, min: 50, max: 2000,
},
{
id: 'gallery_max_images', module: 'gallery', group: 'grenzen', label: 'Bilder je Galerie-Beitrag',
unit: 'Bilder', default: 4, min: 1, max: 10,
},
{
id: 'commits_shown', module: 'commit_feed', group: 'grenzen', label: 'Commits je Push-Embed',
hint: 'Der Rest wird als „… und N weitere" zusammengefasst.',
unit: 'Commits', default: 10, min: 1, max: 20,
},
{
id: 'thread_archive_days', module: ['tickets', 'modmail'], group: 'grenzen', label: 'Threads archivieren nach',
hint: 'Für Ticket- und Modmail-Threads. Discord erlaubt 1, 3 oder 7 Tage.',
unit: 'Tagen', default: 7, min: 1, max: 7, choices: [1, 3, 7],
},
];
const byId = new Map(TUNING.map((t) => [t.id, t]));
/**
* Wert lesen — immer eine gültige Zahl im erlaubten Bereich.
* @param {string} id
* @returns {number}
*/
export function tuning(id) {
const def = byId.get(id);
if (!def) return 0;
// Achtung: Number('') ist 0, nicht NaN — ohne diese Prüfung würde ein
// ungesetzter Wert auf das Minimum fallen statt auf den Standard.
const stored = String(getSetting(`tune_${id}`) ?? '').trim();
if (stored === '') return def.default;
const raw = Number(stored);
if (!Number.isFinite(raw)) return def.default;
return Math.min(def.max, Math.max(def.min, Math.round(raw)));
}
/** Derselbe Wert in Millisekunden — spart die Rechnerei an jeder Aufrufstelle */
export const tuningMs = {
seconds: (id) => tuning(id) * 1000,
minutes: (id) => tuning(id) * 60 * 1000,
hours: (id) => tuning(id) * 60 * 60 * 1000,
};
/**
* Wert setzen. Leer stellt den Standard wieder her.
* Mit `dryRun` wird nur geprüft — damit ein Formular mit mehreren Werten
* entweder ganz oder gar nicht gespeichert wird.
* @returns {{ ok: true } | { ok: false, error: string }}
*/
export function setTuning(id, value, { dryRun = false } = {}) {
const def = byId.get(id);
if (!def) return { ok: false, error: `Unbekannter Wert: ${id}` };
if (value === '' || value === null || value === undefined) {
if (!dryRun) setSetting(`tune_${id}`, '');
return { ok: true };
}
const n = Number(value);
if (!Number.isFinite(n) || !Number.isInteger(n)) {
return { ok: false, error: `${def.label}: ganze Zahl erwartet` };
}
if (n < def.min || n > def.max) {
return { ok: false, error: `${def.label}: ${def.min}${def.max} ${def.unit}` };
}
if (def.choices && !def.choices.includes(n)) {
return { ok: false, error: `${def.label}: erlaubt sind ${def.choices.join(', ')}` };
}
if (!dryRun) setSetting(`tune_${id}`, String(n));
return { ok: true };
}
/** Alle Werte mit Zustand — fürs Webinterface */
export function tuningStates() {
return TUNING.map((t) => {
const custom = getSetting(`tune_${t.id}`);
return {
id: t.id, label: t.label, hint: t.hint ?? null,
group: t.group,
// Immer als Liste nach außen — der Aufrufer muss dann nicht
// zwischen einem Wert und mehreren unterscheiden
modules: t.module ? [t.module].flat() : [],
unit: t.unit, min: t.min, max: t.max,
choices: t.choices ?? null,
default: t.default,
value: tuning(t.id),
customized: Boolean(String(custom ?? '').trim()),
};
});
}
/**
* Wiederkehrende Aufgabe, deren Abstand aus einem Stellwert kommt. Der Wert
* wird vor jeder Runde neu gelesen — ein geändertes Intervall greift damit ab
* dem nächsten Durchlauf, ohne den Bot neu zu starten. (Ein setInterval mit
* festem Abstand könnte das nicht.)
*
* @param {string} id — Stellwert
* @param {'hours'|'minutes'|'seconds'} unit
* @param {() => unknown} task
* @param {string} tag — Kennung fürs Fehler-Log
* @returns {() => void} Abbrechen
*/
export function everyTuned(id, unit, task, tag = id) {
let timer;
// Unbekannte Einheit wäre schlimmer als ein Tippfehler: der Abstand läge
// um Faktor 60 oder 3600 daneben, ohne dass es jemand merkt.
const delay = () => {
const f = tuningMs[unit];
if (!f) throw new Error(`everyTuned(${id}): unbekannte Einheit "${unit}"`);
return f(id);
};
const run = async () => {
try {
await task();
} catch (error) {
console.error(`[${tag}]`, error);
}
timer = setTimeout(run, delay());
};
timer = setTimeout(run, delay());
return () => clearTimeout(timer);
}
/**
* XP-Spanne — sorgt dafür, dass Minimum und Maximum nicht vertauscht sind,
* falls jemand das Maximum unter das Minimum setzt.
*/
export function xpRange() {
const a = tuning('xp_min');
const b = tuning('xp_max');
return { min: Math.min(a, b), max: Math.max(a, b) };
}