Files
d4rkbot/src/modules.js
T
D4rkst3randClaude Fable 5 137452da11
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Linked Roles: Rollen, die an geprueften Werten haengen
Neues Modul, standardmaessig aus. Ein Mitglied klickt in Discord auf "Rolle
holen", landet auf /linked-roles, bestaetigt den OAuth-Dialog — und der Bot
schiebt ihm drei Kennzahlen zu, die Discord dann gegen die Rollen-Einstellung
prueft:

  level        Zahl >= eingestelltem Wert
  dabei_seit   Datum <= "vor X Tagen", also Mitglied seit mindestens X Tagen
  playtester   Wahrheitswert

Der Bot vergibt dabei keine Rollen. Er liefert nur die Zahlen; was daraus
wird, entscheidet die Rollen-Einstellung im Server. Die Daten kommen aus dem,
was ohnehin da ist: Level-Tabelle, Playtester-Liste, Discords Beitrittsdatum.

Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind —
role_connections.write hat der normale Login nicht.

Die Tokens werden gespeichert, und das ist keine Bequemlichkeit: ohne
Refresh-Token bliebe jede Rolle auf dem Stand des Verknuepfungs-Tages stehen,
ein Level-Up kaeme nie an. Discords eigene Anleitung sagt dasselbe. Der
Umfang ist eng (nur identify + role_connections.write), beim Widerruf oder
Entkoppeln fliegt der Datensatz sofort raus. Steht so auch als Kommentar an
der Tabelle.

Abgeglichen wird alle 6 Stunden, einstellbar. Dafuer kann everyTuned jetzt
auch Stunden — und wirft bei einer unbekannten Einheit, statt den Abstand
still um Faktor 60 oder 3600 danebenzulegen.

Beim Bauen gefunden: moduleEnabled fehlte im Import von client.js. Der Bot
waere beim ClientReady mit ReferenceError gestorben.

Geprueft: alle drei Kennzahlen gegen Discords Formatgrenzen (Schluessel-Regex,
Laengen, gueltige Typ-Codes), Modul- und Stellwert-Register, die
Stunden-Umrechnung (6 h = 21600000 ms), Speichern und Loeschen einer
Verknuepfung, dazu 186 SQL-Abfragen und alle Routen auf Rechtepruefung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 05:57:57 +02:00

423 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: 'system', 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: '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: 'system', 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: 'community', default: true,
requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }],
tool: { tab: 'community', label: 'Playtester-Liste' },
},
{
id: 'alpha_keys', group: 'community', name: 'Alpha-Keys',
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
tab: 'community', default: true,
tool: { tab: 'community', label: 'Schlüssel-Pool' },
},
{
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: '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: 'support', 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: 'community', 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: '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.',
// 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: '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),
}));
}