Modul-System: alle 35 Funktionen im Panel ein- und ausschaltbar
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher waren die Schalter über die Oberfläche verstreut: fünf Funktionen
hatten einen Haken, 22 weitere gingen nur über den Umweg „Kanal leeren".
Für jeden außer dem Owner war das nicht auffindbar.

Jetzt steht in src/modules.js ein zentrales Register aller Funktionen mit
Beschreibung, Standard-Zustand und den Einstellungen, die sie brauchen.
Daraus entsteht ein neuer Setup-Tab mit Karten: Schiebeschalter, kurze
Erklärung, Hinweis was noch fehlt ("Fehlt noch: Release-Kanal") und ein
Sprung zu den Einstellungen.

Damit die Schalter keine Attrappen sind, prüfen die Bot-Module jetzt an
18 Stellen zentral, ob sie laufen dürfen — Starboard, Galerie, Modmail,
Willkommen, Protokoll, Auto-Antworten, Sprachkanäle, Erinnerungen,
Events, Twitch/YouTube, Monitor, Wächter, Releases, Rückblick,
Geburtstage, Verlosungen und geplante Beiträge.

Funktionen mit vorhandenem Schalter (Level-System, Backups, Member-Gate …)
nutzen weiterhin dieselbe Einstellung, damit keine zweite Wahrheit
entsteht und bestehende Konfigurationen unverändert weiterlaufen.

Nebenbei gefunden und behoben: Beim Anlegen eines Modmail-Threads wurde
`member.client` benutzt, obwohl es an der Stelle kein `member` gibt — der
erste Modmail-Thread wäre mit einem Fehler abgebrochen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:53:44 +02:00
co-authored by Claude Fable 5
parent bdbd35883c
commit 59aad27534
16 changed files with 451 additions and 1 deletions
+12
View File
@@ -83,6 +83,18 @@ Login via Discord-OAuth2 (identify-Scope, signierte Session-Cookies, keine Token
Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Condensed + Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Condensed +
Share Tech Mono, selbst gehostet). Share Tech Mono, selbst gehostet).
### Module
Alle **35 Funktionen** lassen sich im Tab *Module* einzeln ein- und ausschalten —
gruppiert nach Inhalte, Community, Moderation und Server. Jede Karte zeigt, ob das
Modul einsatzbereit ist oder noch etwas fehlt (z. B. ein Kanal), und verlinkt direkt
zu seinen Einstellungen. Ein ausgeschaltetes Modul reagiert auf nichts mehr: keine
Posts, keine Hintergrund-Prüfungen.
Definiert sind sie zentral in [`src/modules.js`](src/modules.js). Funktionen mit
einem bereits vorhandenen Schalter (Level-System, Backups …) nutzen weiterhin
dieselbe Einstellung — es gibt also keine zweite Wahrheit.
### Setup-Seite (`/settings`) ### Setup-Seite (`/settings`)
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community · Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) — Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) —
+77
View File
@@ -5,6 +5,7 @@ import {
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport, IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
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,
} from '../icons.jsx'; } from '../icons.jsx';
import { Markdown } from '../markdown.jsx'; import { Markdown } from '../markdown.jsx';
@@ -17,6 +18,7 @@ const API_SCOPES = [
const TABS = [ const TABS = [
{ id: 'status', Icon: IconStatus, label: 'Status' }, { id: 'status', Icon: IconStatus, label: 'Status' },
{ id: 'module', Icon: IconLayers, label: 'Module' },
{ id: 'brand', Icon: IconBrand, label: 'Brand' }, { id: 'brand', Icon: IconBrand, label: 'Brand' },
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds' }, { id: 'feeds', Icon: IconFeeds, label: 'Feeds' },
{ id: 'community', Icon: IconCommunity, label: 'Community' }, { id: 'community', Icon: IconCommunity, label: 'Community' },
@@ -34,6 +36,7 @@ const TABS = [
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
const TAB_SCOPES = { const TAB_SCOPES = {
status: 'any', status: 'any',
module: 'settings',
brand: null, brand: null,
feeds: 'settings', feeds: 'settings',
community: 'community', community: 'community',
@@ -169,6 +172,9 @@ export default function Settings({ me }) {
const [botDesc, setBotDesc] = useState(''); const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]); const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]); const [audit, setAudit] = useState([]);
// Module (An/Aus für alle Funktionen)
const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]);
// 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([]);
@@ -226,6 +232,10 @@ export default function Settings({ me }) {
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {}); apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
apiGet('/api/modules').then((d) => {
setModules(d.modules);
setModuleGroups(d.groups);
}).catch(() => {});
}, [me.admin, me.scopes?.length]); }, [me.admin, me.scopes?.length]);
useEffect(() => { useEffect(() => {
@@ -259,6 +269,18 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins); if (res) setTeam(res.admins);
} }
async function toggleModule(id, enabled) {
// Sofort umschalten, damit sich der Schalter nicht träge anfühlt
setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled } : m)));
try {
const res = await apiPut(`/api/modules/${id}`, { enabled });
setModules(res.modules);
} catch {
setModules((old) => old.map((m) => (m.id === id ? { ...m, enabled: !enabled } : m)));
flash('✗ Umschalten fehlgeschlagen');
}
}
/** Titel → Adress-Kürzel (Umlaute mit auflösen) */ /** Titel → Adress-Kürzel (Umlaute mit auflösen) */
function slugify(value) { function slugify(value) {
return String(value).toLowerCase() return String(value).toLowerCase()
@@ -1680,6 +1702,60 @@ export default function Settings({ me }) {
</> </>
); );
const tabModule = (
<div className="settings-section">
<h2 className="settings-title">// Module</h2>
<p className="section-intro">
Jede Funktion des Bots lässt sich hier ein- und ausschalten. Ausgeschaltete
Module reagieren auf nichts mehr keine Posts, keine Befehle, keine
Hintergrund-Prüfungen. Was noch eingerichtet werden muss, steht direkt an
der Karte.
</p>
{moduleGroups.map((group) => {
const mods = modules.filter((m) => m.group === group.id);
if (mods.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.8rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="mod-grid">
{mods.map((m) => (
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
<div className="mod-head">
<span className="mod-name">{m.name}</span>
<button
className={`mod-switch${m.enabled ? ' on' : ''}`}
title={m.enabled ? 'Ausschalten' : 'Einschalten'}
onClick={() => toggleModule(m.id, !m.enabled)}
>
<span className="mod-knob" />
</button>
</div>
<p className="mod-desc">{m.desc}</p>
{m.enabled && m.missing.length > 0 && (
<p className="mod-missing with-icon">
<IconWarning size={13} /> Fehlt noch: {m.missing.join(', ')}
</p>
)}
{m.enabled && m.missing.length === 0 && (
<p className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</p>
)}
{m.tab && (
<button className="mod-link" onClick={() => setTab(m.tab)}>
Einstellungen
</button>
)}
</div>
))}
</div>
</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>
@@ -1866,6 +1942,7 @@ export default function Settings({ me }) {
const content = { const content = {
status: tabStatus, status: tabStatus,
module: tabModule,
brand: tabBrand, brand: tabBrand,
feeds: tabFeeds, feeds: tabFeeds,
community: tabCommunity, community: tabCommunity,
+47
View File
@@ -1603,3 +1603,50 @@ button.with-icon, a.with-icon { justify-content: center; }
@media (max-width: 640px) { @media (max-width: 640px) {
.cmd-name { min-width: 0; } .cmd-name { min-width: 0; }
} }
/* ── MODUL-KARTEN ──────────────────────────────────── */
.mod-grid {
display: grid; gap: .9rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.mod-card {
display: flex; flex-direction: column; gap: .5rem;
padding: 1.1rem 1.2rem; opacity: .6;
transition: opacity .2s, border-color .2s;
}
.mod-card.on { opacity: 1; }
.mod-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.mod-name {
font-family: var(--display); font-size: 1.2rem;
letter-spacing: .04em; color: var(--text);
}
.mod-desc { color: var(--muted); font-weight: 300; font-size: .95rem; line-height: 1.45; flex: 1; }
.mod-missing, .mod-ready {
font-family: var(--mono); font-size: .62rem;
letter-spacing: .1em; text-transform: uppercase;
}
.mod-missing { color: var(--neon2); }
.mod-ready { color: var(--success); }
.mod-link {
align-self: flex-start; background: none; border: 0; padding: 0;
font-family: var(--mono); font-size: .62rem; letter-spacing: .15em;
text-transform: uppercase; color: var(--muted2); cursor: pointer;
transition: color .15s;
}
.mod-link:hover { color: var(--neon); }
/* Schiebeschalter */
.mod-switch {
flex: none; width: 42px; height: 24px; border-radius: 999px;
background: var(--bg3); border: 1px solid var(--border);
position: relative; cursor: pointer; padding: 0;
transition: background .2s, border-color .2s;
}
.mod-switch.on { background: var(--neon); border-color: var(--neon); }
.mod-knob {
position: absolute; top: 2px; left: 2px;
width: 18px; height: 18px; border-radius: 50%;
background: var(--muted2); transition: transform .2s, background .2s;
}
.mod-switch.on .mod-knob { transform: translateX(18px); background: #0a0a0a; }
.mod-switch:hover { border-color: var(--neon); }
+2
View File
@@ -3,6 +3,7 @@
import { getSetting, setSetting, birthdaysToday } from '../db.js'; import { getSetting, setSetting, birthdaysToday } from '../db.js';
import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js'; import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js'; import { brandEmbed } from '../embeds.js';
import { moduleEnabled } from '../modules.js';
const CHECK_INTERVAL_MS = 15 * 60 * 1000; const CHECK_INTERVAL_MS = 15 * 60 * 1000;
@@ -16,6 +17,7 @@ function berlinNow() {
} }
export async function birthdayTick(client) { export async function birthdayTick(client) {
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 < 9) return; // erst ab 09:00
if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen
+3
View File
@@ -8,6 +8,7 @@ import {
saveGalleryItem, hasGalleryItem, deleteGalleryItem, saveGalleryItem, hasGalleryItem, deleteGalleryItem,
} 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 { config } from '../config.js'; import { config } from '../config.js';
const MAX_GALLERY_IMAGES = 4; const MAX_GALLERY_IMAGES = 4;
@@ -23,6 +24,7 @@ mkdirSync(galleryDir, { recursive: true });
* @returns {Promise<boolean>} true, wenn neu gespeichert * @returns {Promise<boolean>} true, wenn neu gespeichert
*/ */
export async function archiveGalleryMessage(message) { export async function archiveGalleryMessage(message) {
if (!moduleEnabled('gallery')) return;
if (hasGalleryItem(message.id)) return false; if (hasGalleryItem(message.id)) return false;
const files = []; const files = [];
@@ -62,6 +64,7 @@ export async function removeGalleryMessage(messageId) {
/* ── Starboard ─────────────────────────────────────── */ /* ── Starboard ─────────────────────────────────────── */
async function handleStarReaction(reaction) { async function handleStarReaction(reaction) {
if (!moduleEnabled('starboard')) return;
const boardChannelId = starboardChannelId(); const boardChannelId = starboardChannelId();
if (!boardChannelId) return; if (!boardChannelId) return;
if (reaction.emoji.name !== '⭐') return; if (reaction.emoji.name !== '⭐') return;
+5
View File
@@ -11,6 +11,7 @@ import {
import { import {
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter, tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
} from '../runtime-settings.js'; } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
/* ── Triggers ──────────────────────────────────────── */ /* ── Triggers ──────────────────────────────────────── */
@@ -18,6 +19,7 @@ const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts
const TRIGGER_COOLDOWN_MS = 30_000; const TRIGGER_COOLDOWN_MS = 30_000;
async function handleTriggers(message) { async function handleTriggers(message) {
if (!moduleEnabled('triggers')) return;
const content = message.content?.toLowerCase(); const content = message.content?.toLowerCase();
if (!content) return; if (!content) return;
for (const t of listTriggers()) { for (const t of listTriggers()) {
@@ -33,6 +35,7 @@ async function handleTriggers(message) {
/* ── Temp-Voice (Join to Create) ───────────────────── */ /* ── Temp-Voice (Join to Create) ───────────────────── */
async function handleVoiceState(oldState, newState) { async function handleVoiceState(oldState, newState) {
if (!moduleEnabled('temp_voice')) return;
const hubId = tempVoiceChannelId(); const hubId = tempVoiceChannelId();
// Join in den Hub → eigenen Kanal erstellen und rüberschieben // Join in den Hub → eigenen Kanal erstellen und rüberschieben
@@ -95,6 +98,7 @@ async function cleanupTempVoice(client) {
/* ── Reminder ──────────────────────────────────────── */ /* ── Reminder ──────────────────────────────────────── */
export async function remindersTick(client) { export async function remindersTick(client) {
if (!moduleEnabled('reminders')) return;
for (const r of dueReminders()) { for (const r of dueReminders()) {
markReminderSent(r.id); markReminderSent(r.id);
const user = await client.users.fetch(r.user_id).catch(() => null); const user = await client.users.fetch(r.user_id).catch(() => null);
@@ -105,6 +109,7 @@ export async function remindersTick(client) {
/* ── Event-Ankündigung ─────────────────────────────── */ /* ── Event-Ankündigung ─────────────────────────────── */
async function announceEvent(event) { async function announceEvent(event) {
if (!moduleEnabled('events')) return;
const channelId = eventsAnnounceChannelId(); const channelId = eventsAnnounceChannelId();
if (!channelId) return; if (!channelId) return;
const channel = await event.client.channels.fetch(channelId).catch(() => null); const channel = await event.client.channels.fetch(channelId).catch(() => null);
+2
View File
@@ -3,6 +3,7 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js'; import { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js';
import { brandFooter } from '../runtime-settings.js'; import { brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
const CHECK_INTERVAL_MS = 60 * 1000; const CHECK_INTERVAL_MS = 60 * 1000;
@@ -18,6 +19,7 @@ function drawWinners(entries, count) {
/** Fällige Giveaways beenden — exportiert für Tests und den Timer */ /** Fällige Giveaways beenden — exportiert für Tests und den Timer */
export async function giveawayTick(client) { export async function giveawayTick(client) {
if (!moduleEnabled('giveaways')) return;
for (const g of dueGiveaways()) { for (const g of dueGiveaways()) {
markGiveawayEnded(g.message_id); markGiveawayEnded(g.message_id);
try { try {
+5 -1
View File
@@ -2,6 +2,7 @@
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js'; import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js'; import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js'; import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
/* ── Modmail ───────────────────────────────────────── */ /* ── Modmail ───────────────────────────────────────── */
@@ -36,7 +37,7 @@ async function ensureModmailThread(client, user) {
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` + `Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.' 'Antworten in diesem Thread gehen als DM an den User.'
) )
.setFooter({ text: brandFooter('MODMAIL'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) }), .setFooter({ text: brandFooter('MODMAIL'), iconURL: client.user?.displayAvatarURL?.({ size: 64 }) }),
], ],
}); });
return thread; return thread;
@@ -44,6 +45,7 @@ async function ensureModmailThread(client, user) {
/** DM vom User → in den Staff-Thread spiegeln */ /** DM vom User → in den Staff-Thread spiegeln */
async function handleIncomingDm(client, message) { async function handleIncomingDm(client, message) {
if (!moduleEnabled('modmail')) return;
const thread = await ensureModmailThread(client, message.author); const thread = await ensureModmailThread(client, message.author);
if (!thread) return; // Feature aus → DM ignorieren if (!thread) return; // Feature aus → DM ignorieren
@@ -78,6 +80,7 @@ async function handleThreadReply(client, message) {
/* ── Willkommens-Embed ─────────────────────────────── */ /* ── Willkommens-Embed ─────────────────────────────── */
async function handleMemberAdd(member) { async function handleMemberAdd(member) {
if (!moduleEnabled('welcome')) return;
const channelId = welcomeChannelId(); const channelId = welcomeChannelId();
if (!channelId) return; if (!channelId) return;
const channel = await member.client.channels.fetch(channelId).catch(() => null); const channel = await member.client.channels.fetch(channelId).catch(() => null);
@@ -137,6 +140,7 @@ async function restoreRoles(member) {
/* ── Mod-Log ───────────────────────────────────────── */ /* ── Mod-Log ───────────────────────────────────────── */
async function logToModlog(client, embed) { async function logToModlog(client, embed) {
if (!moduleEnabled('modlog')) return;
const channelId = modlogChannelId(); const channelId = modlogChannelId();
if (!channelId) return; if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null); const channel = await client.channels.fetch(channelId).catch(() => null);
+2
View File
@@ -2,6 +2,7 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import { releaseChannelId, publicUrl, brandColor2, brandFooter } from '../runtime-settings.js'; import { releaseChannelId, publicUrl, brandColor2, brandFooter } from '../runtime-settings.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { moduleEnabled } from '../modules.js';
/** /**
@@ -12,6 +13,7 @@ import { config } from '../config.js';
* @returns {Promise<boolean>} true, wenn gepostet * @returns {Promise<boolean>} true, wenn gepostet
*/ */
export async function postReleaseEmbed(client, release) { export async function postReleaseEmbed(client, release) {
if (!moduleEnabled('releases')) return false;
const channelId = releaseChannelId(); const channelId = releaseChannelId();
if (!channelId) return false; if (!channelId) return false;
+2
View File
@@ -6,6 +6,7 @@ import { GameDig } from 'gamedig';
import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js'; import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js';
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';
const CHECK_INTERVAL_MS = 2 * 60 * 1000; const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000; const TIMEOUT_MS = 8000;
@@ -154,6 +155,7 @@ async function handleAlerts(client, r) {
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */ /** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) { export async function monitorTick(client) {
if (!moduleEnabled('server_monitor')) return;
const servers = listGameservers(); const servers = listGameservers();
if (servers.length === 0) return; if (servers.length === 0) return;
+3
View File
@@ -8,6 +8,7 @@ import {
twitchChannel, twitchClientId, twitchClientSecret, twitchChannel, twitchClientId, twitchClientSecret,
brandColor, brandColor2, brandFooter, brandColor, brandColor2, brandFooter,
} from '../runtime-settings.js'; } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
const CHECK_INTERVAL_MS = 5 * 60 * 1000; const CHECK_INTERVAL_MS = 5 * 60 * 1000;
let twitchToken = null; // { token, expiresAt } let twitchToken = null; // { token, expiresAt }
@@ -22,6 +23,7 @@ async function announce(client, embed) {
/* ── YouTube (RSS, ohne API-Key) ───────────────────── */ /* ── YouTube (RSS, ohne API-Key) ───────────────────── */
async function checkYouTube(client) { async function checkYouTube(client) {
if (!moduleEnabled('social')) return;
const channelId = youtubeChannelId(); const channelId = youtubeChannelId();
if (!channelId) return; if (!channelId) return;
const res = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, { const res = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, {
@@ -67,6 +69,7 @@ async function getTwitchToken() {
} }
async function checkTwitch(client) { async function checkTwitch(client) {
if (!moduleEnabled('social')) return;
const login = twitchChannel(); const login = twitchChannel();
if (!login || !twitchClientId() || !twitchClientSecret()) return; if (!login || !twitchClientId() || !twitchClientSecret()) return;
+2
View File
@@ -2,6 +2,7 @@
// URLs kommen aus dem Setting watchdog_urls (Setup-Seite), leer = deaktiviert. // URLs kommen aus dem Setting watchdog_urls (Setup-Seite), leer = deaktiviert.
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';
const CHECK_INTERVAL_MS = 2 * 60 * 1000; const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const FAILS_BEFORE_ALERT = 2; // 2 Fehlschläge in Folge → Alarm (gegen Flattern) const FAILS_BEFORE_ALERT = 2; // 2 Fehlschläge in Folge → Alarm (gegen Flattern)
@@ -24,6 +25,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 = FAILS_BEFORE_ALERT } = {}) {
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 };
+2
View File
@@ -4,6 +4,7 @@ import { EmbedBuilder } from 'discord.js';
import { weeklyStats, getSetting, setSetting } from '../db.js'; 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';
const CHECK_INTERVAL_MS = 5 * 60 * 1000; const CHECK_INTERVAL_MS = 5 * 60 * 1000;
@@ -32,6 +33,7 @@ function buildBars(perDay) {
/** Rückblick-Embed bauen und in den Devlog-Kanal posten */ /** Rückblick-Embed bauen und in den Devlog-Kanal posten */
export async function postWeeklyRecap(client) { export async function postWeeklyRecap(client) {
if (!moduleEnabled('weekly_recap')) return;
const channelId = devlogChannelId(); const channelId = devlogChannelId();
if (!channelId) throw new Error('Kein Devlog-Kanal konfiguriert'); if (!channelId) throw new Error('Kein Devlog-Kanal konfiguriert');
+265
View File
@@ -0,0 +1,265 @@
// Modul-Register: eine Stelle, an der alle Funktionen des Bots stehen.
//
// Jedes Modul hat einen Schalter im Webinterface. Damit nichts kaputtgeht,
// nutzen Funktionen mit einem bereits vorhandenen Schalter (etwa das
// Level-System) weiterhin denselben Einstellungs-Namen — es entsteht also
// keine zweite Wahrheit.
//
// `requires` beschreibt, was gesetzt sein muss, damit ein Modul wirklich
// arbeiten kann. Fehlt etwas, zeigt das Panel „noch nicht einsatzbereit"
// statt das Modul stillschweigend nichts tun zu lassen.
import { getSetting, setSetting } from './db.js';
export const MODULE_GROUPS = [
{ id: 'inhalte', label: 'Inhalte & Feeds' },
{ id: 'community', label: 'Community' },
{ id: 'moderation', label: 'Moderation & Support' },
{ id: 'server', label: 'Server & Technik' },
];
export const MODULES = [
/* ── Inhalte & Feeds ──────────────────────────── */
{
id: 'devlogs', group: 'inhalte', name: 'Devlogs',
desc: 'Entwicklungs-Berichte als gebrandetes Embed posten und durchsuchbar archivieren.',
tab: 'feeds', default: true,
requires: [{ setting: 'devlog_channel_id', label: 'Devlog-Kanal' }],
},
{
id: 'commit_feed', group: 'inhalte', name: 'Commit-Feed',
desc: 'Pushes aus Gitea landen als Embed im Kanal. Archiviert wird unabhängig davon immer.',
tab: 'feeds', setting: 'commit_feed_enabled', defaultOn: true,
requires: [{ setting: 'commit_channel_id', label: 'Commit-Kanal' }],
},
{
id: 'releases', group: 'inhalte', name: 'Release-Ankündigungen',
desc: 'Neue Releases werden angekündigt und landen im öffentlichen Changelog.',
tab: 'feeds', default: true,
requires: [{ setting: 'release_channel_id', label: 'Release-Kanal' }],
},
{
id: 'weekly_recap', group: 'inhalte', name: 'Wochen-Rückblick',
desc: 'Sonntags um 20 Uhr eine Zusammenfassung der Woche.',
tab: 'feeds', setting: 'weekly_recap_enabled', defaultOn: true,
requires: [{ setting: 'devlog_channel_id', label: 'Devlog-Kanal' }],
},
{
id: 'devlog_threads', group: 'inhalte', name: 'Devlog-Threads',
desc: 'Unter jedem Devlog entsteht ein Diskussions-Thread.',
tab: 'feeds', setting: 'devlog_threads_enabled', defaultOn: true,
},
{
id: 'social', group: 'inhalte', name: 'Twitch & YouTube',
desc: 'Meldet Livestreams und neue Videos.',
tab: 'system', default: true,
requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal' }],
},
/* ── Community ────────────────────────────────── */
{
id: 'levels', group: 'community', name: 'Level-System',
desc: 'XP fürs Mitreden, Rollen-Belohnungen und öffentliche Bestenliste.',
tab: 'community', setting: 'levels_enabled', defaultOn: false,
},
{
id: 'starboard', group: 'community', name: 'Starboard',
desc: 'Nachrichten mit genug Sternen landen in einem Best-of-Kanal.',
tab: 'community', default: true,
requires: [{ setting: 'starboard_channel_id', label: 'Starboard-Kanal' }],
},
{
id: 'gallery', group: 'community', name: 'Screenshot-Galerie',
desc: 'Bilder aus einem Kanal erscheinen in der öffentlichen Galerie.',
tab: 'community', default: true,
requires: [{ setting: 'screenshot_channel_id', label: 'Screenshot-Kanal' }],
},
{
id: 'voting', group: 'community', name: 'Feature-Wünsche',
desc: 'Wünsche einreichen und abstimmen — im Discord und auf der Roadmap.',
tab: 'community', default: true,
requires: [{ setting: 'voting_channel_id', label: 'Voting-Kanal' }],
},
{
id: 'giveaways', group: 'community', name: 'Verlosungen',
desc: 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung möglich.',
tab: 'community', default: true,
},
{
id: 'birthdays', group: 'community', name: 'Geburtstage',
desc: 'Gratulation am großen Tag, optional mit Tages-Rolle.',
tab: 'community', default: true,
requires: [{ setting: 'birthday_channel_id', label: 'Geburtstags-Kanal' }],
},
{
id: 'events', group: 'community', name: 'Event-Ankündigungen',
desc: 'Neue Discord-Events werden angekündigt und erscheinen auf der Events-Seite.',
tab: 'system', default: true,
requires: [{ setting: 'events_announce_channel_id', label: 'Event-Kanal' }],
},
{
id: 'playtester', group: 'community', name: 'Playtester-Programm',
desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.',
tab: 'community', default: true,
requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle' }],
},
{
id: 'alpha_keys', group: 'community', name: 'Alpha-Keys',
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
tab: 'community', default: true,
},
{
id: 'role_menus', group: 'community', name: 'Rollen-Menüs',
desc: 'Selbstbedienungs-Rollen per Knopf — im Discord und im Profil.',
tab: 'rollen', default: true,
},
{
id: 'applications', group: 'community', name: 'Bewerbungen',
desc: 'Formulare als Eingabefenster, Prüfung mit Annehmen/Ablehnen.',
tab: 'bewerbungen', default: true,
},
/* ── Moderation & Support ─────────────────────── */
{
id: 'moderation', group: 'moderation', name: 'Moderation',
desc: 'Verwarnen, Auszeiten, Aufräumen — mit Verlauf und Protokoll.',
tab: 'support', default: true,
},
{
id: 'modlog', group: 'moderation', name: 'Protokoll',
desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.',
tab: 'support', default: true,
requires: [{ setting: 'modlog_channel_id', label: 'Protokoll-Kanal' }],
},
{
id: 'modmail', group: 'moderation', name: 'Modmail',
desc: 'Direktnachrichten an den Bot landen als Thread beim Team.',
tab: 'support', default: true,
requires: [{ setting: 'modmail_channel_id', label: 'Modmail-Kanal' }],
},
{
id: 'tickets', group: 'moderation', name: 'Tickets',
desc: 'Private Threads per Knopf, beim Schließen ein Gesprächsprotokoll.',
tab: 'support', default: true,
requires: [{ setting: 'ticket_channel_id', label: 'Ticket-Kanal' }],
},
{
id: 'welcome', group: 'moderation', name: 'Willkommens-Karten',
desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.',
tab: 'support', default: true,
requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal' }],
},
{
id: 'autorole', group: 'moderation', name: 'Auto-Rolle',
desc: 'Neue Mitglieder bekommen automatisch eine Startrolle.',
tab: 'community', default: true,
requires: [{ setting: 'autorole_id', label: 'Auto-Rolle' }],
},
{
id: 'sticky_roles', group: 'moderation', name: 'Rollen merken',
desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.',
tab: 'community', setting: 'sticky_roles_enabled', defaultOn: false,
},
{
id: 'bug_reports', group: 'moderation', name: 'Fehlermeldungen',
desc: 'Meldungen werden zu Issues im Repository, mit Rückmeldung beim Schließen.',
tab: 'system', default: true,
requires: [{ setting: 'gitea_api_token', label: 'Gitea-Token' }],
},
/* ── Server & Technik ─────────────────────────── */
{
id: 'server_monitor', group: 'server', name: 'Server-Monitor',
desc: 'Live-Status der Game-Server mit Verlauf und Ausfall-Alarm.',
tab: 'server', default: true,
},
{
id: 'watchdog', group: 'server', name: 'Erreichbarkeits-Wächter',
desc: 'Prüft hinterlegte Adressen und meldet Ausfälle per Direktnachricht.',
tab: 'system', default: true,
requires: [{ setting: 'watchdog_urls', label: 'Adressen' }],
},
{
id: 'temp_voice', group: 'server', name: 'Temporäre Sprachkanäle',
desc: 'Beitritt zum Hub erzeugt einen eigenen Kanal mit Bedienfeld.',
tab: 'system', default: true,
requires: [{ setting: 'tempvoice_channel_id', label: 'Hub-Kanal' }],
},
{
id: 'triggers', group: 'server', name: 'Auto-Antworten',
desc: 'Der Bot antwortet auf hinterlegte Schlüsselwörter.',
tab: 'composer', default: true,
},
{
id: 'reminders', group: 'server', name: 'Erinnerungen',
desc: 'Mitglieder lassen sich per Direktnachricht erinnern.',
tab: 'system', default: true,
},
{
id: 'scheduled_posts', group: 'server', name: 'Geplante Beiträge',
desc: 'Nachrichten zu festen Zeiten, einmalig oder wiederkehrend.',
tab: 'composer', default: true,
},
{
id: 'tags', group: 'server', name: 'Textbausteine',
desc: 'Gespeicherte Texte per Befehl abrufen.',
tab: 'composer', default: true,
},
{
id: 'backups', group: 'server', name: 'Datenbank-Sicherung',
desc: 'Nächtliche Sicherung mit Aufbewahrung über 14 Tage.',
tab: 'system', setting: 'backup_enabled', defaultOn: true,
},
{
id: 'repo_backups', group: 'server', name: 'Repository-Sicherung',
desc: 'Alle Gitea-Repositories als Bundle sichern.',
tab: 'system', setting: 'repo_backup_enabled', defaultOn: false,
},
{
id: 'member_gate', group: 'server', name: 'Login nur für Mitglieder',
desc: 'Nur wer auf dem Discord-Server ist, kann sich im Web anmelden.',
tab: 'system', setting: 'member_gate_enabled', defaultOn: true,
},
];
/** Einstellungs-Name des Schalters für ein Modul */
function toggleKey(mod) {
return mod.setting ?? `module_${mod.id}`;
}
/** Läuft dieses Modul? (unabhängig davon, ob alles Nötige konfiguriert ist) */
export function moduleEnabled(id) {
const mod = MODULES.find((m) => m.id === id);
if (!mod) return true; // unbekannt → nicht blockieren
const value = getSetting(toggleKey(mod));
if (value === '1') return true;
if (value === '0') return false;
return mod.setting ? Boolean(mod.defaultOn) : mod.default !== false;
}
/** Schalter setzen */
export function setModuleEnabled(id, on) {
const mod = MODULES.find((m) => m.id === id);
if (!mod) return false;
setSetting(toggleKey(mod), on ? '1' : '0');
return true;
}
/** Was fehlt diesem Modul noch? (leere Liste = einsatzbereit) */
export function moduleMissing(mod) {
return (mod.requires ?? [])
.filter((r) => !String(getSetting(r.setting) ?? '').trim())
.map((r) => r.label);
}
/** Alle Module mit Zustand — für das Webinterface */
export function moduleStates() {
return MODULES.map((mod) => ({
id: mod.id,
name: mod.name,
desc: mod.desc,
group: mod.group,
tab: mod.tab ?? null,
enabled: moduleEnabled(mod.id),
missing: moduleMissing(mod),
}));
}
+20
View File
@@ -20,6 +20,7 @@ import {
savePage, getPage, listPages, listPublishedPages, deletePage, savePage, getPage, listPages, listPublishedPages, deletePage,
} from '../db.js'; } from '../db.js';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { moduleStates, setModuleEnabled, MODULE_GROUPS } from '../modules.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';
@@ -164,6 +165,25 @@ export function registerApiRoutes(app, client) {
}), }),
})); }));
// --- Module: zentrale An/Aus-Schalter aller Funktionen ---
app.get('/api/modules', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return { modules: moduleStates(), groups: MODULE_GROUPS };
});
app.put('/api/modules/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const id = String(request.params.id);
const on = Boolean(request.body?.enabled);
if (!setModuleEnabled(id, on)) {
return reply.code(404).send({ error: 'Modul unbekannt' });
}
logAudit(getSessionUser(request), on ? 'modul aktiviert' : 'modul deaktiviert', id);
request.log.info(`Modul ${id}${on ? 'an' : 'aus'}`);
return { ok: true, modules: moduleStates() };
});
// --- Frei angelegte Seiten (Regeln, Über uns, …) --- // --- Frei angelegte Seiten (Regeln, Über uns, …) ---
// Adressen, die schon vom Frontend oder Server belegt sind // Adressen, die schon vom Frontend oder Server belegt sind
+2
View File
@@ -4,6 +4,7 @@
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { dueScheduledPosts, markScheduledRun } from '../db.js'; import { dueScheduledPosts, markScheduledRun } from '../db.js';
import { brandColor, brandName } from '../runtime-settings.js'; import { brandColor, brandName } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
const CHECK_INTERVAL_MS = 60 * 1000; const CHECK_INTERVAL_MS = 60 * 1000;
@@ -61,6 +62,7 @@ export function buildScheduledPayload(post) {
/** Fällige Posts senden — exportiert für Tests und den Timer */ /** Fällige Posts senden — exportiert für Tests und den Timer */
export async function scheduledPostsTick(client) { export async function scheduledPostsTick(client) {
if (!moduleEnabled('scheduled_posts')) return;
for (const post of dueScheduledPosts()) { for (const post of dueScheduledPosts()) {
try { try {
const channel = await client.channels.fetch(post.channel_id).catch(() => null); const channel = await client.channels.fetch(post.channel_id).catch(() => null);