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
View File
@@ -4,6 +4,9 @@ async function api(path, options = {}) {
if (!res.ok) { if (!res.ok) {
const error = new Error(`API ${res.status}`); const error = new Error(`API ${res.status}`);
error.status = 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; throw error;
} }
return res.json(); return res.json();
+126 -1
View File
@@ -6,7 +6,7 @@ import {
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam, IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX, IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh, IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
IconSearch, IconSearch, IconSystem as IconTune,
} from '../icons.jsx'; } from '../icons.jsx';
import { Markdown } from '../markdown.jsx'; import { Markdown } from '../markdown.jsx';
@@ -36,6 +36,8 @@ const TABS = [
find: 'funktionen an aus schalter aktivieren deaktivieren' }, find: 'funktionen an aus schalter aktivieren deaktivieren' },
{ id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick', { id: 'texte', Icon: IconMessage, label: 'Texte', section: 'ueberblick',
find: 'nachrichten vorlagen platzhalter begrüßung wortlaut' }, 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', { id: 'brand', Icon: IconBrand, label: 'Brand', section: 'auftritt',
find: 'avatar banner farbe logo name footer aussehen embed' }, find: 'avatar banner farbe logo name footer aussehen embed' },
@@ -76,6 +78,7 @@ const TAB_SCOPES = {
status: 'any', status: 'any',
module: 'settings', module: 'settings',
texte: 'settings', texte: 'settings',
werte: 'settings',
brand: null, brand: null,
feeds: 'settings', feeds: 'settings',
community: 'community', community: 'community',
@@ -227,6 +230,10 @@ export default function Settings({ me }) {
const [templateGroups, setTemplateGroups] = useState([]); const [templateGroups, setTemplateGroups] = useState([]);
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage 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, …) // Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true }; const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]); const [pages, setPages] = useState([]);
@@ -298,6 +305,10 @@ export default function Settings({ me }) {
setTemplates(d.templates); setTemplates(d.templates);
setTemplateGroups(d.groups); setTemplateGroups(d.groups);
}).catch(() => {}); }).catch(() => {});
apiGet('/api/tuning').then((d) => {
setTuningValues(d.values);
setTuningGroups(d.groups);
}).catch(() => {});
}, [me.admin, me.scopes?.length]); }, [me.admin, me.scopes?.length]);
useEffect(() => { 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 */ /** Platzhalter an der Cursor-Position einfügen */
function insertPlaceholder(tpl, key) { function insertPlaceholder(tpl, key) {
const field = document.getElementById(`tpl-${tpl.id}`); const field = document.getElementById(`tpl-${tpl.id}`);
@@ -2112,6 +2154,88 @@ export default function Settings({ me }) {
</div> </div>
); );
const tuneDirty = tuningValues.some(
(t) => tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value)
);
const tabWerte = (
<div className="settings-section">
<h2 className="settings-title">// Werte</h2>
<p className="section-intro">
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.
</p>
{tuningGroups.map((group) => {
const items = tuningValues.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.6rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="tune-list">
{items.map((t) => {
const value = tuneDraft[t.id] ?? t.value;
const dirty = tuneDraft[t.id] !== undefined && String(tuneDraft[t.id]) !== String(t.value);
return (
<div className={`tune-row${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tune-label">
<span className="tune-name">
{t.label}
{t.customized && <span className="tpl-badge">angepasst</span>}
</span>
{t.hint && <span className="tune-hint">{t.hint}</span>}
</div>
<div className="tune-input">
{t.choices ? (
<select
value={value}
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
>
{t.choices.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
) : (
<input
type="number"
min={t.min} max={t.max} step={1}
value={value}
onChange={(e) => setTuneDraft((o) => ({ ...o, [t.id]: e.target.value }))}
/>
)}
<span className="tune-unit">{t.unit}</span>
<span className="tune-range">
{t.choices ? t.choices.join(' · ') : `${t.min}${t.max}`}
{' · Standard '}{t.default}
</span>
{t.customized && (
<button
className="btn-mini"
title="Standard wiederherstellen"
onClick={() => resetTuning(t.id)}
>
Standard
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
<div className="settings-actions">
<button className="btn btn-save" disabled={!tuneDirty} onClick={saveTuning}>
Werte speichern
</button>
</div>
</div>
);
const tabSeiten = ( const tabSeiten = (
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Eigene Seiten</h2> <h2 className="settings-title">// Eigene Seiten</h2>
@@ -2300,6 +2424,7 @@ export default function Settings({ me }) {
status: tabStatus, status: tabStatus,
module: tabModule, module: tabModule,
texte: tabTexte, texte: tabTexte,
werte: tabWerte,
brand: tabBrand, brand: tabBrand,
feeds: tabFeeds, feeds: tabFeeds,
community: tabCommunity, community: tabCommunity,
+45
View File
@@ -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 */ /* Schmal ist für die Vorschau kein Platz mehr — Name und Marken reichen */
.tpl-peek { display: none; } .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; }
}
+3 -4
View File
@@ -5,8 +5,7 @@ import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '
import { brandEmbed } from '../embeds.js'; import { brandEmbed } from '../embeds.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuning, everyTuned } from '../tuning.js';
const CHECK_INTERVAL_MS = 15 * 60 * 1000;
/** Aktuelles Datum/Stunde in Europe/Berlin */ /** Aktuelles Datum/Stunde in Europe/Berlin */
function berlinNow() { function berlinNow() {
@@ -20,7 +19,7 @@ function berlinNow() {
export async function birthdayTick(client) { export async function birthdayTick(client) {
if (!moduleEnabled('birthdays')) return; if (!moduleEnabled('birthdays')) return;
const { day, month, hour, dateKey } = berlinNow(); 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 if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen
setSetting('last_birthday_run', dateKey); setSetting('last_birthday_run', dateKey);
@@ -66,6 +65,6 @@ export async function birthdayTick(client) {
} }
export function startBirthdays(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(() => {}); birthdayTick(client).catch(() => {});
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials,
import { config } from '../config.js'; import { config } from '../config.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js'; import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js'; import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js'; import { registerModTools } from './mod-tools.js';
@@ -210,7 +211,7 @@ export async function startBot() {
name: `🎫 ${interaction.user.username}`, name: `🎫 ${interaction.user.username}`,
type: ChannelType.PrivateThread, type: ChannelType.PrivateThread,
invitable: false, invitable: false,
autoArchiveDuration: 10080, autoArchiveDuration: tuning('thread_archive_days') * 1440,
}); });
await thread.members.add(interaction.user.id); await thread.members.add(interaction.user.id);
saveTicket(thread.id, 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 // Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { commitChannelId } from '../runtime-settings.js'; import { commitChannelId } from '../runtime-settings.js';
import { tuning } from '../tuning.js';
const GITEA_GREEN = 0x609926; const GITEA_GREEN = 0x609926;
const MAX_COMMITS_SHOWN = 10;
/** Erste Zeile der Commit-Message, auf maxLen gekürzt */ /** Erste Zeile der Commit-Message, auf maxLen gekürzt */
function firstLine(message, maxLen = 72) { function firstLine(message, maxLen = 72) {
@@ -29,11 +29,12 @@ export async function postPushEmbed(client, push) {
} }
const count = push.commits.length; 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}` (c) => `[\`${c.sha.slice(0, 7)}\`](${c.url}) ${firstLine(c.message)}${c.author_name}`
); );
if (count > MAX_COMMITS_SHOWN) { if (count > maxShown) {
lines.push(`… und ${count - MAX_COMMITS_SHOWN} weitere`); lines.push(`… und ${count - maxShown} weitere`);
} }
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
+2 -2
View File
@@ -9,9 +9,9 @@ import {
} from '../db.js'; } from '../db.js';
import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js'; import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { tuning } from '../tuning.js';
import { config } from '../config.js'; import { config } from '../config.js';
const MAX_GALLERY_IMAGES = 4;
// Galerie-Bilder liegen neben der SQLite: data/gallery/ // Galerie-Bilder liegen neben der SQLite: data/gallery/
export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery'); export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery');
@@ -29,7 +29,7 @@ export async function archiveGalleryMessage(message) {
const files = []; const files = [];
for (const att of message.attachments?.values?.() ?? []) { 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; if (!att.contentType?.startsWith('image/')) continue;
const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png'; const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png';
const file = `${message.id}_${files.length}.${ext}`; const file = `${message.id}_${files.length}.${ext}`;
+2 -2
View File
@@ -13,11 +13,11 @@ import {
} from '../runtime-settings.js'; } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuningMs } from '../tuning.js';
/* ── Triggers ──────────────────────────────────────── */ /* ── Triggers ──────────────────────────────────────── */
const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts
const TRIGGER_COOLDOWN_MS = 30_000;
async function handleTriggers(message) { async function handleTriggers(message) {
if (!moduleEnabled('triggers')) return; if (!moduleEnabled('triggers')) return;
@@ -26,7 +26,7 @@ async function handleTriggers(message) {
for (const t of listTriggers()) { for (const t of listTriggers()) {
if (!content.includes(t.keyword.toLowerCase())) continue; if (!content.includes(t.keyword.toLowerCase())) continue;
const key = `${message.channelId}:${t.keyword}`; 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()); triggerCooldown.set(key, Date.now());
await message.reply({ content: t.reply.slice(0, 2000), allowedMentions: { parse: [] } }).catch(() => {}); await message.reply({ content: t.reply.slice(0, 2000), allowedMentions: { parse: [] } }).catch(() => {});
break; // max. ein Trigger pro Nachricht 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 { getLevelRow, setLevelRow } from '../db.js';
import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js'; import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuningMs, xpRange } from '../tuning.js';
const XP_COOLDOWN_MS = 60_000;
const XP_MIN = 15;
const XP_SPREAD = 11; // 1525 XP pro gewerteter Nachricht
/** Kumulierte XP-Schwelle, ab der ein Level erreicht ist (MEE6-Formel pro Stufe) */ /** Kumulierte XP-Schwelle, ab der ein Level erreicht ist (MEE6-Formel pro Stufe) */
export function xpForLevel(level) { export function xpForLevel(level) {
@@ -28,9 +25,10 @@ export function levelFromXp(xp) {
*/ */
export function awardXp(userId, username, now = Date.now()) { export function awardXp(userId, username, now = Date.now()) {
const row = getLevelRow(userId) ?? { user_id: userId, username, xp: 0, level: 0, last_xp_ms: 0 }; 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 level = levelFromXp(xp);
const leveledUp = level > row.level; const leveledUp = level > row.level;
setLevelRow({ user_id: userId, username, xp, level, last_xp_ms: now }); 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 { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js';
/* ── Modmail ───────────────────────────────────────── */ /* ── Modmail ───────────────────────────────────────── */
@@ -26,7 +27,7 @@ async function ensureModmailThread(client, user) {
const thread = await channel.threads.create({ const thread = await channel.threads.create({
name: `📬 ${user.username}`, name: `📬 ${user.username}`,
autoArchiveDuration: 10080, // 7 Tage autoArchiveDuration: tuning('thread_archive_days') * 1440,
type: ChannelType.PublicThread, type: ChannelType.PublicThread,
}); });
saveModmail(user.id, thread.id, user.username); 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 { brandFooter, botStatusText } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js'; import { brandEmbed } from '../embeds.js';
import { moduleEnabled } from '../modules.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 GREEN = 0x23a55a;
const RED = 0xf23f43; const RED = 0xf23f43;
@@ -39,7 +37,7 @@ export async function queryServer(server) {
try { try {
if (server.type === 'fivem') { if (server.type === 'fivem') {
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, { 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' }, headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -53,7 +51,7 @@ export async function queryServer(server) {
} }
if (server.type === 'http') { if (server.type === 'http') {
const res = await fetch(server.query_url, { const res = await fetch(server.query_url, {
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(tuningMs.seconds('monitor_timeout')),
redirect: 'follow', redirect: 'follow',
headers: { 'User-Agent': 'd4rkbot-monitor/3.0' }, headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
}); });
@@ -65,7 +63,7 @@ export async function queryServer(server) {
host: server.host, host: server.host,
port: server.port || undefined, port: server.port || undefined,
maxRetries: 1, maxRetries: 1,
socketTimeout: TIMEOUT_MS, socketTimeout: tuningMs.seconds('monitor_timeout'),
}); });
return { return {
...base, online: true, ping: state.ping ?? Date.now() - started, ...base, online: true, ping: state.ping ?? Date.now() - started,
@@ -137,7 +135,7 @@ async function handleAlerts(client, r) {
return; return;
} }
const fails = s.fails + 1; 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); const channel = await client.channels.fetch(channelId).catch(() => null);
await channel?.send({ await channel?.send({
embeds: [ embeds: [
@@ -207,6 +205,6 @@ export async function monitorTick(client) {
} }
export function startServerMonitor(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(() => {})); client.once(Events.ClientReady, () => monitorTick(client).catch(() => {}));
} }
+2 -2
View File
@@ -9,8 +9,8 @@ import {
brandColor, brandColor2, brandFooter, brandColor, brandColor2, brandFooter,
} from '../runtime-settings.js'; } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { everyTuned } from '../tuning.js';
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
let twitchToken = null; // { token, expiresAt } let twitchToken = null; // { token, expiresAt }
async function announce(client, embed) { 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 checkYouTube(client); } catch (e) { console.error('[social] YouTube:', e.message); }
try { await checkTwitch(client); } catch (e) { console.error('[social] Twitch:', 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 { getTicket, closeTicket } from '../db.js';
import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js'; import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js';
const MAX_MESSAGES = 500;
/** Alle Thread-Nachrichten chronologisch als Text-Transcript */ /** Alle Thread-Nachrichten chronologisch als Text-Transcript */
async function fetchTranscript(channel) { async function fetchTranscript(channel) {
const all = []; const all = [];
let before; 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 }); const batch = await channel.messages.fetch({ limit: 100, before });
if (batch.size === 0) break; if (batch.size === 0) break;
all.push(...batch.values()); all.push(...batch.values());
+4 -7
View File
@@ -3,10 +3,7 @@
import { getSetting } from '../db.js'; import { getSetting } from '../db.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { tuning, tuningMs, everyTuned } from '../tuning.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;
// URL → { fails, down, since } // URL → { fails, down, since }
const state = new Map(); const state = new Map();
@@ -24,7 +21,7 @@ async function dmAdmin(client, content) {
} }
/** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */ /** 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; if (!moduleEnabled('watchdog')) return;
for (const url of watchedUrls()) { for (const url of watchedUrls()) {
const s = state.get(url) ?? { fails: 0, down: false, since: null }; 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, { const res = await fetch(url, {
method: 'GET', method: 'GET',
redirect: 'follow', redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(tuningMs.seconds('watchdog_timeout')),
headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' }, headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
}); });
ok = res.status < 500; ok = res.status < 500;
@@ -65,5 +62,5 @@ export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALE
} }
export function startWatchdog(client) { 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 { devlogChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { tuning } from '../tuning.js';
const CHECK_INTERVAL_MS = 5 * 60 * 1000; const CHECK_INTERVAL_MS = 5 * 60 * 1000;
@@ -70,7 +71,8 @@ export function scheduleWeeklyRecap(client) {
if (getSetting('weekly_recap_enabled') === '0') return; if (getSetting('weekly_recap_enabled') === '0') return;
const now = new Date(); 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); const today = now.toISOString().slice(0, 10);
if (getSetting('last_weekly_recap') === today) return; if (getSetting('last_weekly_recap') === today) return;
+215
View File
@@ -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) };
}
+26
View File
@@ -22,6 +22,7 @@ import {
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { moduleStates, setModuleEnabled, MODULE_GROUPS, MODULES } from '../modules.js'; import { moduleStates, setModuleEnabled, MODULE_GROUPS, MODULES } from '../modules.js';
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.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 { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js'; import { sanitizeEmbed } from './api-v1.js';
@@ -185,6 +186,31 @@ export function registerApiRoutes(app, client) {
return { ok: true, modules: moduleStates() }; 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, // Vorschau der Willkommens-Karte. Nimmt das Aussehen als Query entgegen,
// damit man im Panel sieht, was man einstellt, bevor gespeichert wird. // damit man im Panel sieht, was man einstellt, bevor gespeichert wird.
app.get('/api/welcome-preview.png', async (request, reply) => { app.get('/api/welcome-preview.png', async (request, reply) => {