From 075ea36bd234505000f3e2b951a997b1e32b2e88 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 23 Jul 2026 13:14:18 +0200 Subject: [PATCH] Moderation & Kontakt + Game-Server-Monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modmail: DMs an den Bot landen als Threads im privaten Staff-Kanal, Thread-Antworten gehen als DM zurück (Zustell-Feedback per Reaktion) - Willkommens-Embed für neue Member (GuildMemberAdd; Server-Members-Intent nötig) - Mod-Log: gelöschte/bearbeitete User-Nachrichten in privaten Log-Kanal - Server-Monitor: FiveM-kompatible Endpoints (/dynamic.json) alle 2 min, persistentes Status-Embed (edit in place) + Spielerzahl als Bot-Presence - Neue Intents: DirectMessages, GuildMembers; Partials.Channel - Setup-Seite: Sektionen // Moderation & Kontakt und // Game-Server Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +- frontend/src/pages/Settings.jsx | 59 ++++++++++ src/bot/client.js | 10 +- src/bot/mod-tools.js | 185 ++++++++++++++++++++++++++++++++ src/bot/server-monitor.js | 97 +++++++++++++++++ src/db.js | 19 ++++ src/index.js | 2 + src/runtime-settings.js | 15 +++ src/web/api.js | 13 ++- 9 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 src/bot/mod-tools.js create mode 100644 src/bot/server-monitor.js diff --git a/README.md b/README.md index 1160813..8dcf06a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; Rolle + Liste (Setup-Seite); Alpha-Keys später per DM über die API | | ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen automatisch im Best-of-Kanal | | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` | +| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück | +| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | +| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | +| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence | | 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) | ### Webinterface (`bot.d4rkst3r.de`) @@ -62,7 +66,8 @@ kein Redeploy nötig: 1. **New Application** → Name vergeben 2. **General Information** → Application ID = `DISCORD_CLIENT_ID` 3. **Bot** → Reset Token = `DISCORD_TOKEN` (wird nur einmal angezeigt!) - und **Message Content Intent** aktivieren (Devlog-Listener) + und **Message Content Intent** + **Server Members Intent** aktivieren + (Devlog-Listener bzw. Willkommens-Embeds) 4. **OAuth2** → Client Secret = `DISCORD_CLIENT_SECRET`; unter **Redirects** eintragen: - `https://bot.d4rkst3r.de/auth/callback` diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 605e5f1..037b04d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -357,6 +357,65 @@ export default function Settings({ me }) { + {/* Moderation & Kontakt */} +
+

// Moderation & Kontakt

+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Game-Server */} +
+

// Game-Server

+
+ + +
+
+ + setForm({ ...form, gameservers: e.target.value })} + /> +
+
+ {/* Backups */}

// Backups

diff --git a/src/bot/client.js b/src/bot/client.js index 85cc686..3b0e0e2 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -4,6 +4,7 @@ import { config } from '../config.js'; import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-settings.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { registerCommunityListeners } from './community.js'; +import { registerModTools } from './mod-tools.js'; import { addPlaytester, removePlaytester } from '../db.js'; import * as ping from './commands/ping.js'; import * as devlogBackfill from './commands/devlog-backfill.js'; @@ -24,9 +25,13 @@ export async function startBot() { GatewayIntentBits.MessageContent, // Für das Starboard (⭐-Reaktionen) GatewayIntentBits.GuildMessageReactions, + // Für Modmail (DMs) — Willkommens-Embeds brauchen zusätzlich den + // privilegierten "Server Members Intent" im Developer Portal! + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMembers, ], - // Partials, damit Delete/Reaction-Events auch für ungecachte Nachrichten feuern - partials: [Partials.Message, Partials.Reaction], + // Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle + partials: [Partials.Message, Partials.Reaction, Partials.Channel], }); // Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler @@ -145,6 +150,7 @@ export async function startBot() { }); registerCommunityListeners(client); + registerModTools(client); await client.login(config.discordToken); return client; diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js new file mode 100644 index 0000000..298ecbe --- /dev/null +++ b/src/bot/mod-tools.js @@ -0,0 +1,185 @@ +// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log +import { ChannelType, EmbedBuilder, Events } from 'discord.js'; +import { saveModmail, modmailByUser, modmailByThread } from '../db.js'; +import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl } from '../runtime-settings.js'; + +const BRAND_YELLOW = 0xf5c518; +const BRAND_ORANGE = 0xff4d00; + +/* ── Modmail ───────────────────────────────────────── */ + +/** Staff-Thread für einen User finden oder anlegen */ +async function ensureModmailThread(client, user) { + const channelId = modmailChannelId(); + if (!channelId) return null; + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.threads) return null; + + const existing = modmailByUser(user.id); + if (existing) { + const thread = await client.channels.fetch(existing.thread_id).catch(() => null); + if (thread) { + if (thread.archived) await thread.setArchived(false).catch(() => {}); + return thread; + } + } + + const thread = await channel.threads.create({ + name: `📬 ${user.username}`, + autoArchiveDuration: 10080, // 7 Tage + type: ChannelType.PublicThread, + }); + saveModmail(user.id, thread.id, user.username); + await thread.send({ + embeds: [ + new EmbedBuilder() + .setColor(BRAND_YELLOW) + .setDescription( + `Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` + + 'Antworten in diesem Thread gehen als DM an den User.' + ) + .setFooter({ text: 'D4RKST3R // MODMAIL' }), + ], + }); + return thread; +} + +/** DM vom User → in den Staff-Thread spiegeln */ +async function handleIncomingDm(client, message) { + const thread = await ensureModmailThread(client, message.author); + if (!thread) return; // Feature aus → DM ignorieren + + const files = [...message.attachments.values()].map((a) => a.url).slice(0, 5); + await thread.send({ + content: `**${message.author.username}:** ${message.content || ''}${ + files.length ? `\n${files.join('\n')}` : '' + }`.slice(0, 2000), + allowedMentions: { parse: [] }, + }); + await message.react('📬').catch(() => {}); +} + +/** Staff-Antwort im Thread → als DM an den User */ +async function handleThreadReply(client, message) { + const row = modmailByThread(message.channelId); + if (!row) return; + + const user = await client.users.fetch(row.user_id).catch(() => null); + if (!user) return; + try { + await user.send( + `**${message.member?.displayName ?? message.author.username}:** ${message.content}`.slice(0, 2000) + ); + await message.react('✅').catch(() => {}); + } catch { + await message.react('⚠️').catch(() => {}); + await message.reply('DM nicht zustellbar (User blockt DMs).').catch(() => {}); + } +} + +/* ── Willkommens-Embed ─────────────────────────────── */ + +async function handleMemberAdd(member) { + const channelId = welcomeChannelId(); + if (!channelId) return; + const channel = await member.client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased()) return; + + const embed = new EmbedBuilder() + .setColor(BRAND_YELLOW) + .setTitle(`👋 Willkommen, ${member.displayName}!`) + .setThumbnail(member.user.displayAvatarURL({ size: 128 })) + .setDescription( + `Schön, dass du da bist — du bist Mitglied **#${member.guild.memberCount}**.\n\n` + + `📔 Devlogs & Roadmap: ${publicUrl()}\n` + + '🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' + + '🐛 Bug gefunden? Einfach `/bug` benutzen.' + ) + .setFooter({ text: 'D4RKST3R // COMMUNITY' }) + .setTimestamp(); + + await channel.send({ content: `<@${member.id}>`, embeds: [embed] }); +} + +/* ── Mod-Log ───────────────────────────────────────── */ + +async function logToModlog(client, embed) { + const channelId = modlogChannelId(); + if (!channelId) return; + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased()) return; + await channel.send({ embeds: [embed] }); +} + +function trimmed(text) { + const t = (text ?? '').trim(); + return t.length > 900 ? `${t.slice(0, 900)}…` : t || '*[kein Text / nicht im Cache]*'; +} + +/* ── Registrierung ─────────────────────────────────── */ + +export function registerModTools(client) { + // Modmail: eingehende DMs + Staff-Antworten in Threads + client.on(Events.MessageCreate, async (message) => { + try { + if (message.author?.bot) return; + if (message.channel?.type === ChannelType.DM) { + await handleIncomingDm(client, message); + } else if ( + message.channel?.isThread?.() && + message.channel.parentId === modmailChannelId() + ) { + await handleThreadReply(client, message); + } + } catch (error) { + console.error('[modmail] Fehler:', error); + } + }); + + // Willkommens-Embed + client.on(Events.GuildMemberAdd, async (member) => { + try { + await handleMemberAdd(member); + } catch (error) { + console.error('[welcome] Fehler:', error); + } + }); + + // Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen) + client.on(Events.MessageDelete, async (message) => { + try { + if (!message.guildId || message.author?.bot) return; + if (message.channelId === modlogChannelId()) return; + await logToModlog(client, new EmbedBuilder() + .setColor(BRAND_ORANGE) + .setTitle('🗑️ Nachricht gelöscht') + .addFields( + { name: 'User', value: message.author ? `${message.author.username}` : 'unbekannt', inline: true }, + { name: 'Kanal', value: `<#${message.channelId}>`, inline: true }, + { name: 'Inhalt', value: trimmed(message.content) } + ) + .setTimestamp()); + } catch (error) { + console.error('[modlog] Fehler:', error); + } + }); + + client.on(Events.MessageUpdate, async (oldMessage, newMessage) => { + try { + if (!newMessage.guildId || newMessage.author?.bot) return; + if (oldMessage.content === newMessage.content) return; // z. B. nur Embed-Update + await logToModlog(client, new EmbedBuilder() + .setColor(BRAND_YELLOW) + .setTitle('✏️ Nachricht bearbeitet') + .addFields( + { name: 'User', value: newMessage.author?.username ?? 'unbekannt', inline: true }, + { name: 'Kanal', value: `<#${newMessage.channelId}>`, inline: true }, + { name: 'Vorher', value: trimmed(oldMessage.content) }, + { name: 'Nachher', value: trimmed(newMessage.content) } + ) + .setTimestamp()); + } catch (error) { + console.error('[modlog] Fehler:', error); + } + }); +} diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js new file mode 100644 index 0000000..9575852 --- /dev/null +++ b/src/bot/server-monitor.js @@ -0,0 +1,97 @@ +// Game-Server-Monitor: pollt FiveM-kompatible HTTP-Endpoints (/dynamic.json) +// und pflegt ein persistentes Status-Embed im Status-Kanal + Bot-Presence. +// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…" +import { ActivityType, EmbedBuilder, Events } from 'discord.js'; +import { getSetting, setSetting } from '../db.js'; + +const CHECK_INTERVAL_MS = 2 * 60 * 1000; +const TIMEOUT_MS = 8000; +const BRAND_YELLOW = 0xf5c518; + +function serverList() { + return (getSetting('gameservers') ?? '') + .split(',') + .map((entry) => { + const [name, ...rest] = entry.split('='); + const url = rest.join('=').trim().replace(/\/$/, ''); + return name?.trim() && /^https?:\/\//.test(url) ? { name: name.trim(), url } : null; + }) + .filter(Boolean); +} + +/** Einen Server abfragen (FiveM-Format: clients, sv_maxclients, hostname) */ +async function queryServer(server) { + try { + const res = await fetch(`${server.url}/dynamic.json`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { 'User-Agent': 'd4rkbot-monitor/1.0' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return { + ...server, + online: true, + players: Number(data.clients) || 0, + max: Number(data.sv_maxclients) || 0, + map: data.mapname ?? null, + }; + } catch { + return { ...server, online: false, players: 0, max: 0, map: null }; + } +} + +/** Status-Embed bauen */ +function buildEmbed(results) { + const lines = results.map((r) => + r.online + ? `🟢 **${r.name}** — ${r.players}/${r.max} Spieler${r.map ? ` · ${r.map}` : ''}` + : `🔴 **${r.name}** — offline` + ); + return new EmbedBuilder() + .setColor(BRAND_YELLOW) + .setTitle('🎮 Server-Status') + .setDescription(lines.join('\n')) + .setFooter({ text: 'D4RKST3R // STATUS • aktualisiert alle 2 min' }) + .setTimestamp(); +} + +/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */ +export async function monitorTick(client) { + const servers = serverList(); + const channelId = getSetting('status_channel_id'); + if (servers.length === 0) return; + + const results = await Promise.all(servers.map(queryServer)); + + // Bot-Presence: Gesamtspielerzahl (nur wenn mindestens ein Server online) + const online = results.filter((r) => r.online); + const totalPlayers = online.reduce((sum, r) => sum + r.players, 0); + try { + client.user?.setActivity?.( + online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline', + { type: ActivityType.Watching } + ); + } catch { /* Presence ist nice-to-have */ } + + // Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren) + if (!channelId) return; + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased()) return; + + const embed = buildEmbed(results); + const messageId = getSetting('status_message_id'); + if (messageId) { + const existing = await channel.messages.fetch(messageId).catch(() => null); + if (existing) { + await existing.edit({ embeds: [embed] }); + return; + } + } + const message = await channel.send({ embeds: [embed] }); + setSetting('status_message_id', message.id); +} + +export function startServerMonitor(client) { + setInterval(() => monitorTick(client).catch((e) => console.error('[monitor] Fehler:', e)), CHECK_INTERVAL_MS); + client.once(Events.ClientReady, () => monitorTick(client).catch(() => {})); +} diff --git a/src/db.js b/src/db.js index 1561108..cb5aa97 100644 --- a/src/db.js +++ b/src/db.js @@ -178,6 +178,25 @@ export const listGallery = (limit, offset) => ({ total: countGalleryStmt.get().n, }); +// Modmail: DM-Konversationen ↔ Staff-Threads +db.exec(` + CREATE TABLE IF NOT EXISTS modmail ( + user_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL UNIQUE, + username TEXT, + opened_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); +const upsertModmail = db.prepare(` + INSERT INTO modmail (user_id, thread_id, username) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET thread_id = excluded.thread_id, username = excluded.username +`); +const modmailByUserStmt = db.prepare('SELECT * FROM modmail WHERE user_id = ?'); +const modmailByThreadStmt = db.prepare('SELECT * FROM modmail WHERE thread_id = ?'); +export const saveModmail = (userId, threadId, username) => upsertModmail.run(userId, threadId, username); +export const modmailByUser = (userId) => modmailByUserStmt.get(userId) ?? null; +export const modmailByThread = (threadId) => modmailByThreadStmt.get(threadId) ?? null; + // API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert db.exec(` CREATE TABLE IF NOT EXISTS api_keys ( diff --git a/src/index.js b/src/index.js index f8279dc..c9c137b 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { startWebServer } from './web/server.js'; import { scheduleWeeklyRecap } from './bot/weekly-recap.js'; import { startWatchdog } from './bot/watchdog.js'; import { scheduleBackups } from './backup.js'; +import { startServerMonitor } from './bot/server-monitor.js'; process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); @@ -15,6 +16,7 @@ try { scheduleWeeklyRecap(client); startWatchdog(client); scheduleBackups(client); + startServerMonitor(client); } catch (error) { console.error('[main] Start fehlgeschlagen:', error); process.exit(1); diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 0f4a59f..0af100b 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -47,6 +47,21 @@ export function starboardThreshold() { return Number.isInteger(n) && n >= 1 ? n : 3; } +/** Modmail-Staff-Kanal (privat!) — leer = Feature aus */ +export function modmailChannelId() { + return getSetting('modmail_channel_id') || null; +} + +/** Willkommens-Kanal — leer = Feature aus */ +export function welcomeChannelId() { + return getSetting('welcome_channel_id') || null; +} + +/** Mod-Log-Kanal (privat!) — leer = Feature aus */ +export function modlogChannelId() { + return getSetting('modlog_channel_id') || null; +} + /** Screenshot-Kanal für die Galerie — leer = Feature aus */ export function screenshotChannelId() { return getSetting('screenshot_channel_id') || null; diff --git a/src/web/api.js b/src/web/api.js index cc41525..241eda3 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -203,6 +203,11 @@ ${rssItems} starboard_channel_id: getSetting('starboard_channel_id') ?? '', starboard_threshold: Number(getSetting('starboard_threshold')) || 3, screenshot_channel_id: getSetting('screenshot_channel_id') ?? '', + modmail_channel_id: getSetting('modmail_channel_id') ?? '', + welcome_channel_id: getSetting('welcome_channel_id') ?? '', + modlog_channel_id: getSetting('modlog_channel_id') ?? '', + status_channel_id: getSetting('status_channel_id') ?? '', + gameservers: getSetting('gameservers') ?? '', }; } @@ -231,7 +236,11 @@ ${rssItems} const body = request.body ?? {}; // Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus) - const OPTIONAL_CHANNELS = ['release_channel_id', 'backup_channel_id', 'starboard_channel_id', 'screenshot_channel_id']; + const OPTIONAL_CHANNELS = [ + 'release_channel_id', 'backup_channel_id', 'starboard_channel_id', + 'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id', + 'modlog_channel_id', 'status_channel_id', + ]; for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) { if (body[key] === undefined) continue; const value = String(body[key]); @@ -279,7 +288,7 @@ ${rssItems} if (body.backup_enabled !== undefined) { setSetting('backup_enabled', body.backup_enabled ? '1' : '0'); } - for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo']) { + for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) { if (body[key] !== undefined) { setSetting(key, String(body[key]).trim()); }