Two feature batches plus a deploy fix: Moderation & engagement: persistent ModCase history (retrofits ban/kick/ mute/timeout/tempban to record cases), /warn, /watch watchlist with automatic alerts on deletions/tickets/automod hits, /case file lookup, achievement badges (/badges, /profile) with message/streak/ticket/ birthday/event/booster triggers, /weekplan RSVP boards, /poll with anonymous mode and scheduled auto-close, /suggest with vote/comment/ decide flow. Branding & growth: /branding (per-guild embed color/logo/footer/bot name/theme, applied across logging/ticket/register/embed-builder embeds and the dashboard sidebar/accent color at runtime), two new /setup server-type presets (Roleplay, Creator), /rules generate (template-based rule sets), /partner apply/list/config with invite validation and showcase posting, alt-account suspicion scoring on join (account age, avatar, name pattern, join bursts, ban-list similarity), /gallery submit with voting and weekly-winner scheduler, invite-attributed join/leave tracking with a new Wachstum dashboard page, and an expanded Admin dashboard (guild list, feature-usage counters). Also fixes production: the Docker container never ran `prisma migrate deploy`, so schema changes never reached the live database. The compose command now applies pending migrations on every start. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
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<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 {
|
|
private spamTracker = new Collection<string, { count: number; lastMessage: number }>();
|
|
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<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();
|
|
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);
|
|
}
|
|
}
|
|
}
|