import { Collection, GuildMember, Message } from 'discord.js'; import { logger } from '../utils/logger'; import { GuildSettings } from '../config/state'; import { LoggingService } from './loggingService'; import { prisma } from '../database'; import { context } from '../config/context'; export type AutomodFilterKey = | 'linkFilter' | 'inviteFilter' | 'badWordFilter' | 'capsFilter' | 'spamFilter' | 'mentionSpamFilter'; export type AutomodAction = 'delete' | 'warn' | 'timeout' | 'kick' | 'ban'; export interface AutomodFilterConfig { enabled?: boolean; action?: AutomodAction; timeoutMinutes?: number; exemptRoleIds?: string[]; exemptChannelIds?: string[]; } export interface AutomodBadwordRule { pattern: string; isRegex?: boolean; severity?: 'low' | 'medium' | 'high'; } export interface AutomodStrikeThreshold { count: number; action: 'timeout' | 'kick' | 'ban'; timeoutMinutes?: number; } export interface AutomodStrikeConfig { enabled?: boolean; decayHours?: number; thresholds?: AutomodStrikeThreshold[]; } export interface AutomodConfig { spamThreshold?: number; windowMs?: number; linkWhitelist?: string[]; spamTimeoutMinutes?: number; /** @deprecated use linkFilter */ deleteLinks?: boolean; linkFilter?: boolean; inviteFilter?: boolean; spamFilter?: boolean; badWordFilter?: boolean; capsFilter?: boolean; customBadwords?: string[]; whitelistRoles?: string[]; mentionSpamFilter?: boolean; maxMentions?: number; logChannelId?: string; loggingConfig?: { logChannelId?: string; categories?: { automodActions?: boolean; }; }; /** Per-filter action/exemption overrides. Falls back to the legacy flat flags above when unset. */ filters?: Partial>; /** Fine-grained badword rules (regex/severity). Falls back to customBadwords when unset. */ badwordRules?: AutomodBadwordRule[]; /** Strike/escalation system. */ strikeConfig?: AutomodStrikeConfig; } interface ResolvedFilter { enabled: boolean; action: AutomodAction; timeoutMinutes: number; exemptRoleIds: string[]; exemptChannelIds: string[]; } export class AutoModService { private spamTracker = new Collection(); private spamThreshold = 5; private windowMs = 7000; private defaults: AutomodConfig = { spamThreshold: 5, windowMs: 7000, linkWhitelist: [], spamTimeoutMinutes: 10, linkFilter: true, inviteFilter: true, spamFilter: true, badWordFilter: true, capsFilter: false, customBadwords: [], whitelistRoles: [], mentionSpamFilter: true, maxMentions: 5 }; private defaultBadwords = ['badword', 'spamword']; private inviteRegex = /(discord\.gg\/|discord(?:app)?\.com\/invite\/)[a-z0-9-]+/i; constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {} public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) { if (message.author.bot || message.webhookId) return; if (!message.inGuild()) return; const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg; const config: AutomodConfig = { ...this.defaults, ...((guildConfig as AutomodConfig) ?? {}) }; const member = message.member; if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) { const allowed = member.roles.cache.some((r) => config.whitelistRoles!.includes(r.id)); if (allowed) return; } const mentionCfg = this.resolveFilter('mentionSpamFilter', config, 'timeout'); if (mentionCfg.enabled && !this.isExempt(member, message.channelId, mentionCfg) && this.exceedsMentionLimit(message, config.maxMentions)) { const count = message.mentions.users.size + message.mentions.roles.size; const limit = config.maxMentions ?? this.defaults.maxMentions!; await this.triggerFilter( message, config, 'mentionSpamFilter', mentionCfg, `${message.author}, bitte nicht so viele User/Rollen auf einmal erwähnen.`, `${count}/${limit} Erwähnungen in einer Nachricht` ); return true; } const inviteCfg = this.resolveFilter('inviteFilter', config, 'delete'); const linkMatch = this.matchLink(message.content); if (linkMatch && inviteCfg.enabled && !this.isExempt(member, message.channelId, inviteCfg) && this.inviteRegex.test(linkMatch)) { await this.triggerFilter( message, config, 'inviteFilter', inviteCfg, `${message.author}, Einladungslinks sind hier nicht erlaubt.`, 'Discord-Einladungslink erkannt', message.content ); return true; } const linkCfg = this.resolveFilter('linkFilter', config, 'delete'); const linkFilterOn = linkCfg.enabled ?? config.deleteLinks ?? true; if ( this.linkFilterEnabled && linkFilterOn && linkMatch && !this.isExempt(member, message.channelId, linkCfg) && !this.isWhitelisted(linkMatch, config.linkWhitelist) ) { const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`; await this.triggerFilter(message, config, 'linkFilter', linkCfg, `${message.author}, Links sind hier nicht erlaubt.`, reason); return true; } const badwordCfg = this.resolveFilter('badWordFilter', config, 'delete'); if (badwordCfg.enabled && !this.isExempt(member, message.channelId, badwordCfg)) { const match = this.matchBadword(message.content, config); if (match) { await this.triggerFilter( message, config, 'badWordFilter', badwordCfg, `${message.author}, bitte auf deine Wortwahl achten.`, `Badword erkannt (Schweregrad: ${match.severity})`, message.content ); return true; } } const capsCfg = this.resolveFilter('capsFilter', config, 'delete'); if (capsCfg.enabled && !this.isExempt(member, message.channelId, capsCfg)) { const letters = message.content.replace(/[^a-zA-Z]/g, ''); const upper = letters.replace(/[^A-Z]/g, ''); if (letters.length >= 10 && upper.length / letters.length > 0.7) { const ratio = Math.round((upper.length / letters.length) * 100); await this.triggerFilter( message, config, 'capsFilter', capsCfg, `${message.author}, bitte weniger Capslock nutzen.`, `Caps Anteil ${ratio}%`, message.content ); return true; } } const spamCfg = this.resolveFilter('spamFilter', config, 'timeout'); if (this.antiSpamEnabled && spamCfg.enabled && !this.isExempt(member, message.channelId, spamCfg)) { const now = Date.now(); const tracker = this.spamTracker.get(message.author.id) ?? { count: 0, lastMessage: now }; if (now - tracker.lastMessage < (config.windowMs ?? this.windowMs)) { tracker.count += 1; } else { tracker.count = 1; } tracker.lastMessage = now; this.spamTracker.set(message.author.id, tracker); const threshold = config.spamThreshold ?? this.spamThreshold; if (tracker.count >= threshold) { this.spamTracker.delete(message.author.id); const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`; await this.triggerFilter(message, config, 'spamFilter', spamCfg, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`, reason); return true; } } return false; } /** Resolves the effective per-filter config, falling back to the legacy flat flags for backwards compatibility. */ private resolveFilter(key: AutomodFilterKey, config: AutomodConfig, legacyDefaultAction: AutomodAction): ResolvedFilter { const override = config.filters?.[key] ?? {}; const legacyEnabled = (config as any)[key] as boolean | undefined; return { enabled: override.enabled ?? legacyEnabled ?? true, action: override.action ?? legacyDefaultAction, timeoutMinutes: override.timeoutMinutes ?? config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!, exemptRoleIds: Array.isArray(override.exemptRoleIds) ? override.exemptRoleIds : [], exemptChannelIds: Array.isArray(override.exemptChannelIds) ? override.exemptChannelIds : [] }; } private isExempt(member: GuildMember | null, channelId: string, filterCfg: ResolvedFilter): boolean { if (filterCfg.exemptChannelIds.length && filterCfg.exemptChannelIds.includes(channelId)) return true; if (member?.roles.cache.size && filterCfg.exemptRoleIds.length) { return member.roles.cache.some((r) => filterCfg.exemptRoleIds.includes(r.id)); } return false; } private async triggerFilter( message: Message, config: AutomodConfig, filterKey: AutomodFilterKey, filterCfg: ResolvedFilter, userMessage: string, logReason: string, content?: string ) { await this.deleteMessageWithReason(message, userMessage); logger.info(`Automod ${filterKey} triggered for ${message.author.tag} -> ${filterCfg.action}`); await this.applyAction(message, filterCfg.action, filterCfg.timeoutMinutes, `Automod: ${filterKey}`); await this.logAutomodAction(message, config, filterKey, `${logReason} | Aktion: ${filterCfg.action}`, content); await this.registerStrike(message, config, filterKey, logReason); } private async applyAction(message: Message, action: AutomodAction, timeoutMinutes: number, reason: string) { const member = message.member; if (!member) return; switch (action) { case 'timeout': { const ms = Math.max(1, timeoutMinutes) * 60 * 1000; await member.timeout(ms, reason).catch(() => undefined); break; } case 'kick': await member.kick(reason).catch(() => undefined); break; case 'ban': await member.ban({ reason }).catch(() => undefined); break; case 'warn': case 'delete': default: break; } } private async registerStrike(message: Message, config: AutomodConfig, filterKey: AutomodFilterKey, reason: string) { const strikeCfg = config.strikeConfig; if (!strikeCfg?.enabled || !message.guildId) return; try { await prisma.automodStrike.create({ data: { guildId: message.guildId, userId: message.author.id, filterKey, weight: 1, reason } }); context.watchlist.notifyIfWatched(message.guild, message.author.id, 'Automod-Verstoß', reason); const decayHours = strikeCfg.decayHours ?? 0; const since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined; const rows = await prisma.automodStrike.findMany({ where: { guildId: message.guildId, userId: message.author.id, ...(since ? { createdAt: { gte: since } } : {}) } }); const total = rows.reduce((sum, r) => sum + r.weight, 0); const thresholds = (strikeCfg.thresholds || []).slice().sort((a, b) => a.count - b.count); const hit = thresholds.filter((t) => total >= t.count).pop(); if (hit) { const minutes = hit.timeoutMinutes ?? config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!; await this.applyAction(message, hit.action, minutes, `Automod-Eskalation (${total} Verstöße)`); await this.logAutomodAction(message, config, 'strike_escalation', `${total} aktive Verstöße -> ${hit.action}`); } } catch (err) { logger.error('Failed to record automod strike', err); } } private exceedsMentionLimit(message: Message, maxMentions?: number) { const limit = maxMentions ?? this.defaults.maxMentions!; const count = message.mentions.users.size + message.mentions.roles.size; return count >= limit; } private getBadwordRules(config: AutomodConfig): Required[] { if (Array.isArray(config.badwordRules) && config.badwordRules.length) { return config.badwordRules .filter((r) => r?.pattern) .map((r) => ({ pattern: r.pattern, isRegex: !!r.isRegex, severity: r.severity ?? 'low' })); } const combined = [...this.defaultBadwords, ...(config.customBadwords || [])] .map((w) => w?.toString().trim()) .filter(Boolean) as string[]; return combined.map((w) => ({ pattern: w, isRegex: false, severity: 'low' as const })); } private matchBadword(content: string, config: AutomodConfig): { pattern: string; severity: 'low' | 'medium' | 'high' } | null { const rules = this.getBadwordRules(config); if (!rules.length) return null; const lower = content.toLowerCase(); for (const rule of rules) { if (rule.isRegex) { try { if (new RegExp(rule.pattern, 'i').test(content)) return rule; } catch { continue; } } else { const w = rule.pattern.toLowerCase(); const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`\\b${escaped}\\b`, 'i'); if (regex.test(lower) || lower.includes(w)) return rule; } } return null; } private matchLink(content: string): string | null { // Match common link formats, even without protocol const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content); return match ? match[0].toLowerCase() : null; } private isWhitelisted(url: string, whitelist: string[] = []) { const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean); return normalized.some((w) => url.includes(w)); } private async deleteMessageWithReason(message: Message, response: string) { await message.delete().catch(() => undefined); const channel = message.channel; if (!('send' in channel)) return; await channel .send({ content: response }) .then((m: Message) => setTimeout(() => m.delete().catch(() => undefined), 5000)) .catch(() => undefined); } private async logAutomodAction(message: Message, config: AutomodConfig, action: string, reason: string, content?: string) { try { const guild = message.guild; if (!guild) return; if (this.logging) { this.logging.logAutomodAction(guild, { userTag: message.author.tag, userId: message.author.id, action, reason, content, channel: guild.channels.cache.get(message.channelId) ?? null, messageUrl: message.url }); return; } const loggingCfg = config.loggingConfig || {}; const flags = loggingCfg.categories || {}; if (flags.automodActions === false) return; const channelId = loggingCfg.logChannelId || config.logChannelId; if (!channelId) return; const channel = await guild.channels.fetch(channelId).catch(() => null); if (!channel || !channel.isTextBased()) return; const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`; await channel.send({ content: body }); } catch (err) { logger.error('Automod log failed', err); } } }