Moderation & Kontakt + Game-Server-Monitor

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:14:18 +02:00
co-authored by Claude Opus 4.8
parent a3c47656c5
commit 075ea36bd2
9 changed files with 402 additions and 5 deletions
+8 -2
View File
@@ -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;
+185
View File
@@ -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);
}
});
}
+97
View File
@@ -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(() => {}));
}