Modul-System: alle 35 Funktionen im Panel ein- und ausschaltbar
Bisher waren die Schalter über die Oberfläche verstreut: fünf Funktionen
hatten einen Haken, 22 weitere gingen nur über den Umweg „Kanal leeren".
Für jeden außer dem Owner war das nicht auffindbar.
Jetzt steht in src/modules.js ein zentrales Register aller Funktionen mit
Beschreibung, Standard-Zustand und den Einstellungen, die sie brauchen.
Daraus entsteht ein neuer Setup-Tab mit Karten: Schiebeschalter, kurze
Erklärung, Hinweis was noch fehlt ("Fehlt noch: Release-Kanal") und ein
Sprung zu den Einstellungen.
Damit die Schalter keine Attrappen sind, prüfen die Bot-Module jetzt an
18 Stellen zentral, ob sie laufen dürfen — Starboard, Galerie, Modmail,
Willkommen, Protokoll, Auto-Antworten, Sprachkanäle, Erinnerungen,
Events, Twitch/YouTube, Monitor, Wächter, Releases, Rückblick,
Geburtstage, Verlosungen und geplante Beiträge.
Funktionen mit vorhandenem Schalter (Level-System, Backups, Member-Gate …)
nutzen weiterhin dieselbe Einstellung, damit keine zweite Wahrheit
entsteht und bestehende Konfigurationen unverändert weiterlaufen.
Nebenbei gefunden und behoben: Beim Anlegen eines Modmail-Threads wurde
`member.client` benutzt, obwohl es an der Stelle kein `member` gibt — der
erste Modmail-Thread wäre mit einem Fehler abgebrochen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) —
|
||||
|
||||
@@ -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 = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Module</h2>
|
||||
<p className="section-intro">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{moduleGroups.map((group) => {
|
||||
const mods = modules.filter((m) => m.group === group.id);
|
||||
if (mods.length === 0) return null;
|
||||
return (
|
||||
<div key={group.id} style={{ marginBottom: '1.8rem' }}>
|
||||
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
|
||||
{group.label}
|
||||
</h3>
|
||||
<div className="mod-grid">
|
||||
{mods.map((m) => (
|
||||
<div className={`card mod-card${m.enabled ? ' on' : ''}`} key={m.id}>
|
||||
<div className="mod-head">
|
||||
<span className="mod-name">{m.name}</span>
|
||||
<button
|
||||
className={`mod-switch${m.enabled ? ' on' : ''}`}
|
||||
title={m.enabled ? 'Ausschalten' : 'Einschalten'}
|
||||
onClick={() => toggleModule(m.id, !m.enabled)}
|
||||
>
|
||||
<span className="mod-knob" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mod-desc">{m.desc}</p>
|
||||
{m.enabled && m.missing.length > 0 && (
|
||||
<p className="mod-missing with-icon">
|
||||
<IconWarning size={13} /> Fehlt noch: {m.missing.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{m.enabled && m.missing.length === 0 && (
|
||||
<p className="mod-ready with-icon"><IconCheck size={13} /> Einsatzbereit</p>
|
||||
)}
|
||||
{m.tab && (
|
||||
<button className="mod-link" onClick={() => setTab(m.tab)}>
|
||||
Einstellungen →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabSeiten = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Eigene Seiten</h2>
|
||||
@@ -1866,6 +1942,7 @@ export default function Settings({ me }) {
|
||||
|
||||
const content = {
|
||||
status: tabStatus,
|
||||
module: tabModule,
|
||||
brand: tabBrand,
|
||||
feeds: tabFeeds,
|
||||
community: tabCommunity,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean>} 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean>} true, wenn gepostet
|
||||
*/
|
||||
export async function postReleaseEmbed(client, release) {
|
||||
if (!moduleEnabled('releases')) return false;
|
||||
const channelId = releaseChannelId();
|
||||
if (!channelId) return false;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
+265
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user