Mod-Log: eigenes Gedaechtnis, damit beim Loeschen nicht "unbekannt" dasteht
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:
+197
-14
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user