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) => {