From 22054c109e0c2259a7f02950089d3d072e4b88cd Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sun, 2 Aug 2026 09:25:37 +0200 Subject: [PATCH] Mod-Log: eigenes Gedaechtnis, damit beim Loeschen nicht "unbekannt" dasteht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dein Eintrag war der Normalfall, nicht die Ausnahme: User: unbekannt Inhalt: [kein Text / nicht im Cache] Discord schickt beim Loeschen nur eine Huelle, sobald die Nachricht nicht mehr im Cache des Bots liegt. Der reicht ein paar hundert Nachrichten weit und faengt erst beim Start des Bots an — bei einem Post von gestern ist sie also nie drin. Ein Protokoll, das genau dann nichts weiss, wenn es interessant wird, ist keins. Also merkt sich der Bot Nachrichten jetzt kurz selbst: Verfasser, Wortlaut, Namen der Anhaenge. Beim Loeschen steht das im Eintrag, danach wird der Merker weggeworfen. Wie lange aufbewahrt wird, steht unter Werte — sieben Tage voreingestellt, 0 heisst gar nicht mitschreiben. Bewusst knapp und einstellbar: das ist ein Werkzeug zum Nachvollziehen von Loeschungen, kein Archiv. Dazu drei Sachen, die vorher fehlten: - Wer geloescht hat. Steht in Discords Audit-Log, aber nur wenn es jemand anders war als der Verfasser. Gemeldet wird nur, was frisch ist und zu Kanal und Verfasser passt — Discord zaehlt bei wiederholtem Loeschen denselben Eintrag hoch statt neue anzulegen, da ist eine falsche Angabe schnell gemacht. Lieber keine als eine falsche. - Sammel-Loeschungen. /purge und Discords eigenes Aufraeumen standen gar nicht im Log: eine geloeschte Nachricht fiel auf, hundert nicht. Jetzt mit Anzahl, Kanal und den ersten fuenfzehn Zeilen. - Anhaenge. Dass ein Bild dranhing, war vorher nirgends zu sehen — und das Bild selbst ist nach dem Loeschen ohnehin weg. Nebenbei: "[kein Text / nicht im Cache]" waren zwei Aussagen in einer und beide unklar. Jetzt steht dort entweder der Wortlaut, oder dass die Nachricht keinen hatte, oder dass sie aelter ist als die Aufbewahrung reicht. Beim Testen gefunden: pruneMessageCache(0) hat nichts geloescht. "-0 days" ist genau jetzt, und `created_at < jetzt` laesst die eben geschriebene Zeile stehen. Bei 0 wird jetzt alles geleert, wie es gemeint war. Geprueft: Merken, Wiederfinden, Anhaenge durch JSON, Bearbeiten zieht mit, Vergessen nach dem Loggen, unbekannte ID, doppelte ID, Aufbewahrung mit einem zu alten und einem frischen Eintrag, und der 0-Fall. Ereignisnamen und AuditLogEvent gegen die installierte discord.js geprueft. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/bot/mod-tools.js | 211 ++++++++++++++++++++++++++++++++++++++++--- src/db.js | 46 ++++++++++ src/tuning.js | 7 ++ 4 files changed, 251 insertions(+), 15 deletions(-) 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]));