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