Werte: 19 feste Zahlen im Panel einstellbar
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:49:34 +02:00
co-authored by Claude Fable 5
parent fb927d4399
commit 197c2a1ada
17 changed files with 453 additions and 41 deletions
+3 -4
View File
@@ -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(() => {});
}
+2 -1
View File
@@ -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);
+5 -4
View File
@@ -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()
+2 -2
View File
@@ -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}`;
+2 -2
View File
@@ -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
+4 -6
View File
@@ -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; // 1525 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 });
+2 -1
View File
@@ -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);
+6 -8
View File
@@ -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(() => {}));
}
+2 -2
View File
@@ -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');
}
+3 -2
View File
@@ -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());
+4 -7
View File
@@ -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');
}
+3 -1
View File
@@ -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;