overhaul automod escalation and register applications, restore orange theme
Automod: per-filter actions (warn/timeout/kick/ban), channel/role exemptions, regex/severity badword rules, and a strike/escalation system backed by a new AutomodStrike table. Register: fixes missing answer labels, adds internal reviewer notes and cross-form application history for a user, and exposes form/status filters in the dashboard. Dashboard: overrides HeroUI's default blue accent token with the bot's established orange (#f97316), which was lost during the HeroUI v3 migration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,44 @@
|
||||
import { Collection, Message } from 'discord.js';
|
||||
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';
|
||||
|
||||
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;
|
||||
@@ -26,6 +63,20 @@ export interface AutomodConfig {
|
||||
automodActions?: boolean;
|
||||
};
|
||||
};
|
||||
/** Per-filter action/exemption overrides. Falls back to the legacy flat flags above when unset. */
|
||||
filters?: Partial<Record<AutomodFilterKey, AutomodFilterConfig>>;
|
||||
/** 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 {
|
||||
@@ -56,7 +107,7 @@ export class AutoModService {
|
||||
if (message.author.bot || message.webhookId) return;
|
||||
if (!message.inGuild()) return;
|
||||
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
|
||||
const config = { ...this.defaults, ...(guildConfig ?? {}) };
|
||||
const config: AutomodConfig = { ...this.defaults, ...((guildConfig as AutomodConfig) ?? {}) };
|
||||
const member = message.member;
|
||||
|
||||
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
|
||||
@@ -64,53 +115,88 @@ export class AutoModService {
|
||||
if (allowed) return;
|
||||
}
|
||||
|
||||
if (config.mentionSpamFilter !== false && this.exceedsMentionLimit(message, config.maxMentions)) {
|
||||
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!;
|
||||
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
|
||||
message.member?.timeout(timeoutMs, 'Automod: Mass-Mention').catch(() => undefined);
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte nicht so viele User/Rollen auf einmal erwähnen.`);
|
||||
logger.warn(`Timed out ${message.author.tag} for mass-mention`);
|
||||
await this.logAutomodAction(message, config, 'mention_spam', `${count}/${limit} Erwähnungen in einer Nachricht`);
|
||||
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 linkFilterOn = config.linkFilter ?? config.deleteLinks ?? true;
|
||||
const inviteCfg = this.resolveFilter('inviteFilter', config, 'delete');
|
||||
const linkMatch = this.matchLink(message.content);
|
||||
if (linkMatch && config.inviteFilter !== false && this.inviteRegex.test(linkMatch)) {
|
||||
await this.deleteMessageWithReason(message, `${message.author}, Einladungslinks sind hier nicht erlaubt.`);
|
||||
logger.info(`Deleted invite link from ${message.author.tag}`);
|
||||
await this.logAutomodAction(message, config, 'invite_filter', 'Discord-Einladungslink erkannt', 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;
|
||||
}
|
||||
|
||||
if (this.linkFilterEnabled && linkFilterOn && linkMatch && !this.isWhitelisted(linkMatch, config.linkWhitelist)) {
|
||||
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
|
||||
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(', ')}` : ''}`;
|
||||
logger.info(`Deleted link from ${message.author.tag}`);
|
||||
await this.logAutomodAction(message, config, 'link_filter', reason);
|
||||
await this.triggerFilter(message, config, 'linkFilter', linkCfg, `${message.author}, Links sind hier nicht erlaubt.`, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
|
||||
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.capsFilter) {
|
||||
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) {
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
|
||||
const ratio = Math.round((upper.length / letters.length) * 100);
|
||||
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
|
||||
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 spamFilterOn = config.spamFilter ?? true;
|
||||
if (this.antiSpamEnabled && spamFilterOn) {
|
||||
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)) {
|
||||
@@ -123,37 +209,136 @@ export class AutoModService {
|
||||
|
||||
const threshold = config.spamThreshold ?? this.spamThreshold;
|
||||
if (tracker.count >= threshold) {
|
||||
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
|
||||
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
|
||||
logger.warn(`Timed out ${message.author.tag} for spam`);
|
||||
this.spamTracker.delete(message.author.id);
|
||||
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`;
|
||||
await this.logAutomodAction(message, config, 'spam', reason);
|
||||
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 }
|
||||
});
|
||||
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 containsBadword(content: string, custom: string[] = []) {
|
||||
const combined = [...this.defaultBadwords, ...(custom || [])]
|
||||
.map((w) => w?.toString().trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (!combined.length) return false;
|
||||
private getBadwordRules(config: AutomodConfig): Required<AutomodBadwordRule>[] {
|
||||
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();
|
||||
return combined.some((w) => {
|
||||
// Try to match word boundaries first, fall back to substring to remain permissive
|
||||
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
return regex.test(lower) || lower.includes(w);
|
||||
});
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user