/radio holt den Bot in den Sprachkanal und postet eine Tafel: Auswahlmenue mit allen Sendern, Stopp, und ein Embed mit dem laufenden Titel, das sich alle 30 Sekunden selbst nachfuehrt. Der Titel kommt aus dem Icecast-Strom selbst (Icy-MetaData). Bewusst so und nicht ueber eine Sender-API: das funktioniert bei jedem Strom, den jemand eintraegt, und nicht nur bei den drei, die ich kenne. Leere Titel-Bloecke heissen "unveraendert", deshalb werden bis zu drei gelesen. Die Tonkette braucht keine Opus-Bibliothek: ffmpeg holt das MP3 und gibt direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete in fuenf Sekunden. Sonst muesste @discordjs/opus mit ins Image, und das will gebaut werden. Neu im Image ist nur ffmpeg. 44 RauteMusik-Sender sind ab Werk eingetragen — dieselben, die im LS25 und ETS als Bordradio laufen. Die Liste ist nicht abgeschrieben, sondern gemessen: jede Adresse einzeln angefragt, der Name kommt aus icy-name. Doppelgaenger sind raus (deutschrap-charts und wackenradio liefern denselben Strom wie deutschrap und metal). Eingetragen wird einmal, solange die Liste leer ist — wer loescht, behaelt es geloescht. Ein Auswahlmenue fasst nur 25 Eintraege. Bei 44 Sendern haette ein Deckel die letzten 19 stillschweigend verschluckt; stattdessen mehrere Menues, benannt nach ihrem ersten und letzten Sender. Der Bot geht raus, wenn der Kanal leer ist, und kommt nach einem Neustart in den Kanal zurueck — sonst beendet jeder Deploy die Musik endgueltig. Kein YouTube. Genau daran sind Groovy und Rythm gestorben, und was davon uebrig ist, ist ein Dauerlauf gegen kaputte Extraktoren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
25 KiB
JavaScript
507 lines
25 KiB
JavaScript
// 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' },
|
||
// Alles, was an einem Spiel hängt. Vorher stand der Game-Server-Monitor
|
||
// zwischen DB-Sicherung und Member-Gate — technisch verwandt, aber wer
|
||
// seinen Server einträgt, sucht ihn nicht unter „Technik".
|
||
{ id: 'gaming', label: 'Gaming' },
|
||
{ 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: 'radio', group: 'community', name: 'Radio',
|
||
desc: 'Webradio im Sprachkanal — /radio öffnet eine Tafel mit Sender-Auswahl zum '
|
||
+ 'Durchschalten. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, '
|
||
+ 'und kommt nach einem Neustart in den Kanal zurück.',
|
||
tab: 'community', setting: 'radio_enabled', defaultOn: false,
|
||
tool: { tab: 'community', label: 'Sender verwalten' },
|
||
},
|
||
{
|
||
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' },
|
||
],
|
||
},
|
||
|
||
/* ── Gaming ──────────────────────────────────── */
|
||
{
|
||
id: 'server_monitor', group: 'gaming', name: 'Game-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' },
|
||
// Optional. GameDig FRAGT einen Server -- ein startender antwortet
|
||
// nicht, und das sieht aus wie ein Ausfall. Das Panel SIEHT den
|
||
// Container und kann "startet gerade" von "aus" unterscheiden.
|
||
{ setting: 'panel_url', label: 'Panel-Adresse', art: 'text',
|
||
hint: 'optional, z. B. https://panel.d4rkst3r.de — unterscheidet „startet gerade" von „offline"' },
|
||
{ setting: 'panel_zeichen', label: 'Panel-Zeichen', art: 'text',
|
||
hint: 'im Panel unter „Zugang für Programme" anlegen; darf nur lesen' },
|
||
],
|
||
tool: { tab: 'server', label: 'Game-Server verwalten' },
|
||
},
|
||
{
|
||
id: 'ls_farm', group: 'gaming', name: 'LS — Höfe & Preise', nav: true,
|
||
desc: 'Landwirtschafts-Simulator: Besitzkarte, Preiskurven übers Jahr und je Hof ein '
|
||
+ 'Embed mit Land, Fuhrpark und was in die Werkstatt muss. 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_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: 'Aktueller Monat', art: 'text', hint: 'z. B. „September" oder „9" — steht nicht im Feed, deshalb einmal eintragen. Danach zählt der Bot bei jedem Tageswechsel selbst weiter. Leer 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 Einstellungs-Schlüssel aus der Modulliste, nach Art sortiert.
|
||
*
|
||
* Der Speicher-Handler hatte für jede Art eine von Hand gepflegte Liste. Wer
|
||
* ein Modul ergänzte und diese Listen nicht mitpflegte, bekam ein Feld, das
|
||
* sich bedienen liess und beim Speichern stillschweigend verfiel — ohne
|
||
* Fehlermeldung, weil unbekannte Schlüssel einfach durchfielen. Genau das ist
|
||
* beim LS-Modul passiert.
|
||
*
|
||
* Bei `fields` sagt `art`, worum es geht. Bei `requires` steht das nicht dabei
|
||
* — dort stehen Kanäle neben Rollen, dem Gitea-Token und der Adressliste des
|
||
* Wächters. Deshalb wird dort nur übernommen, was der Namenskonvention folgt:
|
||
* `…_channel_id` ist ein Kanal, `…_role_id` eine Rolle. Alles andere bleibt
|
||
* aussen vor und wird weiter von seinem eigenen Zweig behandelt. Ein Token in
|
||
* die Kanal-Prüfung zu schicken hiesse „Kanal nicht gefunden" beim Speichern.
|
||
*
|
||
* @returns {{kanal: string[], rolle: string[], text: string[], farbe: string[], zahl: string[], schalter: string[]}}
|
||
*/
|
||
export function moduleSettingKeys() {
|
||
const raus = { kanal: [], rolle: [], text: [], farbe: [], zahl: [], schalter: [] };
|
||
for (const mod of MODULES) {
|
||
for (const r of mod.requires ?? []) {
|
||
if (!r.setting) continue;
|
||
if (r.setting.endsWith('_channel_id')) raus.kanal.push(r.setting);
|
||
else if (r.setting.endsWith('_role_id')) raus.rolle.push(r.setting);
|
||
}
|
||
for (const f of mod.fields ?? []) {
|
||
const art = f.art ?? 'text';
|
||
if (f.setting && raus[art]) raus[art].push(f.setting);
|
||
}
|
||
// Der Modul-Schalter selbst ist auch eine Einstellung
|
||
if (mod.setting) raus.schalter.push(mod.setting);
|
||
}
|
||
for (const art of Object.keys(raus)) raus[art] = [...new Set(raus[art])];
|
||
return raus;
|
||
}
|
||
|
||
/** 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,
|
||
// Bekommt einen festen Platz in der Seitenleiste, unter der eigenen
|
||
// Gruppe. Für Module gedacht, die eine ganze Seite füllen — sonst
|
||
// erreicht man sie nur über das Raster oder als angeheftete Funktion,
|
||
// und dann steht ausgerechnet das grösste Modul nirgends.
|
||
nav: mod.nav === true,
|
||
enabled: moduleEnabled(mod.id),
|
||
missing: moduleMissing(mod),
|
||
}));
|
||
}
|