diff --git a/.github/config.json b/.github/config.json index 241c7d70..eb2bb4c3 100644 --- a/.github/config.json +++ b/.github/config.json @@ -74,6 +74,14 @@ "sessionToken": "", "leaderBoardJsonUrl": "", "userMap": {} + }, + "autoban": { + "enabled": true, + "deleteThreshold": 40, + "banThreshold": 60, + "banDurationHours": 24, + "timeWindowMinutes": 5, + "spamLogChannelId": "1513948506266538275" } }, diff --git a/config.template.json b/config.template.json index d0ba396d..5a1d6e78 100644 --- a/config.template.json +++ b/config.template.json @@ -78,6 +78,20 @@ "sessionToken": "", "leaderBoardJsonUrl": "", "userMap": {} + }, + "autoban": { + // Set to true to enable automatic spam detection and banning + "enabled": false, + // Score at which a suspicious message is silently deleted (no ban) + "deleteThreshold": 40, + // Score at which the user is deleted + banned for banDurationHours + "banThreshold": 60, + // Duration of the automatic ban in hours + "banDurationHours": 24, + // Time window in minutes used to detect the same message across multiple channels + "timeWindowMinutes": 5, + // Channel ID of a mod-only channel for spam audit logs. Leave empty to disable. + "spamLogChannelId": "" } }, diff --git a/src/app.ts b/src/app.ts index bc018c3d..e4154240 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,7 @@ import { } from "#/handler/commandHandler.ts"; import * as guildMemberHandler from "#/handler/guildMemberHandler.ts"; import deleteThreadMessagesHandler from "#/handler/messageCreate/deleteThreadMessagesHandler.ts"; +import spamDetectionHandler from "#/handler/messageCreate/spamDetectionHandler.ts"; import { handlePresenceUpdate } from "#/handler/presenceHandler.ts"; import { createBotContext, type BotContext } from "#/context.ts"; import { ehreReactionHandler } from "#/commands/ehre.ts"; @@ -151,6 +152,7 @@ login().then( log.info("Registering main event handlers..."); + client.on("messageCreate", m => spamDetectionHandler(m, botContext)); client.on("messageCreate", m => messageCommandHandler(m, botContext)); client.on("messageCreate", m => deleteThreadMessagesHandler(m, botContext)); client.on("guildMemberAdd", m => guildMemberHandler.added(botContext, m)); diff --git a/src/context.ts b/src/context.ts index 6c7ed4ba..47f569b9 100644 --- a/src/context.ts +++ b/src/context.ts @@ -84,6 +84,14 @@ export interface BotContext { leaderBoardJsonUrl: string; userMap: Record; }; + autoban: { + enabled: boolean; + deleteThreshold: number; + banThreshold: number; + banDurationHours: number; + timeWindowMinutes: number; + spamLog: TextChannel | null; + }; }; roles: { @@ -319,6 +327,16 @@ export async function createBotContext(client: Client): Promise, + member: GuildMember, + score: number, + threshold: number, + triggeredLabels: readonly string[], +): APIEmbed { + const isBan = action === "ban"; + return { + color: isBan ? 0xe74c3c : 0xe67e22, + title: isBan ? "🚫 Autoban: Gebannt" : "⚠️ Autoban: Nachricht gelöscht", + fields: [ + { name: "Nutzer", value: `${member} (${member.id})`, inline: true }, + { name: "Kanal", value: `${message.channel}`, inline: true }, + { name: "Score", value: `${score} / ${threshold}`, inline: true }, + { + name: "Erkannte Merkmale", + value: triggeredLabels.map(l => `- ${l}`).join("\n") || "—", + inline: false, + }, + { + name: "Nachricht", + value: message.content.slice(0, 1024) || "*(leer)*", + inline: false, + }, + ], + timestamp: new Date().toISOString(), + footer: { text: `User-ID: ${member.id}` }, + }; +} + +export default async function spamDetectionHandler( + message: Message, + context: BotContext, +): Promise { + if (!context.commandConfig.autoban.enabled) { + return; + } + + if (message.author.bot || !message.inGuild()) { + return; + } + + const { member } = message; + if (!member) { + return; + } + + if ( + context.roleGuard.isMod(member) || + context.roleGuard.isTrusted(member) || + context.roleGuard.isGruendervater(member) + ) { + return; + } + + const { autoban } = context.commandConfig; + const { score, triggeredLabels } = spamDetection.evaluateMessage(message, member, context); + + if (score >= autoban.banThreshold) { + log.info({ userId: member.id, score, triggeredLabels }, "Auto-ban: spam threshold crossed"); + + // Delete previously tracked messages from this user across channels + const tracked = spamDetection.getTrackedMessages(member.id); + spamDetection.flushUser(member.id); + + for (const { messageId, channelId } of tracked) { + const channel = context.guild.channels.cache.get(channelId); + if (!channel?.isTextBased()) { + continue; + } + const msg = await channel.messages.fetch(messageId).catch(() => null); + if (msg) { + await msg.delete().catch(() => undefined); + } + } + + await message.delete().catch(() => undefined); + + autoban.spamLog + ?.send({ + embeds: [ + buildSpamLogEmbed( + "ban", + message, + member, + score, + autoban.banThreshold, + triggeredLabels, + ), + ], + }) + .catch(err => log.warn(err, "Failed to post spam log embed")); + + const reason = [ + "Automatischer Bann: Spam-Erkennung", + ...triggeredLabels.map(l => `- ${l}`), + ].join("\n"); + + const err = await banService.banUser( + context, + member, + context.client.user, + reason, + false, + autoban.banDurationHours, + ); + + if (err) { + sentry.captureException(new Error(err)); + log.error({ userId: member.id, err }, "Auto-ban failed after spam detection"); + } + + return; + } + + if (score >= autoban.deleteThreshold) { + log.info({ userId: member.id, score }, "Auto-delete: suspicious message removed"); + await message.delete().catch(() => undefined); + + autoban.spamLog + ?.send({ + embeds: [ + buildSpamLogEmbed( + "delete", + message, + member, + score, + autoban.deleteThreshold, + triggeredLabels, + ), + ], + }) + .catch(err => log.warn(err, "Failed to post spam log embed")); + + return; + } + + spamDetection.trackMessage(member.id, message.id, message.channelId, message.content); +} diff --git a/src/service/config.ts b/src/service/config.ts index 95d64120..5f81851b 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -145,6 +145,15 @@ export interface Config { } >; }; + autoban?: { + enabled: boolean; + deleteThreshold: number; + banThreshold: number; + banDurationHours: number; + timeWindowMinutes: number; + /** Channel ID for the mod-only spam audit log. Leave empty to disable. */ + spamLogChannelId?: Snowflake; + }; }; deleteThreadMessagesInChannelIds: readonly Snowflake[]; diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts new file mode 100644 index 00000000..665f2e37 --- /dev/null +++ b/src/service/spamDetection.ts @@ -0,0 +1,182 @@ +import type { GuildMember, Message, Snowflake } from "discord.js"; + +import type { BotContext } from "#/context.ts"; + +type RecentMessage = { + messageId: Snowflake; + content: string; + channelId: Snowflake; + recordedAt: Temporal.Instant; +}; + +type SignalEvaluate = ( + message: Message, + member: GuildMember, + context: BotContext, // available if a future signal needs it + history: readonly RecentMessage[], +) => number; + +type SignalDef = { + label: string; + evaluate: SignalEvaluate; +}; + +export type EvaluationResult = { + score: number; + triggeredLabels: readonly string[]; +}; + +const recentMessages = new Map(); + +const URL_PATTERN = /https?:\/\//i; +const DISCORD_INVITE_PATTERN = /discord\.gg\//i; + +const SCORES = { + accountAgeUnder7Days: 30, + accountAgeUnder30Days: 15, + guildJoinUnder10Minutes: 40, + guildJoinUnder1Hour: 25, + guildJoinUnder48Hours: 20, + containsUrl: 20, + containsDiscordInvite: 25, + massUserMentions: 20, + roleMentions: 25, + crossChannelDuplicate: 30, + onlyDefaultRole: 10, +} as const; + +/** Returns true if `instant` occurred more recently than `duration` ago. */ +function isWithin(instant: Temporal.Instant, duration: Temporal.DurationLike): boolean { + return Temporal.Instant.compare(instant, Temporal.Now.instant().subtract(duration)) > 0; +} + +const signals: readonly SignalDef[] = [ + { + label: "Neues Discord-Konto (< 7 Tage alt)", + evaluate: (_msg, member) => { + const created = Temporal.Instant.fromEpochMilliseconds(member.user.createdTimestamp); + return isWithin(created, { hours: 7 * 24 }) ? SCORES.accountAgeUnder7Days : 0; + }, + }, + { + label: "Relativ neues Discord-Konto (7-30 Tage alt)", + evaluate: (_msg, member) => { + const created = Temporal.Instant.fromEpochMilliseconds(member.user.createdTimestamp); + return !isWithin(created, { hours: 7 * 24 }) && isWithin(created, { hours: 30 * 24 }) + ? SCORES.accountAgeUnder30Days + : 0; + }, + }, + { + label: "Dem Server in den letzten 10 Minuten beigetreten", + evaluate: (_msg, member) => { + if (member.joinedTimestamp === null) return 0; + const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + return isWithin(joined, { minutes: 10 }) ? SCORES.guildJoinUnder10Minutes : 0; + }, + }, + { + label: "Dem Server in der letzten Stunde beigetreten", + evaluate: (_msg, member) => { + if (member.joinedTimestamp === null) return 0; + const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + return !isWithin(joined, { minutes: 10 }) && isWithin(joined, { hours: 1 }) + ? SCORES.guildJoinUnder1Hour + : 0; + }, + }, + { + label: "Dem Server in den letzten 48 Stunden beigetreten", + evaluate: (_msg, member) => { + if (member.joinedTimestamp === null) return 0; + const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + return !isWithin(joined, { hours: 1 }) && isWithin(joined, { hours: 48 }) + ? SCORES.guildJoinUnder48Hours + : 0; + }, + }, + { + label: "Nachricht enthält einen Link", + evaluate: msg => (URL_PATTERN.test(msg.content) ? SCORES.containsUrl : 0), + }, + { + label: "Nachricht enthält einen Discord-Einladungslink", + evaluate: msg => + DISCORD_INVITE_PATTERN.test(msg.content) ? SCORES.containsDiscordInvite : 0, + }, + { + label: "Nachricht erwähnt mehrere Nutzer", + evaluate: msg => (msg.mentions.users.size >= 2 ? SCORES.massUserMentions : 0), + }, + { + label: "Nachricht erwähnt eine oder mehrere Rollen", + evaluate: msg => (msg.mentions.roles.size > 0 ? SCORES.roleMentions : 0), + }, + { + label: "Keine selbst zugewiesenen Rollen", + evaluate: (_msg, member) => + // ≤ 2 means only @everyone + the default role, i.e. no self-assigned roles + member.roles.cache.size <= 2 ? SCORES.onlyDefaultRole : 0, + }, + { + label: "Gleiche Nachricht in mehreren Kanälen gesendet", + evaluate: (msg, _member, _context, history) => { + const normalized = msg.content.trim().toLowerCase(); + return history.some(m => m.content === normalized && m.channelId !== msg.channelId) + ? SCORES.crossChannelDuplicate + : 0; + }, + }, +]; + +export function evaluateMessage( + message: Message, + member: GuildMember, + context: BotContext, +): EvaluationResult { + const { timeWindowMinutes } = context.commandConfig.autoban; + const windowStart = Temporal.Now.instant().subtract({ minutes: timeWindowMinutes }); + const history = (recentMessages.get(member.id) ?? []).filter( + m => Temporal.Instant.compare(m.recordedAt, windowStart) > 0, + ); + + if (history.length === 0) { + recentMessages.delete(member.id); + } else { + recentMessages.set(member.id, history); + } + + const results = signals.map(({ label, evaluate }) => ({ + label, + points: evaluate(message, member, context, history), + })); + + return { + score: results.reduce((sum, r) => sum + r.points, 0), + triggeredLabels: results.filter(r => r.points > 0).map(r => r.label), + }; +} + +export function trackMessage( + userId: Snowflake, + messageId: Snowflake, + channelId: Snowflake, + content: string, +): void { + const existing = recentMessages.get(userId) ?? []; + existing.push({ + messageId, + content: content.trim().toLowerCase(), + channelId, + recordedAt: Temporal.Now.instant(), + }); + recentMessages.set(userId, existing); +} + +export function getTrackedMessages(userId: Snowflake): readonly RecentMessage[] { + return recentMessages.get(userId) ?? []; +} + +export function flushUser(userId: Snowflake): void { + recentMessages.delete(userId); +}