diff --git a/README.md b/README.md index 405d1c7..56f65b8 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | 🛡️ **Moderation** | `/warn` (DM + Historie), `/warns`, `/timeout`, `/purge` — alles im Mod-Log | | 🤖 **AutoMod** | Treffer von Discords eigenem Filter landen als Embed im Protokoll (Nutzer, Regel, Auslöser, Maßnahme, getroffenes Wort); Regeln sind im Panel sichtbar und schaltbar. Wortlisten bleiben in Discord | | 🚨 **Raid-Schutz** | Erkennt Beitritts-Wellen und sperrt den Server vorübergehend (Prüfstufe hoch, Einladungen aus). `/raid an\|aus\|status` auch von Hand | -| 📋 **Protokoll** | Gelöschte und bearbeitete Nachrichten, Joins/Leaves, Namens- und Rollen-Änderungen in einem privaten Kanal | +| 📋 **Protokoll** | Gelöschte und bearbeitete Nachrichten, Sammel-Löschungen, Joins/Leaves, Namens- und Rollen-Änderungen in einem privaten Kanal. Damit bei einer Löschung **Verfasser und Wortlaut** dastehen — auch bei alten Posts —, merkt sich der Bot Nachrichten kurz selbst; wie lange, steht unter *Werte* (0 = gar nicht). Wer eine fremde Nachricht gelöscht hat, kommt aus Discords Audit-Log | | 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück | | 🎫 **Tickets** | Button öffnet einen privaten Thread (ein offenes Ticket pro User). **Anliegen** zur Auswahl — je eigener Name, Einstiegstext und Rolle, die benachrichtigt wird; bis fünf als Knöpfe, darüber als Menü. Das Team kann ein Ticket **übernehmen**, damit klar ist, wer sich kümmert. Schließen = **Transcript per DM** an den Ersteller + Kopie ins Mod-Log, inklusive Anliegen und Bearbeiter | | 👋 **Willkommens-Karten** | Begrüßung als gerendertes Bild (Avatar mit Neon-Ring, Member-Nummer, Brand-Look), konfigurierbar; Fallback aufs Text-Embed | diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js index e938982..f340c9d 100644 --- a/src/bot/mod-tools.js +++ b/src/bot/mod-tools.js @@ -1,6 +1,12 @@ // 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 { + AttachmentBuilder, AuditLogEvent, ChannelType, EmbedBuilder, Events, + PermissionFlagsBits, +} from 'discord.js'; +import { + saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf, + rememberMessage, recallMessage, forgetMessage, editRememberedMessage, pruneMessageCache, +} from '../db.js'; import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { renderTemplate } from '../templates.js'; @@ -158,12 +164,87 @@ async function logToModlog(client, embed) { function trimmed(text) { const t = (text ?? '').trim(); - return t.length > 900 ? `${t.slice(0, 900)}…` : t || '*[kein Text / nicht im Cache]*'; + return t.length > 900 ? `${t.slice(0, 900)}…` : t || '*[kein Text]*'; +} + +/** + * Inhalt fürs Log — und wenn keiner da ist, der Grund dafür. + * + * „[kein Text / nicht im Cache]" war zwei Aussagen in einer und beide unklar. + * Jetzt steht dort entweder der Wortlaut, oder dass die Nachricht keinen hatte, + * oder dass sie älter ist als das Gedächtnis reicht. + */ +function inhaltOderHinweis(text, gemerkt) { + const t = (text ?? '').trim(); + if (t) return trimmed(t); + return gemerkt + ? '*— kein Text, nur Anhänge oder Embeds —*' + : '*— nicht mehr im Gedächtnis (älter als die Aufbewahrung) —*'; +} + +/** Nur mitschreiben, was fürs Protokoll überhaupt in Frage kommt */ +function merkenAn(message) { + if (!moduleEnabled('modlog')) return false; + if (tuning('modlog_cache_days') <= 0) return false; + if (!message.guildId || message.author?.bot) return false; + if (message.channelId === modlogChannelId()) return false; + return true; +} + +/** Wie lange ist das her — kurz und ohne Bibliothek */ +function vorWieLange(sqlZeit) { + const ms = Date.now() - new Date(`${String(sqlZeit).replace(' ', 'T')}Z`).getTime(); + if (!Number.isFinite(ms) || ms < 0) return 'gerade eben'; + const min = Math.round(ms / 60000); + if (min < 60) return `vor ${min} Min.`; + const std = Math.round(min / 60); + if (std < 24) return `vor ${std} Std.`; + return `vor ${Math.round(std / 24)} Tagen`; +} + +/** + * Wer die Nachricht gelöscht hat — steht in Discords Audit-Log, aber nur, wenn + * es jemand anders war als der Verfasser. Wer seine eigene Nachricht löscht, + * taucht dort nie auf; dann geben wir auch nichts an, statt zu raten. + * + * Discord zählt bei wiederholtem Löschen denselben Eintrag hoch, statt neue + * anzulegen. Deshalb wird nur gemeldet, was frisch ist und zu Kanal und + * Verfasser passt — lieber keine Angabe als eine falsche. + */ +async function werHatGeloescht(guild, autorId, channelId) { + if (!guild || !autorId) return null; + try { + const me = guild.members.me ?? await guild.members.fetchMe().catch(() => null); + if (!me?.permissions.has(PermissionFlagsBits.ViewAuditLog)) return null; + const log = await guild.fetchAuditLogs({ type: AuditLogEvent.MessageDelete, limit: 5 }); + const treffer = log.entries.find((e) => ( + e.target?.id === autorId + && e.extra?.channel?.id === channelId + && Date.now() - e.createdTimestamp < 10_000 + )); + if (!treffer?.executor || treffer.executor.id === autorId) return null; + return `${treffer.executor.username} (<@${treffer.executor.id}>)`; + } catch { + return null; // fehlende Rechte oder Rate-Limit — dann eben ohne + } } /* ── Registrierung ─────────────────────────────────── */ export function registerModTools(client) { + // Das Gedächtnis täglich stutzen. Ohne das wächst es endlos — und was + // älter ist als die Aufbewahrung, soll auch wirklich weg sein. + const aufraeumen = () => { + try { + const weg = pruneMessageCache(Math.max(0, tuning('modlog_cache_days'))); + if (weg > 0) console.log(`[modlog] ${weg} alte Nachrichten vergessen`); + } catch (error) { + console.error('[modlog] Aufräumen fehlgeschlagen:', error); + } + }; + aufraeumen(); + setInterval(aufraeumen, 86_400_000).unref?.(); + // Modmail: eingehende DMs + Staff-Antworten in Threads client.on(Events.MessageCreate, async (message) => { try { @@ -243,39 +324,141 @@ export function registerModTools(client) { } }); + // Nachrichten kurz mitschreiben — nur damit beim Löschen etwas dasteht. + // Ohne das liefert Discord bei allem, was nicht mehr im Cache liegt, eine + // Hülle ohne Verfasser und ohne Wortlaut. + client.on(Events.MessageCreate, (message) => { + try { + if (!merkenAn(message)) return; + rememberMessage({ + message_id: message.id, + channel_id: message.channelId, + author_id: message.author.id, + author: message.member?.displayName ?? message.author.username, + content: (message.content ?? '').slice(0, 2000), + anhaenge: JSON.stringify( + [...(message.attachments?.values?.() ?? [])].map((a) => a.name).slice(0, 10) + ), + }); + } catch (error) { + console.error('[modlog] Nachricht nicht gemerkt:', 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() + // Was Discord noch weiß, ergänzen wir aus dem eigenen Gedächtnis + const gemerkt = recallMessage(message.id); + if (!message.guildId && !gemerkt) return; + if (message.author?.bot) return; + + const autor = message.author + ? `${message.member?.displayName ?? message.author.username} (<@${message.author.id}>)` + : gemerkt + ? `${gemerkt.author} (<@${gemerkt.author_id}>)` + : 'unbekannt'; + const text = message.content?.trim() || gemerkt?.content?.trim() || ''; + const anhaenge = [...(message.attachments?.values?.() ?? [])].map((a) => a.name); + const dateien = anhaenge.length > 0 ? anhaenge : (gemerkt?.anhaenge ?? []); + + const embed = 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()); + { name: 'User', value: autor, inline: true }, + { name: 'Kanal', value: `<#${message.channelId}>`, inline: true } + ); + + // Wer es war, weiß nur Discords Audit-Log — und auch nur, wenn es + // jemand anders war als der Verfasser selbst + const durch = await werHatGeloescht(message.guild, gemerkt?.author_id ?? message.author?.id, message.channelId); + if (durch) embed.addFields({ name: 'Gelöscht von', value: durch, inline: true }); + + embed.addFields({ name: 'Inhalt', value: inhaltOderHinweis(text, Boolean(gemerkt)) }); + if (dateien.length > 0) { + embed.addFields({ + name: dateien.length === 1 ? 'Anhang' : 'Anhänge', + value: dateien.map((n) => `\`${n}\``).join(', ').slice(0, 1000), + }); + } + if (gemerkt?.created_at) { + embed.setFooter({ text: `${brandFooter('MODLOG')} • geschrieben ${vorWieLange(gemerkt.created_at)}` }); + } + + await logToModlog(client, embed.setTimestamp()); + forgetMessage(message.id); } catch (error) { console.error('[modlog] Fehler:', error); } }); + // Sammel-Löschung (/purge und Discords eigenes Aufräumen). Stand vorher gar + // nicht im Log — eine gelöschte Nachricht fiel auf, hundert nicht. + client.on(Events.MessageBulkDelete, async (messages) => { + try { + const erste = messages.first(); + if (!erste || erste.channelId === modlogChannelId()) return; + + const zeilen = []; + for (const m of messages.values()) { + const g = recallMessage(m.id); + const name = m.author?.username ?? g?.author ?? '?'; + const text = (m.content || g?.content || '').replace(/\s+/g, ' ').trim(); + if (text || name !== '?') zeilen.push(`**${name}:** ${text || '—'}`.slice(0, 180)); + forgetMessage(m.id); + } + + const embed = new EmbedBuilder() + .setColor(brandColor2()) + .setTitle(`🧹 ${messages.size} Nachrichten auf einmal gelöscht`) + .addFields({ name: 'Kanal', value: `<#${erste.channelId}>`, inline: true }); + if (zeilen.length > 0) { + // Nicht alles — der Rest steht als Zahl dahinter + const gezeigt = zeilen.slice(0, 15); + const rest = zeilen.length - gezeigt.length; + embed.addFields({ + name: 'Was drinstand', + value: `${gezeigt.join('\n')}${rest > 0 ? `\n… und ${rest} weitere` : ''}`.slice(0, 1000), + }); + } + await logToModlog(client, embed.setTimestamp()); + } catch (error) { + console.error('[modlog] Sammel-Löschung:', 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 + if (newMessage.channelId === modlogChannelId()) return; + const neu = newMessage.content ?? ''; + const gemerkt = recallMessage(newMessage.id); + const alt = oldMessage.content ?? gemerkt?.content ?? ''; + if (alt === neu) return; // z. B. nur ein Embed ist nachgeladen + await logToModlog(client, new EmbedBuilder() .setColor(brandColor()) .setTitle('✏️ Nachricht bearbeitet') + .setURL(newMessage.url ?? null) .addFields( - { name: 'User', value: newMessage.author?.username ?? 'unbekannt', inline: true }, + { + name: 'User', + value: newMessage.author + ? `${newMessage.member?.displayName ?? newMessage.author.username} (<@${newMessage.author.id}>)` + : gemerkt?.author ?? 'unbekannt', + inline: true, + }, { name: 'Kanal', value: `<#${newMessage.channelId}>`, inline: true }, - { name: 'Vorher', value: trimmed(oldMessage.content) }, - { name: 'Nachher', value: trimmed(newMessage.content) } + { name: 'Vorher', value: inhaltOderHinweis(alt, Boolean(gemerkt)) }, + { name: 'Nachher', value: inhaltOderHinweis(neu, true) } ) .setTimestamp()); + + // Das Gedächtnis auf den neuen Stand bringen, sonst zeigt ein + // späteres Löschen die alte Fassung + if (gemerkt) editRememberedMessage(newMessage.id, neu.slice(0, 2000)); } catch (error) { console.error('[modlog] Fehler:', error); } diff --git a/src/db.js b/src/db.js index 32733b7..fa465a9 100644 --- a/src/db.js +++ b/src/db.js @@ -1269,6 +1269,52 @@ export const getRoleMenu = (id) => getRoleMenuStmt.get(id) ?? null; export const listRoleMenus = () => listRoleMenusStmt.all(); export const deleteRoleMenu = (id) => deleteRoleMenuStmt.run(id).changes > 0; +// Kurzes Gedaechtnis fuer Nachrichten — nur fuers Mod-Log. +// +// Discord schickt beim Loeschen nur eine Huelle, sobald die Nachricht nicht +// mehr im Cache des Bots liegt; bei einem Post von gestern ist sie das nie. +// Im Log stand dann „unbekannt" und „[kein Text]" — also genau nichts. +// +// Deshalb hier eine eigene, kurzlebige Kopie: Verfasser, Wortlaut, Anhaenge. +// Die Aufbewahrung ist einstellbar und bewusst knapp — das hier ist ein +// Werkzeug zum Nachvollziehen von Loeschungen, kein Archiv. +db.exec(` + CREATE TABLE IF NOT EXISTS message_cache ( + message_id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + author_id TEXT NOT NULL, + author TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + anhaenge TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_msgcache_ts ON message_cache (created_at); +`); +const rememberMsgStmt = db.prepare(` + INSERT OR REPLACE INTO message_cache + (message_id, channel_id, author_id, author, content, anhaenge) + VALUES (@message_id, @channel_id, @author_id, @author, @content, @anhaenge) +`); +const editMsgStmt = db.prepare('UPDATE message_cache SET content = ? WHERE message_id = ?'); +const recallMsgStmt = db.prepare('SELECT * FROM message_cache WHERE message_id = ?'); +const forgetMsgStmt = db.prepare('DELETE FROM message_cache WHERE message_id = ?'); +const pruneMsgStmt = db.prepare(`DELETE FROM message_cache WHERE created_at < datetime('now', ?)`); +const clearMsgStmt = db.prepare('DELETE FROM message_cache'); +export const rememberMessage = (m) => rememberMsgStmt.run(m); +export const editRememberedMessage = (id, text) => editMsgStmt.run(text, id).changes > 0; +export const recallMessage = (id) => { + const row = recallMsgStmt.get(id); + return row ? { ...row, anhaenge: JSON.parse(row.anhaenge || '[]') } : null; +}; +export const forgetMessage = (id) => forgetMsgStmt.run(id); +/** + * Zu Altes vergessen. Bei 0 Tagen wird alles vergessen — „-0 days" ist genau + * jetzt, und `< jetzt` liesse die eben geschriebene Zeile stehen. + */ +export const pruneMessageCache = (tage) => (tage > 0 + ? pruneMsgStmt.run(`-${tage} days`).changes + : clearMsgStmt.run().changes); + // Ticket-System (Button → privater Thread) db.exec(` CREATE TABLE IF NOT EXISTS tickets ( diff --git a/src/tuning.js b/src/tuning.js index 22e2853..505ca62 100644 --- a/src/tuning.js +++ b/src/tuning.js @@ -132,6 +132,13 @@ export const TUNING = [ hint: 'Für Ticket- und Modmail-Threads. Discord erlaubt 1, 3 oder 7 Tage.', unit: 'Tagen', default: 7, min: 1, max: 7, choices: [1, 3, 7], }, + { + id: 'modlog_cache_days', module: 'modlog', group: 'grenzen', + label: 'Nachrichten für das Protokoll aufbewahren', + hint: 'Damit bei einer gelöschten Nachricht Verfasser und Wortlaut dastehen. ' + + 'Kürzer ist sparsamer, länger reicht weiter zurück. 0 = gar nicht mitschreiben.', + unit: 'Tagen', default: 7, min: 0, max: 30, + }, ]; const byId = new Map(TUNING.map((t) => [t.id, t]));