From b3300d837f8a2d406e7ffce7807f3080369b882f Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:56:02 +0200 Subject: [PATCH 1/7] Implement automatic spam detection and banning feature --- config.template.json | 12 ++ src/app.ts | 2 + src/context.ts | 14 ++ .../messageCreate/spamDetectionHandler.ts | 81 +++++++++ src/service/config.ts | 7 + src/service/spamDetection.ts | 154 ++++++++++++++++++ 6 files changed, 270 insertions(+) create mode 100644 src/handler/messageCreate/spamDetectionHandler.ts create mode 100644 src/service/spamDetection.ts diff --git a/config.template.json b/config.template.json index d0ba396d..a9e0ab7b 100644 --- a/config.template.json +++ b/config.template.json @@ -78,6 +78,18 @@ "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 } }, 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..6a330927 100644 --- a/src/context.ts +++ b/src/context.ts @@ -84,6 +84,13 @@ export interface BotContext { leaderBoardJsonUrl: string; userMap: Record; }; + autoban: { + enabled: boolean; + deleteThreshold: number; + banThreshold: number; + banDurationHours: number; + timeWindowMinutes: number; + }; }; roles: { @@ -319,6 +326,13 @@ export async function createBotContext(client: Client): 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 = spamDetection.evaluateMessage(message, member, context); + + if (score >= autoban.banThreshold) { + log.info({ userId: member.id, score }, "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); + + const err = await banService.banUser( + context, + member, + context.client.user, + `Automatischer Bann: Spam erkannt (Score: ${score})`, + 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); + 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..a5432853 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -145,6 +145,13 @@ export interface Config { } >; }; + autoban?: { + enabled: boolean; + deleteThreshold: number; + banThreshold: number; + banDurationHours: number; + timeWindowMinutes: number; + }; }; deleteThreadMessagesInChannelIds: readonly Snowflake[]; diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts new file mode 100644 index 00000000..4d18c9cd --- /dev/null +++ b/src/service/spamDetection.ts @@ -0,0 +1,154 @@ +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 Signal = ( + message: Message, + member: GuildMember, + context: BotContext, // available if a future signal needs it + history: readonly RecentMessage[], +) => number; + +const recentMessages = new Map(); + +const URL_PATTERN = /https?:\/\//i; +const DISCORD_INVITE_PATTERN = /discord\.gg\//i; +const TRAILING_DIGITS_PATTERN = /\d{2,}$/; + +const SCORES = { + accountAgeUnder7Days: 30, + accountAgeUnder30Days: 15, + guildJoinUnder10Minutes: 40, + guildJoinUnder1Hour: 25, + guildJoinUnder24Hours: 10, + trailingDigitsInUsername: 10, + containsUrl: 20, + containsDiscordInvite: 25, + massUserMentions: 20, + roleMentions: 25, + crossChannelDuplicate: 30, + onlyDefaultRole: 10, +} as const; + +function scoreAccountAge(_msg: Message, member: GuildMember): number { + const now = Temporal.Now.instant(); + const created = Temporal.Instant.fromEpochMilliseconds(member.user.createdTimestamp); + if (Temporal.Instant.compare(created, now.subtract({ hours: 7 * 24 })) > 0) { + return SCORES.accountAgeUnder7Days; + } + if (Temporal.Instant.compare(created, now.subtract({ hours: 30 * 24 })) > 0) { + return SCORES.accountAgeUnder30Days; + } + return 0; +} + +function scoreGuildJoin(_msg: Message, member: GuildMember): number { + if (member.joinedTimestamp === null) return 0; + const now = Temporal.Now.instant(); + const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + if (Temporal.Instant.compare(joined, now.subtract({ minutes: 10 })) > 0) { + return SCORES.guildJoinUnder10Minutes; + } + if (Temporal.Instant.compare(joined, now.subtract({ hours: 1 })) > 0) { + return SCORES.guildJoinUnder1Hour; + } + if (Temporal.Instant.compare(joined, now.subtract({ hours: 24 })) > 0) { + return SCORES.guildJoinUnder24Hours; + } + return 0; +} + +function scoreTrailingDigits(_msg: Message, member: GuildMember): number { + return TRAILING_DIGITS_PATTERN.test(member.user.username) ? SCORES.trailingDigitsInUsername : 0; +} + +function scoreUrl(msg: Message): number { + return URL_PATTERN.test(msg.content) ? SCORES.containsUrl : 0; +} + +function scoreDiscordInvite(msg: Message): number { + return DISCORD_INVITE_PATTERN.test(msg.content) ? SCORES.containsDiscordInvite : 0; +} + +function scoreMassUserMentions(msg: Message): number { + return msg.mentions.users.size >= 2 ? SCORES.massUserMentions : 0; +} + +function scoreRoleMentions(msg: Message): number { + return msg.mentions.roles.size > 0 ? SCORES.roleMentions : 0; +} + +function scoreOnlyDefaultRole(_msg: Message, member: GuildMember): number { + // ≤ 2 means only @everyone + the default role, i.e. no self-assigned roles + return member.roles.cache.size <= 2 ? SCORES.onlyDefaultRole : 0; +} + +function scoreCrossChannelDuplicate( + msg: Message, + _member: GuildMember, + _context: BotContext, + history: readonly RecentMessage[], +): number { + const normalized = msg.content.trim().toLowerCase(); + return history.some(m => m.content === normalized && m.channelId !== msg.channelId) + ? SCORES.crossChannelDuplicate + : 0; +} + +const signals: readonly Signal[] = [ + scoreAccountAge, + scoreGuildJoin, + scoreTrailingDigits, + scoreUrl, + scoreDiscordInvite, + scoreMassUserMentions, + scoreRoleMentions, + scoreOnlyDefaultRole, + scoreCrossChannelDuplicate, +]; + +export function evaluateMessage( + message: Message, + member: GuildMember, + context: BotContext, +): number { + const { timeWindowMinutes } = context.commandConfig.autoban; + const now = Temporal.Now.instant(); + const windowStart = now.subtract({ minutes: timeWindowMinutes }); + const history = (recentMessages.get(member.id) ?? []).filter( + m => Temporal.Instant.compare(m.recordedAt, windowStart) > 0, + ); + + return signals.reduce((total, signal) => total + signal(message, member, context, history), 0); +} + +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); +} From b014818c1b37e0f50e49c68413fa31de5322a45d Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:38:18 +0200 Subject: [PATCH 2/7] Remove trailing digits scoring from spam detection --- src/service/spamDetection.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts index 4d18c9cd..beaa9824 100644 --- a/src/service/spamDetection.ts +++ b/src/service/spamDetection.ts @@ -20,7 +20,6 @@ const recentMessages = new Map(); const URL_PATTERN = /https?:\/\//i; const DISCORD_INVITE_PATTERN = /discord\.gg\//i; -const TRAILING_DIGITS_PATTERN = /\d{2,}$/; const SCORES = { accountAgeUnder7Days: 30, @@ -28,7 +27,6 @@ const SCORES = { guildJoinUnder10Minutes: 40, guildJoinUnder1Hour: 25, guildJoinUnder24Hours: 10, - trailingDigitsInUsername: 10, containsUrl: 20, containsDiscordInvite: 25, massUserMentions: 20, @@ -65,10 +63,6 @@ function scoreGuildJoin(_msg: Message, member: GuildMember): number { return 0; } -function scoreTrailingDigits(_msg: Message, member: GuildMember): number { - return TRAILING_DIGITS_PATTERN.test(member.user.username) ? SCORES.trailingDigitsInUsername : 0; -} - function scoreUrl(msg: Message): number { return URL_PATTERN.test(msg.content) ? SCORES.containsUrl : 0; } @@ -105,7 +99,6 @@ function scoreCrossChannelDuplicate( const signals: readonly Signal[] = [ scoreAccountAge, scoreGuildJoin, - scoreTrailingDigits, scoreUrl, scoreDiscordInvite, scoreMassUserMentions, From b642a7f3f8619274e3b62c5fe0ba659ae0c5f65a Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:43:31 +0200 Subject: [PATCH 3/7] Update guild join scoring to use 48-hour threshold --- src/service/spamDetection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts index beaa9824..e0cba7a4 100644 --- a/src/service/spamDetection.ts +++ b/src/service/spamDetection.ts @@ -26,7 +26,7 @@ const SCORES = { accountAgeUnder30Days: 15, guildJoinUnder10Minutes: 40, guildJoinUnder1Hour: 25, - guildJoinUnder24Hours: 10, + guildJoinUnder48Hours: 20, containsUrl: 20, containsDiscordInvite: 25, massUserMentions: 20, @@ -57,8 +57,8 @@ function scoreGuildJoin(_msg: Message, member: GuildMember): number { if (Temporal.Instant.compare(joined, now.subtract({ hours: 1 })) > 0) { return SCORES.guildJoinUnder1Hour; } - if (Temporal.Instant.compare(joined, now.subtract({ hours: 24 })) > 0) { - return SCORES.guildJoinUnder24Hours; + if (Temporal.Instant.compare(joined, now.subtract({ hours: 48 })) > 0) { + return SCORES.guildJoinUnder48Hours; } return 0; } From 7502cab2adeca0290cce1b3926ea4a03f746ce22 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:51:31 +0200 Subject: [PATCH 4/7] Enhance spam detection: include triggered labels in auto-ban logging and ban reason --- .../messageCreate/spamDetectionHandler.ts | 11 +- src/service/spamDetection.ts | 175 ++++++++++-------- 2 files changed, 110 insertions(+), 76 deletions(-) diff --git a/src/handler/messageCreate/spamDetectionHandler.ts b/src/handler/messageCreate/spamDetectionHandler.ts index 02e00946..8f7a7042 100644 --- a/src/handler/messageCreate/spamDetectionHandler.ts +++ b/src/handler/messageCreate/spamDetectionHandler.ts @@ -32,10 +32,10 @@ export default async function spamDetectionHandler( } const { autoban } = context.commandConfig; - const score = spamDetection.evaluateMessage(message, member, context); + const { score, triggeredLabels } = spamDetection.evaluateMessage(message, member, context); if (score >= autoban.banThreshold) { - log.info({ userId: member.id, score }, "Auto-ban: spam threshold crossed"); + 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); @@ -54,11 +54,16 @@ export default async function spamDetectionHandler( await message.delete().catch(() => undefined); + const reason = [ + "Automatischer Bann: Spam-Erkennung", + ...triggeredLabels.map(l => `• ${l}`), + ].join("\n"); + const err = await banService.banUser( context, member, context.client.user, - `Automatischer Bann: Spam erkannt (Score: ${score})`, + reason, false, autoban.banDurationHours, ); diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts index e0cba7a4..f8d3d650 100644 --- a/src/service/spamDetection.ts +++ b/src/service/spamDetection.ts @@ -9,13 +9,23 @@ type RecentMessage = { recordedAt: Temporal.Instant; }; -type Signal = ( +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; @@ -35,91 +45,110 @@ const SCORES = { onlyDefaultRole: 10, } as const; -function scoreAccountAge(_msg: Message, member: GuildMember): number { - const now = Temporal.Now.instant(); - const created = Temporal.Instant.fromEpochMilliseconds(member.user.createdTimestamp); - if (Temporal.Instant.compare(created, now.subtract({ hours: 7 * 24 })) > 0) { - return SCORES.accountAgeUnder7Days; - } - if (Temporal.Instant.compare(created, now.subtract({ hours: 30 * 24 })) > 0) { - return SCORES.accountAgeUnder30Days; - } - return 0; -} - -function scoreGuildJoin(_msg: Message, member: GuildMember): number { - if (member.joinedTimestamp === null) return 0; - const now = Temporal.Now.instant(); - const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); - if (Temporal.Instant.compare(joined, now.subtract({ minutes: 10 })) > 0) { - return SCORES.guildJoinUnder10Minutes; - } - if (Temporal.Instant.compare(joined, now.subtract({ hours: 1 })) > 0) { - return SCORES.guildJoinUnder1Hour; - } - if (Temporal.Instant.compare(joined, now.subtract({ hours: 48 })) > 0) { - return SCORES.guildJoinUnder48Hours; - } - return 0; -} - -function scoreUrl(msg: Message): number { - return URL_PATTERN.test(msg.content) ? SCORES.containsUrl : 0; -} - -function scoreDiscordInvite(msg: Message): number { - return DISCORD_INVITE_PATTERN.test(msg.content) ? SCORES.containsDiscordInvite : 0; +/** 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; } -function scoreMassUserMentions(msg: Message): number { - return msg.mentions.users.size >= 2 ? SCORES.massUserMentions : 0; -} - -function scoreRoleMentions(msg: Message): number { - return msg.mentions.roles.size > 0 ? SCORES.roleMentions : 0; -} - -function scoreOnlyDefaultRole(_msg: Message, member: GuildMember): number { - // ≤ 2 means only @everyone + the default role, i.e. no self-assigned roles - return member.roles.cache.size <= 2 ? SCORES.onlyDefaultRole : 0; -} - -function scoreCrossChannelDuplicate( - msg: Message, - _member: GuildMember, - _context: BotContext, - history: readonly RecentMessage[], -): number { - const normalized = msg.content.trim().toLowerCase(); - return history.some(m => m.content === normalized && m.channelId !== msg.channelId) - ? SCORES.crossChannelDuplicate - : 0; -} - -const signals: readonly Signal[] = [ - scoreAccountAge, - scoreGuildJoin, - scoreUrl, - scoreDiscordInvite, - scoreMassUserMentions, - scoreRoleMentions, - scoreOnlyDefaultRole, - scoreCrossChannelDuplicate, +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, -): number { +): EvaluationResult { const { timeWindowMinutes } = context.commandConfig.autoban; - const now = Temporal.Now.instant(); - const windowStart = now.subtract({ minutes: timeWindowMinutes }); + const windowStart = Temporal.Now.instant().subtract({ minutes: timeWindowMinutes }); const history = (recentMessages.get(member.id) ?? []).filter( m => Temporal.Instant.compare(m.recordedAt, windowStart) > 0, ); - return signals.reduce((total, signal) => total + signal(message, member, context, history), 0); + 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( From b24592c46bfeb01444e6d29ea0e13a9a0c38095f Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:52:37 +0200 Subject: [PATCH 5/7] Add autoban configuration and logging for spam detection actions --- .github/config.json | 8 +++ config.template.json | 4 +- src/context.ts | 4 ++ .../messageCreate/spamDetectionHandler.ts | 67 ++++++++++++++++++- src/service/config.ts | 2 + 5 files changed, 83 insertions(+), 2 deletions(-) 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 a9e0ab7b..5a1d6e78 100644 --- a/config.template.json +++ b/config.template.json @@ -89,7 +89,9 @@ // Duration of the automatic ban in hours "banDurationHours": 24, // Time window in minutes used to detect the same message across multiple channels - "timeWindowMinutes": 5 + "timeWindowMinutes": 5, + // Channel ID of a mod-only channel for spam audit logs. Leave empty to disable. + "spamLogChannelId": "" } }, diff --git a/src/context.ts b/src/context.ts index 6a330927..47f569b9 100644 --- a/src/context.ts +++ b/src/context.ts @@ -90,6 +90,7 @@ export interface BotContext { banThreshold: number; banDurationHours: number; timeWindowMinutes: number; + spamLog: TextChannel | null; }; }; @@ -332,6 +333,9 @@ 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, @@ -54,6 +88,21 @@ export default async function spamDetectionHandler( 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}`), @@ -79,6 +128,22 @@ export default async function spamDetectionHandler( 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; } diff --git a/src/service/config.ts b/src/service/config.ts index a5432853..5f81851b 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -151,6 +151,8 @@ export interface Config { banThreshold: number; banDurationHours: number; timeWindowMinutes: number; + /** Channel ID for the mod-only spam audit log. Leave empty to disable. */ + spamLogChannelId?: Snowflake; }; }; From 038014ac1a9388964d48de91b29c7713b5adfde7 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:55:11 +0200 Subject: [PATCH 6/7] Markdownify --- src/handler/messageCreate/spamDetectionHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handler/messageCreate/spamDetectionHandler.ts b/src/handler/messageCreate/spamDetectionHandler.ts index 8579ce98..417c6ad7 100644 --- a/src/handler/messageCreate/spamDetectionHandler.ts +++ b/src/handler/messageCreate/spamDetectionHandler.ts @@ -26,7 +26,7 @@ function buildSpamLogEmbed( { name: "Score", value: `${score} / ${threshold}`, inline: true }, { name: "Erkannte Merkmale", - value: triggeredLabels.map(l => `• ${l}`).join("\n") || "—", + value: triggeredLabels.map(l => `- ${l}`).join("\n") || "—", inline: false, }, { @@ -105,7 +105,7 @@ export default async function spamDetectionHandler( const reason = [ "Automatischer Bann: Spam-Erkennung", - ...triggeredLabels.map(l => `• ${l}`), + ...triggeredLabels.map(l => `- ${l}`), ].join("\n"); const err = await banService.banUser( From 5dc62443997494d5f190163432a0bea61c1b9153 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:50:51 +0200 Subject: [PATCH 7/7] Manage recent messages in evaluation: clear history if empty, update map accordingly --- src/service/spamDetection.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts index f8d3d650..665f2e37 100644 --- a/src/service/spamDetection.ts +++ b/src/service/spamDetection.ts @@ -140,6 +140,12 @@ export function evaluateMessage( 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),