Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@
"sessionToken": "",
"leaderBoardJsonUrl": "",
"userMap": {}
},
"autoban": {
"enabled": true,
"deleteThreshold": 40,
"banThreshold": 60,
"banDurationHours": 24,
"timeWindowMinutes": 5,
"spamLogChannelId": "1513948506266538275"
}
},

Expand Down
14 changes: 14 additions & 0 deletions config.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@
"sessionToken": "",
"leaderBoardJsonUrl": "",
"userMap": {}
},
"autoban": {
// Set to true to enable automatic spam detection and banning
"enabled": false,
// Score at which a suspicious message is silently deleted (no ban)
"deleteThreshold": 40,
// Score at which the user is deleted + banned for banDurationHours
"banThreshold": 60,
// Duration of the automatic ban in hours
"banDurationHours": 24,
// Time window in minutes used to detect the same message across multiple channels
"timeWindowMinutes": 5,
// Channel ID of a mod-only channel for spam audit logs. Leave empty to disable.
"spamLogChannelId": ""
}
},

Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
18 changes: 18 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ export interface BotContext {
leaderBoardJsonUrl: string;
userMap: Record<Snowflake, UserMapEntry>;
};
autoban: {
enabled: boolean;
deleteThreshold: number;
banThreshold: number;
banDurationHours: number;
timeWindowMinutes: number;
spamLog: TextChannel | null;
};
};

roles: {
Expand Down Expand Up @@ -319,6 +327,16 @@ export async function createBotContext(client: Client<true>): Promise<BotContext
leaderBoardJsonUrl: config.command.aoc.leaderBoardJsonUrl,
userMap: config.command.aoc.userMap,
},
autoban: {
enabled: config.command.autoban?.enabled ?? false,
deleteThreshold: config.command.autoban?.deleteThreshold ?? 40,
banThreshold: config.command.autoban?.banThreshold ?? 60,
banDurationHours: config.command.autoban?.banDurationHours ?? 24,
timeWindowMinutes: config.command.autoban?.timeWindowMinutes ?? 5,
spamLog: config.command.autoban?.spamLogChannelId
? ensureTextChannel(guild, config.command.autoban.spamLogChannelId)
: null,
},
},

deleteThreadMessagesInChannelIds: new Set(config.deleteThreadMessagesInChannelIds),
Expand Down
151 changes: 151 additions & 0 deletions src/handler/messageCreate/spamDetectionHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { APIEmbed, GuildMember, Message } from "discord.js";
import * as sentry from "@sentry/node";

import type { BotContext } from "#/context.ts";
import * as banService from "#/service/ban.ts";
import * as spamDetection from "#/service/spamDetection.ts";
import log from "#log";

type SpamAction = "ban" | "delete";

function buildSpamLogEmbed(
action: SpamAction,
message: Message<true>,
member: GuildMember,
score: number,
threshold: number,
triggeredLabels: readonly string[],
): APIEmbed {
const isBan = action === "ban";
return {
color: isBan ? 0xe74c3c : 0xe67e22,
title: isBan ? "🚫 Autoban: Gebannt" : "⚠️ Autoban: Nachricht gelöscht",
fields: [
{ name: "Nutzer", value: `${member} (${member.id})`, inline: true },
{ name: "Kanal", value: `${message.channel}`, inline: true },
{ name: "Score", value: `${score} / ${threshold}`, inline: true },
{
name: "Erkannte Merkmale",
value: triggeredLabels.map(l => `- ${l}`).join("\n") || "—",
inline: false,
},
{
name: "Nachricht",
value: message.content.slice(0, 1024) || "*(leer)*",
inline: false,
},
],
timestamp: new Date().toISOString(),
footer: { text: `User-ID: ${member.id}` },
};
}

export default async function spamDetectionHandler(
message: Message,
context: BotContext,
): Promise<void> {
if (!context.commandConfig.autoban.enabled) {
return;
}

if (message.author.bot || !message.inGuild()) {
return;
}

const { member } = message;
if (!member) {
return;
}

if (
context.roleGuard.isMod(member) ||
context.roleGuard.isTrusted(member) ||
context.roleGuard.isGruendervater(member)
) {
return;
}

const { autoban } = context.commandConfig;
const { score, triggeredLabels } = spamDetection.evaluateMessage(message, member, context);

if (score >= autoban.banThreshold) {
log.info({ userId: member.id, score, triggeredLabels }, "Auto-ban: spam threshold crossed");

// Delete previously tracked messages from this user across channels
const tracked = spamDetection.getTrackedMessages(member.id);
spamDetection.flushUser(member.id);

for (const { messageId, channelId } of tracked) {
const channel = context.guild.channels.cache.get(channelId);
if (!channel?.isTextBased()) {
continue;
}
const msg = await channel.messages.fetch(messageId).catch(() => null);
if (msg) {
await msg.delete().catch(() => undefined);
}
}

await message.delete().catch(() => undefined);

autoban.spamLog
?.send({
embeds: [
buildSpamLogEmbed(
"ban",
message,
member,
score,
autoban.banThreshold,
triggeredLabels,
),
],
})
.catch(err => log.warn(err, "Failed to post spam log embed"));

const reason = [
"Automatischer Bann: Spam-Erkennung",
...triggeredLabels.map(l => `- ${l}`),
].join("\n");

const err = await banService.banUser(
context,
member,
context.client.user,
reason,
false,
autoban.banDurationHours,
);

if (err) {
sentry.captureException(new Error(err));
log.error({ userId: member.id, err }, "Auto-ban failed after spam detection");
}

return;
}

if (score >= autoban.deleteThreshold) {
log.info({ userId: member.id, score }, "Auto-delete: suspicious message removed");
await message.delete().catch(() => undefined);

autoban.spamLog
?.send({
embeds: [
buildSpamLogEmbed(
"delete",
message,
member,
score,
autoban.deleteThreshold,
triggeredLabels,
),
],
})
.catch(err => log.warn(err, "Failed to post spam log embed"));

return;
}

spamDetection.trackMessage(member.id, message.id, message.channelId, message.content);
}
9 changes: 9 additions & 0 deletions src/service/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ export interface Config {
}
>;
};
autoban?: {
enabled: boolean;
deleteThreshold: number;
banThreshold: number;
banDurationHours: number;
timeWindowMinutes: number;
/** Channel ID for the mod-only spam audit log. Leave empty to disable. */
spamLogChannelId?: Snowflake;
};
};

deleteThreadMessagesInChannelIds: readonly Snowflake[];
Expand Down
Loading
Loading