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());
}