diff --git a/README.md b/README.md index 47a203c..7325492 100644 --- a/README.md +++ b/README.md @@ -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 + 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`) Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community · Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) — diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 520dc48..b76e104 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -5,6 +5,7 @@ import { IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport, IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam, IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX, + IconHome as IconLayers, IconWarning, IconCheck, } from '../icons.jsx'; import { Markdown } from '../markdown.jsx'; @@ -17,6 +18,7 @@ const API_SCOPES = [ const TABS = [ { id: 'status', Icon: IconStatus, label: 'Status' }, + { id: 'module', Icon: IconLayers, label: 'Module' }, { id: 'brand', Icon: IconBrand, label: 'Brand' }, { id: 'feeds', Icon: IconFeeds, label: 'Feeds' }, { id: 'community', Icon: IconCommunity, label: 'Community' }, @@ -34,6 +36,7 @@ const TABS = [ // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) const TAB_SCOPES = { status: 'any', + module: 'settings', brand: null, feeds: 'settings', community: 'community', @@ -169,6 +172,9 @@ export default function Settings({ me }) { const [botDesc, setBotDesc] = useState(''); const [team, setTeam] = 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, …) const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true }; const [pages, setPages] = useState([]); @@ -226,6 +232,10 @@ export default function Settings({ me }) { apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).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]); useEffect(() => { @@ -259,6 +269,18 @@ export default function Settings({ me }) { 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) */ function slugify(value) { return String(value).toLowerCase() @@ -1680,6 +1702,60 @@ export default function Settings({ me }) { ); + const tabModule = ( +
+

// Module

+

+ 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. +

+ + {moduleGroups.map((group) => { + const mods = modules.filter((m) => m.group === group.id); + if (mods.length === 0) return null; + return ( +
+

+ {group.label} +

+
+ {mods.map((m) => ( +
+
+ {m.name} + +
+

{m.desc}

+ {m.enabled && m.missing.length > 0 && ( +

+ Fehlt noch: {m.missing.join(', ')} +

+ )} + {m.enabled && m.missing.length === 0 && ( +

Einsatzbereit

+ )} + {m.tab && ( + + )} +
+ ))} +
+
+ ); + })} +
+ ); + const tabSeiten = (

// Eigene Seiten

@@ -1866,6 +1942,7 @@ export default function Settings({ me }) { const content = { status: tabStatus, + module: tabModule, brand: tabBrand, feeds: tabFeeds, community: tabCommunity, diff --git a/frontend/src/style.css b/frontend/src/style.css index fc0ff23..0fa52d9 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1603,3 +1603,50 @@ button.with-icon, a.with-icon { justify-content: center; } @media (max-width: 640px) { .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); } diff --git a/src/bot/birthdays.js b/src/bot/birthdays.js index bdc3177..14539f8 100644 --- a/src/bot/birthdays.js +++ b/src/bot/birthdays.js @@ -3,6 +3,7 @@ import { getSetting, setSetting, birthdaysToday } from '../db.js'; import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js'; import { brandEmbed } from '../embeds.js'; +import { moduleEnabled } from '../modules.js'; const CHECK_INTERVAL_MS = 15 * 60 * 1000; @@ -16,6 +17,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 (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen diff --git a/src/bot/community.js b/src/bot/community.js index 8d17b08..c71528c 100644 --- a/src/bot/community.js +++ b/src/bot/community.js @@ -8,6 +8,7 @@ import { saveGalleryItem, hasGalleryItem, deleteGalleryItem, } from '../db.js'; import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; import { config } from '../config.js'; const MAX_GALLERY_IMAGES = 4; @@ -23,6 +24,7 @@ mkdirSync(galleryDir, { recursive: true }); * @returns {Promise} true, wenn neu gespeichert */ export async function archiveGalleryMessage(message) { + if (!moduleEnabled('gallery')) return; if (hasGalleryItem(message.id)) return false; const files = []; @@ -62,6 +64,7 @@ export async function removeGalleryMessage(messageId) { /* ── Starboard ─────────────────────────────────────── */ async function handleStarReaction(reaction) { + if (!moduleEnabled('starboard')) return; const boardChannelId = starboardChannelId(); if (!boardChannelId) return; if (reaction.emoji.name !== '⭐') return; diff --git a/src/bot/extras.js b/src/bot/extras.js index f31051a..5857b83 100644 --- a/src/bot/extras.js +++ b/src/bot/extras.js @@ -11,6 +11,7 @@ import { import { tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter, } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; /* ── Triggers ──────────────────────────────────────── */ @@ -18,6 +19,7 @@ const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts const TRIGGER_COOLDOWN_MS = 30_000; async function handleTriggers(message) { + if (!moduleEnabled('triggers')) return; const content = message.content?.toLowerCase(); if (!content) return; for (const t of listTriggers()) { @@ -33,6 +35,7 @@ async function handleTriggers(message) { /* ── Temp-Voice (Join to Create) ───────────────────── */ async function handleVoiceState(oldState, newState) { + if (!moduleEnabled('temp_voice')) return; const hubId = tempVoiceChannelId(); // Join in den Hub → eigenen Kanal erstellen und rüberschieben @@ -95,6 +98,7 @@ async function cleanupTempVoice(client) { /* ── Reminder ──────────────────────────────────────── */ export async function remindersTick(client) { + if (!moduleEnabled('reminders')) return; for (const r of dueReminders()) { markReminderSent(r.id); const user = await client.users.fetch(r.user_id).catch(() => null); @@ -105,6 +109,7 @@ export async function remindersTick(client) { /* ── Event-Ankündigung ─────────────────────────────── */ async function announceEvent(event) { + if (!moduleEnabled('events')) return; const channelId = eventsAnnounceChannelId(); if (!channelId) return; const channel = await event.client.channels.fetch(channelId).catch(() => null); diff --git a/src/bot/giveaways.js b/src/bot/giveaways.js index ec30df0..bb506bd 100644 --- a/src/bot/giveaways.js +++ b/src/bot/giveaways.js @@ -3,6 +3,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js'; import { brandFooter } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; 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 */ export async function giveawayTick(client) { + if (!moduleEnabled('giveaways')) return; for (const g of dueGiveaways()) { markGiveawayEnded(g.message_id); try { diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js index 75e759f..777a45c 100644 --- a/src/bot/mod-tools.js +++ b/src/bot/mod-tools.js @@ -2,6 +2,7 @@ import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.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 { moduleEnabled } from '../modules.js'; /* ── Modmail ───────────────────────────────────────── */ @@ -36,7 +37,7 @@ async function ensureModmailThread(client, user) { `Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` + '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; @@ -44,6 +45,7 @@ async function ensureModmailThread(client, user) { /** DM vom User → in den Staff-Thread spiegeln */ async function handleIncomingDm(client, message) { + if (!moduleEnabled('modmail')) return; const thread = await ensureModmailThread(client, message.author); if (!thread) return; // Feature aus → DM ignorieren @@ -78,6 +80,7 @@ async function handleThreadReply(client, message) { /* ── Willkommens-Embed ─────────────────────────────── */ async function handleMemberAdd(member) { + if (!moduleEnabled('welcome')) return; const channelId = welcomeChannelId(); if (!channelId) return; const channel = await member.client.channels.fetch(channelId).catch(() => null); @@ -137,6 +140,7 @@ async function restoreRoles(member) { /* ── Mod-Log ───────────────────────────────────────── */ async function logToModlog(client, embed) { + if (!moduleEnabled('modlog')) return; const channelId = modlogChannelId(); if (!channelId) return; const channel = await client.channels.fetch(channelId).catch(() => null); diff --git a/src/bot/release-feed.js b/src/bot/release-feed.js index f96726e..a781754 100644 --- a/src/bot/release-feed.js +++ b/src/bot/release-feed.js @@ -2,6 +2,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { releaseChannelId, publicUrl, brandColor2, brandFooter } from '../runtime-settings.js'; import { config } from '../config.js'; +import { moduleEnabled } from '../modules.js'; /** @@ -12,6 +13,7 @@ import { config } from '../config.js'; * @returns {Promise} true, wenn gepostet */ export async function postReleaseEmbed(client, release) { + if (!moduleEnabled('releases')) return false; const channelId = releaseChannelId(); if (!channelId) return false; diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index 8cbdeed..a892122 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -6,6 +6,7 @@ import { GameDig } from 'gamedig'; import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js'; import { brandFooter, botStatusText } from '../runtime-settings.js'; import { brandEmbed } from '../embeds.js'; +import { moduleEnabled } from '../modules.js'; const CHECK_INTERVAL_MS = 2 * 60 * 1000; const TIMEOUT_MS = 8000; @@ -154,6 +155,7 @@ async function handleAlerts(client, r) { /** Ein Poll-Durchlauf — exportiert für Tests und den Timer */ export async function monitorTick(client) { + if (!moduleEnabled('server_monitor')) return; const servers = listGameservers(); if (servers.length === 0) return; diff --git a/src/bot/social-notify.js b/src/bot/social-notify.js index 20bba9b..af45b06 100644 --- a/src/bot/social-notify.js +++ b/src/bot/social-notify.js @@ -8,6 +8,7 @@ import { twitchChannel, twitchClientId, twitchClientSecret, brandColor, brandColor2, brandFooter, } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; const CHECK_INTERVAL_MS = 5 * 60 * 1000; let twitchToken = null; // { token, expiresAt } @@ -22,6 +23,7 @@ async function announce(client, embed) { /* ── YouTube (RSS, ohne API-Key) ───────────────────── */ async function checkYouTube(client) { + if (!moduleEnabled('social')) return; const channelId = youtubeChannelId(); if (!channelId) return; 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) { + if (!moduleEnabled('social')) return; const login = twitchChannel(); if (!login || !twitchClientId() || !twitchClientSecret()) return; diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index 6a872be..2b83219 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -2,6 +2,7 @@ // URLs kommen aus dem Setting watchdog_urls (Setup-Seite), leer = deaktiviert. 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) @@ -24,6 +25,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 } = {}) { + if (!moduleEnabled('watchdog')) return; for (const url of watchedUrls()) { const s = state.get(url) ?? { fails: 0, down: false, since: null }; diff --git a/src/bot/weekly-recap.js b/src/bot/weekly-recap.js index de26070..920cc17 100644 --- a/src/bot/weekly-recap.js +++ b/src/bot/weekly-recap.js @@ -4,6 +4,7 @@ import { EmbedBuilder } from 'discord.js'; 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'; const CHECK_INTERVAL_MS = 5 * 60 * 1000; @@ -32,6 +33,7 @@ function buildBars(perDay) { /** Rückblick-Embed bauen und in den Devlog-Kanal posten */ export async function postWeeklyRecap(client) { + if (!moduleEnabled('weekly_recap')) return; const channelId = devlogChannelId(); if (!channelId) throw new Error('Kein Devlog-Kanal konfiguriert'); diff --git a/src/modules.js b/src/modules.js new file mode 100644 index 0000000..bf87992 --- /dev/null +++ b/src/modules.js @@ -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), + })); +} diff --git a/src/web/api.js b/src/web/api.js index d11bb65..dcb49bb 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -20,6 +20,7 @@ import { savePage, getPage, listPages, listPublishedPages, deletePage, } from '../db.js'; import crypto from 'node:crypto'; +import { moduleStates, setModuleEnabled, MODULE_GROUPS } from '../modules.js'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { sanitizeEmbed } from './api-v1.js'; @@ -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, …) --- // Adressen, die schon vom Frontend oder Server belegt sind diff --git a/src/web/scheduled-posts.js b/src/web/scheduled-posts.js index 0675f32..bc57dc1 100644 --- a/src/web/scheduled-posts.js +++ b/src/web/scheduled-posts.js @@ -4,6 +4,7 @@ import { EmbedBuilder } from 'discord.js'; import { dueScheduledPosts, markScheduledRun } from '../db.js'; import { brandColor, brandName } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; 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 */ export async function scheduledPostsTick(client) { + if (!moduleEnabled('scheduled_posts')) return; for (const post of dueScheduledPosts()) { try { const channel = await client.channels.fetch(post.channel_id).catch(() => null);