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.choices.map((c) => {c} )}
+
+ ) : (
+ setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
+ />
+ )}
+ {t.unit}
+
+ {t.choices ? t.choices.join(' · ') : `${t.min}–${t.max}`}
+ {' · Standard '}{t.default}
+
+ {t.customized && (
+ resetTuning(t.id)}
+ >
+ Standard
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+
+ Werte speichern
+
+
+
+ );
+
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) => {