Files
d4rkbot/src/bot/mod-tools.js
T
D4rkst3randClaude Fable 5 676fc45bb1 Willkommens-Karten, Geburtstage, Devlog-Permalinks mit OG-Vorschau, Auto-Publish
- Willkommens-Karten: gerendertes PNG (SVG → sharp) mit Avatar im
  Neon-Ring, Willkommens-Schriftzug und Member-Nummer; Fallback aufs
  bisherige Embed wenn das Rendering scheitert; Dockerfile installiert
  fonts-dejavu-core für die Text-Darstellung
- Geburtstags-System: /geburtstag setzen|entfernen (birthdays-Tabelle),
  tägliche Runde ab 09:00 Europe/Berlin (Doppel-Post-Schutz über
  last_birthday_run), Gratulations-Embed + Tages-Rolle (wird am
  nächsten Morgen wieder abgeräumt); Kanal + Rolle im Community-Tab
- Devlog-Permalinks: /devlogs/:id als eigene Seite (GET /api/devlogs/:id),
  Link-Symbol an jeder Karte, Link-kopieren-Button; der Server injiziert
  Open-Graph-Tags ins SPA-HTML — Devlog-Links zeigen Titel, Anriss und
  Bild, alle anderen Seiten bekommen Default-Tags (auch die Startseite)
- Auto-Publish: maybeCrosspost() veröffentlicht Devlog-, Release-,
  Composer- und geplante Posts automatisch in Ankündigungs-Kanälen
- brandEmbed: leere Avatar-URL crasht nicht mehr die Footer-Validierung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:43:15 +02:00

272 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
/* ── 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(brandColor())
.setDescription(
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.'
)
.setFooter({ text: brandFooter('MODMAIL'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
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(brandColor())
.setTitle(`👋 Willkommen, ${member.displayName}!`)
.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: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
// Gerenderte Willkommens-Karte — wenn das Rendering klemmt, gibt's das Embed pur
let files = [];
try {
const { buildWelcomeCard } = await import('./welcome-card.js');
const png = await buildWelcomeCard({
username: member.displayName,
avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 128 }),
memberNumber: member.guild.memberCount,
});
files = [new AttachmentBuilder(png, { name: 'welcome.png' })];
embed.setImage('attachment://welcome.png');
} catch (error) {
embed.setThumbnail(member.user.displayAvatarURL({ size: 128 }));
console.error('[welcome] Karte fehlgeschlagen, nutze Embed:', error.message);
}
await channel.send({ content: `<@${member.id}>`, embeds: [embed], files });
}
/* ── Auto- & Sticky-Roles ──────────────────────────── */
/** Beim Join: gesicherte Rollen wiederherstellen + Auto-Rolle vergeben */
async function restoreRoles(member) {
const roleIds = new Set();
if (stickyRolesEnabled()) {
for (const id of stickyRolesOf(member.id)) roleIds.add(id);
}
const auto = autoroleId();
if (auto) roleIds.add(auto);
for (const id of roleIds) {
const role = member.guild.roles.cache.get(id);
if (!role || role.managed || role.id === member.guild.id) continue;
await member.roles.add(id).catch((e) =>
console.error(`[roles] Rolle ${id} bei Join fehlgeschlagen: ${e.message}`)
);
}
}
/* ── 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 + Auto-/Sticky-Roles + Join-Log
client.on(Events.GuildMemberAdd, async (member) => {
try {
await handleMemberAdd(member);
await restoreRoles(member);
await logToModlog(client, new EmbedBuilder()
.setColor(0x00ff88)
.setTitle('📥 Member beigetreten')
.setDescription(`**${member.user.username}** (<@${member.id}>) — Mitglied #${member.guild.memberCount}`)
.setTimestamp());
} catch (error) {
console.error('[welcome] Fehler:', error);
}
});
// Leave: Rollen für Sticky-Restore sichern + Log
client.on(Events.GuildMemberRemove, async (member) => {
try {
const roleIds = [...(member.roles?.cache?.values() ?? [])]
.filter((r) => r.id !== member.guild.id && !r.managed)
.map((r) => r.id);
if (roleIds.length > 0) saveStickyRoles(member.id, roleIds);
await logToModlog(client, new EmbedBuilder()
.setColor(brandColor2())
.setTitle('📤 Member gegangen')
.setDescription(`**${member.user?.username ?? member.id}**${roleIds.length ? ` — Rollen gesichert (${roleIds.length})` : ''}`)
.setTimestamp());
} catch (error) {
console.error('[modlog] Leave-Handling fehlgeschlagen:', error);
}
});
// Nick-/Rollen-Änderungen loggen
client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => {
try {
const changes = [];
if (oldMember.nickname !== newMember.nickname) {
changes.push({
name: 'Nickname',
value: `\`${oldMember.nickname ?? oldMember.user?.username ?? '—'}\` → \`${newMember.nickname ?? newMember.user?.username ?? '—'}\``,
});
}
const oldIds = new Set(oldMember.roles?.cache?.keys?.() ?? []);
const newIds = new Set(newMember.roles?.cache?.keys?.() ?? []);
const added = [...newIds].filter((id) => !oldIds.has(id) && id !== newMember.guild.id);
const removed = [...oldIds].filter((id) => !newIds.has(id) && id !== newMember.guild.id);
if (added.length) changes.push({ name: 'Rollen +', value: added.map((id) => `<@&${id}>`).join(' ') });
if (removed.length) changes.push({ name: 'Rollen ', value: removed.map((id) => `<@&${id}>`).join(' ') });
if (changes.length === 0) return;
await logToModlog(client, new EmbedBuilder()
.setColor(brandColor())
.setTitle('👤 Member geändert')
.setDescription(`**${newMember.user?.username ?? newMember.id}**`)
.addFields(changes)
.setTimestamp());
} catch (error) {
console.error('[modlog] Member-Update fehlgeschlagen:', 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(brandColor2())
.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(brandColor())
.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);
}
});
}