diff --git a/.github/config.json b/.github/config.json index 241c7d70..0cb2d95c 100644 --- a/.github/config.json +++ b/.github/config.json @@ -74,6 +74,14 @@ "sessionToken": "", "leaderBoardJsonUrl": "", "userMap": {} + }, + "autoban": { + "enabled": true, + "dryRun": true, + "deleteThreshold": 40, + "banThreshold": 60, + "banDurationHours": 24, + "timeWindowDuration": "PT5M" } }, @@ -90,7 +98,8 @@ "votesChannelId": "893179191556706384", "botSpamChannelId": "893179191556706387", "hauptwoisTextChannelId": "893179191825162371", - "roleAssignerChannelId": "893179190881452115" + "roleAssignerChannelId": "893179190881452115", + "spamLogChannelId": "1513948506266538275" }, "voiceChannel": { 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 diff --git a/config.template.json b/config.template.json index d0ba396d..2ad3187d 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, + // 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 + "banThreshold": 60, + // Duration of the automatic ban in hours + "banDurationHours": 24, + // ISO8601 duration used as the time window to detect the same message across multiple channels + "timeWindowDuration": "PT5M" } }, @@ -94,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/app.ts b/src/app.ts index bc018c3d..455d0d96 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"; @@ -135,22 +136,27 @@ 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); } 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..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. @@ -84,6 +85,14 @@ export interface BotContext { leaderBoardJsonUrl: string; userMap: Record; }; + autoban: { + enabled: boolean; + dryRun: boolean; + deleteThreshold: number; + banThreshold: number; + banDurationHours: number; + timeWindowDuration: Temporal.Duration; + }; }; roles: { @@ -120,6 +129,7 @@ export interface BotContext { botSpam: TextChannel; hauptwoisText: TextChannel; roleAssigner: TextChannel; + spamLog: TextChannel | null; }; voiceChannels: { @@ -228,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}"`); @@ -240,7 +252,9 @@ export async function createBotContext(client: Client): Promise): Promise): Promise, + member: GuildMember, + score: number, + threshold: number, + triggeredLabels: readonly string[], + dryRun: boolean, +): APIEmbed { + const isBan = action === "ban"; + const dryRunPrefix = dryRun ? "🧪 [Testmodus] " : ""; + const title = isBan + ? `${dryRunPrefix}🚫 Autoban: ${dryRun ? "Würde gebannt werden" : "Gebannt"}` + : `${dryRunPrefix}⚠️ Autoban: ${dryRun ? "Nachricht würde gelöscht werden" : "Nachricht gelöscht"}`; + return { + color: isBan ? 0xe74c3c : 0xe67e22, + title, + 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) + ) { + log.debug({ userId: member.id }, "spamDetectionHandler: skipping guarded member"); + return; + } + + const { autoban } = context.commandConfig; + const { score, triggeredLabels } = spamDetection.evaluateMessage(message, member, context); + + 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 }, + 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); + + 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", + ); + } + } + + await message.delete().catch(() => undefined); + } else { + spamDetection.trackMessage(member.id, message.id, message.channelId, message.content); + } + + context.textChannels.spamLog + ?.send({ + embeds: [ + buildSpamLogEmbed( + "ban", + message, + member, + 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}`), + ].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 }, + 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); + } + + context.textChannels.spamLog + ?.send({ + embeds: [ + buildSpamLogEmbed( + "delete", + message, + member, + score, + autoban.deleteThreshold, + triggeredLabels, + dryRun, + ), + ], + }) + .catch(err => log.warn(err, "Failed to post spam log embed")); + + 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/config.ts b/src/service/config.ts index 95d64120..aa912e70 100644 --- a/src/service/config.ts +++ b/src/service/config.ts @@ -145,6 +145,16 @@ 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; + /** ISO8601 duration string, e.g. "PT5M" for 5 minutes. */ + timeWindowDuration: string; + }; }; deleteThreadMessagesInChannelIds: readonly Snowflake[]; @@ -161,6 +171,8 @@ export interface Config { botSpamChannelId: Snowflake; hauptwoisTextChannelId: Snowflake; roleAssignerChannelId: Snowflake; + /** Channel ID for the mod-only spam audit log. Leave empty to disable. */ + spamLogChannelId?: Snowflake; }; voiceChannel: { diff --git a/src/service/spamDetection.ts b/src/service/spamDetection.ts new file mode 100644 index 00000000..922d838b --- /dev/null +++ b/src/service/spamDetection.ts @@ -0,0 +1,243 @@ +import type { GuildMember, Message, Snowflake } from "discord.js"; + +import type { BotContext } from "#/context.ts"; +import log from "#log"; + +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; + +/** + * "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; +}; + +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: 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; +} + +const signals: readonly SignalDef[] = [ + { + label: "Neues Discord-Konto (< 7 Tage alt)", + category: "identity", + evaluate: (_msg, member) => { + 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 = member.user.createdAt.toTemporalInstant(); + return !isWithin(created, { hours: 7 * 24 }) && isWithin(created, { hours: 30 * 24 }) + ? SCORES.accountAgeUnder30Days + : 0; + }, + }, + { + label: "Dem Server in den letzten 10 Minuten beigetreten", + category: "identity", + evaluate: (_msg, member) => { + 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) => { + const joined = member.joinedAt?.toTemporalInstant(); + if (joined === undefined) return 0; + return !isWithin(joined, { minutes: 10 }) && isWithin(joined, { hours: 1 }) + ? SCORES.guildJoinUnder1Hour + : 0; + }, + }, + { + label: "Dem Server in den letzten 48 Stunden beigetreten", + category: "identity", + evaluate: (_msg, member) => { + const joined = member.joinedAt?.toTemporalInstant(); + if (joined === undefined) return 0; + return !isWithin(joined, { hours: 1 }) && isWithin(joined, { hours: 48 }) + ? SCORES.guildJoinUnder48Hours + : 0; + }, + }, + { + 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) + ? SCORES.crossChannelDuplicate + : 0; + }, + }, +]; + +export function evaluateMessage( + message: Message, + member: GuildMember, + context: BotContext, +): EvaluationResult { + 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, + ); + + if (history.length === 0) { + recentMessages.delete(member.id); + } else { + 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, + 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); + 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: cappedIdentityScore + contentScore, + 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); + + log.debug( + { userId, messageId, channelId, trackedCount: existing.length }, + "spamDetection: tracked message", + ); +} + +export function getTrackedMessages(userId: Snowflake): readonly RecentMessage[] { + return recentMessages.get(userId) ?? []; +} + +export function flushUser(userId: Snowflake): void { + const hadHistory = recentMessages.delete(userId); + if (hadHistory) { + log.debug({ userId }, "spamDetection: flushed tracked history for user"); + } +}