Mod-Log: eigenes Gedaechtnis, damit beim Loeschen nicht "unbekannt" dasteht
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 09:25:37 +02:00
co-authored by Claude Opus 5
parent b6b767acb7
commit 22054c109e
4 changed files with 251 additions and 15 deletions
+197 -14
View File
@@ -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);
}
+46
View File
@@ -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 (
+7
View File
@@ -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]));