diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js index f340c9d..740607b 100644 --- a/src/bot/mod-tools.js +++ b/src/bot/mod-tools.js @@ -7,7 +7,7 @@ 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 { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter, welcomeCard, modlogArten, modlogArtenGrundstock } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { renderTemplate } from '../templates.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 - * 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. + * Einmal sagen, dass das Audit-Log nicht lesbar ist — und dann Ruhe geben. * - * 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. + * Ohne diese Meldung sieht ein fehlendes Recht genauso aus wie „alles in + * Ordnung": das Protokoll meldet einfach wieder jede Löschung, und niemand + * weiss, warum der Filter nicht greift. */ -async function werHatGeloescht(guild, autorId, channelId) { - if (!guild || !autorId) return null; +let auditGemeldet = false; +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 { - 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}>)`; + if (!treffer?.executor || treffer.executor.id === autorId) return { selbst: true }; + 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 { - return null; // fehlende Rechte oder Rate-Limit — dann eben ohne + return false; } } /* ── Registrierung ─────────────────────────────────── */ 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 // älter ist als die Aufbewahrung, soll auch wirklich weg sein. const aufraeumen = () => { @@ -265,8 +333,11 @@ export function registerModTools(client) { // Willkommens-Embed + Auto-/Sticky-Roles + Join-Log client.on(Events.GuildMemberAdd, async (member) => { try { + // Begrüssung und Rollen laufen immer — der Schalter betrifft nur + // die Protokollzeile, nicht das Verhalten des Servers. await handleMemberAdd(member); await restoreRoles(member); + if (!modlogArten().beitritte) return; await logToModlog(client, new EmbedBuilder() .setColor(0x00ff88) .setTitle('📥 Member beigetreten') @@ -283,7 +354,10 @@ export function registerModTools(client) { const roleIds = [...(member.roles?.cache?.values() ?? [])] .filter((r) => r.id !== member.guild.id && !r.managed) .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 (!modlogArten().beitritte) return; await logToModlog(client, new EmbedBuilder() .setColor(brandColor2()) @@ -298,6 +372,7 @@ export function registerModTools(client) { // Nick-/Rollen-Änderungen loggen client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => { try { + if (!modlogArten().mitglieder) return; const changes = []; if (oldMember.nickname !== newMember.nickname) { changes.push({ @@ -305,6 +380,7 @@ export function registerModTools(client) { 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 newIds = new Set(newMember.roles?.cache?.keys?.() ?? []); 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 (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() .setColor(brandColor()) .setTitle('👤 Member geändert') @@ -348,12 +432,27 @@ export function registerModTools(client) { // Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen) client.on(Events.MessageDelete, async (message) => { try { + if (!modlogArten().loeschungen) return; if (message.channelId === modlogChannelId()) return; // 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; + // 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 ? `${message.member?.displayName ?? message.author.username} (<@${message.author.id}>)` : gemerkt @@ -371,10 +470,11 @@ export function registerModTools(client) { { 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(durch.wer + ? { name: 'Gelöscht von', value: durch.wer, inline: true } + // Steht da, damit niemand die Zeile für „von einem Mod gelöscht" + // 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)) }); if (dateien.length > 0) { @@ -438,6 +538,15 @@ export function registerModTools(client) { const alt = oldMessage.content ?? gemerkt?.content ?? ''; 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() .setColor(brandColor()) .setTitle('✏️ Nachricht bearbeitet') diff --git a/src/modules.js b/src/modules.js index 732c270..cd210c1 100644 --- a/src/modules.js +++ b/src/modules.js @@ -184,6 +184,20 @@ export const MODULES = [ desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.', tab: 'support', default: true, 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', diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 0851ba9..6861e34 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -1,6 +1,6 @@ // Effektive Laufzeit-Konfiguration: DB-Setting (Webinterface) vor Env-Variable. // 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'; export function commitChannelId() { @@ -343,6 +343,42 @@ export function panelZeichen() { 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. *