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 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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), From 6f4775ae6039f5ec24c204b06b2c8d4ba1cf92d0 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:09:36 +0200 Subject: [PATCH 08/19] Add dry run option --- config.template.json | 2 + src/context.ts | 2 + .../messageCreate/spamDetectionHandler.ts | 60 ++++++++++++++----- src/service/config.ts | 2 + 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/config.template.json b/config.template.json index 5a1d6e78..040b2b60 100644 --- a/config.template.json +++ b/config.template.json @@ -82,6 +82,8 @@ "autoban": { // Set to true to enable automatic spam detection and banning "enabled": false, + // Set to false to actually delete messages and ban users. While true, spam is only detected and logged. + "dryRun": true, // Score at which a suspicious message is silently deleted (no ban) "deleteThreshold": 40, // Score at which the user is deleted + banned for banDurationHours diff --git a/src/context.ts b/src/context.ts index 47f569b9..d730c926 100644 --- a/src/context.ts +++ b/src/context.ts @@ -86,6 +86,7 @@ export interface BotContext { }; autoban: { enabled: boolean; + dryRun: boolean; deleteThreshold: number; banThreshold: number; banDurationHours: number; @@ -329,6 +330,7 @@ export async function createBotContext(client: Client): Promise= autoban.banThreshold) { - log.info({ userId: member.id, score, triggeredLabels }, "Auto-ban: spam threshold crossed"); + log.info( + { userId: member.id, score, triggeredLabels }, + dryRun + ? "Auto-ban (dry run): spam threshold crossed" + : "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); + if (!dryRun) { + 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); + await message.delete().catch(() => undefined); + } else { + spamDetection.trackMessage(member.id, message.id, message.channelId, message.content); + } autoban.spamLog ?.send({ @@ -98,11 +114,16 @@ export default async function spamDetectionHandler( score, autoban.banThreshold, triggeredLabels, + dryRun, ), ], }) .catch(err => log.warn(err, "Failed to post spam log embed")); + if (dryRun) { + return; + } + const reason = [ "Automatischer Bann: Spam-Erkennung", ...triggeredLabels.map(l => `- ${l}`), @@ -126,8 +147,18 @@ 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); + log.info( + { userId: member.id, score }, + dryRun + ? "Auto-delete (dry run): suspicious message detected" + : "Auto-delete: suspicious message removed", + ); + + if (!dryRun) { + await message.delete().catch(() => undefined); + } else { + spamDetection.trackMessage(member.id, message.id, message.channelId, message.content); + } autoban.spamLog ?.send({ @@ -139,6 +170,7 @@ export default async function spamDetectionHandler( score, autoban.deleteThreshold, triggeredLabels, + dryRun, ), ], }) diff --git a/src/service/config.ts b/src/service/config.ts index 5f81851b..2fdb7bb1 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -147,6 +147,8 @@ export interface Config { }; autoban?: { enabled: boolean; + /** When true, spam is detected and logged but no message is deleted and no user is banned. */ + dryRun?: boolean; deleteThreshold: number; banThreshold: number; banDurationHours: number; From e759b1a6846475b9ed6439d46c1c1715f7ca1f2c Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:18:35 +0200 Subject: [PATCH 09/19] Apply minor changes to code style --- config.template.json | 10 +++++----- src/context.ts | 14 ++++++++------ src/handler/messageCreate/spamDetectionHandler.ts | 4 ++-- src/service/config.ts | 7 ++++--- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/config.template.json b/config.template.json index 040b2b60..2ad3187d 100644 --- a/config.template.json +++ b/config.template.json @@ -90,10 +90,8 @@ "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": "" + // ISO8601 duration used as the time window to detect the same message across multiple channels + "timeWindowDuration": "PT5M" } }, @@ -110,7 +108,9 @@ "votesChannelId": "893179191556706384", "botSpamChannelId": "893179191556706387", "hauptwoisTextChannelId": "893179191825162371", - "roleAssignerChannelId": "893179190881452115" + "roleAssignerChannelId": "893179190881452115", + // Channel ID of a mod-only channel for spam audit logs. Leave empty to disable. + "spamLogChannelId": "" }, "voiceChannel": { diff --git a/src/context.ts b/src/context.ts index d730c926..07a752a7 100644 --- a/src/context.ts +++ b/src/context.ts @@ -90,8 +90,7 @@ export interface BotContext { deleteThreshold: number; banThreshold: number; banDurationHours: number; - timeWindowMinutes: number; - spamLog: TextChannel | null; + timeWindowDuration: Temporal.Duration; }; }; @@ -129,6 +128,7 @@ export interface BotContext { botSpam: TextChannel; hauptwoisText: TextChannel; roleAssigner: TextChannel; + spamLog: TextChannel | null; }; voiceChannels: { @@ -334,10 +334,9 @@ export async function createBotContext(client: Client): Promise): Promise Date: Wed, 8 Jul 2026 21:19:34 +0200 Subject: [PATCH 10/19] Separate between identity- and content-based signals --- src/service/spamDetection.ts | 60 ++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts index 665f2e37..b164e51d 100644 --- a/src/service/spamDetection.ts +++ b/src/service/spamDetection.ts @@ -16,8 +16,16 @@ type SignalEvaluate = ( history: readonly RecentMessage[], ) => number; +/** + * "identity" signals are derived purely from account/member profile data (age, join time, roles) + * and are capped below the ban threshold on their own - see IDENTITY_SCORE_CAP. "content" signals + * are derived from the message itself and are what actually pushes a score over the ban threshold. + */ +type SignalCategory = "identity" | "content"; + type SignalDef = { label: string; + category: SignalCategory; evaluate: SignalEvaluate; }; @@ -38,13 +46,20 @@ const SCORES = { guildJoinUnder1Hour: 25, guildJoinUnder48Hours: 20, containsUrl: 20, - containsDiscordInvite: 25, + containsDiscordInvite: 45, massUserMentions: 20, roleMentions: 25, crossChannelDuplicate: 30, onlyDefaultRole: 10, } as const; +/** + * Ceiling for the combined score of "identity" signals alone, kept below the default banThreshold + * so a fresh account joining and saying hello can't be auto-banned on profile data alone - + * at least one "content" signal (spammy message content/behavior) must also be triggered. + */ +const IDENTITY_SCORE_CAP = 50; + /** 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; @@ -53,15 +68,17 @@ function isWithin(instant: Temporal.Instant, duration: Temporal.DurationLike): b const signals: readonly SignalDef[] = [ { label: "Neues Discord-Konto (< 7 Tage alt)", + category: "identity", evaluate: (_msg, member) => { - const created = Temporal.Instant.fromEpochMilliseconds(member.user.createdTimestamp); + const created = member.user.createdAt.toTemporalInstant(); return isWithin(created, { hours: 7 * 24 }) ? SCORES.accountAgeUnder7Days : 0; }, }, { label: "Relativ neues Discord-Konto (7-30 Tage alt)", + category: "identity", evaluate: (_msg, member) => { - const created = Temporal.Instant.fromEpochMilliseconds(member.user.createdTimestamp); + const created = member.user.createdAt.toTemporalInstant(); return !isWithin(created, { hours: 7 * 24 }) && isWithin(created, { hours: 30 * 24 }) ? SCORES.accountAgeUnder30Days : 0; @@ -69,17 +86,19 @@ const signals: readonly SignalDef[] = [ }, { label: "Dem Server in den letzten 10 Minuten beigetreten", + category: "identity", evaluate: (_msg, member) => { - if (member.joinedTimestamp === null) return 0; - const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + const joined = member.joinedAt?.toTemporalInstant(); + if (joined === undefined) return 0; return isWithin(joined, { minutes: 10 }) ? SCORES.guildJoinUnder10Minutes : 0; }, }, { label: "Dem Server in der letzten Stunde beigetreten", + category: "identity", evaluate: (_msg, member) => { - if (member.joinedTimestamp === null) return 0; - const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + const joined = member.joinedAt?.toTemporalInstant(); + if (joined === undefined) return 0; return !isWithin(joined, { minutes: 10 }) && isWithin(joined, { hours: 1 }) ? SCORES.guildJoinUnder1Hour : 0; @@ -87,9 +106,10 @@ const signals: readonly SignalDef[] = [ }, { label: "Dem Server in den letzten 48 Stunden beigetreten", + category: "identity", evaluate: (_msg, member) => { - if (member.joinedTimestamp === null) return 0; - const joined = Temporal.Instant.fromEpochMilliseconds(member.joinedTimestamp); + const joined = member.joinedAt?.toTemporalInstant(); + if (joined === undefined) return 0; return !isWithin(joined, { hours: 1 }) && isWithin(joined, { hours: 48 }) ? SCORES.guildJoinUnder48Hours : 0; @@ -97,29 +117,35 @@ const signals: readonly SignalDef[] = [ }, { label: "Nachricht enthält einen Link", + category: "content", evaluate: msg => (URL_PATTERN.test(msg.content) ? SCORES.containsUrl : 0), }, { label: "Nachricht enthält einen Discord-Einladungslink", + category: "content", evaluate: msg => DISCORD_INVITE_PATTERN.test(msg.content) ? SCORES.containsDiscordInvite : 0, }, { label: "Nachricht erwähnt mehrere Nutzer", + category: "content", evaluate: msg => (msg.mentions.users.size >= 2 ? SCORES.massUserMentions : 0), }, { label: "Nachricht erwähnt eine oder mehrere Rollen", + category: "content", evaluate: msg => (msg.mentions.roles.size > 0 ? SCORES.roleMentions : 0), }, { label: "Keine selbst zugewiesenen Rollen", + category: "identity", 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", + category: "content", evaluate: (msg, _member, _context, history) => { const normalized = msg.content.trim().toLowerCase(); return history.some(m => m.content === normalized && m.channelId !== msg.channelId) @@ -134,8 +160,8 @@ export function evaluateMessage( member: GuildMember, context: BotContext, ): EvaluationResult { - const { timeWindowMinutes } = context.commandConfig.autoban; - const windowStart = Temporal.Now.instant().subtract({ minutes: timeWindowMinutes }); + const { timeWindowDuration } = context.commandConfig.autoban; + const windowStart = Temporal.Now.instant().subtract(timeWindowDuration); const history = (recentMessages.get(member.id) ?? []).filter( m => Temporal.Instant.compare(m.recordedAt, windowStart) > 0, ); @@ -146,13 +172,21 @@ export function evaluateMessage( recentMessages.set(member.id, history); } - const results = signals.map(({ label, evaluate }) => ({ + const results = signals.map(({ label, category, evaluate }) => ({ label, + category, points: evaluate(message, member, context, history), })); + const identityScore = results + .filter(r => r.category === "identity") + .reduce((sum, r) => sum + r.points, 0); + const contentScore = results + .filter(r => r.category === "content") + .reduce((sum, r) => sum + r.points, 0); + return { - score: results.reduce((sum, r) => sum + r.points, 0), + score: Math.min(identityScore, IDENTITY_SCORE_CAP) + contentScore, triggeredLabels: results.filter(r => r.points > 0).map(r => r.label), }; } From 0bd0d82c588182894596a2626cbcc5d7d1713447 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:24:45 +0200 Subject: [PATCH 11/19] update demo config --- .github/config.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/config.json b/.github/config.json index eb2bb4c3..bed0c153 100644 --- a/.github/config.json +++ b/.github/config.json @@ -77,11 +77,11 @@ }, "autoban": { "enabled": true, + "dryRun": true, "deleteThreshold": 40, "banThreshold": 60, "banDurationHours": 24, - "timeWindowMinutes": 5, - "spamLogChannelId": "1513948506266538275" + "timeWindowMinutes": 5 } }, @@ -98,7 +98,8 @@ "votesChannelId": "893179191556706384", "botSpamChannelId": "893179191556706387", "hauptwoisTextChannelId": "893179191825162371", - "roleAssignerChannelId": "893179190881452115" + "roleAssignerChannelId": "893179190881452115", + "spamLogChannelId": "1513948506266538275" }, "voiceChannel": { From 5498624532c69dbd773df359838178ef238c4e73 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:48:27 +0200 Subject: [PATCH 12/19] Add debug logs --- .../messageCreate/spamDetectionHandler.ts | 30 ++++++++++++++++++ src/service/spamDetection.ts | 31 +++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/handler/messageCreate/spamDetectionHandler.ts b/src/handler/messageCreate/spamDetectionHandler.ts index 2ebbe23b..bd19936b 100644 --- a/src/handler/messageCreate/spamDetectionHandler.ts +++ b/src/handler/messageCreate/spamDetectionHandler.ts @@ -67,6 +67,7 @@ export default async function spamDetectionHandler( context.roleGuard.isTrusted(member) || context.roleGuard.isGruendervater(member) ) { + log.debug({ userId: member.id }, "spamDetectionHandler: skipping guarded member"); return; } @@ -75,6 +76,17 @@ export default async function spamDetectionHandler( const { dryRun } = autoban; + log.debug( + { + userId: member.id, + score, + deleteThreshold: autoban.deleteThreshold, + banThreshold: autoban.banThreshold, + dryRun, + }, + "spamDetectionHandler: message evaluated", + ); + if (score >= autoban.banThreshold) { log.info( { userId: member.id, score, triggeredLabels }, @@ -88,14 +100,28 @@ export default async function spamDetectionHandler( spamDetection.flushUser(member.id); if (!dryRun) { + log.debug( + { userId: member.id, trackedCount: tracked.length }, + "spamDetectionHandler: deleting tracked cross-channel messages", + ); + for (const { messageId, channelId } of tracked) { const channel = context.guild.channels.cache.get(channelId); if (!channel?.isTextBased()) { + log.debug( + { userId: member.id, messageId, channelId }, + "spamDetectionHandler: channel not found or not text-based, skipping", + ); continue; } const msg = await channel.messages.fetch(messageId).catch(() => null); if (msg) { await msg.delete().catch(() => undefined); + } else { + log.debug( + { userId: member.id, messageId, channelId }, + "spamDetectionHandler: tracked message not found, skipping", + ); } } @@ -179,5 +205,9 @@ export default async function spamDetectionHandler( return; } + log.debug( + { userId: member.id, score }, + "spamDetectionHandler: message below thresholds, tracking", + ); spamDetection.trackMessage(member.id, message.id, message.channelId, message.content); } diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts index b164e51d..922d838b 100644 --- a/src/service/spamDetection.ts +++ b/src/service/spamDetection.ts @@ -1,6 +1,7 @@ import type { GuildMember, Message, Snowflake } from "discord.js"; import type { BotContext } from "#/context.ts"; +import log from "#log"; type RecentMessage = { messageId: Snowflake; @@ -172,6 +173,11 @@ export function evaluateMessage( recentMessages.set(member.id, history); } + log.debug( + { userId: member.id, historySize: history.length }, + "spamDetection: evaluating message", + ); + const results = signals.map(({ label, category, evaluate }) => ({ label, category, @@ -184,9 +190,22 @@ export function evaluateMessage( const contentScore = results .filter(r => r.category === "content") .reduce((sum, r) => sum + r.points, 0); + const cappedIdentityScore = Math.min(identityScore, IDENTITY_SCORE_CAP); + + log.debug( + { + userId: member.id, + identityScore, + cappedIdentityScore, + contentScore, + score: cappedIdentityScore + contentScore, + triggered: results.filter(r => r.points > 0).map(r => `${r.label} (+${r.points})`), + }, + "spamDetection: evaluation result", + ); return { - score: Math.min(identityScore, IDENTITY_SCORE_CAP) + contentScore, + score: cappedIdentityScore + contentScore, triggeredLabels: results.filter(r => r.points > 0).map(r => r.label), }; } @@ -205,6 +224,11 @@ export function trackMessage( recordedAt: Temporal.Now.instant(), }); recentMessages.set(userId, existing); + + log.debug( + { userId, messageId, channelId, trackedCount: existing.length }, + "spamDetection: tracked message", + ); } export function getTrackedMessages(userId: Snowflake): readonly RecentMessage[] { @@ -212,5 +236,8 @@ export function getTrackedMessages(userId: Snowflake): readonly RecentMessage[] } export function flushUser(userId: Snowflake): void { - recentMessages.delete(userId); + const hadHistory = recentMessages.delete(userId); + if (hadHistory) { + log.debug({ userId }, "spamDetection: flushed tracked history for user"); + } } From 64d29b1232b35e8deb97535b6227fdb261322ce7 Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:54:50 +0200 Subject: [PATCH 13/19] fix time window config --- .github/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/config.json b/.github/config.json index bed0c153..0cb2d95c 100644 --- a/.github/config.json +++ b/.github/config.json @@ -81,7 +81,7 @@ "deleteThreshold": 40, "banThreshold": 60, "banDurationHours": 24, - "timeWindowMinutes": 5 + "timeWindowDuration": "PT5M" } }, From 29d4437b49b53e5c3d04885d7c66288f0e68358d Mon Sep 17 00:00:00 2001 From: twobiers <22715034+twobiers@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:01:45 +0200 Subject: [PATCH 14/19] Add debug logs --- src/app.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index e4154240..455d0d96 100644 --- a/src/app.ts +++ b/src/app.ts @@ -136,17 +136,21 @@ login().then( client.user.setActivity(config.activity); try { + log.debug("Creating bot context..."); botContext = await createBotContext(client); + log.debug("Bot context created, loading commands..."); await loadCommands(botContext); + log.debug("Commands loaded, scheduling cron jobs..."); await cronService.schedule(botContext); // When the application is ready, slash commands should be registered + log.debug("Registering application commands as guild commands..."); await registerAllApplicationCommandsAsGuildCommands(botContext); } catch (err) { - sentry.captureException(err); log.error(err, "Error in `ready` handler"); + sentry.captureException(err); process.exit(1); } From 930a03d1ae3a10d8c2e64e2997c52c7599b8c6f7 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Wed, 8 Jul 2026 22:34:39 +0200 Subject: [PATCH 15/19] Prevent name collision --- .github/workflows/CI.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 6235dd41..81ba0599 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -126,13 +126,13 @@ jobs: DISCORD_CLIENT_ID: ${{ secrets.CI_CLIENT_ID }} PR_ID: ${{ github.event.number }} run: | - envsubst < .github/config.json > config.json + envsubst < .github/config.json > config-ephemeral.json - name: Read config.json id: config uses: juliangruber/read-file-action@v1 with: - path: ./config.json + path: ./config-ephemeral.json - name: Build args id: args From cae72454fd2e791112838199a7fee608b44c2d9b Mon Sep 17 00:00:00 2001 From: holzmaster Date: Wed, 8 Jul 2026 22:34:50 +0200 Subject: [PATCH 16/19] Add temp-dir prefix --- .infra/deploy-ephemeral.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.infra/deploy-ephemeral.sh b/.infra/deploy-ephemeral.sh index 08a13e83..a81231df 100755 --- a/.infra/deploy-ephemeral.sh +++ b/.infra/deploy-ephemeral.sh @@ -19,13 +19,13 @@ mkdir -p "$TEMP_DIR/data" cp -r "$BOT_HOME_PATH/data" "$TEMP_DIR/data" cp -r "$BOT_HOME_PATH/.infra" "$TEMP_DIR/.infra" -echo "$EPHEMERAL_BOT_CONFIG" > config.json -echo "" >.env # Dummy env +echo "$EPHEMERAL_BOT_CONFIG" > "$TEMP_DIR/config.json" +echo "" > "$TEMP_DIR/.env" # Dummy env docker stop csz-bot-ephemeral || true docker rm csz-bot-ephemeral || true -docker compose -p e2e -f .infra/compose.ephemeral.yaml pull bot -docker compose -p e2e -f .infra/compose.ephemeral.yaml up \ +docker compose -p e2e -f "$TEMP_DIR/.infra/compose.ephemeral.yaml" pull bot +docker compose -p e2e -f "$TEMP_DIR/.infra/compose.ephemeral.yaml" up \ -d \ --remove-orphans From ad7fa481999534b257781a169cd10f58017f5d12 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Wed, 8 Jul 2026 22:44:25 +0200 Subject: [PATCH 17/19] Add even more logging --- src/context.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/context.ts b/src/context.ts index 07a752a7..3541db15 100644 --- a/src/context.ts +++ b/src/context.ts @@ -18,6 +18,7 @@ import { SpotifyApi } from "@spotify/web-api-ts-sdk"; import type { UserMapEntry } from "#/commands/aoc.ts"; import { readConfig } from "#/service/config.ts"; +import log from "#log"; /** * Object that's passed to every executed command to make it easier to access common channels without repeatedly retrieving stuff via IDs. @@ -237,8 +238,10 @@ function ensureEmoji(guild: Guild, emojiId: Snowflake, fallbackName: string): Gu // #endregion export async function createBotContext(client: Client): Promise { + log.debug("createBotContext: reading config..."); const config = await readConfig(); + log.debug("createBotContext: config read, resolving guild..."); const guild = client.guilds.cache.get(config.guildGuildId); if (!guild) { throw new Error(`Cannot find configured guild "${config.guildGuildId}"`); @@ -249,7 +252,9 @@ export async function createBotContext(client: Client): Promise Date: Wed, 8 Jul 2026 23:03:58 +0200 Subject: [PATCH 18/19] 'Fix' permissions --- .infra/deploy-ephemeral.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.infra/deploy-ephemeral.sh b/.infra/deploy-ephemeral.sh index a81231df..6ade6a7b 100755 --- a/.infra/deploy-ephemeral.sh +++ b/.infra/deploy-ephemeral.sh @@ -22,6 +22,8 @@ cp -r "$BOT_HOME_PATH/.infra" "$TEMP_DIR/.infra" echo "$EPHEMERAL_BOT_CONFIG" > "$TEMP_DIR/config.json" echo "" > "$TEMP_DIR/.env" # Dummy env +chmod -R 777 "$TEMP_DIR/data" + docker stop csz-bot-ephemeral || true docker rm csz-bot-ephemeral || true docker compose -p e2e -f "$TEMP_DIR/.infra/compose.ephemeral.yaml" pull bot From 5dc4b6df0cf7a2aaa552a54b4fb901462046ec22 Mon Sep 17 00:00:00 2001 From: holzmaster Date: Wed, 8 Jul 2026 23:09:35 +0200 Subject: [PATCH 19/19] Dont remove temp dir --- .infra/deploy-ephemeral.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.infra/deploy-ephemeral.sh b/.infra/deploy-ephemeral.sh index 6ade6a7b..b723751e 100755 --- a/.infra/deploy-ephemeral.sh +++ b/.infra/deploy-ephemeral.sh @@ -31,4 +31,4 @@ docker compose -p e2e -f "$TEMP_DIR/.infra/compose.ephemeral.yaml" up \ -d \ --remove-orphans -rm -rvf -- "$TEMP_DIR" +# rm -rvf -- "$TEMP_DIR"