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:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user