From 197c2a1ada87f2aac20755c0ae187fb986ba2bdf Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 31 Jul 2026 16:49:34 +0200 Subject: [PATCH] Werte: 19 feste Zahlen im Panel einstellbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XP-Beträge, Wartezeiten, Prüf-Intervalle, Obergrenzen und Postzeiten standen als Konstanten im Code. Wer den Server-Monitor seltener prüfen lassen wollte oder das Level-System anders austarieren, musste die Quelldateien anfassen. src/tuning.js hält sie jetzt an einer Stelle, mit Standard, erlaubtem Bereich und Erklärung. Der neue Config-Bereich „Werte" zeigt sie nach Themen gruppiert; neben jedem Feld steht der erlaubte Bereich und der Standard, angepasste Werte lassen sich einzeln zurücksetzen. Die Grenzen sind nicht Kosmetik: ein Vertipper beim Prüf-Intervall würde sonst den Bot in eine Schleife im Sekundentakt schicken. Gespeichert wird ganz oder gar nicht — ein ungültiger Wert im Formular lässt auch die gültigen daneben unverändert, statt die Hälfte zu schreiben. Intervalle laufen nicht mehr über setInterval mit festem Abstand, sondern über everyTuned: der Wert wird vor jeder Runde neu gelesen. Ein geändertes Intervall greift damit ab dem nächsten Durchlauf, ohne Neustart. Ein Fehler in einer Runde beendet die Schleife nicht. Beim Testen aufgefallen und behoben: Number('') ist 0 und nicht NaN — ein nie gesetzter Wert wäre dadurch auf sein Minimum gefallen statt auf den Standard. Das Level-System hätte also 1 XP pro Nachricht vergeben und der Monitor jede Minute geprüft. Der Fetch-Wrapper im Frontend hat die Fehlermeldung des Servers verworfen; jetzt steht im Panel „XP pro Nachricht: 1–500 XP" statt „fehlgeschlagen". Co-Authored-By: Claude Fable 5 --- frontend/src/api.js | 3 + frontend/src/pages/Settings.jsx | 127 ++++++++++++++++++- frontend/src/style.css | 45 +++++++ src/bot/birthdays.js | 7 +- src/bot/client.js | 3 +- src/bot/commit-feed.js | 9 +- src/bot/community.js | 4 +- src/bot/extras.js | 4 +- src/bot/levels.js | 10 +- src/bot/mod-tools.js | 3 +- src/bot/server-monitor.js | 14 +-- src/bot/social-notify.js | 4 +- src/bot/tickets.js | 5 +- src/bot/watchdog.js | 11 +- src/bot/weekly-recap.js | 4 +- src/tuning.js | 215 ++++++++++++++++++++++++++++++++ src/web/api.js | 26 ++++ 17 files changed, 453 insertions(+), 41 deletions(-) create mode 100644 src/tuning.js diff --git a/frontend/src/api.js b/frontend/src/api.js index d2c2e94..8e11248 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -4,6 +4,9 @@ async function api(path, options = {}) { if (!res.ok) { const error = new Error(`API ${res.status}`); error.status = res.status; + // Der Server erklärt in vielen Fällen, was genau nicht stimmt — + // ohne das hier stünde überall nur „fehlgeschlagen". + error.body = await res.json().catch(() => null); throw error; } return res.json(); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6a73e33..41a448e 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -6,7 +6,7 @@ import { IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam, IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX, IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh, - IconSearch, + IconSearch, IconSystem as IconTune, } from '../icons.jsx'; import { Markdown } from '../markdown.jsx'; @@ -36,6 +36,8 @@ const TABS = [ find: 'funktionen an aus schalter aktivieren deaktivieren' }, { id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick', find: 'nachrichten vorlagen platzhalter begrüßung wortlaut' }, + { id: 'werte', Icon: IconTune, label: 'Werte', section: 'ueberblick', + find: 'xp cooldown wartezeit intervall prüfung timeout obergrenze zahlen uhrzeit' }, { id: 'brand', Icon: IconBrand, label: 'Brand', section: 'auftritt', find: 'avatar banner farbe logo name footer aussehen embed' }, @@ -76,6 +78,7 @@ const TAB_SCOPES = { status: 'any', module: 'settings', texte: 'settings', + werte: 'settings', brand: null, feeds: 'settings', community: 'community', @@ -227,6 +230,10 @@ export default function Settings({ me }) { const [templateGroups, setTemplateGroups] = useState([]); const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage + // Stellwerte (XP, Wartezeiten, Intervalle, Obergrenzen) + const [tuningValues, setTuningValues] = useState([]); + const [tuningGroups, setTuningGroups] = useState([]); + const [tuneDraft, setTuneDraft] = useState({}); // id → eingetippter Wert // Eigene Seiten (Regeln, Über uns, …) const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true }; const [pages, setPages] = useState([]); @@ -298,6 +305,10 @@ export default function Settings({ me }) { setTemplates(d.templates); setTemplateGroups(d.groups); }).catch(() => {}); + apiGet('/api/tuning').then((d) => { + setTuningValues(d.values); + setTuningGroups(d.groups); + }).catch(() => {}); }, [me.admin, me.scopes?.length]); useEffect(() => { @@ -390,6 +401,37 @@ export default function Settings({ me }) { } } + /** Alle geänderten Stellwerte auf einmal speichern */ + async function saveTuning() { + const changed = Object.fromEntries( + Object.entries(tuneDraft).filter(([id, v]) => { + const current = tuningValues.find((t) => t.id === id); + return current && String(v) !== String(current.value); + }) + ); + if (Object.keys(changed).length === 0) return; + try { + const res = await apiPut('/api/tuning', changed); + setTuningValues(res.values); + setTuneDraft({}); + flash('✓ Werte gespeichert'); + } catch (error) { + flash(`✗ ${error.body?.error ?? 'Speichern fehlgeschlagen'}`); + } + } + + /** Einen Wert auf den Standard zurücksetzen */ + async function resetTuning(id) { + try { + const res = await apiPut('/api/tuning', { [id]: '' }); + setTuningValues(res.values); + setTuneDraft(({ [id]: _drop, ...rest }) => rest); + flash('✓ Auf den Standard zurückgesetzt'); + } catch { + flash('✗ Zurücksetzen fehlgeschlagen'); + } + } + /** Platzhalter an der Cursor-Position einfügen */ function insertPlaceholder(tpl, key) { const field = document.getElementById(`tpl-${tpl.id}`); @@ -2112,6 +2154,88 @@ export default function Settings({ me }) { ); + const tuneDirty = tuningValues.some( + (t) => tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value) + ); + + const tabWerte = ( +
+

// Werte

+

+ Zahlen, die das Verhalten steuern — wie viel XP eine Nachricht bringt, + wie oft geprüft wird, wann gepostet wird. Geänderte Intervalle greifen ab + dem nächsten Durchlauf, ohne Neustart. Neben jedem Feld steht der erlaubte + Bereich; angepasste Werte lassen sich einzeln zurücksetzen. +

+ + {tuningGroups.map((group) => { + const items = tuningValues.filter((t) => t.group === group.id); + if (items.length === 0) return null; + return ( +
+

+ {group.label} +

+
+ {items.map((t) => { + const value = tuneDraft[t.id] ?? t.value; + const dirty = tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value); + return ( +
+
+ + {t.label} + {t.customized && angepasst} + + {t.hint && {t.hint}} +
+
+ {t.choices ? ( + + ) : ( + setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))} + /> + )} + {t.unit} + + {t.choices ? t.choices.join(' · ') : `${t.min}–${t.max}`} + {' · Standard '}{t.default} + + {t.customized && ( + + )} +
+
+ ); + })} +
+
+ ); + })} + +
+ +
+
+ ); + const tabSeiten = (

// Eigene Seiten

@@ -2300,6 +2424,7 @@ export default function Settings({ me }) { status: tabStatus, module: tabModule, texte: tabTexte, + werte: tabWerte, brand: tabBrand, feeds: tabFeeds, community: tabCommunity, diff --git a/frontend/src/style.css b/frontend/src/style.css index a770df0..e39e10d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1884,3 +1884,48 @@ button.with-icon, a.with-icon { justify-content: center; } /* Schmal ist für die Vorschau kein Platz mehr — Name und Marken reichen */ .tpl-peek { display: none; } } + +/* ── STELLWERTE ────────────────────────────────────── */ +.tune-list { + display: flex; flex-direction: column; + border: 1px solid rgba(255, 255, 255, .06); + border-radius: 10px; overflow: hidden; +} +.tune-row { + display: flex; align-items: center; gap: 1.5rem; + justify-content: space-between; flex-wrap: wrap; + padding: .75rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, .06); +} +.tune-row:last-child { border-bottom: 0; } +.tune-row.dirty { box-shadow: inset 2px 0 0 var(--neon); } +.tune-label { display: flex; flex-direction: column; gap: .15rem; min-width: 14rem; flex: 1; } +.tune-name { + display: flex; align-items: center; gap: .5rem; + color: var(--text); font-size: .95rem; +} +.tune-hint { color: var(--muted2); font-weight: 300; font-size: .82rem; line-height: 1.4; } +.tune-input { display: flex; align-items: center; gap: .6rem; flex: none; } +.tune-input input, .tune-input select { + width: 6rem; text-align: right; + font-family: var(--mono); font-size: .85rem; + color: var(--text); background: #0d0d0d; + border: 1px solid var(--border); border-radius: 5px; + padding: .4rem .55rem; +} +.tune-input select { text-align: left; } +.tune-input input:focus, .tune-input select:focus { outline: none; border-color: var(--neon); } +.tune-unit { + font-family: var(--mono); font-size: .64rem; + letter-spacing: .12em; text-transform: uppercase; + color: var(--muted); min-width: 6.5rem; +} +.tune-range { + font-family: var(--mono); font-size: .6rem; + letter-spacing: .06em; color: var(--muted2); min-width: 9rem; +} + +@media (max-width: 720px) { + .tune-row { gap: .6rem; } + .tune-range { display: none; } +} diff --git a/src/bot/birthdays.js b/src/bot/birthdays.js index b6a3037..435a308 100644 --- a/src/bot/birthdays.js +++ b/src/bot/birthdays.js @@ -5,8 +5,7 @@ import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from ' import { brandEmbed } from '../embeds.js'; import { moduleEnabled } from '../modules.js'; import { renderTemplate } from '../templates.js'; - -const CHECK_INTERVAL_MS = 15 * 60 * 1000; +import { tuning, everyTuned } from '../tuning.js'; /** Aktuelles Datum/Stunde in Europe/Berlin */ function berlinNow() { @@ -20,7 +19,7 @@ function berlinNow() { export async function birthdayTick(client) { if (!moduleEnabled('birthdays')) return; const { day, month, hour, dateKey } = berlinNow(); - if (hour < 9) return; // erst ab 09:00 + if (hour < tuning('birthday_hour')) return; if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen setSetting('last_birthday_run', dateKey); @@ -66,6 +65,6 @@ export async function birthdayTick(client) { } export function startBirthdays(client) { - setInterval(() => birthdayTick(client).catch((e) => console.error('[birthday]', e)), CHECK_INTERVAL_MS); + everyTuned('birthday_interval', 'minutes', () => birthdayTick(client), 'birthday'); birthdayTick(client).catch(() => {}); } diff --git a/src/bot/client.js b/src/bot/client.js index 0b5bb29..1ff36a3 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -3,6 +3,7 @@ import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, import { config } from '../config.js'; import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; +import { tuning } from '../tuning.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { registerCommunityListeners } from './community.js'; import { registerModTools } from './mod-tools.js'; @@ -210,7 +211,7 @@ export async function startBot() { name: `🎫 ${interaction.user.username}`, type: ChannelType.PrivateThread, invitable: false, - autoArchiveDuration: 10080, + autoArchiveDuration: tuning('thread_archive_days') * 1440, }); await thread.members.add(interaction.user.id); saveTicket(thread.id, interaction.user.id); diff --git a/src/bot/commit-feed.js b/src/bot/commit-feed.js index d8302c3..eb04ca1 100644 --- a/src/bot/commit-feed.js +++ b/src/bot/commit-feed.js @@ -1,9 +1,9 @@ // Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal import { EmbedBuilder } from 'discord.js'; import { commitChannelId } from '../runtime-settings.js'; +import { tuning } from '../tuning.js'; const GITEA_GREEN = 0x609926; -const MAX_COMMITS_SHOWN = 10; /** Erste Zeile der Commit-Message, auf maxLen gekürzt */ function firstLine(message, maxLen = 72) { @@ -29,11 +29,12 @@ export async function postPushEmbed(client, push) { } const count = push.commits.length; - const lines = push.commits.slice(0, MAX_COMMITS_SHOWN).map( + const maxShown = tuning('commits_shown'); + const lines = push.commits.slice(0, maxShown).map( (c) => `[\`${c.sha.slice(0, 7)}\`](${c.url}) ${firstLine(c.message)} — ${c.author_name}` ); - if (count > MAX_COMMITS_SHOWN) { - lines.push(`… und ${count - MAX_COMMITS_SHOWN} weitere`); + if (count > maxShown) { + lines.push(`… und ${count - maxShown} weitere`); } const embed = new EmbedBuilder() diff --git a/src/bot/community.js b/src/bot/community.js index c71528c..edfaa88 100644 --- a/src/bot/community.js +++ b/src/bot/community.js @@ -9,9 +9,9 @@ import { } from '../db.js'; import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; +import { tuning } from '../tuning.js'; import { config } from '../config.js'; -const MAX_GALLERY_IMAGES = 4; // Galerie-Bilder liegen neben der SQLite: data/gallery/ export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery'); @@ -29,7 +29,7 @@ export async function archiveGalleryMessage(message) { const files = []; for (const att of message.attachments?.values?.() ?? []) { - if (files.length >= MAX_GALLERY_IMAGES) break; + if (files.length >= tuning('gallery_max_images')) break; if (!att.contentType?.startsWith('image/')) continue; const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png'; const file = `${message.id}_${files.length}.${ext}`; diff --git a/src/bot/extras.js b/src/bot/extras.js index 49ce7f0..511d124 100644 --- a/src/bot/extras.js +++ b/src/bot/extras.js @@ -13,11 +13,11 @@ import { } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { renderTemplate } from '../templates.js'; +import { tuningMs } from '../tuning.js'; /* ── Triggers ──────────────────────────────────────── */ const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts -const TRIGGER_COOLDOWN_MS = 30_000; async function handleTriggers(message) { if (!moduleEnabled('triggers')) return; @@ -26,7 +26,7 @@ async function handleTriggers(message) { for (const t of listTriggers()) { if (!content.includes(t.keyword.toLowerCase())) continue; const key = `${message.channelId}:${t.keyword}`; - if (Date.now() - (triggerCooldown.get(key) ?? 0) < TRIGGER_COOLDOWN_MS) continue; + if (Date.now() - (triggerCooldown.get(key) ?? 0) < tuningMs.seconds('trigger_cooldown')) continue; triggerCooldown.set(key, Date.now()); await message.reply({ content: t.reply.slice(0, 2000), allowedMentions: { parse: [] } }).catch(() => {}); break; // max. ein Trigger pro Nachricht diff --git a/src/bot/levels.js b/src/bot/levels.js index 7de1903..f767e37 100644 --- a/src/bot/levels.js +++ b/src/bot/levels.js @@ -4,10 +4,7 @@ import { Events } from 'discord.js'; import { getLevelRow, setLevelRow } from '../db.js'; import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; - -const XP_COOLDOWN_MS = 60_000; -const XP_MIN = 15; -const XP_SPREAD = 11; // 15–25 XP pro gewerteter Nachricht +import { tuningMs, xpRange } from '../tuning.js'; /** Kumulierte XP-Schwelle, ab der ein Level erreicht ist (MEE6-Formel pro Stufe) */ export function xpForLevel(level) { @@ -28,9 +25,10 @@ export function levelFromXp(xp) { */ export function awardXp(userId, username, now = Date.now()) { const row = getLevelRow(userId) ?? { user_id: userId, username, xp: 0, level: 0, last_xp_ms: 0 }; - if (now - row.last_xp_ms < XP_COOLDOWN_MS) return null; + if (now - row.last_xp_ms < tuningMs.seconds('xp_cooldown')) return null; - const xp = row.xp + XP_MIN + Math.floor(Math.random() * XP_SPREAD); + const { min, max } = xpRange(); + const xp = row.xp + min + Math.floor(Math.random() * (max - min + 1)); const level = levelFromXp(xp); const leveledUp = level > row.level; setLevelRow({ user_id: userId, username, xp, level, last_xp_ms: now }); diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js index 2f79120..e938982 100644 --- a/src/bot/mod-tools.js +++ b/src/bot/mod-tools.js @@ -4,6 +4,7 @@ import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRol import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { renderTemplate } from '../templates.js'; +import { tuning } from '../tuning.js'; /* ── Modmail ───────────────────────────────────────── */ @@ -26,7 +27,7 @@ async function ensureModmailThread(client, user) { const thread = await channel.threads.create({ name: `📬 ${user.username}`, - autoArchiveDuration: 10080, // 7 Tage + autoArchiveDuration: tuning('thread_archive_days') * 1440, type: ChannelType.PublicThread, }); saveModmail(user.id, thread.id, user.username); diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index a892122..d0c7a7b 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -7,10 +7,8 @@ import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, import { brandFooter, botStatusText } from '../runtime-settings.js'; import { brandEmbed } from '../embeds.js'; import { moduleEnabled } from '../modules.js'; +import { tuning, tuningMs, everyTuned } from '../tuning.js'; -const CHECK_INTERVAL_MS = 2 * 60 * 1000; -const TIMEOUT_MS = 8000; -const FAILS_BEFORE_ALERT = 2; const GREEN = 0x23a55a; const RED = 0xf23f43; @@ -39,7 +37,7 @@ export async function queryServer(server) { try { if (server.type === 'fivem') { const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, { - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(tuningMs.seconds('monitor_timeout')), headers: { 'User-Agent': 'd4rkbot-monitor/3.0' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -53,7 +51,7 @@ export async function queryServer(server) { } if (server.type === 'http') { const res = await fetch(server.query_url, { - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(tuningMs.seconds('monitor_timeout')), redirect: 'follow', headers: { 'User-Agent': 'd4rkbot-monitor/3.0' }, }); @@ -65,7 +63,7 @@ export async function queryServer(server) { host: server.host, port: server.port || undefined, maxRetries: 1, - socketTimeout: TIMEOUT_MS, + socketTimeout: tuningMs.seconds('monitor_timeout'), }); return { ...base, online: true, ping: state.ping ?? Date.now() - started, @@ -137,7 +135,7 @@ async function handleAlerts(client, r) { return; } const fails = s.fails + 1; - if (!s.down && fails >= FAILS_BEFORE_ALERT) { + if (!s.down && fails >= tuning('monitor_fails')) { const channel = await client.channels.fetch(channelId).catch(() => null); await channel?.send({ embeds: [ @@ -207,6 +205,6 @@ export async function monitorTick(client) { } export function startServerMonitor(client) { - setInterval(() => monitorTick(client).catch((e) => console.error('[monitor] Fehler:', e)), CHECK_INTERVAL_MS); + everyTuned('monitor_interval', 'minutes', () => monitorTick(client), 'monitor'); client.once(Events.ClientReady, () => monitorTick(client).catch(() => {})); } diff --git a/src/bot/social-notify.js b/src/bot/social-notify.js index af45b06..ffae575 100644 --- a/src/bot/social-notify.js +++ b/src/bot/social-notify.js @@ -9,8 +9,8 @@ import { brandColor, brandColor2, brandFooter, } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; +import { everyTuned } from '../tuning.js'; -const CHECK_INTERVAL_MS = 5 * 60 * 1000; let twitchToken = null; // { token, expiresAt } async function announce(client, embed) { @@ -102,5 +102,5 @@ export function startSocialNotify(client) { try { await checkYouTube(client); } catch (e) { console.error('[social] YouTube:', e.message); } try { await checkTwitch(client); } catch (e) { console.error('[social] Twitch:', e.message); } }; - setInterval(tick, CHECK_INTERVAL_MS); + everyTuned('social_interval', 'minutes', tick, 'social'); } diff --git a/src/bot/tickets.js b/src/bot/tickets.js index 6280a38..fc452f9 100644 --- a/src/bot/tickets.js +++ b/src/bot/tickets.js @@ -4,14 +4,15 @@ import { AttachmentBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { getTicket, closeTicket } from '../db.js'; import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; +import { tuning } from '../tuning.js'; -const MAX_MESSAGES = 500; /** Alle Thread-Nachrichten chronologisch als Text-Transcript */ async function fetchTranscript(channel) { const all = []; let before; - while (all.length < MAX_MESSAGES) { + const maxMessages = tuning('ticket_transcript_max'); + while (all.length < maxMessages) { const batch = await channel.messages.fetch({ limit: 100, before }); if (batch.size === 0) break; all.push(...batch.values()); diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index b09151c..10554f6 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -3,10 +3,7 @@ import { getSetting } from '../db.js'; import { config } from '../config.js'; import { moduleEnabled } from '../modules.js'; - -const CHECK_INTERVAL_MS = 2 * 60 * 1000; -const FAILS_BEFORE_ALERT = 2; // 2 Fehlschläge in Folge → Alarm (gegen Flattern) -const TIMEOUT_MS = 10_000; +import { tuning, tuningMs, everyTuned } from '../tuning.js'; // URL → { fails, down, since } const state = new Map(); @@ -24,7 +21,7 @@ async function dmAdmin(client, content) { } /** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */ -export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALERT } = {}) { +export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog_fails') } = {}) { if (!moduleEnabled('watchdog')) return; for (const url of watchedUrls()) { const s = state.get(url) ?? { fails: 0, down: false, since: null }; @@ -35,7 +32,7 @@ export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALE const res = await fetch(url, { method: 'GET', redirect: 'follow', - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(tuningMs.seconds('watchdog_timeout')), headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' }, }); ok = res.status < 500; @@ -65,5 +62,5 @@ export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALE } export function startWatchdog(client) { - setInterval(() => watchdogTick(client), CHECK_INTERVAL_MS); + everyTuned('watchdog_interval', 'minutes', () => watchdogTick(client), 'watchdog'); } diff --git a/src/bot/weekly-recap.js b/src/bot/weekly-recap.js index 920cc17..dd18b1d 100644 --- a/src/bot/weekly-recap.js +++ b/src/bot/weekly-recap.js @@ -5,6 +5,7 @@ import { weeklyStats, getSetting, setSetting } from '../db.js'; import { devlogChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js'; import { config } from '../config.js'; import { moduleEnabled } from '../modules.js'; +import { tuning } from '../tuning.js'; const CHECK_INTERVAL_MS = 5 * 60 * 1000; @@ -70,7 +71,8 @@ export function scheduleWeeklyRecap(client) { if (getSetting('weekly_recap_enabled') === '0') return; const now = new Date(); - if (now.getDay() !== 0 || now.getHours() < 20) return; // Sonntag ab 20:00 (lokale TZ) + // Wochentag und Uhrzeit kommen aus den Stellwerten (lokale TZ des Containers) + if (now.getDay() !== tuning('recap_weekday') || now.getHours() < tuning('recap_hour')) return; const today = now.toISOString().slice(0, 10); if (getSetting('last_weekly_recap') === today) return; diff --git a/src/tuning.js b/src/tuning.js new file mode 100644 index 0000000..f9523fa --- /dev/null +++ b/src/tuning.js @@ -0,0 +1,215 @@ +// Stellwerte: alle Zahlen, die vorher fest im Code standen — XP-Beträge, +// Wartezeiten, Prüf-Intervalle, Obergrenzen. +// +// 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', group: 'level', label: 'XP pro Nachricht — mindestens', + unit: 'XP', default: 15, min: 1, max: 500, + }, + { + id: 'xp_max', 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', 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', 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', 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', 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', group: 'zeiten', label: 'Wochen-Rückblick ab', + unit: 'Uhr', default: 20, min: 0, max: 23, + }, + + /* ── Prüf-Intervalle ──────────────────────────────── */ + { + id: 'monitor_interval', group: 'pruefung', label: 'Game-Server prüfen alle', + unit: 'Minuten', default: 2, min: 1, max: 120, + }, + { + id: 'monitor_timeout', group: 'pruefung', label: 'Game-Server — Antwort abwarten', + unit: 'Sekunden', default: 8, min: 2, max: 60, + }, + { + id: 'monitor_fails', 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', group: 'pruefung', label: 'Adressen prüfen alle', + unit: 'Minuten', default: 2, min: 1, max: 120, + }, + { + id: 'watchdog_timeout', group: 'pruefung', label: 'Adressen — Antwort abwarten', + unit: 'Sekunden', default: 10, min: 2, max: 60, + }, + { + id: 'watchdog_fails', group: 'pruefung', label: 'Adressen — Alarm nach', + unit: 'Fehlversuchen', default: 2, min: 1, max: 10, + }, + { + id: 'social_interval', 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', group: 'pruefung', label: 'Geburtstage prüfen alle', + unit: 'Minuten', default: 15, min: 1, max: 120, + }, + + /* ── Obergrenzen ──────────────────────────────────── */ + { + id: 'ticket_transcript_max', 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', group: 'grenzen', label: 'Bilder je Galerie-Beitrag', + unit: 'Bilder', default: 4, min: 1, max: 10, + }, + { + id: 'commits_shown', 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', 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, +}; + +/** + * 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, + 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 {'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; + const delay = () => (unit === 'minutes' ? tuningMs.minutes(id) : tuningMs.seconds(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) }; +} diff --git a/src/web/api.js b/src/web/api.js index 58ecf11..21b5d07 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -22,6 +22,7 @@ import { import crypto from 'node:crypto'; import { moduleStates, setModuleEnabled, MODULE_GROUPS, MODULES } from '../modules.js'; import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; +import { tuningStates, setTuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { sanitizeEmbed } from './api-v1.js'; @@ -185,6 +186,31 @@ export function registerApiRoutes(app, client) { return { ok: true, modules: moduleStates() }; }); + // --- Stellwerte: Zahlen, die vorher fest im Code standen --- + + app.get('/api/tuning', async (request, reply) => { + if (requireAnyScope(request, reply)) return; + return { values: tuningStates(), groups: TUNING_GROUPS }; + }); + + app.put('/api/tuning', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const body = request.body ?? {}; + // Erst alles prüfen, dann schreiben — sonst bleibt bei einem Fehler + // die Hälfte gesetzt und die andere nicht. + const pending = []; + for (const [id, value] of Object.entries(body)) { + const check = setTuning(id, value, { dryRun: true }); + if (!check.ok) return reply.code(400).send({ error: check.error }); + pending.push([id, value]); + } + for (const [id, value] of pending) setTuning(id, value); + if (pending.length > 0) { + logAudit(getSessionUser(request), 'werte geändert', pending.map(([id]) => id).join(', ')); + } + return { ok: true, values: tuningStates() }; + }); + // Vorschau der Willkommens-Karte. Nimmt das Aussehen als Query entgegen, // damit man im Panel sieht, was man einstellt, bevor gespeichert wird. app.get('/api/welcome-preview.png', async (request, reply) => {