Protokoll: melden, was Moderation ist -- nicht alles, was passiert
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

Der Betreiber bekam "fuer alles einen Alert". Zu Recht: sechs verschiedene
Meldungen liefen in denselben Kanal, und dahinter stand ein einziger
Modulschalter -- entweder alles oder nichts.

  geloescht / bearbeitet / Nick+Rollen / beigetreten / gegangen / AutoMod

Zwei davon waren der eigentliche Laerm, und beide sind gar keine
Moderationsvorgaenge:

1. Jede selbst zurueckgenommene Nachricht wurde gemeldet. Wer seinen eigenen
   Tippfehler loescht, ist kein Vorfall.

2. Jede Rollenaenderung, die der Bot selbst ausgeloest hat -- Rollenmenue-Klick,
   Level-Aufstieg, Sticky-Restore nach Wiedereintritt, Playtester-Knopf. Der
   Bot hat sich selbst bei der Arbeit zugesehen und darueber Protokoll gefuehrt.

Beides wird jetzt gefiltert. Erkannt ueber Discords Audit-Log und nicht ueber
Merker an den vier Stellen, die Rollen vergeben -- so ist auch die fuenfte
erfasst, die jemand spaeter baut.

Dafuer musste werHatGeloescht() zuerst repariert werden. Sie gab fuer VIER
verschiedene Lagen dasselbe `null` zurueck: kein Server, kein Recht aufs
Audit-Log, Abfrage fehlgeschlagen, und "der Verfasser war es selbst". Zum
Anzeigen reichte das ("dann eben ohne Angabe"). Als Filter waere es die
schlimmste Sorte Fehler gewesen: fehlt dem Bot das Recht, saehe "selbst
geloescht" genauso aus wie "konnte nicht nachsehen" -- und das Protokoll
haette stillschweigend alles verschluckt, ohne dass es jemandem auffaellt.

Jetzt drei unterscheidbare Antworten: { wer } / { selbst } / { unklar, Grund }.
Verschwiegen wird nur, was sicher selbst geloescht wurde. Unklares wird
gemeldet, und der Grund steht im Embed, damit niemand die Zeile fuer "von
einem Mod geloescht" haelt. Fehlt das Recht, sagt das Log es einmal und dann
nicht wieder -- sonst waere die Meldung selbst wieder Rauschen.

Dazu vier Einzelschalter im Panel, weil "alles oder nichts" die Ursache war.
Vorgabe an: ein Protokoll, das nach einem Update stillschweigend aufhoert zu
protokollieren, waere die schlechtere Ueberraschung. Gelesen wird mit
!== '0', geschrieben werden sie einmalig als '1' -- das Panel liest
Feld-Schalter naemlich mit === '1', und ein Formular, das das Gegenteil
dessen zeigt, was gilt, ist schlimmer als gar keins.

Aufgepasst bei den Schaltern: an drei der Handler haengt mehr als nur die
Meldung. Begruessung und Auto-Rollen beim Beitritt, das Sichern der Rollen
beim Austritt, und das Nachfuehren des Nachrichten-Gedaechtnisses beim
Bearbeiten laufen weiter, auch wenn niemand die Meldung sehen will. Sonst
haette ein Protokoll-Schalter das Verhalten des Servers geaendert.

Geprueft mit gestellten Objekten, zwoelf Faelle: kein Server, kein Recht,
Abfrage wirft, kein Eintrag, Eintrag vom Autor selbst, Eintrag zu alt, Mod
war es -- und fuer die Rollen: Bot war es, Mensch war es, kein Eintrag, kein
Recht, Abfrage wirft. Im Zweifel wird immer gemeldet. Dazu die Schalter gegen
die Panel-Anzeige: beide sagen dasselbe.

Ungeprueft: der Lauf gegen ein echtes Audit-Log. Ob der Bot das Recht
"Audit-Log ansehen" hier ueberhaupt hat, steht nach dem Ausrollen im Log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 17:16:14 +02:00
co-authored by Claude Opus 5
parent 582d68580b
commit e39d4b33b6
3 changed files with 178 additions and 19 deletions
+127 -18
View File
@@ -7,7 +7,7 @@ import {
saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf, saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf,
rememberMessage, recallMessage, forgetMessage, editRememberedMessage, pruneMessageCache, rememberMessage, recallMessage, forgetMessage, editRememberedMessage, pruneMessageCache,
} from '../db.js'; } from '../db.js';
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard } from '../runtime-settings.js'; import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard, modlogArten, modlogArtenGrundstock } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js'; import { tuning } from '../tuning.js';
@@ -203,35 +203,103 @@ function vorWieLange(sqlZeit) {
} }
/** /**
* Wer die Nachricht gelöscht hat — steht in Discords Audit-Log, aber nur, wenn * Einmal sagen, dass das Audit-Log nicht lesbar ist — und dann Ruhe geben.
* 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 * Ohne diese Meldung sieht ein fehlendes Recht genauso aus wie „alles in
* anzulegen. Deshalb wird nur gemeldet, was frisch ist und zu Kanal und * Ordnung": das Protokoll meldet einfach wieder jede Löschung, und niemand
* Verfasser passt — lieber keine Angabe als eine falsche. * weiss, warum der Filter nicht greift.
*/ */
async function werHatGeloescht(guild, autorId, channelId) { let auditGemeldet = false;
if (!guild || !autorId) return null; function auditFehlt(grund) {
if (auditGemeldet) return;
auditGemeldet = true;
console.error(`[modlog] ${grund}. Damit lässt sich nicht unterscheiden, ob jemand `
+ 'eine fremde Nachricht gelöscht hat oder seine eigene — im Zweifel wird gemeldet '
+ 'statt verschwiegen. Recht **Audit-Log ansehen** erteilen, dann wird es ruhiger.');
}
/**
* Wer die Nachricht gelöscht hat. Drei Antworten, und der Unterschied zählt.
*
* { wer } jemand anders war es — das ist der Moderationsvorgang
* { selbst } der Verfasser hat sie selbst zurückgenommen
* { unklar } wir konnten es nicht feststellen, mit Grund
*
* Vorher gab diese Funktion für alle drei Lagen `null` zurück. Zum Anzeigen
* reichte das ("dann eben ohne Angabe"), zum *Filtern* nicht: fehlt dem Bot
* das Recht aufs Audit-Log, sähe „selbst gelöscht" genauso aus wie „konnte
* nicht nachsehen" — und der Filter würde entweder alles verschlucken oder
* gar nichts, ohne dass es jemandem auffällt.
*
* Discord schreibt beim Löschen der *eigenen* Nachricht keinen Eintrag. Kein
* Treffer heisst deshalb „selbst" — mit dem Vorbehalt, dass das Audit-Log
* gelegentlich nachhinkt; das Zeitfenster von zehn Sekunden fängt das ab.
* Discord zählt ausserdem wiederholtes Löschen im selben Eintrag hoch, statt
* neue anzulegen; darum muss er frisch sein und zu Kanal und Verfasser passen.
*/
export async function werHatGeloescht(guild, autorId, channelId) {
if (!guild || !autorId) return { unklar: 'kein Server-Zusammenhang' };
const me = guild.members.me ?? await guild.members.fetchMe().catch(() => null);
if (!me?.permissions.has(PermissionFlagsBits.ViewAuditLog)) {
auditFehlt('Dem Bot fehlt das Recht „Audit-Log ansehen"');
return { unklar: 'Audit-Log nicht einsehbar' };
}
try { 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 log = await guild.fetchAuditLogs({ type: AuditLogEvent.MessageDelete, limit: 5 });
const treffer = log.entries.find((e) => ( const treffer = log.entries.find((e) => (
e.target?.id === autorId e.target?.id === autorId
&& e.extra?.channel?.id === channelId && e.extra?.channel?.id === channelId
&& Date.now() - e.createdTimestamp < 10_000 && Date.now() - e.createdTimestamp < 10_000
)); ));
if (!treffer?.executor || treffer.executor.id === autorId) return null; if (!treffer?.executor || treffer.executor.id === autorId) return { selbst: true };
return `${treffer.executor.username} (<@${treffer.executor.id}>)`; return { wer: `${treffer.executor.username} (<@${treffer.executor.id}>)` };
} catch (error) {
auditFehlt(`Audit-Log nicht lesbar (${error.message})`);
return { unklar: 'Audit-Log nicht lesbar' };
}
}
/**
* Hat der Bot die Rollen selbst vergeben?
*
* Rollenmenü-Klick, Level-Aufstieg, Sticky-Restore nach Wiedereintritt, der
* Playtester-Knopf — bei alldem ändert der Bot Rollen, und jede dieser
* Änderungen erzeugte bisher eine Protokollzeile. Das ist kein
* Moderationsvorgang, sondern der Bot beim Zusehen, wie er sich selbst bei
* der Arbeit meldet.
*
* Bewusst über das Audit-Log und nicht über Merker an den vier Stellen, die
* Rollen vergeben: so ist auch die fünfte erfasst, die jemand später baut.
*
* Im Zweifel `false` — dann wird gemeldet. Ein Protokoll, das lieber zu viel
* zeigt, ist besser als eines, das still etwas unterschlägt.
*/
export async function botHatRollenGesetzt(guild, memberId, botId) {
if (!guild || !memberId || !botId) return false;
const me = guild.members.me ?? await guild.members.fetchMe().catch(() => null);
if (!me?.permissions.has(PermissionFlagsBits.ViewAuditLog)) {
auditFehlt('Dem Bot fehlt das Recht „Audit-Log ansehen"');
return false;
}
try {
const log = await guild.fetchAuditLogs({ type: AuditLogEvent.MemberRoleUpdate, limit: 5 });
const treffer = log.entries.find((e) => (
e.target?.id === memberId && Date.now() - e.createdTimestamp < 10_000
));
return treffer?.executor?.id === botId;
} catch { } catch {
return null; // fehlende Rechte oder Rate-Limit — dann eben ohne return false;
} }
} }
/* ── Registrierung ─────────────────────────────────── */ /* ── Registrierung ─────────────────────────────────── */
export function registerModTools(client) { export function registerModTools(client) {
// Die Protokoll-Schalter einmalig eintragen, damit das Panel sie so zeigt,
// wie sie sich verhalten. Passiert genau einmal — wer abschaltet, behält
// es abgeschaltet.
modlogArtenGrundstock();
// Das Gedächtnis täglich stutzen. Ohne das wächst es endlos — und was // Das Gedächtnis täglich stutzen. Ohne das wächst es endlos — und was
// älter ist als die Aufbewahrung, soll auch wirklich weg sein. // älter ist als die Aufbewahrung, soll auch wirklich weg sein.
const aufraeumen = () => { const aufraeumen = () => {
@@ -265,8 +333,11 @@ export function registerModTools(client) {
// Willkommens-Embed + Auto-/Sticky-Roles + Join-Log // Willkommens-Embed + Auto-/Sticky-Roles + Join-Log
client.on(Events.GuildMemberAdd, async (member) => { client.on(Events.GuildMemberAdd, async (member) => {
try { try {
// Begrüssung und Rollen laufen immer — der Schalter betrifft nur
// die Protokollzeile, nicht das Verhalten des Servers.
await handleMemberAdd(member); await handleMemberAdd(member);
await restoreRoles(member); await restoreRoles(member);
if (!modlogArten().beitritte) return;
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(0x00ff88) .setColor(0x00ff88)
.setTitle('📥 Member beigetreten') .setTitle('📥 Member beigetreten')
@@ -283,7 +354,10 @@ export function registerModTools(client) {
const roleIds = [...(member.roles?.cache?.values() ?? [])] const roleIds = [...(member.roles?.cache?.values() ?? [])]
.filter((r) => r.id !== member.guild.id && !r.managed) .filter((r) => r.id !== member.guild.id && !r.managed)
.map((r) => r.id); .map((r) => r.id);
// Das Sichern der Rollen muss auch dann laufen, wenn niemand die
// Meldung sehen will — sonst kommt jemand ohne seine Rollen wieder.
if (roleIds.length > 0) saveStickyRoles(member.id, roleIds); if (roleIds.length > 0) saveStickyRoles(member.id, roleIds);
if (!modlogArten().beitritte) return;
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(brandColor2()) .setColor(brandColor2())
@@ -298,6 +372,7 @@ export function registerModTools(client) {
// Nick-/Rollen-Änderungen loggen // Nick-/Rollen-Änderungen loggen
client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => { client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => {
try { try {
if (!modlogArten().mitglieder) return;
const changes = []; const changes = [];
if (oldMember.nickname !== newMember.nickname) { if (oldMember.nickname !== newMember.nickname) {
changes.push({ changes.push({
@@ -305,6 +380,7 @@ export function registerModTools(client) {
value: `\`${oldMember.nickname ?? oldMember.user?.username ?? '—'}\`\`${newMember.nickname ?? newMember.user?.username ?? '—'}\``, value: `\`${oldMember.nickname ?? oldMember.user?.username ?? '—'}\`\`${newMember.nickname ?? newMember.user?.username ?? '—'}\``,
}); });
} }
const nurName = changes.length > 0;
const oldIds = new Set(oldMember.roles?.cache?.keys?.() ?? []); const oldIds = new Set(oldMember.roles?.cache?.keys?.() ?? []);
const newIds = new Set(newMember.roles?.cache?.keys?.() ?? []); const newIds = new Set(newMember.roles?.cache?.keys?.() ?? []);
const added = [...newIds].filter((id) => !oldIds.has(id) && id !== newMember.guild.id); const added = [...newIds].filter((id) => !oldIds.has(id) && id !== newMember.guild.id);
@@ -313,6 +389,14 @@ export function registerModTools(client) {
if (removed.length) changes.push({ name: 'Rollen ', value: removed.map((id) => `<@&${id}>`).join(' ') }); if (removed.length) changes.push({ name: 'Rollen ', value: removed.map((id) => `<@&${id}>`).join(' ') });
if (changes.length === 0) return; if (changes.length === 0) return;
// Rollen, die der Bot selbst vergeben hat, sind kein
// Moderationsvorgang — Rollenmenü, Level-Aufstieg, Sticky-Restore.
// Eine Umbenennung wird dagegen immer gemeldet: die macht ein
// Mensch, und sie ist der Grund, warum es diese Meldung gibt.
if (!nurName && await botHatRollenGesetzt(newMember.guild, newMember.id, client.user?.id)) {
return;
}
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setTitle('👤 Member geändert') .setTitle('👤 Member geändert')
@@ -348,12 +432,27 @@ export function registerModTools(client) {
// Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen) // Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen)
client.on(Events.MessageDelete, async (message) => { client.on(Events.MessageDelete, async (message) => {
try { try {
if (!modlogArten().loeschungen) return;
if (message.channelId === modlogChannelId()) return; if (message.channelId === modlogChannelId()) return;
// Was Discord noch weiß, ergänzen wir aus dem eigenen Gedächtnis // Was Discord noch weiß, ergänzen wir aus dem eigenen Gedächtnis
const gemerkt = recallMessage(message.id); const gemerkt = recallMessage(message.id);
if (!message.guildId && !gemerkt) return; if (!message.guildId && !gemerkt) return;
if (message.author?.bot) return; if (message.author?.bot) return;
// Wer es war, weiß nur Discords Audit-Log. Das ist hier nicht nur
// eine Zusatzangabe, sondern die Entscheidung: eine selbst
// zurückgenommene Nachricht ist kein Moderationsvorgang und
// gehört nicht ins Protokoll. Alles andere schon — auch das
// Unklare, denn Verschweigen wäre die schlechtere Hälfte des
// Zweifels.
const durch = await werHatGeloescht(
message.guild, gemerkt?.author_id ?? message.author?.id, message.channelId
);
if (durch.selbst) {
forgetMessage(message.id);
return;
}
const autor = message.author const autor = message.author
? `${message.member?.displayName ?? message.author.username} (<@${message.author.id}>)` ? `${message.member?.displayName ?? message.author.username} (<@${message.author.id}>)`
: gemerkt : gemerkt
@@ -371,10 +470,11 @@ export function registerModTools(client) {
{ name: 'Kanal', value: `<#${message.channelId}>`, inline: true } { name: 'Kanal', value: `<#${message.channelId}>`, inline: true }
); );
// Wer es war, weiß nur Discords Audit-Log — und auch nur, wenn es embed.addFields(durch.wer
// jemand anders war als der Verfasser selbst ? { name: 'Gelöscht von', value: durch.wer, inline: true }
const durch = await werHatGeloescht(message.guild, gemerkt?.author_id ?? message.author?.id, message.channelId); // Steht da, damit niemand die Zeile für „von einem Mod gelöscht"
if (durch) embed.addFields({ name: 'Gelöscht von', value: durch, inline: true }); // hält, obwohl wir es schlicht nicht feststellen konnten.
: { name: 'Gelöscht von', value: `_unklar — ${durch.unklar}_`, inline: true });
embed.addFields({ name: 'Inhalt', value: inhaltOderHinweis(text, Boolean(gemerkt)) }); embed.addFields({ name: 'Inhalt', value: inhaltOderHinweis(text, Boolean(gemerkt)) });
if (dateien.length > 0) { if (dateien.length > 0) {
@@ -438,6 +538,15 @@ export function registerModTools(client) {
const alt = oldMessage.content ?? gemerkt?.content ?? ''; const alt = oldMessage.content ?? gemerkt?.content ?? '';
if (alt === neu) return; // z. B. nur ein Embed ist nachgeladen if (alt === neu) return; // z. B. nur ein Embed ist nachgeladen
// Das Gedächtnis wird auch dann nachgeführt, wenn die Meldung
// abgeschaltet ist — sonst zeigt ein späteres Löschen die alte
// Fassung, und der Schalter würde still eine zweite Meldung
// verfälschen, die gar nicht gemeint war.
if (!modlogArten().bearbeitungen) {
if (gemerkt) editRememberedMessage(newMessage.id, neu.slice(0, 2000));
return;
}
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setTitle('✏️ Nachricht bearbeitet') .setTitle('✏️ Nachricht bearbeitet')
+14
View File
@@ -184,6 +184,20 @@ export const MODULES = [
desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.', desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.',
tab: 'support', default: true, tab: 'support', default: true,
requires: [{ setting: 'modlog_channel_id', label: 'Protokoll-Kanal', create: { name: 'protokoll', privat: true, topic: 'Bearbeitet, geloescht, beigetreten' } }], requires: [{ setting: 'modlog_channel_id', label: 'Protokoll-Kanal', create: { name: 'protokoll', privat: true, topic: 'Bearbeitet, geloescht, beigetreten' } }],
// Einzeln schaltbar, weil es sonst nur alles oder nichts gibt — und
// „alles" heisst bei einem lebendigen Server sehr schnell „Rauschen".
fields: [
{
setting: 'modlog_loeschungen', label: 'Gelöschte Nachrichten', art: 'schalter',
hint: 'nur, wenn jemand die Nachricht eines anderen gelöscht hat',
},
{ setting: 'modlog_bearbeitungen', label: 'Bearbeitete Nachrichten', art: 'schalter' },
{
setting: 'modlog_mitglieder', label: 'Namens- und Rollenwechsel', art: 'schalter',
hint: 'ohne die Rollen, die der Bot selbst vergibt (Rollenmenü, Level, Sticky)',
},
{ setting: 'modlog_beitritte', label: 'Beitritte und Austritte', art: 'schalter' },
],
}, },
{ {
id: 'modmail', group: 'moderation', name: 'Modmail', id: 'modmail', group: 'moderation', name: 'Modmail',
+37 -1
View File
@@ -1,6 +1,6 @@
// Effektive Laufzeit-Konfiguration: DB-Setting (Webinterface) vor Env-Variable. // Effektive Laufzeit-Konfiguration: DB-Setting (Webinterface) vor Env-Variable.
// Wird bei jedem Zugriff frisch aufgelöst — Änderungen greifen ohne Neustart. // Wird bei jedem Zugriff frisch aufgelöst — Änderungen greifen ohne Neustart.
import { getSetting } from './db.js'; import { getSetting, setSetting } from './db.js';
import { config } from './config.js'; import { config } from './config.js';
export function commitChannelId() { export function commitChannelId() {
@@ -343,6 +343,42 @@ export function panelZeichen() {
return getSetting('panel_zeichen') || ''; return getSetting('panel_zeichen') || '';
} }
/**
* Welche Protokoll-Meldungen überhaupt geschrieben werden.
*
* Vorher hing alles an einem einzigen Modulschalter: gelöschte und bearbeitete
* Nachrichten, Beitritte, Austritte, Namens- und Rollenwechsel — entweder alles
* oder nichts. Wer nur die Löschungen sehen wollte, bekam auch jeden
* Rollenmenü-Klick.
*
* `!== '0'` und nicht `=== '1'`: unbekannt heisst an. Ein Protokoll, das nach
* einem Update stillschweigend aufhört zu protokollieren, wäre die schlechtere
* Überraschung.
*/
export const modlogArten = () => ({
loeschungen: getSetting('modlog_loeschungen') !== '0',
bearbeitungen: getSetting('modlog_bearbeitungen') !== '0',
mitglieder: getSetting('modlog_mitglieder') !== '0',
beitritte: getSetting('modlog_beitritte') !== '0',
});
/**
* Die vier Schalter einmalig auf „an" setzen.
*
* Nötig, weil das Panel Schalter aus `fields` mit `=== '1'` liest: ohne
* geschriebenen Wert stünden sie dort auf aus, während sie sich in Wahrheit
* an verhalten. Ein Formular, das das Gegenteil dessen zeigt, was gilt, ist
* schlimmer als gar keins.
*
* Nur wenn nichts dasteht — wer abschaltet, behält es abgeschaltet.
*/
export function modlogArtenGrundstock() {
for (const key of ['modlog_loeschungen', 'modlog_bearbeitungen',
'modlog_mitglieder', 'modlog_beitritte']) {
if (getSetting(key) === null) setSetting(key, '1');
}
}
/** /**
* Lautstärke des Radios, in Prozent. * Lautstärke des Radios, in Prozent.
* *