Files
d4rkbot/src/modules.js
T
D4rkst3randClaude Fable 5 aef708c405
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Raid-Schutz: erkennt Beitritts-Wellen und sperrt voruebergehend
Neues Modul anti_raid, standardmaessig AUS. Zaehlt Beitritte in einem
Zeitfenster; reisst es, geht der Server dicht: Verifizierung auf Hoch,
Einladungen gesperrt, DM an den Owner und Eintrag ins Protokoll.

Bewusst zurueckhaltend — es wird niemand gekickt oder gebannt. Bei einem
Fehlalarm, etwa nach einem Stream-Shoutout, waere ein Massenkick schlimmer
als der Raid. Beide Massnahmen sind reversibel und treffen niemanden, der
schon drin ist.

Der Sperrzustand liegt in den Einstellungen, nicht nur im Speicher: startet
der Bot mitten in einer Sperre neu, waere der Timer sonst weg und der Server
bliebe fuer immer dicht. Beim Start wird die Restzeit abgesessen oder, wenn
laengst abgelaufen, sofort aufgehoben.

Drei Werte im Panel: Schwelle (8), Fenster (20 s), Sperrdauer (15 min).

disableInvites() gibt es erst ab discord.js 14.4 und nur mit ManageGuild —
beides wird geprueft, statt im Ernstfall einen Fehler zu werfen.

Die Fenster-Logik ist als reine Funktion herausgezogen und mit neun Faellen
geprueft: langsame Beitritte loesen nicht aus, die Welle schon, der Rand des
Zeitfensters zaehlt nicht mehr mit, und nach Ruhe faengt das Fenster von vorn.

Offen: ein Befehl zum sofortigen Aufheben. Bis dahin laeuft die Sperre aus
oder man setzt die Verifizierung in Discord selbst zurueck.

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

418 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: '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),
}));
}