diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 10ecf7d..094a9b3 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -302,6 +302,8 @@ export default { 'feat.moderation.desc': 'Warnings, timeouts, cleanup — with history and a log.', 'feat.anti_raid': 'Raid protection', 'feat.anti_raid.desc': 'Spots waves of joins and locks the server down for a while.', + 'feat.automod': 'AutoMod log', + 'feat.automod.desc': "Hits from Discord's own filter land in the log; rules can be switched here.", 'feat.modlog': 'Audit log', 'feat.modlog.desc': 'Deleted and edited messages, joins, name and role changes.', 'feat.modmail': 'Modmail', diff --git a/frontend/src/module-icons.jsx b/frontend/src/module-icons.jsx index c7b82c1..42dbfa0 100644 --- a/frontend/src/module-icons.jsx +++ b/frontend/src/module-icons.jsx @@ -10,6 +10,7 @@ import { IconArchive, IconMail, IconSupport, IconMagic, IconTeam, IconRefresh, IconWarning, IconServer, IconMonitor, IconVoice, IconZap, IconClock, IconSend, IconTag, IconDatabase, IconPackage, IconLock, IconIdea, IconLink, + IconBot, } from './icons.jsx'; export const MODULE_ICONS = { @@ -19,7 +20,8 @@ export const MODULE_ICONS = { voting: IconThumbsUp, giveaways: IconGiveaway, birthdays: IconBirthday, events: IconEvents, playtester: IconGame, alpha_keys: IconKey, role_menus: IconRoles, applications: IconApplications, - moderation: IconShield, anti_raid: IconLock, modlog: IconArchive, modmail: IconMail, + moderation: IconShield, anti_raid: IconLock, automod: IconBot, + modlog: IconArchive, modmail: IconMail, linked_roles: IconLink, polls: IconThumbsUp, tickets: IconSupport, welcome: IconMagic, autorole: IconTeam, sticky_roles: IconRefresh, bug_reports: IconWarning, diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fa79e9c..c19b3f7 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -343,6 +343,7 @@ export default function Settings({ me }) { const [audit, setAudit] = useState([]); const [serverStats, setServerStats] = useState(null); const [watchdog, setWatchdog] = useState(null); + const [automod, setAutomod] = useState(null); const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false }); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); @@ -471,6 +472,7 @@ export default function Settings({ me }) { // Fuers Dashboard: Mitgliederzahl und Nachrichten pro Tag apiGet('/api/serverstats').then(setServerStats).catch(() => {}); apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); + apiGet('/api/automod').then(setAutomod).catch(() => {}); apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {}); @@ -1818,8 +1820,76 @@ export default function Settings({ me }) { ); + /** AutoMod-Regel an- oder abschalten */ + async function automodSchalten(id, aktiv) { + try { + await apiPut(`/api/automod/${id}`, { aktiv }); + const frisch = await apiGet('/api/automod'); + setAutomod(frisch); + } catch (e) { + setFeedback(e.body?.error ?? 'Umschalten fehlgeschlagen.'); + setTimeout(() => setFeedback(null), 3000); + } + } + const tabSupport = ( <> + {automod && ( +
+

// AutoMod

+

+ Discords eigener Filter — er läuft serverseitig und ohne Umweg über den Bot. + Hier siehst du, was eingerichtet ist, und kannst Regeln schalten. + Wortlisten bearbeitest du in Discord unter Servereinstellungen → AutoMod. + {!automod.aktiv && ' Das Protokollieren der Treffer ist gerade ausgeschaltet.'} +

+ + {!automod.ok && ( +

+ Regeln nicht abrufbar: {automod.grund} + {' '}— fehlt dem Bot das Recht „Server verwalten"? +

+ )} + {automod.ok && automod.regeln.length === 0 && ( +

+ Noch keine AutoMod-Regel eingerichtet. Anlegen geht in Discord unter + {' '}Servereinstellungen → AutoMod. +

+ )} + + {automod.regeln.map((r) => ( +
+
+ + {r.name} + {r.ausloeser} + +
+
+ {r.details.length > 0 && {r.details.join(' · ')}} + + {r.aktionen.map((a) => {a})} + + {(r.ausnahmen.rollen > 0 || r.ausnahmen.kanaele > 0) && ( + + ausgenommen: {[ + r.ausnahmen.rollen > 0 && `${r.ausnahmen.rollen} ${r.ausnahmen.rollen === 1 ? 'Rolle' : 'Rollen'}`, + r.ausnahmen.kanaele > 0 && `${r.ausnahmen.kanaele} ${r.ausnahmen.kanaele === 1 ? 'Kanal' : 'Kanäle'}`, + ].filter(Boolean).join(', ')} + + )} +
+
+ ))} +
+ )}

// Moderation & Kontakt

diff --git a/frontend/src/style.css b/frontend/src/style.css index 4ff43a2..0bee597 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1873,6 +1873,17 @@ label.toggle { font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap; } +/* ── AUTOMOD-REGELN ────────────────────────────────── */ +.am-row { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; } +.am-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } +.am-toggle { margin-left: auto; } +.am-meta { + display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; + margin-top: .55rem; font-size: .78rem; color: var(--muted); +} +.am-actions { display: flex; gap: .35rem; flex-wrap: wrap; } +.am-exempt { font-family: var(--mono); font-size: .64rem; color: var(--muted2); } + /* ── ERREICHBARKEITS-BALKEN ────────────────────────── */ /* Ein Strich pro Stunde, 168 Stück. Auf schmalen Schirmen werden die Striche dünner als ein Pixel — deshalb scrollt der Balken dort lieber, statt zu diff --git a/src/bot/automod.js b/src/bot/automod.js new file mode 100644 index 0000000..36ef040 --- /dev/null +++ b/src/bot/automod.js @@ -0,0 +1,132 @@ +// AutoMod: Discords eigener Filter, sichtbar gemacht. +// +// Bewusst kein eigener Wortfilter. Discord filtert serverseitig, ohne Latenz +// und ohne dass eine Nachricht erst bei uns ankommen muss — das kann kein Bot +// besser. Was fehlt, ist die Sicht darauf: Treffer landen nur in Discords +// eigenem Kanal, und die Regeln pflegt man in einer anderen Oberfläche als +// alles andere. +// +// Deshalb hier zweierlei: Treffer ins Mod-Log spiegeln, und die Regeln im +// Panel anzeigen und an-/abschalten. Das Bearbeiten der Wortlisten bleibt in +// Discord — dort ist es gut gelöst. +import { Events } from 'discord.js'; +import { + AutoModerationRuleTriggerType as Trigger, + AutoModerationActionType as Aktion, + AutoModerationRuleKeywordPresetType as Preset, +} from 'discord.js'; +import { moduleEnabled } from '../modules.js'; +import { modlogChannelId } from '../runtime-settings.js'; +import { brandEmbed } from '../embeds.js'; + +const AUSLOESER = { + [Trigger.Keyword]: 'Eigene Wörter', + [Trigger.Spam]: 'Spam-Erkennung', + [Trigger.KeywordPreset]: 'Discords Wortlisten', + [Trigger.MentionSpam]: 'Erwähnungs-Spam', + [Trigger.MemberProfile]: 'Profil-Inhalte', +}; + +const AKTIONEN = { + [Aktion.BlockMessage]: 'Nachricht blockiert', + [Aktion.SendAlertMessage]: 'Meldung an Team', + [Aktion.Timeout]: 'Auszeit', + [Aktion.BlockMemberInteraction]: 'Interaktion blockiert', +}; + +const PRESETS = { + [Preset.Profanity]: 'Schimpfwörter', + [Preset.SexualContent]: 'Sexuelle Inhalte', + [Preset.Slurs]: 'Beleidigungen', +}; + +const zaehl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`; + +/** Eine Regel so beschreiben, dass man sie ohne Discord-Vorwissen versteht */ +export function regelBeschreiben(regel) { + const md = regel.triggerMetadata ?? {}; + const details = []; + if (md.keywordFilter?.length) details.push(zaehl(md.keywordFilter.length, 'Wort', 'Wörter')); + if (md.regexPatterns?.length) details.push(zaehl(md.regexPatterns.length, 'Muster', 'Muster')); + if (md.presets?.length) details.push(md.presets.map((p) => PRESETS[p] ?? p).join(', ')); + if (md.mentionTotalLimit) details.push(`ab ${md.mentionTotalLimit} Erwähnungen`); + if (md.allowList?.length) details.push(zaehl(md.allowList.length, 'erlaubtes Wort', 'erlaubte Wörter')); + + return { + id: regel.id, + name: regel.name, + aktiv: regel.enabled, + ausloeser: AUSLOESER[regel.triggerType] ?? `Typ ${regel.triggerType}`, + details, + aktionen: (regel.actions ?? []).map((a) => AKTIONEN[a.type] ?? `Typ ${a.type}`), + // Rollen und Kanäle, die ausgenommen sind — oft der Grund, warum eine + // Regel scheinbar nicht greift + ausnahmen: { + rollen: regel.exemptRoles?.size ?? 0, + kanaele: regel.exemptChannels?.size ?? 0, + }, + }; +} + +/** Alle Regeln der Guild holen — leer, wenn keine Guild oder keine Rechte */ +export async function listRules(client, guildId) { + const guild = guildId ? client.guilds.cache.get(guildId) : client.guilds.cache.first(); + if (!guild) return { ok: false, grund: 'Kein Server verbunden', regeln: [] }; + try { + const regeln = await guild.autoModerationRules.fetch(); + return { ok: true, regeln: [...regeln.values()].map(regelBeschreiben) }; + } catch (error) { + // Häufigster Fall: dem Bot fehlt „Server verwalten" + return { ok: false, grund: error.message, regeln: [] }; + } +} + +/** Regel an- oder abschalten */ +export async function setRuleEnabled(client, guildId, ruleId, aktiv, durch) { + const guild = guildId ? client.guilds.cache.get(guildId) : client.guilds.cache.first(); + if (!guild) throw new Error('Kein Server verbunden'); + const regel = await guild.autoModerationRules.fetch(ruleId); + await regel.edit({ enabled: Boolean(aktiv), reason: `Über das Panel durch ${durch}` }); + return regelBeschreiben(await guild.autoModerationRules.fetch(ruleId)); +} + +export function registerAutoMod(client) { + client.on(Events.AutoModerationActionExecution, async (treffer) => { + try { + if (!moduleEnabled('automod')) return; + const channelId = modlogChannelId(); + if (!channelId) return; + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased()) return; + + const regelName = treffer.autoModerationRule?.name ?? `Regel ${treffer.ruleId}`; + const embed = brandEmbed(client, 'AUTOMOD', { secondary: true }) + .setTitle('🛑 AutoMod hat eingegriffen') + .setDescription( + `**${treffer.user?.username ?? treffer.userId}** ` + + `(<@${treffer.userId}>)${treffer.channelId ? ` in <#${treffer.channelId}>` : ''}` + ) + .addFields( + { name: 'Regel', value: regelName, inline: true }, + { name: 'Auslöser', value: AUSLOESER[treffer.ruleTriggerType] ?? '—', inline: true }, + { name: 'Maßnahme', value: AKTIONEN[treffer.action?.type] ?? '—', inline: true }, + ); + + if (treffer.matchedKeyword) { + embed.addFields({ name: 'Treffer', value: `\`${treffer.matchedKeyword}\``, inline: true }); + } + if (treffer.content) { + // Der Wortlaut gehört ins Protokoll, aber gekürzt und ohne + // Erwähnungen, die den Kanal anpingen + embed.addFields({ + name: 'Nachricht', + value: treffer.content.slice(0, 500), + }); + } + + await channel.send({ embeds: [embed], allowedMentions: { parse: [] } }).catch(() => {}); + } catch (error) { + console.error('[automod]', error); + } + }); +} diff --git a/src/bot/client.js b/src/bot/client.js index cea1a10..3f0b2a9 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -9,6 +9,7 @@ import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { registerCommunityListeners } from './community.js'; import { registerModTools } from './mod-tools.js'; import { registerAntiRaid } from './anti-raid.js'; +import { registerAutoMod } from './automod.js'; import { registerMetadata, startLinkedRoles } from './linked-roles.js'; import { registerLevels } from './levels.js'; import { registerPresence } from './presence.js'; @@ -66,6 +67,9 @@ export async function startBot() { // Temp-Voice (Join to Create) + Event-Ankündigungen GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildScheduledEvents, + // AutoMod-Treffer mitbekommen. Nicht privilegiert, aber Discord + // schickt die Ereignisse nur an Bots mit „Server verwalten". + GatewayIntentBits.AutoModerationExecution, ], // Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle partials: [Partials.Message, Partials.Reaction, Partials.Channel], @@ -376,6 +380,7 @@ export async function startBot() { registerModTools(client); // Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit registerAntiRaid(client); + registerAutoMod(client); registerLevels(client); registerPresence(client); registerExtras(client); diff --git a/src/modules.js b/src/modules.js index 966e140..073c4b9 100644 --- a/src/modules.js +++ b/src/modules.js @@ -156,6 +156,12 @@ export const MODULES = [ tab: 'support', default: true, tool: { tab: 'support', label: 'Moderation' }, }, + { + id: 'automod', group: 'moderation', name: 'AutoMod-Protokoll', + desc: 'Treffer von Discords eigenem Filter landen im Protokoll, Regeln sind hier schaltbar.', + tab: 'support', setting: 'automod_enabled', defaultOn: false, + requires: [{ setting: 'modlog_channel_id', label: 'Protokoll-Kanal', create: { name: 'protokoll', privat: true, topic: 'Bearbeitet, geloescht, beigetreten' } }], + }, { id: 'anti_raid', group: 'moderation', name: 'Raid-Schutz', desc: 'Erkennt Beitritts-Wellen und macht den Server vorübergehend dicht.', diff --git a/src/web/api.js b/src/web/api.js index fa7c0d0..ddd0f88 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -28,6 +28,7 @@ import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; import { watchdogState, watchedServices } from '../bot/watchdog.js'; +import { listRules, setRuleEnabled } from '../bot/automod.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { sanitizeEmbed } from './api-v1.js'; import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js'; @@ -293,6 +294,29 @@ export function registerApiRoutes(app, client) { return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() }; }); + // AutoMod: Discords eigene Regeln lesen und schalten. Die Wortlisten + // bleiben in Discord — dort ist das Bearbeiten besser geloest. + app.get('/api/automod', async (request, reply) => { + if (requireAnyScope(request, reply)) return; + const res = await listRules(client, discordGuildId()); + return { aktiv: moduleEnabled('automod'), ...res }; + }); + + app.put('/api/automod/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const user = getSessionUser(request); + try { + const regel = await setRuleEnabled( + client, discordGuildId(), String(request.params.id), + Boolean(request.body?.aktiv), user?.username ?? 'Panel' + ); + logAudit(user, `automod-regel ${regel.aktiv ? 'an' : 'aus'}`, regel.name); + return { ok: true, regel }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } + }); + // --- Module: zentrale An/Aus-Schalter aller Funktionen --- app.get('/api/modules', async (request, reply) => {