From 0ee5e711f85f9e4cc8e50f61cc2f20fef6c07087 Mon Sep 17 00:00:00 2001 From: Pdzly Date: Sun, 28 Dec 2025 19:27:03 +0100 Subject: [PATCH 1/3] - Implement member join listener for threat detection features - Add spam detection mechanism for message similarity and frequency - Integrate warning escalation and expiration handling - Add modules for raid and mention spam detection - Enhance account analysis with suspicious patterns and actions - Update database models for threat logging and user reputation tracking --- src/Config.ts | 9 + src/config.type.ts | 69 +++ src/index.ts | 2 + src/modules/moderation/logs.ts | 63 ++- src/modules/moderation/moderation.module.ts | 13 +- src/modules/moderation/pardon.command.ts | 151 +++++++ src/modules/moderation/reputation.command.ts | 421 ++++++++++++++++++ src/modules/moderation/reputation.service.ts | 225 ++++++++++ src/modules/moderation/warn.command.ts | 204 +++++++++ .../moderation/warningScheduler.listener.ts | 102 +++++ src/modules/moderation/warnings.command.ts | 168 +++++++ src/modules/moderation/wordlist.command.ts | 348 +++++++++++++++ src/modules/starboard/starboard.listener.ts | 17 + .../detectors/accountAnalyzer.ts | 155 +++++++ .../detectors/mentionSpamDetector.ts | 121 +++++ .../threatDetection/detectors/raidDetector.ts | 144 ++++++ .../detectors/scamLinkDetector.ts | 238 ++++++++++ .../threatDetection/detectors/spamDetector.ts | 119 +++++ .../detectors/toxicContentDetector.ts | 217 +++++++++ .../listeners/memberJoin.listener.ts | 157 +++++++ .../listeners/messageAnalysis.listener.ts | 330 ++++++++++++++ src/modules/threatDetection/logs.ts | 163 +++++++ .../threatDetection/threatDetection.module.ts | 9 + .../utils/textNormalization.ts | 249 +++++++++++ src/modules/xp/xpForMessage.util.ts | 42 +- src/store/models/BlockedWord.ts | 86 ++++ src/store/models/DDUser.ts | 10 + src/store/models/ReputationEvent.ts | 178 ++++++++ src/store/models/ScamDomain.ts | 44 ++ src/store/models/ThreatLog.ts | 95 ++++ src/store/models/Warning.ts | 115 +++++ src/store/storage.ts | 12 +- 32 files changed, 4266 insertions(+), 10 deletions(-) create mode 100644 src/modules/moderation/pardon.command.ts create mode 100644 src/modules/moderation/reputation.command.ts create mode 100644 src/modules/moderation/reputation.service.ts create mode 100644 src/modules/moderation/warn.command.ts create mode 100644 src/modules/moderation/warningScheduler.listener.ts create mode 100644 src/modules/moderation/warnings.command.ts create mode 100644 src/modules/moderation/wordlist.command.ts create mode 100644 src/modules/threatDetection/detectors/accountAnalyzer.ts create mode 100644 src/modules/threatDetection/detectors/mentionSpamDetector.ts create mode 100644 src/modules/threatDetection/detectors/raidDetector.ts create mode 100644 src/modules/threatDetection/detectors/scamLinkDetector.ts create mode 100644 src/modules/threatDetection/detectors/spamDetector.ts create mode 100644 src/modules/threatDetection/detectors/toxicContentDetector.ts create mode 100644 src/modules/threatDetection/listeners/memberJoin.listener.ts create mode 100644 src/modules/threatDetection/listeners/messageAnalysis.listener.ts create mode 100644 src/modules/threatDetection/logs.ts create mode 100644 src/modules/threatDetection/threatDetection.module.ts create mode 100644 src/modules/threatDetection/utils/textNormalization.ts create mode 100644 src/store/models/BlockedWord.ts create mode 100644 src/store/models/ReputationEvent.ts create mode 100644 src/store/models/ScamDomain.ts create mode 100644 src/store/models/ThreatLog.ts create mode 100644 src/store/models/Warning.ts diff --git a/src/Config.ts b/src/Config.ts index 85b320ba..4d026799 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -52,6 +52,15 @@ const devConfig: Config = { yesEmojiId: "👍", noEmojiId: "👎", }, + threatDetection: { + enabled: true, + alertChannel: "1432483525155623063", + scamLinks: { + enabled: true, + blockShorteners: true, + useExternalApi: true, + }, + }, modmail: { pingRole: "1412470653050818724", archiveChannel: "1412470199495561338", diff --git a/src/config.type.ts b/src/config.type.ts index 3413e480..7b32d005 100644 --- a/src/config.type.ts +++ b/src/config.type.ts @@ -2,6 +2,58 @@ import type { Snowflake } from "discord.js"; import type { InformationMessage } from "./modules/information/information.js"; import type { BrandingConfig } from "./util/branding.js"; +export interface ThreatDetectionConfig { + enabled: boolean; + alertChannel?: Snowflake; + exemptRoles?: Snowflake[]; + scamLinks?: { + enabled: boolean; + useExternalApi?: boolean; + blockShorteners?: boolean; + safeDomains?: string[]; + }; + spam?: { + enabled: boolean; + maxMessagesPerWindow: number; + windowSeconds: number; + duplicateThreshold: number; + action: "delete" | "mute"; + muteDuration?: number; + }; + raid?: { + enabled: boolean; + maxJoinsPerWindow: number; + windowSeconds: number; + action: "alert" | "lockdown" | "kick_new"; + newAccountThreshold: number; + }; + mentionSpam?: { + enabled: boolean; + maxMentionsPerMessage: number; + maxMentionsPerWindow: number; + windowSeconds: number; + action: "delete" | "mute"; + }; + toxicContent?: { + enabled: boolean; + detectBypasses: boolean; + action: "flag" | "delete"; + }; + suspiciousAccounts?: { + enabled: boolean; + minAgeDays: number; + flagDefaultAvatar: boolean; + flagSuspiciousNames: boolean; + suspiciousNamePatterns?: string[]; + action: "flag" | "kick"; + }; + escalation?: { + warningsBeforeMute: number; + mutesBeforeKick: number; + scoreDecayRate: number; + }; +} + export interface Config { guildId: string; clientId: string; @@ -54,4 +106,21 @@ export interface Config { }; branding: BrandingConfig; informationMessage?: InformationMessage; + threatDetection?: ThreatDetectionConfig; + reputation?: { + enabled: boolean; + warningThresholds: { + muteAt: number; + muteDuration: string; + banAt: number; + }; + warningExpiration: { + minor: string; + moderate: string; + severe: string; + }; + scoreVisibility: "public" | "mods-only" | "self-only"; + allowAppeals: boolean; + appealCooldown: string; + }; } diff --git a/src/index.ts b/src/index.ts index 04c7413d..827b8bc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { RolesModule } from "./modules/roles/roles.module.js"; import { ShowcaseModule } from "./modules/showcase.module.js"; import { StarboardModule } from "./modules/starboard/starboard.module.js"; import SuggestModule from "./modules/suggest/suggest.module.js"; +import { ThreatDetectionModule } from "./modules/threatDetection/threatDetection.module.js"; import { TokenScannerModule } from "./modules/tokenScanner.module.js"; import { UserModule } from "./modules/user/user.module.js"; import { XpModule } from "./modules/xp/xp.module.js"; @@ -69,6 +70,7 @@ export const moduleManager = new ModuleManager( ModmailModule, LeaderboardModule, UserModule, + ThreatDetectionModule, ], ); diff --git a/src/modules/moderation/logs.ts b/src/modules/moderation/logs.ts index d8a5d270..0765223c 100644 --- a/src/modules/moderation/logs.ts +++ b/src/modules/moderation/logs.ts @@ -18,7 +18,10 @@ export type ModerationLog = | TempBanExpiredLog | SoftBanLog | KickLog - | InviteDeletedLog; + | InviteDeletedLog + | WarningLog + | WarningPardonedLog + | ReputationGrantedLog; interface BanLog { kind: "Ban"; @@ -71,6 +74,35 @@ interface InviteDeletedLog { matches: string[]; } +interface WarningLog { + kind: "Warning"; + moderator: User; + target: UserResolvable; + reason: string; + severity: number; + warningId: number; + warningCount: number; + expiresAt: Date | null; +} + +interface WarningPardonedLog { + kind: "WarningPardoned"; + moderator: User; + target: UserResolvable; + warningId: number; + reason: string; +} + +interface ReputationGrantedLog { + kind: "ReputationGranted"; + moderator: User; + target: UserResolvable; + eventType: string; + scoreChange: number; + newScore: number; + reason: string; +} + type ModerationKindMapping = { [f in ModerationLog["kind"]]: T; }; @@ -83,6 +115,9 @@ const embedTitles: ModerationKindMapping = { TempBan: "Member Tempbanned", Kick: "Member Kicked", TempBanEnded: "Tempban Expired", + Warning: "Member Warned", + WarningPardoned: "Warning Pardoned", + ReputationGranted: "Reputation Granted", }; const embedColors: ModerationKindMapping = { @@ -93,6 +128,15 @@ const embedColors: ModerationKindMapping = { Unban: "Green", TempBanEnded: "DarkGreen", InviteDeleted: "Blurple", + Warning: "Gold", + WarningPardoned: "Aqua", + ReputationGranted: "Green", +}; + +const SEVERITY_LABELS: Record = { + 1: "Minor", + 2: "Moderate", + 3: "Severe", }; const embedReasons: { @@ -108,6 +152,23 @@ const embedReasons: { TempBan: (tempBan) => `**Ban duration**: \`${prettyPrintDuration(tempBan.banDuration)}\``, + + Warning: (warning) => + `**Severity:** ${SEVERITY_LABELS[warning.severity] || "Unknown"}\n` + + `**Warning ID:** #${warning.warningId}\n` + + `**Total Active Warnings:** ${warning.warningCount}\n` + + (warning.expiresAt + ? `**Expires:** ` + : "**Expires:** Never"), + + WarningPardoned: (pardon) => + `**Warning ID:** #${pardon.warningId}\n` + + `**Pardon Reason:** ${pardon.reason}`, + + ReputationGranted: (rep) => + `**Type:** ${rep.eventType}\n` + + `**Score Change:** +${rep.scoreChange}\n` + + `**New Score:** ${rep.newScore >= 0 ? "+" : ""}${rep.newScore}`, }; export async function logModerationAction( diff --git a/src/modules/moderation/moderation.module.ts b/src/modules/moderation/moderation.module.ts index cdc47786..989c05f1 100644 --- a/src/modules/moderation/moderation.module.ts +++ b/src/modules/moderation/moderation.module.ts @@ -2,10 +2,16 @@ import type Module from "../module.js"; import { BanCommand } from "./ban.command.js"; import { InviteListeners } from "./discordInvitesMonitor.listener.js"; import { KickCommand } from "./kick.command.js"; +import { PardonCommand } from "./pardon.command.js"; +import { ReputationCommand } from "./reputation.command.js"; import { SoftBanCommand } from "./softBan.command.js"; import { TempBanCommand } from "./tempBan.command.js"; import { TempBanListener } from "./tempBan.listener.js"; import { UnbanCommand } from "./unban.command.js"; +import { WarnCommand } from "./warn.command.js"; +import { WarningSchedulerListener } from "./warningScheduler.listener.js"; +import { WarningsCommand } from "./warnings.command.js"; +import { WordlistCommand } from "./wordlist.command.js"; import { ZookeepCommand } from "./zookeep.command.js"; export const ModerationModule: Module = { @@ -17,6 +23,11 @@ export const ModerationModule: Module = { TempBanCommand, KickCommand, ZookeepCommand, + WarnCommand, + WarningsCommand, + PardonCommand, + WordlistCommand, + ReputationCommand, ], - listeners: [...InviteListeners, TempBanListener], + listeners: [...InviteListeners, TempBanListener, WarningSchedulerListener], }; diff --git a/src/modules/moderation/pardon.command.ts b/src/modules/moderation/pardon.command.ts new file mode 100644 index 00000000..68d70116 --- /dev/null +++ b/src/modules/moderation/pardon.command.ts @@ -0,0 +1,151 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + MessageFlags, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { logger } from "../../logging.js"; +import { Warning } from "../../store/models/Warning.js"; +import { fakeMention } from "../../util/users.js"; +import { logModerationAction } from "./logs.js"; + +export const PardonCommand: Command = { + name: "pardon", + description: "Pardon (remove) a warning from a user", + type: ApplicationCommandType.ChatInput, + default_permission: false, + options: [ + { + type: ApplicationCommandOptionType.Integer, + name: "warning_id", + description: "The ID of the warning to pardon", + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "reason", + description: "Reason for pardoning the warning", + required: true, + }, + ], + + handle: async (interaction) => { + if ( + !interaction.isChatInputCommand() || + !interaction.inGuild() || + interaction.guild === null + ) + return; + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const warningId = interaction.options.getInteger("warning_id", true); + const reason = interaction.options.getString("reason", true).trim(); + + // Validate reason length + if (reason.length === 0) { + await interaction.editReply("Pardon reason cannot be empty."); + return; + } + if (reason.length > 500) { + await interaction.editReply( + "Pardon reason must be under 500 characters.", + ); + return; + } + + // Use optimistic locking to prevent race conditions + const [affectedRows] = await Warning.update( + { + pardoned: true, + pardonedBy: BigInt(interaction.user.id), + pardonReason: reason, + }, + { + where: { + id: warningId, + pardoned: false, + expired: false, + }, + }, + ); + + if (affectedRows === 0) { + // Check why it failed - either not found, already pardoned, or expired + const warning = await Warning.findByPk(warningId); + if (!warning) { + await interaction.editReply({ + content: `Warning #${warningId} not found.`, + }); + } else if (warning.pardoned) { + await interaction.editReply({ + content: `Warning #${warningId} has already been pardoned.`, + }); + } else if (warning.expired) { + await interaction.editReply({ + content: `Warning #${warningId} has already expired.`, + }); + } else { + await interaction.editReply({ + content: `Could not pardon warning #${warningId}. Please try again.`, + }); + } + return; + } + + // Fetch the warning for logging purposes + const warning = await Warning.findByPk(warningId); + if (!warning) { + // Extremely unlikely: warning was deleted between update and fetch + await interaction.editReply({ + content: `Warning #${warningId} was pardoned but could not be found for logging.`, + }); + return; + } + + const targetUser = await interaction.client.users + .fetch(warning.userId.toString()) + .catch(() => null); + + if (targetUser) { + await logModerationAction(interaction.client, { + kind: "WarningPardoned", + moderator: interaction.user, + target: targetUser, + warningId: warning.id, + reason, + }); + + try { + await targetUser.send({ + content: + `Your warning #${warningId} in **${interaction.guild.name}** has been pardoned.\n` + + `**Reason:** ${reason}`, + }); + } catch { + logger.info( + `Could not DM pardon notice to user ${targetUser.id} - DMs may be disabled`, + ); + } + } + + await interaction.editReply({ + content: + `Pardoned warning #${warningId}` + + (targetUser ? ` for ${fakeMention(targetUser)}` : "") + + `\n**Reason:** ${reason}`, + }); + } catch (e) { + logger.error("Failed to pardon warning:", e); + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong!"); + } else { + await interaction.reply({ + content: "Something went wrong!", + flags: MessageFlags.Ephemeral, + }); + } + } + }, +}; diff --git a/src/modules/moderation/reputation.command.ts b/src/modules/moderation/reputation.command.ts new file mode 100644 index 00000000..3a5df153 --- /dev/null +++ b/src/modules/moderation/reputation.command.ts @@ -0,0 +1,421 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + EmbedBuilder, + type GuildMember, + MessageFlags, + PermissionFlagsBits, +} from "discord.js"; +import type { Command, ExecutableSubcommand } from "djs-slash-helper"; +import { logger } from "../../logging.js"; +import { + REPUTATION_EVENT_LABELS, + ReputationEventType, +} from "../../store/models/ReputationEvent.js"; +import { fakeMention } from "../../util/users.js"; +import { logModerationAction } from "./logs.js"; +import { + getReputationHistoryForUser, + getReputationTier, + getUserReputation, + grantReputation, + REPUTATION_TIER_COLORS, + REPUTATION_TIER_LABELS, + REPUTATION_TIER_THRESHOLDS, + ReputationTier, + updateReputation, +} from "./reputation.service.js"; + +// Grantable positive reputation types +const grantableTypes = [ + { + name: "Helped User (+10)", + value: ReputationEventType.HELPED_USER, + }, + { + name: "Quality Contribution (+15)", + value: ReputationEventType.QUALITY_CONTRIBUTION, + }, + { + name: "Valid Report (+20)", + value: ReputationEventType.VALID_REPORT, + }, + { + name: "Custom Amount", + value: ReputationEventType.MANUAL_GRANT, + }, +]; + +const ViewSubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "view", + description: "View a user's reputation", + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to check", + required: true, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to view reputation.", + }); + } + + const targetUser = interaction.options.getUser("user", true); + + try { + const reputation = await getUserReputation(BigInt(targetUser.id)); + const history = await getReputationHistoryForUser( + BigInt(targetUser.id), + 5, + ); + + const tierEmoji = getTierEmoji(reputation.tier); + const progressToNextTier = getProgressToNextTier( + reputation.score, + reputation.tier, + ); + + const embed = new EmbedBuilder() + .setTitle(`Reputation: ${targetUser.username}`) + .setColor(reputation.tierColor) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { + name: "Score", + value: formatScore(reputation.score), + inline: true, + }, + { + name: "Tier", + value: `${tierEmoji} ${reputation.tierLabel}`, + inline: true, + }, + { + name: "Progress", + value: progressToNextTier, + inline: true, + }, + ) + .setTimestamp(); + + if (history.length > 0) { + const recentEvents = history + .map((event) => { + const sign = event.scoreChange >= 0 ? "+" : ""; + const label = REPUTATION_EVENT_LABELS[event.eventType]; + const time = ``; + return `${sign}${event.scoreChange} - ${label} (${time})`; + }) + .join("\n"); + + embed.addFields({ + name: "Recent Activity", + value: recentEvents, + inline: false, + }); + } + + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + embeds: [embed], + }); + } catch (error) { + logger.error("Failed to fetch reputation:", error); + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Failed to fetch reputation data.", + }); + } + }, +}; + +const GrantSubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "grant", + description: "Grant positive reputation to a user", + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to grant reputation to", + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "type", + description: "Type of reputation to grant", + required: true, + choices: grantableTypes, + }, + { + type: ApplicationCommandOptionType.String, + name: "reason", + description: "Reason for granting reputation", + required: true, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "amount", + description: "Custom amount (only for Custom Amount type)", + required: false, + min_value: 1, + max_value: 100, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to grant reputation.", + }); + } + + const targetUser = interaction.options.getUser("user", true); + const eventTypeStr = interaction.options.getString("type", true); + const reason = interaction.options.getString("reason", true); + const customAmount = interaction.options.getInteger("amount"); + + // Validate event type is one of the allowed grantable types + const validEventTypes = grantableTypes.map((t) => t.value); + if (!validEventTypes.includes(eventTypeStr as ReputationEventType)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Invalid reputation type.", + }); + } + const eventType = eventTypeStr as ReputationEventType; + + if (targetUser.bot) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Cannot grant reputation to bots.", + }); + } + + if ( + eventType === ReputationEventType.MANUAL_GRANT && + customAmount === null + ) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Please provide an amount for custom reputation grants.", + }); + } + + try { + // Use custom score for manual grants, otherwise use the default + const options = + eventType === ReputationEventType.MANUAL_GRANT && customAmount + ? { customScore: customAmount } + : undefined; + + const result = await grantReputation( + BigInt(targetUser.id), + eventType, + BigInt(interaction.user.id), + reason, + undefined, + options?.customScore, + ); + + // Log the reputation grant + await logModerationAction(interaction.client, { + kind: "ReputationGranted", + moderator: interaction.user, + target: targetUser, + eventType: REPUTATION_EVENT_LABELS[eventType], + scoreChange: result.event.scoreChange, + newScore: result.newScore, + reason, + }); + + const tierChange = result.tierChanged + ? `\nTier changed: **${REPUTATION_TIER_LABELS[result.newTier]}**` + : ""; + + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: + `Granted **+${result.event.scoreChange}** reputation to ${fakeMention(targetUser)}\n` + + `**Reason:** ${reason}\n` + + `**New Score:** ${formatScore(result.newScore)}${tierChange}`, + }); + } catch (error) { + logger.error("Failed to grant reputation:", error); + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Failed to grant reputation.", + }); + } + }, +}; + +const HistorySubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "history", + description: "View a user's reputation history", + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to check", + required: true, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "limit", + description: "Number of events to show (default: 20)", + required: false, + min_value: 5, + max_value: 50, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to view reputation history.", + }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const targetUser = interaction.options.getUser("user", true); + const limit = interaction.options.getInteger("limit") ?? 20; + + try { + const history = await getReputationHistoryForUser( + BigInt(targetUser.id), + limit, + ); + const reputation = await getUserReputation(BigInt(targetUser.id)); + + if (history.length === 0) { + return await interaction.editReply({ + content: `${fakeMention(targetUser)} has no reputation history.`, + }); + } + + const embed = new EmbedBuilder() + .setTitle(`Reputation History: ${targetUser.username}`) + .setColor(reputation.tierColor) + .setThumbnail(targetUser.displayAvatarURL()) + .setDescription( + `**Current Score:** ${formatScore(reputation.score)} (${reputation.tierLabel})`, + ) + .setTimestamp(); + + // Group events by date + const eventLines = history.map((event) => { + const sign = event.scoreChange >= 0 ? "+" : ""; + const label = REPUTATION_EVENT_LABELS[event.eventType]; + const time = ``; + const reason = event.reason ? ` - ${event.reason.slice(0, 50)}` : ""; + return `\`${sign}${event.scoreChange.toString().padStart(3)}\` ${label}${reason}\n ${time}`; + }); + + // Split into chunks if too long + const chunkSize = 10; + for (let i = 0; i < eventLines.length; i += chunkSize) { + const chunk = eventLines.slice(i, i + chunkSize); + embed.addFields({ + name: i === 0 ? "Events" : "\u200b", + value: chunk.join("\n"), + inline: false, + }); + } + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + logger.error("Failed to fetch reputation history:", error); + return await interaction.editReply({ + content: "Failed to fetch reputation history.", + }); + } + }, +}; + +function getTierEmoji(tier: ReputationTier): string { + switch (tier) { + case ReputationTier.TRUSTED: + return "🌟"; + case ReputationTier.GOOD: + return "✅"; + case ReputationTier.NEUTRAL: + return "➖"; + case ReputationTier.WATCH: + return "⚠️"; + case ReputationTier.RESTRICTED: + return "🚫"; + default: + return "❓"; + } +} + +function formatScore(score: number): string { + if (score >= 0) { + return `+${score}`; + } + return score.toString(); +} + +function getProgressToNextTier( + score: number, + currentTier: ReputationTier, +): string { + const tiers = [ + ReputationTier.RESTRICTED, + ReputationTier.WATCH, + ReputationTier.NEUTRAL, + ReputationTier.GOOD, + ReputationTier.TRUSTED, + ]; + + const currentIndex = tiers.indexOf(currentTier); + + if (currentTier === ReputationTier.TRUSTED) { + return "Max tier reached"; + } + + const nextTier = tiers[currentIndex + 1]; + const nextThreshold = REPUTATION_TIER_THRESHOLDS[nextTier]; + const pointsNeeded = nextThreshold - score; + + return `${pointsNeeded} to ${REPUTATION_TIER_LABELS[nextTier]}`; +} + +export const ReputationCommand: Command = { + name: "reputation", + description: "Manage user reputation (mods only)", + type: ApplicationCommandType.ChatInput, + default_permission: false, + options: [ViewSubcommand, GrantSubcommand, HistorySubcommand], + handle() {}, +}; diff --git a/src/modules/moderation/reputation.service.ts b/src/modules/moderation/reputation.service.ts new file mode 100644 index 00000000..529f620d --- /dev/null +++ b/src/modules/moderation/reputation.service.ts @@ -0,0 +1,225 @@ +import { getOrCreateUserById } from "../../store/models/DDUser.js"; +import { + createReputationEvent, + getReputationHistory, + type ReputationEvent, + ReputationEventType, +} from "../../store/models/ReputationEvent.js"; +import { WarningSeverity } from "../../store/models/Warning.js"; + +export enum ReputationTier { + TRUSTED = "TRUSTED", + GOOD = "GOOD", + NEUTRAL = "NEUTRAL", + WATCH = "WATCH", + RESTRICTED = "RESTRICTED", +} + +export const REPUTATION_TIER_THRESHOLDS: Record = { + [ReputationTier.TRUSTED]: 200, + [ReputationTier.GOOD]: 50, + [ReputationTier.NEUTRAL]: -50, + [ReputationTier.WATCH]: -200, + [ReputationTier.RESTRICTED]: Number.NEGATIVE_INFINITY, +}; + +export const REPUTATION_TIER_LABELS: Record = { + [ReputationTier.TRUSTED]: "Trusted", + [ReputationTier.GOOD]: "Good", + [ReputationTier.NEUTRAL]: "Neutral", + [ReputationTier.WATCH]: "Watch", + [ReputationTier.RESTRICTED]: "Restricted", +}; + +export const REPUTATION_TIER_COLORS: Record = { + [ReputationTier.TRUSTED]: 0x22c55e, // Green + [ReputationTier.GOOD]: 0x3b82f6, // Blue + [ReputationTier.NEUTRAL]: 0x6b7280, // Gray + [ReputationTier.WATCH]: 0xf59e0b, // Amber + [ReputationTier.RESTRICTED]: 0xef4444, // Red +}; + +/** + * Get the reputation tier for a given score + */ +export function getReputationTier(score: number): ReputationTier { + if (score >= REPUTATION_TIER_THRESHOLDS[ReputationTier.TRUSTED]) { + return ReputationTier.TRUSTED; + } + if (score >= REPUTATION_TIER_THRESHOLDS[ReputationTier.GOOD]) { + return ReputationTier.GOOD; + } + if (score >= REPUTATION_TIER_THRESHOLDS[ReputationTier.NEUTRAL]) { + return ReputationTier.NEUTRAL; + } + if (score >= REPUTATION_TIER_THRESHOLDS[ReputationTier.WATCH]) { + return ReputationTier.WATCH; + } + return ReputationTier.RESTRICTED; +} + +/** + * Get user's current reputation data + */ +export async function getUserReputation(userId: bigint): Promise<{ + score: number; + tier: ReputationTier; + tierLabel: string; + tierColor: number; +}> { + const user = await getOrCreateUserById(userId); + const score = user.reputationScore; + const tier = getReputationTier(score); + + return { + score, + tier, + tierLabel: REPUTATION_TIER_LABELS[tier], + tierColor: REPUTATION_TIER_COLORS[tier], + }; +} + +/** + * Update a user's reputation score and create an event + */ +export async function updateReputation( + userId: bigint, + eventType: ReputationEventType, + options?: { + reason?: string; + grantedBy?: bigint; + relatedId?: number; + customScore?: number; + }, +): Promise<{ + event: ReputationEvent; + newScore: number; + oldTier: ReputationTier; + newTier: ReputationTier; +}> { + const user = await getOrCreateUserById(userId); + const oldScore = user.reputationScore; + const oldTier = getReputationTier(oldScore); + + const event = await createReputationEvent(userId, eventType, options); + + const newScore = oldScore + event.scoreChange; + user.reputationScore = newScore; + user.lastReputationUpdate = new Date(); + await user.save(); + + const newTier = getReputationTier(newScore); + + return { + event, + newScore, + oldTier, + newTier, + }; +} + +/** + * Grant positive reputation to a user + */ +export async function grantReputation( + userId: bigint, + eventType: ReputationEventType, + grantedBy: bigint, + reason?: string, + relatedId?: number, + customScore?: number, +): Promise<{ + event: ReputationEvent; + newScore: number; + tierChanged: boolean; + newTier: ReputationTier; +}> { + const result = await updateReputation(userId, eventType, { + reason, + grantedBy, + relatedId, + customScore, + }); + + return { + event: result.event, + newScore: result.newScore, + tierChanged: result.oldTier !== result.newTier, + newTier: result.newTier, + }; +} + +/** + * Deduct reputation from a user (for automated systems) + */ +export async function deductReputation( + userId: bigint, + eventType: ReputationEventType, + reason?: string, + relatedId?: number, +): Promise<{ + event: ReputationEvent; + newScore: number; + tierChanged: boolean; + newTier: ReputationTier; +}> { + const result = await updateReputation(userId, eventType, { + reason, + relatedId, + }); + + return { + event: result.event, + newScore: result.newScore, + tierChanged: result.oldTier !== result.newTier, + newTier: result.newTier, + }; +} + +/** + * Get warning event type based on severity + */ +export function getWarningEventType( + severity: WarningSeverity, +): ReputationEventType { + switch (severity) { + case WarningSeverity.MINOR: + return ReputationEventType.WARNING_MINOR; + case WarningSeverity.MODERATE: + return ReputationEventType.WARNING_MODERATE; + case WarningSeverity.SEVERE: + return ReputationEventType.WARNING_SEVERE; + default: + return ReputationEventType.WARNING_MINOR; + } +} + +/** + * Get reputation history for a user + */ +export async function getReputationHistoryForUser( + userId: bigint, + limit = 20, +): Promise { + return getReputationHistory(userId, limit); +} + +/** + * Calculate XP modifier based on reputation tier + */ +export function getXpModifier(tier: ReputationTier): number { + switch (tier) { + case ReputationTier.TRUSTED: + return 1.25; // 25% bonus XP + case ReputationTier.GOOD: + return 1.1; // 10% bonus XP + case ReputationTier.NEUTRAL: + return 1; // Normal XP + case ReputationTier.WATCH: + return 0.75; // 25% less XP + case ReputationTier.RESTRICTED: + return 0; // No XP + default: + return 1; + } +} diff --git a/src/modules/moderation/warn.command.ts b/src/modules/moderation/warn.command.ts new file mode 100644 index 00000000..8727c533 --- /dev/null +++ b/src/modules/moderation/warn.command.ts @@ -0,0 +1,204 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + EmbedBuilder, + MessageFlags, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { getOrCreateUserById } from "../../store/models/DDUser.js"; +import { + getWarningCount, + Warning, + WarningSeverity, +} from "../../store/models/Warning.js"; +import { parseTimespan } from "../../util/timespan.js"; +import { fakeMention } from "../../util/users.js"; +import { logModerationAction } from "./logs.js"; +import { deductReputation, getWarningEventType } from "./reputation.service.js"; + +const SEVERITY_LABELS: Record = { + [WarningSeverity.MINOR]: "Minor", + [WarningSeverity.MODERATE]: "Moderate", + [WarningSeverity.SEVERE]: "Severe", +}; + +const DEFAULT_EXPIRY: Record = { + [WarningSeverity.MINOR]: 30 * 24 * 60 * 60 * 1000, + [WarningSeverity.MODERATE]: 60 * 24 * 60 * 60 * 1000, + [WarningSeverity.SEVERE]: null, +}; + +export const WarnCommand: Command = { + name: "warn", + description: "Issue a formal warning to a user", + type: ApplicationCommandType.ChatInput, + default_permission: false, + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to warn", + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "reason", + description: "Reason for the warning", + required: true, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "severity", + description: "Warning severity (default: Minor)", + required: false, + choices: [ + { name: "Minor", value: WarningSeverity.MINOR }, + { name: "Moderate", value: WarningSeverity.MODERATE }, + { name: "Severe", value: WarningSeverity.SEVERE }, + ], + }, + { + type: ApplicationCommandOptionType.String, + name: "duration", + description: + "How long before warning expires (e.g., 30d, 90d). Leave empty for default based on severity", + required: false, + }, + ], + + handle: async (interaction) => { + if ( + !interaction.isChatInputCommand() || + !interaction.inGuild() || + interaction.guild === null + ) + return; + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const user = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason", true).trim(); + const severity = + (interaction.options.getInteger("severity") as WarningSeverity) || + WarningSeverity.MINOR; + const durationStr = interaction.options.getString("duration"); + + // Validate reason + if (reason.length === 0) { + await interaction.editReply("Warning reason cannot be empty."); + return; + } + if (reason.length > 500) { + await interaction.editReply( + "Warning reason must be under 500 characters.", + ); + return; + } + + let expiresAt: Date | null = null; + const MAX_DURATION_MS = 365 * 24 * 60 * 60 * 1000; // 1 year + if (durationStr) { + const durationMs = parseTimespan(durationStr); + if (durationMs <= 0) { + await interaction.editReply( + "Invalid duration format. Use formats like '30d', '2w', '24h'.", + ); + return; + } + if (durationMs > MAX_DURATION_MS) { + await interaction.editReply("Warning duration cannot exceed 1 year."); + return; + } + expiresAt = new Date(Date.now() + durationMs); + } else if (DEFAULT_EXPIRY[severity] !== null) { + expiresAt = new Date(Date.now() + DEFAULT_EXPIRY[severity]); + } + + // Ensure user exists in database before creating warning + await getOrCreateUserById(BigInt(user.id)); + + const warning = await Warning.create({ + userId: BigInt(user.id), + moderatorId: BigInt(interaction.user.id), + reason, + severity, + expiresAt, + }); + + // Deduct reputation based on warning severity + const reputationEventType = getWarningEventType(severity); + await deductReputation( + BigInt(user.id), + reputationEventType, + reason, + warning.id, + ); + + const warningCount = await getWarningCount(BigInt(user.id)); + + try { + const dmEmbed = new EmbedBuilder() + .setTitle("You have received a warning") + .setColor("Orange") + .setDescription( + `You have been warned in **${interaction.guild.name}**.\n\n` + + `**Reason:** ${reason}\n` + + `**Severity:** ${SEVERITY_LABELS[severity]}\n` + + (expiresAt + ? `**Expires:** \n` + : "") + + `\nThis is warning #${warningCount}. Please review the server rules to avoid further action.`, + ) + .setTimestamp(); + + await user.send({ embeds: [dmEmbed] }); + } catch { + logger.info( + `Could not DM warning to user ${user.id} - DMs may be disabled`, + ); + } + + await logModerationAction(interaction.client, { + kind: "Warning", + moderator: interaction.user, + target: user, + reason, + severity, + warningId: warning.id, + warningCount, + expiresAt, + }); + + const thresholds = config.reputation?.warningThresholds; + let escalationNote = ""; + if (thresholds) { + if (warningCount >= thresholds.banAt) { + escalationNote = `\n\n**Note:** User has ${warningCount} warnings and may be eligible for a ban.`; + } else if (warningCount >= thresholds.muteAt) { + escalationNote = `\n\n**Note:** User has ${warningCount} warnings and may be eligible for a mute.`; + } + } + + await interaction.editReply({ + content: + `Warned ${fakeMention(user)} (Warning #${warningCount})\n` + + `**Reason:** ${reason}\n` + + `**Severity:** ${SEVERITY_LABELS[severity]}` + + escalationNote, + }); + } catch (e) { + logger.error("Failed to warn user:", e); + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong!"); + } else { + await interaction.reply({ + content: "Something went wrong!", + ephemeral: true, + }); + } + } + }, +}; diff --git a/src/modules/moderation/warningScheduler.listener.ts b/src/modules/moderation/warningScheduler.listener.ts new file mode 100644 index 00000000..b1a00cde --- /dev/null +++ b/src/modules/moderation/warningScheduler.listener.ts @@ -0,0 +1,102 @@ +import * as Sentry from "@sentry/bun"; +import { Op } from "@sequelize/core"; +import type { Client, Guild } from "discord.js"; +import * as schedule from "node-schedule"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { getWarningCount, Warning } from "../../store/models/Warning.js"; +import type { EventListener } from "../module.js"; + +async function expireWarnings(): Promise { + const now = new Date(); + + const [expiredCount] = await Warning.update( + { expired: true }, + { + where: { + expired: false, + pardoned: false, + expiresAt: { + [Op.not]: null, + [Op.lte]: now, + }, + }, + }, + ); + + if (expiredCount > 0) { + logger.info(`Expired ${expiredCount} warnings`); + } + + return expiredCount; +} + +async function checkEscalations(client: Client, guild: Guild): Promise { + const thresholds = config.reputation?.warningThresholds; + if (!thresholds) return; + + const recentWarnings = await Warning.findAll({ + where: { + expired: false, + pardoned: false, + createdAt: { + [Op.gte]: new Date(Date.now() - 60 * 60 * 1000), + }, + }, + order: [["createdAt", "DESC"]], + }); + + const userWarningCounts = new Map(); + + for (const warning of recentWarnings) { + const count = await getWarningCount(warning.userId); + userWarningCounts.set(warning.userId.toString(), count); + } + + for (const [userId, count] of userWarningCounts) { + try { + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) continue; + + if (count >= thresholds.banAt) { + logger.info( + `User ${userId} has ${count} warnings, eligible for ban (threshold: ${thresholds.banAt})`, + ); + } else if (count >= thresholds.muteAt) { + const isTimedOut = member.isCommunicationDisabled(); + if (!isTimedOut) { + logger.info( + `User ${userId} has ${count} warnings, eligible for auto-mute (threshold: ${thresholds.muteAt})`, + ); + } + } + } catch (error) { + logger.error(`Failed to check escalation for user ${userId}:`, error); + Sentry.captureException(error); + } + } +} + +export const WarningSchedulerListener: EventListener = { + async clientReady(client) { + logger.info("Starting warning expiration scheduler"); + + schedule.scheduleJob("0 * * * *", async () => { + try { + await expireWarnings(); + + const guild = await client.guilds + .fetch(config.guildId) + .catch(() => null); + if (guild) { + await checkEscalations(client, guild); + } + } catch (error) { + logger.error("Warning scheduler job failed:", error); + Sentry.captureException(error); + } + }); + + await expireWarnings(); + }, +}; diff --git a/src/modules/moderation/warnings.command.ts b/src/modules/moderation/warnings.command.ts new file mode 100644 index 00000000..e88f7282 --- /dev/null +++ b/src/modules/moderation/warnings.command.ts @@ -0,0 +1,168 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + EmbedBuilder, + MessageFlags, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { logger } from "../../logging.js"; +import { + getAllWarnings, + type Warning, + WarningSeverity, +} from "../../store/models/Warning.js"; +import { isSpecialUser } from "../../util/users.js"; + +const SEVERITY_LABELS: Record = { + [WarningSeverity.MINOR]: "Minor", + [WarningSeverity.MODERATE]: "Moderate", + [WarningSeverity.SEVERE]: "Severe", +}; + +const SEVERITY_EMOJI: Record = { + [WarningSeverity.MINOR]: "🟡", + [WarningSeverity.MODERATE]: "🟠", + [WarningSeverity.SEVERE]: "🔴", +}; + +function formatWarning(warning: Warning, isMod: boolean): string { + const emoji = SEVERITY_EMOJI[warning.severity]; + const severity = SEVERITY_LABELS[warning.severity]; + const date = ``; + + let status = ""; + if (warning.pardoned) { + status = " *(Pardoned)*"; + } else if (warning.expired) { + status = " *(Expired)*"; + } + + if (isMod) { + const expires = warning.expiresAt + ? `` + : "Never"; + return ( + `${emoji} **#${warning.id}** - ${severity}${status}\n` + + ` ${date} | Expires: ${expires}\n` + + ` Reason: ${warning.reason.slice(0, 100)}${warning.reason.length > 100 ? "..." : ""}` + ); + } + + return `${emoji} ${date} - ${severity}${status}: ${warning.reason.slice(0, 50)}${warning.reason.length > 50 ? "..." : ""}`; +} + +export const WarningsCommand: Command = { + name: "warnings", + description: "View warnings for a user", + type: ApplicationCommandType.ChatInput, + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to check (leave empty for yourself)", + required: false, + }, + { + type: ApplicationCommandOptionType.Boolean, + name: "include_expired", + description: "Include expired and pardoned warnings (mods only)", + required: false, + }, + ], + + handle: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.inGuild()) return; + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const targetUser = + interaction.options.getUser("user") || interaction.user; + const includeExpired = + interaction.options.getBoolean("include_expired") ?? false; + + const member = await interaction.guild?.members + .fetch(interaction.user.id) + .catch(() => null); + const isMod = member ? isSpecialUser(member) : false; + + const isSelf = targetUser.id === interaction.user.id; + if (!isSelf && !isMod) { + await interaction.editReply({ + content: "You can only view your own warnings.", + }); + return; + } + + const effectiveIncludeExpired = isMod && includeExpired; + const warnings = await getAllWarnings( + BigInt(targetUser.id), + effectiveIncludeExpired, + ); + + if (warnings.length === 0) { + await interaction.editReply({ + content: isSelf + ? "You have no warnings." + : `${targetUser.username} has no warnings.`, + }); + return; + } + + const activeWarnings = warnings.filter((w) => !w.expired && !w.pardoned); + const inactiveWarnings = warnings.filter((w) => w.expired || w.pardoned); + + const embed = new EmbedBuilder() + .setTitle( + isSelf ? "Your Warnings" : `Warnings for ${targetUser.username}`, + ) + .setColor(activeWarnings.length > 0 ? "Orange" : "Green") + .setThumbnail(targetUser.displayAvatarURL()) + .setTimestamp(); + + if (activeWarnings.length > 0) { + const activeList = activeWarnings + .slice(0, 10) + .map((w) => formatWarning(w, isMod)) + .join("\n\n"); + + embed.addFields({ + name: `Active Warnings (${activeWarnings.length})`, + value: activeList || "None", + }); + } else { + embed.setDescription("No active warnings."); + } + + if (effectiveIncludeExpired && inactiveWarnings.length > 0) { + const inactiveList = inactiveWarnings + .slice(0, 5) + .map((w) => formatWarning(w, isMod)) + .join("\n\n"); + + embed.addFields({ + name: `Expired/Pardoned (${inactiveWarnings.length})`, + value: inactiveList, + }); + } + + if (isMod) { + embed.setFooter({ + text: `Total: ${warnings.length} | Active: ${activeWarnings.length}`, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (e) { + logger.error("Failed to fetch warnings:", e); + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Something went wrong!"); + } else { + await interaction.reply({ + content: "Something went wrong!", + flags: MessageFlags.Ephemeral, + }); + } + } + }, +}; diff --git a/src/modules/moderation/wordlist.command.ts b/src/modules/moderation/wordlist.command.ts new file mode 100644 index 00000000..0abfb78a --- /dev/null +++ b/src/modules/moderation/wordlist.command.ts @@ -0,0 +1,348 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + EmbedBuilder, + type GuildMember, + MessageFlags, + PermissionFlagsBits, +} from "discord.js"; +import type { Command, ExecutableSubcommand } from "djs-slash-helper"; +import { logger } from "../../logging.js"; +import { + addBlockedWord, + BlockedWordCategory, + getAllBlockedWords, + getBlockedWordsByCategory, + removeBlockedWord, +} from "../../store/models/BlockedWord.js"; +import { + invalidateBlockedWordsCache, + testForToxicContent, +} from "../threatDetection/detectors/toxicContentDetector.js"; + +const CATEGORY_LABELS: Record = { + [BlockedWordCategory.SLUR]: "Slur", + [BlockedWordCategory.HARASSMENT]: "Harassment", + [BlockedWordCategory.NSFW]: "NSFW", + [BlockedWordCategory.SPAM]: "Spam", + [BlockedWordCategory.OTHER]: "Other", +}; + +const categoryChoices = Object.entries(CATEGORY_LABELS).map( + ([value, name]) => ({ + name, + value, + }), +); + +const AddSubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "add", + description: "Add a word to the blocklist", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "word", + description: "The word to block", + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "category", + description: "Category for the blocked word", + required: false, + choices: categoryChoices, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ManageMessages)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to manage the wordlist.", + }); + } + + const word = interaction.options.getString("word", true); + const category = + (interaction.options.getString("category") as BlockedWordCategory) || + BlockedWordCategory.OTHER; + + try { + const blockedWord = await addBlockedWord( + word, + category, + BigInt(interaction.user.id), + ); + invalidateBlockedWordsCache(); + + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: `Added "${blockedWord.word}" to the blocklist under category **${CATEGORY_LABELS[category]}**.`, + }); + } catch (error) { + logger.error("Failed to add blocked word:", error); + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Failed to add word to blocklist.", + }); + } + }, +}; + +const RemoveSubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "remove", + description: "Remove a word from the blocklist", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "word", + description: "The word to remove", + required: true, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ManageMessages)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to manage the wordlist.", + }); + } + + const word = interaction.options.getString("word", true); + + try { + const deleted = await removeBlockedWord(word); + invalidateBlockedWordsCache(); + + if (deleted) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: `Removed "${word.toLowerCase()}" from the blocklist.`, + }); + } + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: `Word "${word.toLowerCase()}" was not found in the blocklist.`, + }); + } catch (error) { + logger.error("Failed to remove blocked word:", error); + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Failed to remove word from blocklist.", + }); + } + }, +}; + +const ListSubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "list", + description: "View the current blocklist", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "category", + description: "Filter by category", + required: false, + choices: categoryChoices, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ManageMessages)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to view the wordlist.", + }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const category = interaction.options.getString( + "category", + ) as BlockedWordCategory | null; + + try { + const words = category + ? await getBlockedWordsByCategory(category) + : await getAllBlockedWords(); + + if (words.length === 0) { + return await interaction.editReply({ + content: category + ? `No blocked words in category **${CATEGORY_LABELS[category]}**.` + : "The blocklist is empty.", + }); + } + + // Group by category + const grouped = new Map(); + for (const word of words) { + const existing = grouped.get(word.category) || []; + existing.push(word.word); + grouped.set(word.category, existing); + } + + const embed = new EmbedBuilder() + .setTitle("Blocked Words") + .setColor("Red") + .setTimestamp() + .setFooter({ text: `Total: ${words.length} words` }); + + for (const [cat, wordList] of grouped) { + // Spoiler each word for safety + const spoilered = wordList.map((w) => `||${w}||`).join(", "); + const truncated = + spoilered.length > 1000 ? `${spoilered.slice(0, 997)}...` : spoilered; + + embed.addFields({ + name: `${CATEGORY_LABELS[cat]} (${wordList.length})`, + value: truncated || "None", + }); + } + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + logger.error("Failed to list blocked words:", error); + return await interaction.editReply({ + content: "Failed to fetch blocklist.", + }); + } + }, +}; + +const WordListTestSubcommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "test", + description: "Test if text would trigger the filter", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "text", + description: "The text to test", + required: true, + }, + { + type: ApplicationCommandOptionType.Boolean, + name: "detect_bypasses", + description: "Whether to detect bypass attempts (default: true)", + required: false, + }, + ], + async handle(interaction) { + const member = interaction.member as GuildMember | null; + if (!member) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "This command must be used in a server.", + }); + } + if (!member.permissions.has(PermissionFlagsBits.ManageMessages)) { + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "You don't have permission to test the wordlist.", + }); + } + + const text = interaction.options.getString("text", true); + const detectBypasses = + interaction.options.getBoolean("detect_bypasses") ?? true; + + try { + const result = await testForToxicContent(text, detectBypasses); + + const embed = new EmbedBuilder() + .setTitle("Wordlist Test Result") + .setColor(result.detected ? "Red" : "Green") + .addFields( + { + name: "Input", + value: `||${text}||`, + inline: false, + }, + { + name: "Detected", + value: result.detected ? "Yes" : "No", + inline: true, + }, + { + name: "Bypass Detection", + value: detectBypasses ? "Enabled" : "Disabled", + inline: true, + }, + ) + .setTimestamp(); + + if (result.detected) { + embed.addFields( + { + name: "Matched Word", + value: `||${result.matchedWord}||`, + inline: true, + }, + { + name: "Category", + value: result.category + ? CATEGORY_LABELS[result.category] + : "Unknown", + inline: true, + }, + ); + + if (result.bypassAttempted) { + embed.addFields({ + name: "Bypass Attempted", + value: "Yes (text was normalized to detect)", + inline: false, + }); + } + } + + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + embeds: [embed], + }); + } catch (error) { + logger.error("Failed to test text:", error); + return await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: "Failed to test text against blocklist.", + }); + } + }, +}; + +export const WordlistCommand: Command = { + name: "wordlist", + description: "Manage the blocked words list", + type: ApplicationCommandType.ChatInput, + default_permission: false, + options: [ + AddSubcommand, + RemoveSubcommand, + ListSubcommand, + WordListTestSubcommand, + ], + handle() {}, +}; diff --git a/src/modules/starboard/starboard.listener.ts b/src/modules/starboard/starboard.listener.ts index 4fb52edd..f729f315 100644 --- a/src/modules/starboard/starboard.listener.ts +++ b/src/modules/starboard/starboard.listener.ts @@ -9,9 +9,11 @@ import type { import * as schedule from "node-schedule"; import { config } from "../../Config.js"; import { logger } from "../../logging.js"; +import { ReputationEventType } from "../../store/models/ReputationEvent.js"; import { StarboardMessage } from "../../store/models/StarboardMessage.js"; import { getMember } from "../../util/member.js"; import { MessageFetcher } from "../../util/ratelimiting.js"; +import { grantReputation } from "../moderation/reputation.service.js"; import type { EventListener } from "../module.js"; import { createStarboardMessage, @@ -326,6 +328,21 @@ export const StarboardListener: EventListener = { message.channelId, starboardMessage.id, ); + + // Grant reputation for reaching starboard + try { + await grantReputation( + BigInt(message.author.id), + ReputationEventType.STARBOARD_MESSAGE, + BigInt(message.author.id), // Self-granted via starboard + `Message reached starboard with ${count} stars`, + ); + logger.debug( + `Granted starboard reputation to user ${message.author.id}`, + ); + } catch (error) { + logger.error("Failed to grant starboard reputation:", error); + } } } catch (error) { logger.error("Error sending starboard message", error); diff --git a/src/modules/threatDetection/detectors/accountAnalyzer.ts b/src/modules/threatDetection/detectors/accountAnalyzer.ts new file mode 100644 index 00000000..24fac018 --- /dev/null +++ b/src/modules/threatDetection/detectors/accountAnalyzer.ts @@ -0,0 +1,155 @@ +import type { GuildMember } from "discord.js"; +import { logger } from "../../../logging.js"; +import { ThreatAction, ThreatType } from "../../../store/models/ThreatLog.js"; + +export interface AccountAnalysisResult { + detected: boolean; + threatType: ThreatType; + severity: number; + action: ThreatAction; + details: { + accountAgeDays: number; + hasDefaultAvatar: boolean; + suspiciousNameMatch: boolean; + matchedPatterns: string[]; + reasons: string[]; + }; +} + +const DEFAULT_SUSPICIOUS_PATTERNS = [ + "^.{1,2}$", + "^[a-z]{5,}\\d{4,}$", + "nitro|free.*gift|steam.*gift", + "discord.*mod|discord.*admin", + "giveaway|airdrop|crypto", +]; + +// Pre-compiled regex cache for performance +const compiledPatternCache = new Map(); +const MAX_PATTERN_LENGTH = 200; + +// Detect patterns that could cause catastrophic backtracking (ReDoS) +const DANGEROUS_PATTERN_REGEX = /(\+|\*|\{[0-9]+,\})\s*\1|\(\?[^)]*\+/; + +function isPatternSafe(pattern: string): boolean { + // Reject overly long patterns + if (pattern.length > MAX_PATTERN_LENGTH) { + return false; + } + // Reject patterns with nested quantifiers (e.g., (a+)+ or (a*)*) + if (DANGEROUS_PATTERN_REGEX.test(pattern)) { + return false; + } + return true; +} + +function getCompiledPattern(pattern: string): RegExp | null { + const cached = compiledPatternCache.get(pattern); + if (cached) return cached; + + // Check for potentially dangerous patterns (ReDoS prevention) + if (!isPatternSafe(pattern)) { + logger.warn( + `Rejected potentially dangerous regex pattern: "${pattern.slice(0, 50)}..."`, + ); + return null; + } + + try { + const compiled = new RegExp(pattern, "i"); + compiledPatternCache.set(pattern, compiled); + return compiled; + } catch (error) { + logger.warn(`Invalid regex pattern "${pattern}":`, error); + return null; + } +} + +function getAccountAgeDays(member: GuildMember): number { + const createdAt = member.user.createdTimestamp; + const now = Date.now(); + return (now - createdAt) / (1000 * 60 * 60 * 24); +} + +function hasDefaultAvatar(member: GuildMember): boolean { + return member.user.avatar === null; +} + +function matchesSuspiciousPatterns( + username: string, + patterns: string[], +): { matches: boolean; matchedPatterns: string[] } { + const matchedPatterns: string[] = []; + + for (const pattern of patterns) { + const regex = getCompiledPattern(pattern); + if (regex && regex.test(username)) { + matchedPatterns.push(pattern); + } + } + + return { + matches: matchedPatterns.length > 0, + matchedPatterns, + }; +} + +export function analyzeAccount( + member: GuildMember, + config: { + minAgeDays: number; + flagDefaultAvatar: boolean; + flagSuspiciousNames: boolean; + suspiciousNamePatterns?: string[]; + action: "flag" | "kick"; + }, +): AccountAnalysisResult { + const accountAgeDays = getAccountAgeDays(member); + const defaultAvatar = hasDefaultAvatar(member); + const patterns = config.suspiciousNamePatterns || DEFAULT_SUSPICIOUS_PATTERNS; + const nameAnalysis = matchesSuspiciousPatterns( + member.user.username, + patterns, + ); + + const reasons: string[] = []; + let severity = 0; + + if (accountAgeDays < config.minAgeDays) { + reasons.push(`Account is only ${accountAgeDays.toFixed(1)} days old`); + severity += 0.4; + } + + if (config.flagDefaultAvatar && defaultAvatar) { + reasons.push("Using default avatar"); + severity += 0.2; + } + + if (config.flagSuspiciousNames && nameAnalysis.matches) { + reasons.push( + `Username matches suspicious patterns: ${nameAnalysis.matchedPatterns.join(", ")}`, + ); + severity += 0.3; + } + + severity = Math.min(severity, 1); + const detected = reasons.length > 0; + + return { + detected, + threatType: ThreatType.SUSPICIOUS_ACCOUNT, + severity, + action: detected + ? config.action === "kick" + ? ThreatAction.KICKED + : ThreatAction.FLAGGED + : ThreatAction.FLAGGED, + details: { + accountAgeDays, + hasDefaultAvatar: defaultAvatar, + suspiciousNameMatch: nameAnalysis.matches, + matchedPatterns: nameAnalysis.matchedPatterns, + reasons, + }, + }; +} diff --git a/src/modules/threatDetection/detectors/mentionSpamDetector.ts b/src/modules/threatDetection/detectors/mentionSpamDetector.ts new file mode 100644 index 00000000..52e40037 --- /dev/null +++ b/src/modules/threatDetection/detectors/mentionSpamDetector.ts @@ -0,0 +1,121 @@ +import type { Message } from "discord.js"; +import ExpiryMap from "expiry-map"; +import { ThreatAction, ThreatType } from "../../../store/models/ThreatLog.js"; + +export interface MentionSpamResult { + detected: boolean; + threatType: ThreatType; + severity: number; + action: ThreatAction; + details: { + mentionsInMessage: number; + mentionsInWindow: number; + windowSeconds: number; + hasEveryone: boolean; + }; +} + +interface UserMentionWindow { + mentions: Array<{ + count: number; + timestamp: number; + }>; +} + +const userMentionCache = new ExpiryMap(60_000); + +function cleanOldMentions( + window: UserMentionWindow, + windowMs: number, +): UserMentionWindow { + const now = Date.now(); + return { + mentions: window.mentions.filter((m) => now - m.timestamp < windowMs), + }; +} + +function countMentions(message: Message): { + total: number; + users: number; + roles: number; + hasEveryone: boolean; +} { + const users = message.mentions.users.size; + const roles = message.mentions.roles.size; + const hasEveryone = message.mentions.everyone; + + return { + total: users + roles + (hasEveryone ? 1 : 0), + users, + roles, + hasEveryone, + }; +} + +export function detectMentionSpam( + message: Message, + config: { + maxMentionsPerMessage: number; + maxMentionsPerWindow: number; + windowSeconds: number; + action: "delete" | "mute"; + }, +): MentionSpamResult { + const userId = message.author.id; + const windowMs = config.windowSeconds * 1000; + + const mentionCounts = countMentions(message); + + let userWindow = userMentionCache.get(userId) || { mentions: [] }; + userWindow = cleanOldMentions(userWindow, windowMs); + + const mentionsInWindow = + userWindow.mentions.reduce((sum, m) => sum + m.count, 0) + + mentionCounts.total; + + userWindow.mentions.push({ + count: mentionCounts.total, + timestamp: Date.now(), + }); + userMentionCache.set(userId, userWindow); + + const messageExceeded = mentionCounts.total > config.maxMentionsPerMessage; + const windowExceeded = mentionsInWindow > config.maxMentionsPerWindow; + const detected = + messageExceeded || windowExceeded || mentionCounts.hasEveryone; + + let severity = 0; + if (messageExceeded) { + severity = Math.min(mentionCounts.total / config.maxMentionsPerMessage, 1); + } + if (windowExceeded) { + severity = Math.max( + severity, + Math.min(mentionsInWindow / config.maxMentionsPerWindow, 1), + ); + } + if (mentionCounts.hasEveryone) { + severity = Math.max(severity, 0.9); + } + + return { + detected, + threatType: ThreatType.MENTION_SPAM, + severity, + action: detected + ? config.action === "mute" + ? ThreatAction.MUTED + : ThreatAction.DELETED + : ThreatAction.FLAGGED, + details: { + mentionsInMessage: mentionCounts.total, + mentionsInWindow, + windowSeconds: config.windowSeconds, + hasEveryone: mentionCounts.hasEveryone, + }, + }; +} + +export function clearUserMentionCache(userId: string): void { + userMentionCache.delete(userId); +} diff --git a/src/modules/threatDetection/detectors/raidDetector.ts b/src/modules/threatDetection/detectors/raidDetector.ts new file mode 100644 index 00000000..5c2e5572 --- /dev/null +++ b/src/modules/threatDetection/detectors/raidDetector.ts @@ -0,0 +1,144 @@ +import type { GuildMember } from "discord.js"; +import ExpiryMap from "expiry-map"; +import { ThreatAction, ThreatType } from "../../../store/models/ThreatLog.js"; + +export interface RaidDetectionResult { + detected: boolean; + threatType: ThreatType; + severity: number; + action: ThreatAction; + details: { + joinCount: number; + windowSeconds: number; + newAccountCount: number; + raidModeActive: boolean; + }; +} + +interface JoinWindow { + joins: Array<{ + userId: string; + timestamp: number; + accountCreatedAt: number; + }>; + raidModeActive: boolean; + raidModeStartedAt?: number; +} + +const joinWindowCache = new ExpiryMap(300_000); + +const RAID_MODE_DURATION_MS = 5 * 60 * 1000; + +function cleanOldJoins(window: JoinWindow, windowMs: number): JoinWindow { + const now = Date.now(); + return { + ...window, + joins: window.joins.filter((j) => now - j.timestamp < windowMs), + }; +} + +function countNewAccounts( + joins: JoinWindow["joins"], + thresholdDays: number, +): number { + const thresholdMs = thresholdDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + return joins.filter((j) => now - j.accountCreatedAt < thresholdMs).length; +} + +export function detectRaid( + member: GuildMember, + config: { + maxJoinsPerWindow: number; + windowSeconds: number; + newAccountThreshold: number; + action: "alert" | "lockdown" | "kick_new"; + }, +): RaidDetectionResult { + const guildId = member.guild.id; + const windowMs = config.windowSeconds * 1000; + + let joinWindow = joinWindowCache.get(guildId) || { + joins: [], + raidModeActive: false, + }; + joinWindow = cleanOldJoins(joinWindow, windowMs); + + if ( + joinWindow.raidModeActive && + joinWindow.raidModeStartedAt && + Date.now() - joinWindow.raidModeStartedAt > RAID_MODE_DURATION_MS + ) { + joinWindow.raidModeActive = false; + joinWindow.raidModeStartedAt = undefined; + } + + joinWindow.joins.push({ + userId: member.id, + timestamp: Date.now(), + accountCreatedAt: member.user.createdTimestamp, + }); + + const joinCount = joinWindow.joins.length; + const newAccountCount = countNewAccounts( + joinWindow.joins, + config.newAccountThreshold, + ); + + const raidDetected = joinCount >= config.maxJoinsPerWindow; + + if (raidDetected && !joinWindow.raidModeActive) { + joinWindow.raidModeActive = true; + joinWindow.raidModeStartedAt = Date.now(); + } + + joinWindowCache.set(guildId, joinWindow); + + let severity = 0; + if (raidDetected) { + severity = Math.min(joinCount / config.maxJoinsPerWindow, 1); + if (newAccountCount > joinCount * 0.5) { + severity = Math.min(severity + 0.2, 1); + } + } + + let action: ThreatAction = ThreatAction.FLAGGED; + if (raidDetected) { + switch (config.action) { + case "kick_new": + action = ThreatAction.KICKED; + break; + case "lockdown": + case "alert": + action = ThreatAction.FLAGGED; + break; + } + } + + return { + detected: raidDetected, + threatType: ThreatType.RAID, + severity, + action, + details: { + joinCount, + windowSeconds: config.windowSeconds, + newAccountCount, + raidModeActive: joinWindow.raidModeActive, + }, + }; +} + +export function isRaidModeActive(guildId: string): boolean { + const window = joinWindowCache.get(guildId); + return window?.raidModeActive ?? false; +} + +export function clearRaidMode(guildId: string): void { + const window = joinWindowCache.get(guildId); + if (window) { + window.raidModeActive = false; + window.raidModeStartedAt = undefined; + joinWindowCache.set(guildId, window); + } +} diff --git a/src/modules/threatDetection/detectors/scamLinkDetector.ts b/src/modules/threatDetection/detectors/scamLinkDetector.ts new file mode 100644 index 00000000..d480aa47 --- /dev/null +++ b/src/modules/threatDetection/detectors/scamLinkDetector.ts @@ -0,0 +1,238 @@ +import type { Message } from "discord.js"; +import { logger } from "../../../logging.js"; +import { + ScamDomain, + ScamDomainCategory, +} from "../../../store/models/ScamDomain.js"; +import { ThreatAction, ThreatType } from "../../../store/models/ThreatLog.js"; + +export interface ScamDetectionResult { + detected: boolean; + threatType: ThreatType; + severity: number; + action: ThreatAction; + details: { + matchedUrls: string[]; + matchedDomains: string[]; + matchReason: "pattern" | "api" | "database"; + }; +} + +const URL_PATTERN = /https?:\/\/[^\s<>[\]{}|\\^`]+/gi; + +const SCAM_PATTERNS = [ + /discord[\W_]*nitro/i, + /free[\W_]*nitro/i, + /steam[\W_]*community[\W_]*gift/i, + /dls[\W_]*?cord/i, + /disc0rd|d1scord|dlscord|disçord|dïscord/i, + /discordgift/i, + /discord-app\./i, + /discordapp\.co[^m]/i, + /steamcommunlty/i, + /stearncommun/i, + /stearncommunity/i, +]; + +const FAKE_DISCORD_DOMAINS = [ + /disc[o0]rd[^.]*\.(com|gg|gift|app|io|me|org)/i, + /dlsc[o0]rd/i, + /discord-nitro/i, + /discordn[i1]tro/i, + /discordgift\./i, + /discord\.gift(?!\.)/i, + /d[i1]sc[o0]rd[^.]*\./i, +]; + +const SHORTENER_DOMAINS = [ + "bit.ly", + "tinyurl.com", + "t.co", + "goo.gl", + "ow.ly", + "is.gd", + "buff.ly", + "adf.ly", + "shorte.st", + "bc.vc", + "j.mp", + "soo.gd", + "s.id", + "cutt.ly", + "rb.gy", +]; + +const SAFE_DOMAINS = [ + "discord.com", + "discord.gg", + "discordapp.com", + "discord.gift", + "discordstatus.com", + "github.com", + "github.io", + "githubusercontent.com", + "stackoverflow.com", + "npmjs.com", + "youtube.com", + "youtu.be", + "twitter.com", + "x.com", + "reddit.com", + "wikipedia.org", + "developer.mozilla.org", + "docs.google.com", + "google.com", +]; + +function extractDomain(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.hostname.toLowerCase(); + } catch { + return null; + } +} + +function isDomainSafe(domain: string): boolean { + return SAFE_DOMAINS.some( + (safe) => domain === safe || domain.endsWith(`.${safe}`), + ); +} + +function isShortenerDomain(domain: string): boolean { + return SHORTENER_DOMAINS.some( + (shortener) => domain === shortener || domain.endsWith(`.${shortener}`), + ); +} + +function matchesScamPattern(url: string): boolean { + return SCAM_PATTERNS.some((pattern) => pattern.test(url)); +} + +function matchesFakeDomain(domain: string): boolean { + return FAKE_DISCORD_DOMAINS.some((pattern) => pattern.test(domain)); +} + +async function checkDatabaseBlocklist( + domain: string, +): Promise { + try { + return await ScamDomain.findOne({ + where: { domain }, + }); + } catch { + return null; + } +} + +async function checkPhishingApi(domain: string): Promise { + try { + const response = await fetch( + `https://phish.sinking.yachts/v2/check/${encodeURIComponent(domain)}`, + { + method: "GET", + headers: { + "X-Identity": "DevDenBot", + }, + signal: AbortSignal.timeout(3000), + }, + ); + if (!response.ok) { + logger.debug(`Phishing API returned ${response.status} for ${domain}`); + return false; + } + const body = await response.text(); + return body === "true"; + } catch (error) { + logger.debug(`Phishing API check failed for ${domain}:`, error); + return false; + } +} + +export async function detectScamLinks( + message: Message, + config: { + useExternalApi: boolean; + blockShorteners: boolean; + safeDomains?: string[]; + }, +): Promise { + const content = message.content; + const urls = content.match(URL_PATTERN) || []; + + const matchedUrls: string[] = []; + const matchedDomains: string[] = []; + let matchReason: "pattern" | "api" | "database" = "pattern"; + let highestSeverity = 0; + + const additionalSafeDomains = config.safeDomains || []; + + for (const url of urls) { + const domain = extractDomain(url); + if (!domain) continue; + + if (isDomainSafe(domain) || additionalSafeDomains.includes(domain)) { + continue; + } + + if (matchesScamPattern(url)) { + matchedUrls.push(url); + matchedDomains.push(domain); + highestSeverity = Math.max(highestSeverity, 0.9); + matchReason = "pattern"; + continue; + } + + if (matchesFakeDomain(domain)) { + matchedUrls.push(url); + matchedDomains.push(domain); + highestSeverity = Math.max(highestSeverity, 0.95); + matchReason = "pattern"; + continue; + } + + const dbEntry = await checkDatabaseBlocklist(domain); + if (dbEntry) { + matchedUrls.push(url); + matchedDomains.push(domain); + highestSeverity = Math.max( + highestSeverity, + dbEntry.category === ScamDomainCategory.PHISHING ? 1 : 0.8, + ); + matchReason = "database"; + continue; + } + + if (config.blockShorteners && isShortenerDomain(domain)) { + matchedUrls.push(url); + matchedDomains.push(domain); + highestSeverity = Math.max(highestSeverity, 0.5); + matchReason = "pattern"; + continue; + } + + if (config.useExternalApi) { + const isPhishing = await checkPhishingApi(domain); + if (isPhishing) { + matchedUrls.push(url); + matchedDomains.push(domain); + highestSeverity = Math.max(highestSeverity, 1.0); + matchReason = "api"; + } + } + } + + const detected = matchedUrls.length > 0; + + return { + detected, + threatType: ThreatType.SCAM_LINK, + severity: highestSeverity, + action: detected ? ThreatAction.DELETED : ThreatAction.FLAGGED, + details: { + matchedUrls, + matchedDomains, + matchReason, + }, + }; +} diff --git a/src/modules/threatDetection/detectors/spamDetector.ts b/src/modules/threatDetection/detectors/spamDetector.ts new file mode 100644 index 00000000..7708fd06 --- /dev/null +++ b/src/modules/threatDetection/detectors/spamDetector.ts @@ -0,0 +1,119 @@ +import type { Message } from "discord.js"; +import ExpiryMap from "expiry-map"; +import { compareTwoStrings } from "string-similarity"; +import { ThreatAction, ThreatType } from "../../../store/models/ThreatLog.js"; + +export interface SpamDetectionResult { + detected: boolean; + threatType: ThreatType; + severity: number; + action: ThreatAction; + details: { + messageCount: number; + windowSeconds: number; + maxSimilarity: number; + duplicateDetected: boolean; + }; +} + +interface UserMessageWindow { + messages: Array<{ + content: string; + timestamp: number; + channelId: string; + }>; +} + +const userMessageCache = new ExpiryMap(60_000); +const MAX_MESSAGES_PER_USER = 50; + +function cleanOldMessages( + window: UserMessageWindow, + windowMs: number, +): UserMessageWindow { + const now = Date.now(); + return { + messages: window.messages + .filter((m) => now - m.timestamp < windowMs) + .slice(-MAX_MESSAGES_PER_USER), + }; +} + +function calculateMaxSimilarity( + newContent: string, + existingMessages: UserMessageWindow["messages"], +): number { + if (existingMessages.length === 0) return 0; + + let maxSimilarity = 0; + for (const msg of existingMessages) { + const similarity = compareTwoStrings( + newContent.toLowerCase(), + msg.content.toLowerCase(), + ); + maxSimilarity = Math.max(maxSimilarity, similarity); + } + return maxSimilarity; +} + +export function detectSpam( + message: Message, + config: { + maxMessagesPerWindow: number; + windowSeconds: number; + duplicateThreshold: number; + action: "delete" | "mute"; + }, +): SpamDetectionResult { + const userId = message.author.id; + const windowMs = config.windowSeconds * 1000; + + let userWindow = userMessageCache.get(userId) || { messages: [] }; + userWindow = cleanOldMessages(userWindow, windowMs); + + const messageCount = userWindow.messages.length + 1; + const maxSimilarity = calculateMaxSimilarity( + message.content, + userWindow.messages, + ); + const duplicateDetected = maxSimilarity >= config.duplicateThreshold; + + userWindow.messages.push({ + content: message.content, + timestamp: Date.now(), + channelId: message.channelId, + }); + userMessageCache.set(userId, userWindow); + + const rateExceeded = messageCount > config.maxMessagesPerWindow; + const detected = rateExceeded || duplicateDetected; + + let severity = 0; + if (rateExceeded) { + severity = Math.min(messageCount / config.maxMessagesPerWindow, 1); + } + if (duplicateDetected) { + severity = Math.max(severity, maxSimilarity); + } + + return { + detected, + threatType: ThreatType.SPAM, + severity, + action: detected + ? config.action === "mute" + ? ThreatAction.MUTED + : ThreatAction.DELETED + : ThreatAction.FLAGGED, + details: { + messageCount, + windowSeconds: config.windowSeconds, + maxSimilarity, + duplicateDetected, + }, + }; +} + +export function clearUserSpamCache(userId: string): void { + userMessageCache.delete(userId); +} diff --git a/src/modules/threatDetection/detectors/toxicContentDetector.ts b/src/modules/threatDetection/detectors/toxicContentDetector.ts new file mode 100644 index 00000000..50c2cd7c --- /dev/null +++ b/src/modules/threatDetection/detectors/toxicContentDetector.ts @@ -0,0 +1,217 @@ +import type { Message } from "discord.js"; +import { + type BlockedWord, + type BlockedWordCategory, + getAllBlockedWords, +} from "../../../store/models/BlockedWord.js"; +import { ThreatAction, ThreatType } from "../../../store/models/ThreatLog.js"; +import { + containsBlockedWord, + normalizeText, +} from "../utils/textNormalization.js"; + +export interface ToxicContentResult { + detected: boolean; + threatType: ThreatType; + severity: number; + action: ThreatAction; + details: { + matchedWord: string | null; + category: BlockedWordCategory | null; + normalizedMatch: string | null; + bypassAttempted: boolean; + }; +} + +// Cache blocked words to avoid repeated database queries +let cachedBlockedWords: BlockedWord[] | null = null; +let cacheExpiry = 0; +const CACHE_TTL = 60_000; // 1 minute cache + +async function getBlockedWordsFromCache(): Promise { + const now = Date.now(); + if (cachedBlockedWords === null || now > cacheExpiry) { + cachedBlockedWords = await getAllBlockedWords(); + cacheExpiry = now + CACHE_TTL; + } + return cachedBlockedWords; +} + +/** + * Invalidate the blocked words cache (call after adding/removing words) + */ +export function invalidateBlockedWordsCache(): void { + cachedBlockedWords = null; + cacheExpiry = 0; +} + +// Severity weights by category +const CATEGORY_SEVERITY: Record = { + SLUR: 1, + HARASSMENT: 0.8, + NSFW: 0.6, + SPAM: 0.4, + OTHER: 0.5, +}; + +export async function detectToxicContent( + message: Message, + config: { + detectBypasses: boolean; + action: "flag" | "delete"; + }, +): Promise { + const blockedWords = await getBlockedWordsFromCache(); + + if (blockedWords.length === 0) { + return { + detected: false, + threatType: ThreatType.TOXIC_CONTENT, + severity: 0, + action: ThreatAction.FLAGGED, + details: { + matchedWord: null, + category: null, + normalizedMatch: null, + bypassAttempted: false, + }, + }; + } + + const content = message.content; + const wordList = blockedWords.map((w) => w.word); + + // Check for bypass attempts by comparing original vs normalized text + const originalLower = content.toLowerCase(); + const normalized = normalizeText(content); + const bypassAttempted = originalLower !== normalized; + + const result = containsBlockedWord(content, wordList); + + if (result.found && result.word) { + const matchedBlockedWord = blockedWords.find( + (w) => w.word === result.word?.toLowerCase(), + ); + const category = matchedBlockedWord?.category ?? null; + const severity = + category !== null + ? CATEGORY_SEVERITY[category as BlockedWordCategory] + : 0.5; + + // If bypasses aren't being detected and one was used, don't flag + if (bypassAttempted && !config.detectBypasses) { + // Only detect if the original text contains the word + if (!originalLower.includes(result.word.toLowerCase())) { + return { + detected: false, + threatType: ThreatType.TOXIC_CONTENT, + severity: 0, + action: ThreatAction.FLAGGED, + details: { + matchedWord: null, + category: null, + normalizedMatch: null, + bypassAttempted, + }, + }; + } + } + + return { + detected: true, + threatType: ThreatType.TOXIC_CONTENT, + severity, + action: + config.action === "delete" + ? ThreatAction.DELETED + : ThreatAction.FLAGGED, + details: { + matchedWord: result.word, + category, + normalizedMatch: result.matched || null, + bypassAttempted, + }, + }; + } + + return { + detected: false, + threatType: ThreatType.TOXIC_CONTENT, + severity: 0, + action: ThreatAction.FLAGGED, + details: { + matchedWord: null, + category: null, + normalizedMatch: null, + bypassAttempted, + }, + }; +} + +/** + * Test a string against the blocked wordlist without taking action + * Useful for the /wordlist test command + */ +export async function testForToxicContent( + text: string, + detectBypasses = true, +): Promise<{ + detected: boolean; + matchedWord: string | null; + category: BlockedWordCategory | null; + normalizedMatch: string | null; + bypassAttempted: boolean; +}> { + const blockedWords = await getBlockedWordsFromCache(); + + if (blockedWords.length === 0) { + return { + detected: false, + matchedWord: null, + category: null, + normalizedMatch: null, + bypassAttempted: false, + }; + } + + const wordList = blockedWords.map((w) => w.word); + const originalLower = text.toLowerCase(); + const normalized = normalizeText(text); + const bypassAttempted = originalLower !== normalized; + + const result = containsBlockedWord(text, wordList); + + if (result.found && result.word) { + const matchedBlockedWord = blockedWords.find( + (w) => w.word === result.word?.toLowerCase(), + ); + + if (bypassAttempted && !detectBypasses) { + if (!originalLower.includes(result.word.toLowerCase())) { + return { + detected: false, + matchedWord: null, + category: null, + normalizedMatch: null, + bypassAttempted, + }; + } + } + + return { + detected: true, + matchedWord: result.word, + category: matchedBlockedWord?.category || null, + normalizedMatch: result.matched || null, + bypassAttempted, + }; + } + + return { + detected: false, + matchedWord: null, + category: null, + normalizedMatch: null, + bypassAttempted, + }; +} diff --git a/src/modules/threatDetection/listeners/memberJoin.listener.ts b/src/modules/threatDetection/listeners/memberJoin.listener.ts new file mode 100644 index 00000000..432d843a --- /dev/null +++ b/src/modules/threatDetection/listeners/memberJoin.listener.ts @@ -0,0 +1,157 @@ +import * as Sentry from "@sentry/bun"; +import type { GuildMember } from "discord.js"; +import { config } from "../../../Config.js"; +import { logger } from "../../../logging.js"; +import { ThreatLog } from "../../../store/models/ThreatLog.js"; +import type { EventListener } from "../../module.js"; +import { + type AccountAnalysisResult, + analyzeAccount, +} from "../detectors/accountAnalyzer.js"; +import { + detectRaid, + isRaidModeActive, + type RaidDetectionResult, +} from "../detectors/raidDetector.js"; +import { logThreatAction } from "../logs.js"; + +async function logThreatToDatabase( + userId: bigint, + result: RaidDetectionResult | AccountAnalysisResult, +): Promise { + try { + await ThreatLog.create({ + userId, + threatType: result.threatType, + severity: result.severity, + actionTaken: result.action, + messageContent: null, + messageId: null, + channelId: null, + metadata: result.details as Record, + }); + } catch (error) { + logger.error("Failed to log threat to database:", error); + Sentry.captureException(error); + } +} + +async function handleRaidDetection( + member: GuildMember, + result: RaidDetectionResult, +): Promise { + try { + await logThreatToDatabase(BigInt(member.id), result); + + await logThreatAction(member.client, { + kind: "RaidAlert", + joinCount: result.details.joinCount, + windowSeconds: result.details.windowSeconds, + newAccountCount: result.details.newAccountCount, + raidModeActivated: result.details.raidModeActive, + }); + + if ( + config.threatDetection?.raid?.action === "kick_new" && + result.details.raidModeActive + ) { + const accountAgeDays = + (Date.now() - member.user.createdTimestamp) / (1000 * 60 * 60 * 24); + if ( + accountAgeDays < (config.threatDetection.raid.newAccountThreshold || 7) + ) { + try { + await member.kick("Auto-kicked: Raid protection - new account"); + } catch (kickError) { + logger.warn( + `Failed to kick member ${member.id} during raid protection:`, + kickError, + ); + } + } + } + } catch (error) { + logger.error("Failed to handle raid detection:", error); + Sentry.captureException(error); + } +} + +async function handleSuspiciousAccount( + member: GuildMember, + result: AccountAnalysisResult, +): Promise { + try { + await logThreatToDatabase(BigInt(member.id), result); + + await logThreatAction(member.client, { + kind: "SuspiciousAccount", + target: member.user, + accountAgeDays: result.details.accountAgeDays, + reasons: result.details.reasons, + }); + + if (config.threatDetection?.suspiciousAccounts?.action === "kick") { + try { + await member.kick( + `Auto-kicked: Suspicious account - ${result.details.reasons.join(", ")}`, + ); + } catch (kickError) { + logger.warn( + `Failed to kick suspicious account ${member.id}:`, + kickError, + ); + } + } + } catch (error) { + logger.error("Failed to handle suspicious account:", error); + Sentry.captureException(error); + } +} + +export const MemberJoinListener: EventListener = { + async guildMemberAdd(_, member) { + if (!config.threatDetection?.enabled) return; + + if (config.threatDetection.raid?.enabled) { + const raidResult = detectRaid(member, { + maxJoinsPerWindow: config.threatDetection.raid.maxJoinsPerWindow ?? 10, + windowSeconds: config.threatDetection.raid.windowSeconds ?? 60, + newAccountThreshold: + config.threatDetection.raid.newAccountThreshold ?? 7, + action: config.threatDetection.raid.action ?? "alert", + }); + + if (raidResult.detected) { + await handleRaidDetection(member, raidResult); + } + } + + if (config.threatDetection.suspiciousAccounts?.enabled) { + const raidMode = isRaidModeActive(member.guild.id); + const shouldAnalyze = + raidMode || + config.threatDetection.suspiciousAccounts.minAgeDays > 0 || + config.threatDetection.suspiciousAccounts.flagDefaultAvatar || + config.threatDetection.suspiciousAccounts.flagSuspiciousNames; + + if (shouldAnalyze) { + const accountResult = analyzeAccount(member, { + minAgeDays: config.threatDetection.suspiciousAccounts.minAgeDays ?? 7, + flagDefaultAvatar: + config.threatDetection.suspiciousAccounts.flagDefaultAvatar ?? + false, + flagSuspiciousNames: + config.threatDetection.suspiciousAccounts.flagSuspiciousNames ?? + false, + suspiciousNamePatterns: + config.threatDetection.suspiciousAccounts.suspiciousNamePatterns, + action: config.threatDetection.suspiciousAccounts.action ?? "flag", + }); + + if (accountResult.detected) { + await handleSuspiciousAccount(member, accountResult); + } + } + } + }, +}; diff --git a/src/modules/threatDetection/listeners/messageAnalysis.listener.ts b/src/modules/threatDetection/listeners/messageAnalysis.listener.ts new file mode 100644 index 00000000..5022f3e0 --- /dev/null +++ b/src/modules/threatDetection/listeners/messageAnalysis.listener.ts @@ -0,0 +1,330 @@ +import * as Sentry from "@sentry/bun"; +import type { GuildMember, Message } from "discord.js"; +import { config } from "../../../Config.js"; +import { logger } from "../../../logging.js"; +import { ReputationEventType } from "../../../store/models/ReputationEvent.js"; +import { ThreatAction, ThreatLog } from "../../../store/models/ThreatLog.js"; +import { getMember } from "../../../util/member.js"; +import { actualMention, isSpecialUser } from "../../../util/users.js"; +import { deductReputation } from "../../moderation/reputation.service.js"; +import type { EventListener } from "../../module.js"; +import { + detectMentionSpam, + type MentionSpamResult, +} from "../detectors/mentionSpamDetector.js"; +import { + detectScamLinks, + type ScamDetectionResult, +} from "../detectors/scamLinkDetector.js"; +import { + detectSpam, + type SpamDetectionResult, +} from "../detectors/spamDetector.js"; +import { + detectToxicContent, + type ToxicContentResult, +} from "../detectors/toxicContentDetector.js"; +import { logThreatAction } from "../logs.js"; + +type DetectionResult = + | ScamDetectionResult + | SpamDetectionResult + | MentionSpamResult + | ToxicContentResult; + +function isExemptRole(member: GuildMember): boolean { + const exemptRoles = config.threatDetection?.exemptRoles || []; + return exemptRoles.some((roleId) => member.roles.cache.has(roleId)); +} + +async function logThreatToDatabase( + userId: bigint, + result: DetectionResult, + message: Message, +): Promise { + try { + await ThreatLog.create({ + userId, + threatType: result.threatType, + severity: result.severity, + actionTaken: result.action, + messageContent: message.content.slice(0, 4000), + messageId: BigInt(message.id), + channelId: BigInt(message.channelId), + metadata: result.details as Record, + }); + } catch (error) { + logger.error("Failed to log threat to database:", error); + Sentry.captureException(error); + } +} + +async function handleScamDetection( + message: Message, + member: GuildMember, + result: ScamDetectionResult, + wasEdit: boolean, +): Promise { + try { + try { + await message.delete(); + } catch (deleteError) { + logger.warn(`Failed to delete scam message ${message.id}:`, deleteError); + } + + const warningMessage = await message.channel.send({ + content: `${actualMention(member)}, your message was removed because it contained a potentially malicious link. If you believe this was a mistake, please contact a moderator.`, + }); + + setTimeout(() => { + warningMessage.delete().catch(() => {}); + }, 15000); + + await logThreatToDatabase(BigInt(member.id), result, message); + + await logThreatAction(message.client, { + kind: "ScamLinkDetected", + target: member.user, + messageId: message.id, + messageCreatedTimestamp: message.createdTimestamp, + edited: wasEdit, + matchedUrls: result.details.matchedUrls, + matchedDomains: result.details.matchedDomains, + matchReason: result.details.matchReason, + severity: result.severity, + }); + } catch (error) { + logger.error("Failed to handle scam detection:", error); + Sentry.captureException(error); + } +} + +async function handleSpamDetection( + message: Message, + member: GuildMember, + result: SpamDetectionResult, +): Promise { + try { + try { + await message.delete(); + } catch (deleteError) { + logger.warn(`Failed to delete spam message ${message.id}:`, deleteError); + } + + if (result.action === ThreatAction.MUTED) { + const muteDuration = config.threatDetection?.spam?.muteDuration ?? 300000; + try { + await member.timeout(muteDuration, "Auto-mute: Spam detection"); + } catch (timeoutError) { + logger.warn( + `Failed to timeout member ${member.id} for spam:`, + timeoutError, + ); + } + } + + // Deduct reputation for spam + await deductReputation( + BigInt(member.id), + ReputationEventType.SPAM_DELETED, + "Spam message deleted", + ); + + await logThreatToDatabase(BigInt(member.id), result, message); + + await logThreatAction(message.client, { + kind: "SpamDetected", + target: member.user, + messageCount: result.details.messageCount, + windowSeconds: result.details.windowSeconds, + action: result.action, + }); + } catch (error) { + logger.error("Failed to handle spam detection:", error); + Sentry.captureException(error); + } +} + +async function handleMentionSpamDetection( + message: Message, + member: GuildMember, + result: MentionSpamResult, +): Promise { + try { + try { + await message.delete(); + } catch (deleteError) { + logger.warn( + `Failed to delete mention spam message ${message.id}:`, + deleteError, + ); + } + + if (result.action === ThreatAction.MUTED) { + const muteDuration = + config.threatDetection?.mentionSpam?.windowSeconds ?? 60; + try { + await member.timeout( + muteDuration * 1000 * 5, + "Auto-mute: Mention spam", + ); + } catch (timeoutError) { + logger.warn( + `Failed to timeout member ${member.id} for mention spam:`, + timeoutError, + ); + } + } + + const warningMessage = await message.channel.send({ + content: `${actualMention(member)}, please avoid mass mentioning users.`, + }); + + setTimeout(() => { + warningMessage.delete().catch(() => {}); + }, 10000); + + await logThreatToDatabase(BigInt(member.id), result, message); + + await logThreatAction(message.client, { + kind: "SpamDetected", + target: member.user, + messageCount: result.details.mentionsInMessage, + windowSeconds: result.details.windowSeconds, + action: result.action, + }); + } catch (error) { + logger.error("Failed to handle mention spam detection:", error); + Sentry.captureException(error); + } +} + +async function handleToxicContentDetection( + message: Message, + member: GuildMember, + result: ToxicContentResult, +): Promise { + try { + if (result.action === ThreatAction.DELETED) { + try { + await message.delete(); + } catch (deleteError) { + logger.warn( + `Failed to delete toxic message ${message.id}:`, + deleteError, + ); + } + + const warningMessage = await message.channel.send({ + content: `${actualMention(member)}, your message was removed for containing inappropriate content.`, + }); + + setTimeout(() => { + warningMessage.delete().catch(() => {}); + }, 10000); + + // Deduct reputation for toxic content + await deductReputation( + BigInt(member.id), + ReputationEventType.TOXIC_CONTENT, + `Toxic content detected: ${result.details.category || "unknown category"}`, + ); + } + + await logThreatToDatabase(BigInt(member.id), result, message); + + await logThreatAction(message.client, { + kind: "ToxicContentDetected", + target: member.user, + matchedWord: result.details.matchedWord, + category: result.details.category, + bypassAttempted: result.details.bypassAttempted, + action: result.action, + }); + } catch (error) { + logger.error("Failed to handle toxic content detection:", error); + Sentry.captureException(error); + } +} + +async function analyzeMessage( + message: Message, + wasEdit: boolean, +): Promise { + if (message.author.bot || !message.inGuild()) return; + if (!config.threatDetection?.enabled) return; + + const member = await getMember(message); + if (!member) return; + if (isSpecialUser(member) || isExemptRole(member)) return; + + if (config.threatDetection.scamLinks?.enabled) { + const scamResult = await detectScamLinks(message, { + useExternalApi: config.threatDetection.scamLinks.useExternalApi ?? true, + blockShorteners: + config.threatDetection.scamLinks.blockShorteners ?? false, + safeDomains: config.threatDetection.scamLinks.safeDomains, + }); + + if (scamResult.detected) { + await handleScamDetection(message, member, scamResult, wasEdit); + return; + } + } + + if (config.threatDetection.spam?.enabled && !wasEdit) { + const spamResult = detectSpam(message, { + maxMessagesPerWindow: + config.threatDetection.spam.maxMessagesPerWindow ?? 5, + windowSeconds: config.threatDetection.spam.windowSeconds ?? 10, + duplicateThreshold: config.threatDetection.spam.duplicateThreshold ?? 0.8, + action: config.threatDetection.spam.action ?? "delete", + }); + + if (spamResult.detected) { + await handleSpamDetection(message, member, spamResult); + return; + } + } + + if (config.threatDetection.mentionSpam?.enabled) { + const mentionResult = detectMentionSpam(message, { + maxMentionsPerMessage: + config.threatDetection.mentionSpam.maxMentionsPerMessage ?? 5, + maxMentionsPerWindow: + config.threatDetection.mentionSpam.maxMentionsPerWindow ?? 10, + windowSeconds: config.threatDetection.mentionSpam.windowSeconds ?? 60, + action: config.threatDetection.mentionSpam.action ?? "delete", + }); + + if (mentionResult.detected) { + await handleMentionSpamDetection(message, member, mentionResult); + return; + } + } + + if (config.threatDetection.toxicContent?.enabled) { + const toxicResult = await detectToxicContent(message, { + detectBypasses: + config.threatDetection.toxicContent.detectBypasses ?? true, + action: config.threatDetection.toxicContent.action ?? "flag", + }); + + if (toxicResult.detected) { + await handleToxicContentDetection(message, member, toxicResult); + return; + } + } +} + +export const MessageAnalysisListener: EventListener = { + async messageCreate(_, message) { + await analyzeMessage(message, false); + }, + + async messageUpdate(_, _oldMessage, message) { + if (!message.partial) { + await analyzeMessage(message, true); + } + }, +}; diff --git a/src/modules/threatDetection/logs.ts b/src/modules/threatDetection/logs.ts new file mode 100644 index 00000000..710110d6 --- /dev/null +++ b/src/modules/threatDetection/logs.ts @@ -0,0 +1,163 @@ +import { + type Client, + type Colors, + EmbedBuilder, + type Snowflake, + type User, +} from "discord.js"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { actualMention, fakeMention } from "../../util/users.js"; + +export type ThreatLog = + | ScamLinkDetectedLog + | RaidAlertLog + | SpamDetectedLog + | ToxicContentDetectedLog + | SuspiciousAccountLog; + +interface ScamLinkDetectedLog { + kind: "ScamLinkDetected"; + target: User; + messageId: Snowflake; + messageCreatedTimestamp: number; + edited: boolean; + matchedUrls: string[]; + matchedDomains: string[]; + matchReason: "pattern" | "api" | "database"; + severity: number; +} + +interface RaidAlertLog { + kind: "RaidAlert"; + joinCount: number; + windowSeconds: number; + newAccountCount: number; + raidModeActivated: boolean; +} + +interface SpamDetectedLog { + kind: "SpamDetected"; + target: User; + messageCount: number; + windowSeconds: number; + action: string; +} + +interface ToxicContentDetectedLog { + kind: "ToxicContentDetected"; + target: User; + matchedWord: string | null; + category: string | null; + bypassAttempted: boolean; + action: string; +} + +interface SuspiciousAccountLog { + kind: "SuspiciousAccount"; + target: User; + accountAgeDays: number; + reasons: string[]; +} + +type ThreatKindMapping = { + [f in ThreatLog["kind"]]: T; +}; + +const embedTitles: ThreatKindMapping = { + ScamLinkDetected: "Scam Link Detected", + RaidAlert: "Raid Alert", + SpamDetected: "Spam Detected", + ToxicContentDetected: "Toxic Content Detected", + SuspiciousAccount: "Suspicious Account", +}; + +const embedColors: ThreatKindMapping = { + ScamLinkDetected: "Red", + RaidAlert: "DarkRed", + SpamDetected: "Orange", + ToxicContentDetected: "DarkOrange", + SuspiciousAccount: "Yellow", +}; + +const embedDescriptions: { + [K in ThreatLog["kind"]]?: (t: Extract) => string; +} = { + ScamLinkDetected: (log) => { + let desc = `Message at ${log.edited ? "was edited to contain" : "contained"} a scam link!\n\n`; + desc += `**Matched URLs:** \`${log.matchedUrls.join("`, `")}\`\n`; + desc += `**Matched Domains:** \`${log.matchedDomains.join("`, `")}\`\n`; + desc += `**Detection Method:** ${log.matchReason}\n`; + desc += `**Severity:** ${(log.severity * 100).toFixed(0)}%`; + return desc; + }, + RaidAlert: (log) => + `**${log.joinCount} users joined** in ${log.windowSeconds} seconds!\n` + + `**New accounts (<7 days):** ${log.newAccountCount}\n` + + `**Raid mode activated:** ${log.raidModeActivated ? "Yes" : "No"}`, + SpamDetected: (log) => + `Sent **${log.messageCount} messages** in ${log.windowSeconds} seconds.\n` + + `**Action taken:** ${log.action}`, + ToxicContentDetected: (log) => { + let desc = `Message contained toxic content.\n`; + if (log.matchedWord) { + desc += `**Matched word:** ||${log.matchedWord}||\n`; + } + if (log.category) { + desc += `**Category:** ${log.category}\n`; + } + if (log.bypassAttempted) { + desc += `**Bypass attempted:** Yes\n`; + } + desc += `**Action taken:** ${log.action}`; + return desc; + }, + SuspiciousAccount: (log) => + `Account is **${log.accountAgeDays.toFixed(1)} days old**.\n` + + `**Reasons:** ${log.reasons.join(", ")}`, +}; + +export async function logThreatAction( + client: Client, + action: ThreatLog, +): Promise { + const alertChannel = config.threatDetection?.alertChannel; + const channelId = alertChannel || config.channels.modLog; + + const channel = await client.channels.fetch(channelId); + if (!channel) { + logger.error("Threat log channel does not exist"); + return; + } + + if (!channel.isSendable()) { + logger.error("Threat log channel is not sendable"); + return; + } + + const embed = new EmbedBuilder(); + embed.setTitle(embedTitles[action.kind]); + embed.setColor(embedColors[action.kind]); + + let description = ""; + + if ("target" in action) { + const targetUser = await client.users + .fetch(action.target) + .catch(() => null); + description += `**User:** ${targetUser && fakeMention(targetUser)} ${actualMention(action.target)}\n\n`; + } + + const descriptionFn = embedDescriptions[action.kind]; + if (descriptionFn) { + // biome-ignore lint/suspicious/noExplicitAny: type safety ensured by kind + description += descriptionFn(action as any); + } + + embed.setDescription(description); + embed.setTimestamp(); + + await channel.send({ + embeds: [embed], + }); +} diff --git a/src/modules/threatDetection/threatDetection.module.ts b/src/modules/threatDetection/threatDetection.module.ts new file mode 100644 index 00000000..12acb0dc --- /dev/null +++ b/src/modules/threatDetection/threatDetection.module.ts @@ -0,0 +1,9 @@ +import type Module from "../module.js"; +import { MemberJoinListener } from "./listeners/memberJoin.listener.js"; +import { MessageAnalysisListener } from "./listeners/messageAnalysis.listener.js"; + +export const ThreatDetectionModule: Module = { + name: "threatDetection", + commands: [], + listeners: [MessageAnalysisListener, MemberJoinListener], +}; diff --git a/src/modules/threatDetection/utils/textNormalization.ts b/src/modules/threatDetection/utils/textNormalization.ts new file mode 100644 index 00000000..48eef0bc --- /dev/null +++ b/src/modules/threatDetection/utils/textNormalization.ts @@ -0,0 +1,249 @@ +/** + * Text normalization utilities for detecting bypass attempts + * Handles l33tspeak, zalgo text, homoglyphs, and other evasion techniques + */ + +// L33tspeak character mappings +const LEET_MAP: Record = { + "0": "o", + "1": "i", + "2": "z", + "3": "e", + "4": "a", + "5": "s", + "6": "g", + "7": "t", + "8": "b", + "9": "g", + "@": "a", + $: "s", + "!": "i", + "|": "i", + "+": "t", + "(": "c", + ")": "d", + "[": "c", + "]": "d", + "{": "c", + "}": "d", + "<": "c", + ">": "d", + "*": "a", + "#": "h", + "%": "x", + "^": "a", + "&": "e", +}; + +// Common homoglyphs (look-alike characters) +const HOMOGLYPHS: Record = { + // Cyrillic + "\u0430": "a", // а + "\u0435": "e", // е + "\u043E": "o", // о + "\u0440": "p", // р + "\u0441": "c", // с + "\u0443": "y", // у + "\u0445": "x", // х + "\u0456": "i", // і + "\u0458": "j", // ј + "\u04CF": "i", // ӏ + // Greek + "\u03B1": "a", // α + "\u03B5": "e", // ε + "\u03B9": "i", // ι + "\u03BF": "o", // ο + "\u03C1": "p", // ρ + "\u03C5": "u", // υ + "\u03C9": "w", // ω + // Special characters + "\u00E0": "a", + "\u00E1": "a", + "\u00E2": "a", + "\u00E3": "a", + "\u00E4": "a", + "\u00E5": "a", + "\u00E8": "e", + "\u00E9": "e", + "\u00EA": "e", + "\u00EB": "e", + "\u00EC": "i", + "\u00ED": "i", + "\u00EE": "i", + "\u00EF": "i", + "\u00F2": "o", + "\u00F3": "o", + "\u00F4": "o", + "\u00F5": "o", + "\u00F6": "o", + "\u00F9": "u", + "\u00FA": "u", + "\u00FB": "u", + "\u00FC": "u", + "\u00FD": "y", + "\u00FF": "y", + "\u00F1": "n", + "\u00DF": "ss", + "\u00E6": "ae", + "\u0153": "oe", + // Full-width characters + "\uFF41": "a", + "\uFF42": "b", + "\uFF43": "c", + "\uFF44": "d", + "\uFF45": "e", + "\uFF46": "f", + "\uFF47": "g", + "\uFF48": "h", + "\uFF49": "i", + "\uFF4A": "j", + "\uFF4B": "k", + "\uFF4C": "l", + "\uFF4D": "m", + "\uFF4E": "n", + "\uFF4F": "o", + "\uFF50": "p", + "\uFF51": "q", + "\uFF52": "r", + "\uFF53": "s", + "\uFF54": "t", + "\uFF55": "u", + "\uFF56": "v", + "\uFF57": "w", + "\uFF58": "x", + "\uFF59": "y", + "\uFF5A": "z", +}; + +/** + * Remove zalgo/combining diacritical marks + */ +export function removeZalgo(text: string): string { + // Remove combining diacritical marks (U+0300 to U+036F) + // and other combining marks + return text.normalize("NFD").replace(/[\u0300-\u036f\u0489]/g, ""); +} + +/** + * Convert l33tspeak to normal text + */ +export function decodeLeetspeak(text: string): string { + return text + .split("") + .map((char) => LEET_MAP[char] || char) + .join(""); +} + +/** + * Convert homoglyphs to ASCII equivalents + */ +export function normalizeHomoglyphs(text: string): string { + return text + .split("") + .map((char) => HOMOGLYPHS[char] || char) + .join(""); +} + +/** + * Remove repeated characters (e.g., "helllllo" -> "helo") + * Keeps at most 2 consecutive identical characters + */ +export function removeExcessiveRepeats(text: string): string { + return text.replace(/(.)\1{2,}/g, "$1$1"); +} + +/** + * Remove spaces and common separators used to bypass filters + */ +export function removeSeparators(text: string): string { + return text.replace(/[\s._\-*~`'"]/g, ""); +} + +/** + * Remove invisible characters + */ +export function removeInvisibleChars(text: string): string { + // Remove zero-width characters and other invisible unicode + return text.replace( + /[\u200B-\u200D\uFEFF\u00AD\u2060\u180E\u2800\u3164]/g, + "", + ); +} + +/** + * Fully normalize text for toxic content detection + * Applies all normalization techniques + */ +export function normalizeText(text: string): string { + let normalized = text.toLowerCase(); + normalized = removeInvisibleChars(normalized); + normalized = removeZalgo(normalized); + normalized = normalizeHomoglyphs(normalized); + normalized = decodeLeetspeak(normalized); + normalized = removeExcessiveRepeats(normalized); + return normalized; +} + +/** + * Generate variations of a word for matching + * Returns the original plus normalized versions + */ +export function generateWordVariations(word: string): string[] { + const variations = new Set(); + + // Original lowercase + variations.add(word.toLowerCase()); + + // Fully normalized + variations.add(normalizeText(word)); + + // Without separators + variations.add(removeSeparators(normalizeText(word))); + + return [...variations]; +} + +/** + * Check if text contains a blocked word, accounting for bypass attempts + */ +export function containsBlockedWord( + text: string, + blockedWords: string[], +): { found: boolean; word?: string; matched?: string } { + const normalizedText = normalizeText(text); + const textWithoutSeparators = removeSeparators(normalizedText); + + for (const blockedWord of blockedWords) { + const normalizedBlocked = normalizeText(blockedWord); + + // Check in normalized text + if (normalizedText.includes(normalizedBlocked)) { + return { found: true, word: blockedWord, matched: normalizedBlocked }; + } + + // Check in text without separators + if (textWithoutSeparators.includes(normalizedBlocked)) { + return { found: true, word: blockedWord, matched: normalizedBlocked }; + } + + // Word boundary check for short words to avoid false positives + if (normalizedBlocked.length <= 3) { + const wordBoundaryRegex = new RegExp( + `\\b${escapeRegex(normalizedBlocked)}\\b`, + "i", + ); + if ( + wordBoundaryRegex.test(normalizedText) || + wordBoundaryRegex.test(textWithoutSeparators) + ) { + return { found: true, word: blockedWord, matched: normalizedBlocked }; + } + } + } + + return { found: false }; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/modules/xp/xpForMessage.util.ts b/src/modules/xp/xpForMessage.util.ts index b8853242..d1ec9687 100644 --- a/src/modules/xp/xpForMessage.util.ts +++ b/src/modules/xp/xpForMessage.util.ts @@ -6,6 +6,10 @@ import type { Config } from "../../config.type.js"; import { logger } from "../../logging.js"; import { getOrCreateUserById } from "../../store/models/DDUser.js"; import { compose } from "../../util/functions.js"; +import { + getReputationTier, + getXpModifier, +} from "../moderation/reputation.service.js"; import { levelUp } from "./xpRoles.util.js"; const pingRegex = /<[a-zA-Z0-9@:&!#]+?[0-9]+>/g; @@ -119,17 +123,19 @@ export function tierRoleId(level: number): string { * Result of giving a member XP * @param xpGiven The amount of XP given * @param multiplier The multiplier used. If undefined, no multiplier was used, i.e. the multiplier was 1 + * @param reputationModifier The reputation-based modifier applied */ export interface XPResult { xpGiven: number; multiplier?: number; + reputationModifier?: number; } /** * Gives XP to a member * @param user the member to give XP to * @param xp the amount of XP to give - * @returns How much XP was given. This may be affected by perks such as boosting. If something went wrong, -1 will be returned. + * @returns How much XP was given. This may be affected by perks such as boosting or reputation. If something went wrong, -1 will be returned. */ export const giveXp = async ( user: GuildMember, @@ -142,13 +148,35 @@ export const giveXp = async ( const client = user.client; const ddUser = await getOrCreateUserById(BigInt(user.id)); - const multiplier = user.premiumSince != null ? 2 : 1; - ddUser.xp += BigInt(xp * multiplier); + // Calculate reputation-based XP modifier + const reputationTier = getReputationTier(ddUser.reputationScore); + const reputationModifier = getXpModifier(reputationTier); + + // If reputation modifier is 0 (Restricted tier), no XP is given + if (reputationModifier === 0) { + logger.debug( + `User ${user.id} is in Restricted reputation tier, no XP given`, + ); + return { + xpGiven: 0, + reputationModifier: 0, + }; + } + + const boostMultiplier = user.premiumSince != null ? 2 : 1; + const totalMultiplier = boostMultiplier * reputationModifier; + const finalXp = Math.round(xp * totalMultiplier); + + ddUser.xp += BigInt(finalXp); await Promise.all([levelUp(client, user, ddUser), ddUser.save()]); - logger.info(`Gave ${xp} XP to user ${user.id}`); + logger.info( + `Gave ${finalXp} XP to user ${user.id} (base: ${xp}, boost: ${boostMultiplier}x, rep: ${reputationModifier}x)`, + ); return { - xpGiven: xp, - multiplier: multiplier === 1 ? undefined : multiplier, - }; // A multiplier of 1 means no multiplier was used + xpGiven: finalXp, + multiplier: boostMultiplier === 1 ? undefined : boostMultiplier, + reputationModifier: + reputationModifier === 1 ? undefined : reputationModifier, + }; }, ); diff --git a/src/store/models/BlockedWord.ts b/src/store/models/BlockedWord.ts new file mode 100644 index 00000000..4e80d0af --- /dev/null +++ b/src/store/models/BlockedWord.ts @@ -0,0 +1,86 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + Attribute, + AutoIncrement, + Default, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; + +export enum BlockedWordCategory { + SLUR = "SLUR", + HARASSMENT = "HARASSMENT", + SPAM = "SPAM", + NSFW = "NSFW", + OTHER = "OTHER", +} + +@Table({ tableName: "BlockedWords" }) +export class BlockedWord extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + public declare id: CreationOptional; + + @Attribute(DataTypes.STRING(100)) + @NotNull + public declare word: string; + + @Attribute(DataTypes.STRING(20)) + @NotNull + @Default(BlockedWordCategory.OTHER) + public declare category: CreationOptional; + + @Attribute(RealBigInt) + @NotNull + public declare addedBy: bigint; + + @Attribute(DataTypes.DATE) + @Default(DataTypes.NOW) + public declare createdAt: CreationOptional; +} + +export async function getAllBlockedWords(): Promise { + return BlockedWord.findAll(); +} + +export async function getBlockedWordsByCategory( + category: BlockedWordCategory, +): Promise { + return BlockedWord.findAll({ where: { category } }); +} + +export async function addBlockedWord( + word: string, + category: BlockedWordCategory, + addedBy: bigint, +): Promise { + const normalized = word.toLowerCase().trim(); + const [blockedWord] = await BlockedWord.findOrCreate({ + where: { word: normalized }, + defaults: { word: normalized, category, addedBy }, + }); + return blockedWord; +} + +export async function removeBlockedWord(word: string): Promise { + const normalized = word.toLowerCase().trim(); + const deleted = await BlockedWord.destroy({ where: { word: normalized } }); + return deleted > 0; +} + +export async function isWordBlocked(word: string): Promise { + const normalized = word.toLowerCase().trim(); + return BlockedWord.findOne({ where: { word: normalized } }); +} diff --git a/src/store/models/DDUser.ts b/src/store/models/DDUser.ts index 1eb8bcc8..01b1d89f 100644 --- a/src/store/models/DDUser.ts +++ b/src/store/models/DDUser.ts @@ -9,6 +9,7 @@ import { import { AllowNull, Attribute, + Default, NotNull, PrimaryKey, Table, @@ -47,6 +48,14 @@ export class DDUser extends Model< @Attribute(DataTypes.DATE) public declare lastDailyTime: Date | null; + @Attribute(DataTypes.INTEGER) + @Default(0) + public declare reputationScore: number; + + @AllowNull + @Attribute(DataTypes.DATE) + public declare lastReputationUpdate: Date | null; + override async save(options?: SaveOptions): Promise { return await Sentry.startSpan( { @@ -125,6 +134,7 @@ export const getOrCreateUserById = async (id: bigint) => bumps: 0, currentDailyStreak: 0, highestDailyStreak: 0, + reputationScore: 0, }, benchmark: true, }); diff --git a/src/store/models/ReputationEvent.ts b/src/store/models/ReputationEvent.ts new file mode 100644 index 00000000..2a1926e3 --- /dev/null +++ b/src/store/models/ReputationEvent.ts @@ -0,0 +1,178 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + Op, +} from "@sequelize/core"; +import { + Attribute, + AutoIncrement, + Default, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; + +export enum ReputationEventType { + // Positive events + HELPED_USER = "HELPED_USER", + QUALITY_CONTRIBUTION = "QUALITY_CONTRIBUTION", + VALID_REPORT = "VALID_REPORT", + STARBOARD_MESSAGE = "STARBOARD_MESSAGE", + + // Negative events + WARNING_MINOR = "WARNING_MINOR", + WARNING_MODERATE = "WARNING_MODERATE", + WARNING_SEVERE = "WARNING_SEVERE", + TIMEOUT = "TIMEOUT", + SPAM_DELETED = "SPAM_DELETED", + TOXIC_CONTENT = "TOXIC_CONTENT", + + // Manual adjustments + MANUAL_GRANT = "MANUAL_GRANT", + MANUAL_DEDUCT = "MANUAL_DEDUCT", +} + +// Score values for each event type +export const REPUTATION_SCORES: Record = { + [ReputationEventType.HELPED_USER]: 10, + [ReputationEventType.QUALITY_CONTRIBUTION]: 15, + [ReputationEventType.VALID_REPORT]: 20, + [ReputationEventType.STARBOARD_MESSAGE]: 5, + + [ReputationEventType.WARNING_MINOR]: -20, + [ReputationEventType.WARNING_MODERATE]: -40, + [ReputationEventType.WARNING_SEVERE]: -75, + [ReputationEventType.TIMEOUT]: -15, + [ReputationEventType.SPAM_DELETED]: -5, + [ReputationEventType.TOXIC_CONTENT]: -10, + + [ReputationEventType.MANUAL_GRANT]: 0, // Custom amount + [ReputationEventType.MANUAL_DEDUCT]: 0, // Custom amount +}; + +export const REPUTATION_EVENT_LABELS: Record = { + [ReputationEventType.HELPED_USER]: "Helped User", + [ReputationEventType.QUALITY_CONTRIBUTION]: "Quality Contribution", + [ReputationEventType.VALID_REPORT]: "Valid Report", + [ReputationEventType.STARBOARD_MESSAGE]: "Starboard Message", + + [ReputationEventType.WARNING_MINOR]: "Warning (Minor)", + [ReputationEventType.WARNING_MODERATE]: "Warning (Moderate)", + [ReputationEventType.WARNING_SEVERE]: "Warning (Severe)", + [ReputationEventType.TIMEOUT]: "Timeout", + [ReputationEventType.SPAM_DELETED]: "Spam Deleted", + [ReputationEventType.TOXIC_CONTENT]: "Toxic Content", + + [ReputationEventType.MANUAL_GRANT]: "Manual Grant", + [ReputationEventType.MANUAL_DEDUCT]: "Manual Deduction", +}; + +@Table({ tableName: "ReputationEvents" }) +export class ReputationEvent extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + public declare id: CreationOptional; + + @Attribute(RealBigInt) + @NotNull + public declare userId: bigint; + + @Attribute(DataTypes.STRING(30)) + @NotNull + public declare eventType: ReputationEventType; + + @Attribute(DataTypes.INTEGER) + @NotNull + public declare scoreChange: number; + + @Attribute(DataTypes.STRING(500)) + public declare reason: string | null; + + @Attribute(RealBigInt) + public declare grantedBy: bigint | null; + + @Attribute(DataTypes.INTEGER) + public declare relatedId: number | null; // e.g., warning ID, starboard message ID + + @Attribute(DataTypes.DATE) + @Default(DataTypes.NOW) + public declare createdAt: CreationOptional; +} + +/** + * Create a reputation event and return the score change + */ +export async function createReputationEvent( + userId: bigint, + eventType: ReputationEventType, + options?: { + reason?: string; + grantedBy?: bigint; + relatedId?: number; + customScore?: number; + }, +): Promise { + const scoreChange = options?.customScore ?? REPUTATION_SCORES[eventType]; + + return ReputationEvent.create({ + userId, + eventType, + scoreChange, + reason: options?.reason ?? null, + grantedBy: options?.grantedBy ?? null, + relatedId: options?.relatedId ?? null, + }); +} + +/** + * Get all reputation events for a user + */ +export async function getReputationHistory( + userId: bigint, + limit = 50, +): Promise { + return ReputationEvent.findAll({ + where: { userId }, + order: [["createdAt", "DESC"]], + limit, + }); +} + +/** + * Calculate total reputation score for a user + */ +export async function calculateReputationScore( + userId: bigint, +): Promise { + const events = await ReputationEvent.findAll({ + where: { userId }, + attributes: ["scoreChange"], + }); + + return events.reduce((sum, event) => sum + event.scoreChange, 0); +} + +/** + * Get reputation events within a time window + */ +export async function getRecentReputationEvents( + userId: bigint, + windowMs: number, +): Promise { + const since = new Date(Date.now() - windowMs); + return ReputationEvent.findAll({ + where: { + userId, + createdAt: { [Op.gte]: since }, + }, + order: [["createdAt", "DESC"]], + }); +} diff --git a/src/store/models/ScamDomain.ts b/src/store/models/ScamDomain.ts new file mode 100644 index 00000000..0219313d --- /dev/null +++ b/src/store/models/ScamDomain.ts @@ -0,0 +1,44 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + Attribute, + Default, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; + +export enum ScamDomainCategory { + SCAM = "SCAM", + PHISHING = "PHISHING", + MALWARE = "MALWARE", + SHORTENER = "SHORTENER", +} + +@Table({ tableName: "ScamDomains" }) +export class ScamDomain extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.STRING(255)) + @PrimaryKey + public declare domain: string; + + @Attribute(DataTypes.STRING(20)) + @NotNull + public declare category: ScamDomainCategory; + + @Attribute(RealBigInt) + @NotNull + public declare addedBy: bigint; + + @Attribute(DataTypes.DATE) + @Default(DataTypes.NOW) + public declare createdAt: CreationOptional; +} diff --git a/src/store/models/ThreatLog.ts b/src/store/models/ThreatLog.ts new file mode 100644 index 00000000..9fb086c7 --- /dev/null +++ b/src/store/models/ThreatLog.ts @@ -0,0 +1,95 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + AllowNull, + Attribute, + AutoIncrement, + BelongsTo, + Default, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; +import { DDUser } from "./DDUser.js"; + +export enum ThreatType { + SPAM = "SPAM", + RAID = "RAID", + MENTION_SPAM = "MENTION_SPAM", + SCAM_LINK = "SCAM_LINK", + TOXIC_CONTENT = "TOXIC_CONTENT", + SUSPICIOUS_ACCOUNT = "SUSPICIOUS_ACCOUNT", +} + +export enum ThreatAction { + FLAGGED = "FLAGGED", + DELETED = "DELETED", + WARNED = "WARNED", + MUTED = "MUTED", + KICKED = "KICKED", + BANNED = "BANNED", +} + +@Table({ tableName: "ThreatLogs" }) +export class ThreatLog extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + public declare id: CreationOptional; + + @Attribute(RealBigInt) + @NotNull + public declare userId: bigint; + + @Attribute(DataTypes.STRING(30)) + @NotNull + public declare threatType: ThreatType; + + @Attribute(DataTypes.FLOAT) + @NotNull + public declare severity: number; + + @Attribute(DataTypes.STRING(20)) + @NotNull + public declare actionTaken: ThreatAction; + + @Attribute(DataTypes.TEXT) + @AllowNull + public declare messageContent: string | null; + + @Attribute(RealBigInt) + @AllowNull + public declare messageId: bigint | null; + + @Attribute(RealBigInt) + @AllowNull + public declare channelId: bigint | null; + + @Attribute(DataTypes.JSON) + @AllowNull + public declare metadata: Record | null; + + @Attribute(DataTypes.BOOLEAN) + @Default(false) + public declare falsePositive: CreationOptional; + + @Attribute(RealBigInt) + @AllowNull + public declare reviewedBy: bigint | null; + + @Attribute(DataTypes.DATE) + @Default(DataTypes.NOW) + public declare createdAt: CreationOptional; + + @BelongsTo(() => DDUser, "userId") + public declare user?: DDUser; +} diff --git a/src/store/models/Warning.ts b/src/store/models/Warning.ts new file mode 100644 index 00000000..fd2ac80f --- /dev/null +++ b/src/store/models/Warning.ts @@ -0,0 +1,115 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + AllowNull, + Attribute, + AutoIncrement, + BelongsTo, + Default, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; +import { DDUser } from "./DDUser.js"; + +export enum WarningSeverity { + MINOR = 1, + MODERATE = 2, + SEVERE = 3, +} + +@Table({ tableName: "Warnings", timestamps: true }) +export class Warning extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + public declare id: CreationOptional; + + @Attribute(RealBigInt) + @NotNull + public declare userId: bigint; + + @Attribute(RealBigInt) + @NotNull + public declare moderatorId: bigint; + + @Attribute(DataTypes.TEXT) + @NotNull + public declare reason: string; + + @Attribute(DataTypes.INTEGER) + @Default(WarningSeverity.MINOR) + @NotNull + public declare severity: WarningSeverity; + + @Attribute(DataTypes.DATE) + @AllowNull + public declare expiresAt: Date | null; + + @Attribute(DataTypes.BOOLEAN) + @Default(false) + public declare expired: CreationOptional; + + @Attribute(DataTypes.BOOLEAN) + @Default(false) + public declare pardoned: CreationOptional; + + @Attribute(RealBigInt) + @AllowNull + public declare pardonedBy: bigint | null; + + @Attribute(DataTypes.TEXT) + @AllowNull + public declare pardonReason: string | null; + + public declare createdAt: CreationOptional; + public declare updatedAt: CreationOptional; + + @BelongsTo(() => DDUser, "userId") + public declare user?: DDUser; +} + +export async function getActiveWarnings(userId: bigint): Promise { + return Warning.findAll({ + where: { + userId, + expired: false, + pardoned: false, + }, + order: [["createdAt", "DESC"]], + }); +} + +export async function getWarningCount(userId: bigint): Promise { + return Warning.count({ + where: { + userId, + expired: false, + pardoned: false, + }, + }); +} + +export async function getAllWarnings( + userId: bigint, + includeExpired = false, +): Promise { + const where: Record = { userId }; + if (!includeExpired) { + where.expired = false; + where.pardoned = false; + } + return Warning.findAll({ + where, + order: [["createdAt", "DESC"]], + }); +} diff --git a/src/store/storage.ts b/src/store/storage.ts index dfc87b41..cf217b4c 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -6,6 +6,7 @@ import { import { SqliteDialect } from "@sequelize/sqlite3"; import type { ConnectionConfig } from "pg"; import { logger } from "../logging.js"; +import { BlockedWord } from "./models/BlockedWord.js"; import { Bump } from "./models/Bump.js"; import { ColourRoles } from "./models/ColourRoles.js"; import { DDUser } from "./models/DDUser.js"; @@ -13,9 +14,13 @@ import { FAQ } from "./models/FAQ.js"; import { ModeratorActions } from "./models/ModeratorActions.js"; import { ModMailNote } from "./models/ModMailNote.js"; import { ModMailTicket } from "./models/ModMailTicket.js"; +import { ReputationEvent } from "./models/ReputationEvent.js"; +import { ScamDomain } from "./models/ScamDomain.js"; import { StarboardMessage } from "./models/StarboardMessage.js"; import { Suggestion } from "./models/Suggestion.js"; import { SuggestionVote } from "./models/SuggestionVote.js"; +import { ThreatLog } from "./models/ThreatLog.js"; +import { Warning } from "./models/Warning.js"; function sequelizeLog(sql: string, timing?: number) { if (timing) { @@ -44,7 +49,7 @@ export async function initStorage() { user: username, password, host, - port: parseInt(port, 10), + port: Number.parseInt(port, 10), logging: sequelizeLog, benchmark: true, }); @@ -74,6 +79,11 @@ export async function initStorage() { SuggestionVote, ModMailTicket, ModMailNote, + ThreatLog, + ScamDomain, + Warning, + BlockedWord, + ReputationEvent, ]; sequelize.addModels(models); From 9384be569a9656c293abad5fe40e2865f88dbae5 Mon Sep 17 00:00:00 2001 From: Pdzly Date: Sun, 28 Dec 2025 19:32:34 +0100 Subject: [PATCH 2/3] - Adjust offender label to dynamically support "Recipient" for reputation actions --- src/modules/moderation/logs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/moderation/logs.ts b/src/modules/moderation/logs.ts index 0765223c..06163694 100644 --- a/src/modules/moderation/logs.ts +++ b/src/modules/moderation/logs.ts @@ -191,7 +191,8 @@ export async function logModerationAction( embed.setColor(embedColors[action.kind]); const targetUser = await client.users.fetch(action.target).catch(() => null); - let description = `**Offender**: ${targetUser && fakeMention(targetUser)} ${actualMention(action.target)}\n`; + const targetLabel = action.kind === "ReputationGranted" ? "Recipient" : "Offender"; + let description = `**${targetLabel}**: ${targetUser && fakeMention(targetUser)} ${actualMention(action.target)}\n`; if ("reason" in action && action.reason) { description += `**Reason**: ${action.reason}\n`; } From cc72ec61ac30270d1263df9bda3ecc87c2e62ddf Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 6 Jan 2026 20:27:12 +0000 Subject: [PATCH 3/3] fix missing commas --- src/modules/moderation/moderation.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/moderation/moderation.module.ts b/src/modules/moderation/moderation.module.ts index 69711a58..dadafe40 100644 --- a/src/modules/moderation/moderation.module.ts +++ b/src/modules/moderation/moderation.module.ts @@ -30,5 +30,5 @@ export const ModerationModule: Module = { WordlistCommand, ReputationCommand, ], - listeners: [...InviteListeners, TempBanListener, WarningSchedulerListener DeletedMessagesListener], + listeners: [...InviteListeners, TempBanListener, WarningSchedulerListener, DeletedMessagesListener], };