// 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'; import { effectiveSetting } from './runtime-settings.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', create: { name: 'dev-log', topic: 'Entwicklungs-Updates vom Bot' } }], fields: [ { setting: 'devlog_ping_role_id', label: 'Ping-Rolle', art: 'rolle', hint: 'wird bei jedem Devlog erwähnt; leer = kein Ping' }, ], }, { 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', create: { name: 'commits', privat: true, topic: 'Pushes aus Gitea' } }], fields: [ { setting: 'commit_branch_filter', label: 'Branch-Filter', art: 'text', hint: 'kommagetrennt, leer = alle' }, { setting: 'ignored_repos', label: 'Ignorierte Repos', art: 'text', hint: 'kommagetrennt, volle Namen' }, ], }, { 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', create: { name: 'releases', topic: 'Neue Versionen' } }], }, { 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', create: { name: 'dev-log', topic: 'Entwicklungs-Updates vom Bot' } }], }, { 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: 'feeds', default: true, requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal', create: { name: 'streams', topic: 'Twitch- und YouTube-Meldungen' } }], fields: [ { setting: 'twitch_channel', label: 'Twitch-Kanal', art: 'text', hint: 'Login-Name, z. B. d4rkst3r' }, { setting: 'youtube_channel_id', label: 'YouTube-Kanal', art: 'text', hint: 'Kanal-ID (UC…)' }, ], }, /* ── 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, fields: [ { setting: 'levels_announce', label: 'Level-Up im Kanal feiern', art: 'schalter' }, { setting: 'level_rewards', label: 'Rollen-Belohnungen', art: 'text', hint: 'Level=Rollen-ID, kommagetrennt — z. B. 5=1234, 10=5678' }, ], }, { 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', create: { name: 'best-of', topic: 'Nachrichten mit genug Sternen' } }], fields: [ { setting: 'starboard_threshold', label: 'Schwellwert', art: 'zahl', hint: 'so viele ⭐ braucht eine Nachricht' }, ], }, { 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', create: { name: 'screenshots', topic: 'Bilder landen in der Galerie' } }], }, { 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', create: { name: 'ideen', topic: 'Wuensche und Abstimmungen' } }], }, { id: 'polls', group: 'community', name: 'Umfragen', desc: 'Schnelle Abstimmungen als native Discord-Umfrage — /umfrage.', tab: 'community', default: true, }, { 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', create: { name: 'geburtstage', topic: 'Der Bot gratuliert hier' } }], fields: [ { setting: 'birthday_role_id', label: 'Tages-Rolle', art: 'rolle', hint: 'wird am nächsten Morgen wieder entfernt' }, ], }, { id: 'events', group: 'community', name: 'Event-Ankündigungen', desc: 'Neue Discord-Events werden angekündigt und erscheinen auf der Events-Seite.', tab: 'community', default: true, requires: [{ setting: 'events_announce_channel_id', label: 'Event-Kanal', create: { name: 'events', topic: 'Playtests, Streams, Runden' } }], }, { id: 'playtester', group: 'community', name: 'Playtester-Programm', desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.', tab: 'playtester', default: true, requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }], tool: { tab: 'playtester', label: 'Playtester-Liste' }, }, { id: 'alpha_keys', group: 'community', name: 'Alpha-Keys', desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.', tab: 'playtester', default: true, tool: { tab: 'playtester', label: 'Schlüssel-Vorrat' }, }, { id: 'role_menus', group: 'community', name: 'Rollen-Menüs', desc: 'Selbstbedienungs-Rollen per Knopf — im Discord und im Profil.', tab: 'rollen', default: true, tool: { tab: 'rollen', label: 'Rollen-Menüs bauen' }, }, { id: 'applications', group: 'community', name: 'Bewerbungen', desc: 'Formulare als Eingabefenster, Prüfung mit Annehmen/Ablehnen.', tab: 'bewerbungen', default: true, tool: { tab: 'bewerbungen', label: 'Formulare bearbeiten' }, }, /* ── Moderation & Support ─────────────────────── */ { id: 'moderation', group: 'moderation', name: 'Moderation', desc: 'Verwarnen, Auszeiten, Aufräumen — mit Verlauf und Protokoll.', tab: 'support', default: true, tool: { tab: 'support', label: 'Moderation' }, }, { id: 'automod', group: 'moderation', name: 'AutoMod-Protokoll', desc: 'Treffer von Discords eigenem Filter landen im Protokoll, Regeln sind hier schaltbar.', tab: 'support', setting: 'automod_enabled', defaultOn: false, requires: [{ setting: 'modlog_channel_id', label: 'Protokoll-Kanal', create: { name: 'protokoll', privat: true, topic: 'Bearbeitet, geloescht, beigetreten' } }], }, { id: 'anti_raid', group: 'moderation', name: 'Raid-Schutz', desc: 'Erkennt Beitritts-Wellen und macht den Server vorübergehend dicht.', tab: 'support', setting: 'anti_raid_enabled', defaultOn: false, }, { 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', create: { name: 'protokoll', privat: true, topic: 'Bearbeitet, geloescht, beigetreten' } }], }, { 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', create: { name: 'modmail', privat: true, topic: 'Direktnachrichten an den Bot' } }], }, { 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', create: { name: 'support', topic: 'Tickets per Knopf' } }], }, { id: 'welcome', group: 'moderation', name: 'Willkommens-Karten', desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.', tab: 'brand', default: true, requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal', create: { name: 'willkommen', topic: 'Begruessung neuer Mitglieder' } }], fields: [ { setting: 'welcome_card_enabled', label: 'Karte rendern', art: 'schalter', hint: 'aus = nur das Text-Embed' }, { setting: 'welcome_card_kicker', label: 'Kleine Zeile', art: 'text', hint: 'über dem Namen — leer = weglassen' }, { setting: 'welcome_card_sub', label: 'Zeile darunter', art: 'text', hint: '{count} wird zur Mitglieds-Nummer' }, { setting: 'welcome_card_image', label: 'Hintergrundbild', art: 'text', hint: 'https-Adresse, 900×300 passt genau' }, { setting: 'welcome_card_bg', label: 'Hintergrund', art: 'farbe' }, { setting: 'welcome_card_accent', label: 'Akzent 1', art: 'farbe' }, { setting: 'welcome_card_accent2', label: 'Akzent 2', art: 'farbe' }, { setting: 'welcome_card_text', label: 'Name', art: 'farbe' }, { setting: 'welcome_card_watermark', label: 'Logo im Hintergrund', art: 'schalter' }, ], vorschau: 'welcome', }, { id: 'autorole', group: 'moderation', name: 'Auto-Rolle', desc: 'Neue Mitglieder bekommen automatisch eine Startrolle.', tab: 'rollen', default: true, requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }], }, { id: 'linked_roles', group: 'community', name: 'Verknüpfte Rollen', desc: 'Rollen, die an geprüfte Werte hängen — Level, Playtester, Dauer der Mitgliedschaft.', tab: 'rollen', setting: 'linked_roles_enabled', defaultOn: false, }, { id: 'sticky_roles', group: 'moderation', name: 'Rollen merken', desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.', tab: 'rollen', 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.', // Der Token steht im Brand-Bereich („Aus der Env verlagert"), nicht im // System-Bereich — sonst landet „Einrichten" an der falschen Stelle. tab: 'brand', default: true, requires: [{ setting: 'gitea_api_token', label: 'Gitea-Token' }], fields: [ { setting: 'bug_report_repo', label: 'Ziel-Repository', art: 'text', hint: 'Besitzer/Name, z. B. D4rkst3r/EcoGame' }, ], }, /* ── 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, fields: [ { setting: 'status_channel_id', label: 'Status-Kanal', art: 'kanal', hint: 'hier steht das Live-Embed' }, { setting: 'server_alert_channel_id', label: 'Alarm-Kanal', art: 'kanal', hint: '🚨 down / ✅ wieder online' }, ], tool: { tab: 'server', label: 'Game-Server verwalten' }, }, { id: 'ls_farm', group: 'server', name: 'LS — Höfe & Preise', desc: 'Landwirtschafts-Simulator: Besitzkarte mit Höfen, Fahrzeugen und Spielern, ' + 'dazu die Preiskurven übers Jahr. Braucht einen LS-Server mit Zugangs-Code unter „Game-Server".', tab: 'server', setting: 'ls_farm_enabled', requires: [{ setting: 'ls_channel_id', label: 'Karten-Kanal', create: { name: 'ls-hoefe', topic: 'Besitzkarte und Preise vom LS-Server' } }], fields: [ { setting: 'ls_event_channel_id', label: 'Ereignis-Kanal', art: 'kanal', hint: 'Land gekauft oder verkauft; leer = in den Karten-Kanal' }, { setting: 'ls_farm_names', label: 'Hof-Namen', art: 'text', hint: 'der Feed kennt nur Nummern — z. B. „1=Alexander, 2=Timo, 3=Kitty"' }, { setting: 'ls_period', label: 'Aktuelle Jahreszeit', art: 'text', hint: 'z. B. MID_SUMMER — steht nicht im Feed. Leer lassen heißt: Kurve ohne „jetzt verkaufen"-Rat.' }, { setting: 'ls_crops', label: 'Sorten in der Preisliste', art: 'text', hint: 'kommagetrennt, leer = die üblichen. Ohne Auswahl verdrängt Spargel jede echte Feldfrucht.' }, ], }, { 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' }], fields: [ { setting: 'watchdog_urls', label: 'Zu prüfende Adressen', art: 'text', hint: 'kommagetrennt, mit http(s)' }, ], }, { 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', create: { name: 'Eigenen Kanal erstellen', sprache: true } }], }, { id: 'triggers', group: 'server', name: 'Auto-Antworten', desc: 'Der Bot antwortet auf hinterlegte Schlüsselwörter.', tab: 'composer', default: true, tool: { tab: 'composer', label: 'Auto-Antworten pflegen' }, }, { 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, tool: { tab: 'composer', label: 'Geplante Beiträge' }, }, { id: 'tags', group: 'server', name: 'Textbausteine', desc: 'Gespeicherte Texte per Befehl abrufen.', tab: 'composer', default: true, tool: { tab: 'composer', label: 'Textbausteine' }, }, { id: 'backups', group: 'server', name: 'Datenbank-Sicherung', desc: 'Nächtliche Sicherung mit Aufbewahrung über 14 Tage.', tab: 'system', setting: 'backup_enabled', defaultOn: true, fields: [ { setting: 'backup_channel_id', label: 'Kanal für die Sicherung', art: 'kanal', hint: 'leer = nur lokal' }, ], }, { 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, fields: [ { setting: 'discord_invite_url', label: 'Einladungs-Link', art: 'text', hint: 'bekommt, wer nicht auf dem Server ist' }, ], }, ]; /** * Funktionen, die in der Seitenleiste stehen — die paar, die man dauernd * anfasst. Der Rest bleibt im Raster. * * Der Standard ist eine Vermutung, kein Gesetz: geändert wird er über den * Stern auf der Modul-Karte. Deckel bei acht, damit die Leiste kurz bleibt — * sonst ist der Gewinn gegenüber dem Raster wieder weg. */ export const DEFAULT_FAVORITES = ['devlogs', 'welcome', 'tickets', 'starboard', 'gallery']; export const MAX_FAVORITES = 8; /** Aktuelle Auswahl — nur existierende Module, in gespeicherter Reihenfolge */ export function moduleFavorites() { const gespeichert = String(getSetting('module_favorites') ?? '').trim(); if (!gespeichert) return DEFAULT_FAVORITES.filter((id) => MODULES.some((m) => m.id === id)); // Ein einzelner Bindestrich heißt „bewusst keine" — sonst käme der Standard zurück if (gespeichert === '-') return []; return gespeichert .split(',') .map((s) => s.trim()) .filter((id) => MODULES.some((m) => m.id === id)) .slice(0, MAX_FAVORITES); } /** * Auswahl setzen. * @returns {{ ok: true, favorites: string[] } | { ok: false, error: string }} */ export function setModuleFavorites(ids) { if (!Array.isArray(ids)) return { ok: false, error: 'Liste erwartet' }; const gefiltert = [...new Set(ids.map(String))].filter((id) => MODULES.some((m) => m.id === id)); if (gefiltert.length > MAX_FAVORITES) { return { ok: false, error: `Höchstens ${MAX_FAVORITES} Funktionen in der Seitenleiste` }; } setSetting('module_favorites', gefiltert.length ? gefiltert.join(',') : '-'); return { ok: true, favorites: gefiltert }; } /** 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) * Liefert Objekte statt nur Beschriftungen, damit das Panel weiß, was es * anlegen könnte: `{ setting, label, create }`. */ export function moduleMissing(mod) { return (mod.requires ?? []) // Effektiver Wert, nicht nur die Einstellung: was über die Umgebung // gesetzt ist, fehlt nicht. .filter((r) => !effectiveSetting(r.setting)) .map((r) => ({ setting: r.setting, label: r.label, create: r.create ?? null })); } /** Bauanleitung zu einer Einstellung — null, wenn sie sich nicht anlegen lässt */ export function creationPlan(setting) { for (const mod of MODULES) { for (const req of mod.requires ?? []) { if (req.setting === setting && req.create) return { ...req.create, label: req.label }; } } return null; } /** * Alles, was zu einem Modul gehört — für seine eigene Seite im Panel. * Bringt Kanäle/Rollen, Zusatzfelder und den Verweis auf ein Werkzeug * zusammen; Texte und Werte holt die API aus ihren eigenen Registern. */ export function moduleDetail(id) { const mod = MODULES.find((m) => m.id === id); if (!mod) return null; return { id: mod.id, name: mod.name, desc: mod.desc, group: mod.group, tab: mod.tab ?? null, enabled: moduleEnabled(mod.id), missing: moduleMissing(mod), // Voraussetzungen zuerst — ohne sie tut das Modul nichts felder: [ ...(mod.requires ?? []).map((r) => ({ setting: r.setting, label: r.label, art: r.create?.rolle ? 'rolle' : 'kanal', hint: null, pflicht: true, })), ...(mod.fields ?? []).map((f) => ({ ...f, hint: f.hint ?? null, pflicht: false })), ], tool: mod.tool ?? null, vorschau: mod.vorschau ?? null, }; } /** 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), })); }