overhaul automod escalation and register applications, restore orange theme
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m12s
SonarQube / sonar (push) Successful in -21s

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:
2026-07-03 00:21:20 +02:00
parent fe41bfdd88
commit e708cac790
15 changed files with 3689 additions and 178 deletions

View File

@@ -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 {