Files
Papo/src/services/automodService.ts
Pepe44DEV aa246cd7ea
Some checks failed
Deploy Discord Bot / deploy (push) Failing after -1m12s
SonarQube / sonar (push) Successful in 4s
add badges, weekly plans, polls, suggestions, mod-case system, branding, partner system, alt-account detection, gallery, growth tracking
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>
2026-07-03 23:15:35 +02:00

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);
}
}
}