diff --git a/.env.example b/.env.example index 9713e5e..b230c0c 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ GUIDES_CHANNEL_ID=your_guides_channel_id_here ADVENT_OF_CODE_CHANNEL_ID=your_advent_of_code_forum_channel_id_here REPEL_LOG_CHANNEL_ID=your_repel_log_channel_id_here ONBOARDING_CHANNEL_ID=onboarding_channel_id_here +SPAM_DETECTION_CHANNEL_ID=spam_detection_channel_id_here ARCHIVE_CATEGORY_ID=archive_category_id_here # Role IDs (REQUIRED) diff --git a/.env.production b/.env.production index d3660a6..8cc3dff 100644 --- a/.env.production +++ b/.env.production @@ -12,6 +12,7 @@ ADVENT_OF_CODE_CHANNEL_ID=1047623689488830495 SHOWCASE_CHANNEL_ID=1517161718818541658 SHOWCASE_LOG_CHANNEL_ID=1517565847982444634 SHOWCASE_RULES_CHANNEL_ID=1517948527098073158 +SPAM_DETECTION_CHANNEL_ID=973895712314110032 ARCHIVE_CATEGORY_ID=837507969859977258 diff --git a/.env.test b/.env.test index 0e25d88..4e68226 100644 --- a/.env.test +++ b/.env.test @@ -19,6 +19,7 @@ REPEL_LOG_CHANNEL_ID=your-repel-log-channel-id SHOWCASE_CHANNEL_ID=your-showcase-forum-channel-id SHOWCASE_LOG_CHANNEL_ID=your-showcase-log-channel-id SHOWCASE_RULES_CHANNEL_ID=your-showcase-rules-channel-id +SPAM_DETECTION_CHANNEL_ID=spam-detection-channel-id-here ARCHIVE_CATEGORY_ID=your-archived-category-id # Role IDs (from your dev server) @@ -28,4 +29,4 @@ REGULAR_ROLE_ID=your-regular-role-id # Other GUIDES_TRACKER_PATH=guides-tracker.json -ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json \ No newline at end of file +ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json diff --git a/src/common/commands/index.ts b/src/common/commands/index.ts index 568fd1e..4bace6b 100644 --- a/src/common/commands/index.ts +++ b/src/common/commands/index.ts @@ -1,6 +1,6 @@ import { docsCommands } from '@/features/docs/index.js'; import { guidesCommand } from '@/features/guides/index.js'; -import cacheMessages from '@/features/moderation/cache-messages.js'; +import cacheMessages from '@/features/cache-messages/index.js'; import { repelCommand } from '@/features/moderation/repel.js'; import { pingCommand } from '@/features/ping/index.js'; import { publicGuidesCommand } from '@/features/public-guides/index.js'; @@ -8,6 +8,7 @@ import { createShowcaseCommand } from '@/features/showcase/create-showcase.js'; import { sendShowcasePinnedMessage } from '@/features/showcase/send-pinned-message.js'; import { tipsCommands } from '@/features/tips/index.js'; import type { Command } from './types.js'; +import { reportMessage } from '@/features/report-message/index.js'; export const commands = new Map( [ @@ -20,6 +21,7 @@ export const commands = new Map( publicGuidesCommand, createShowcaseCommand, sendShowcasePinnedMessage, + reportMessage, ] .flat() .map((command) => [command.data.name, command]) diff --git a/src/common/events/index.ts b/src/common/events/index.ts index 2b8c335..872b46e 100644 --- a/src/common/events/index.ts +++ b/src/common/events/index.ts @@ -3,6 +3,7 @@ import { hasVarEvent } from '@/features/has-var/index.js'; import { interactionCreateEvent } from '@/features/interaction-create/index.js'; import { readyEvent } from '@/features/ready/index.js'; import type { DiscordEvent } from './types.js'; +import { spamDetection } from '@/features/spam-detection/index.js'; import archiveChannels from '@/features/archive-channels/index.js'; export const events: DiscordEvent[] = [ @@ -10,5 +11,6 @@ export const events: DiscordEvent[] = [ guildCreateEvent, hasVarEvent, interactionCreateEvent, + spamDetection, archiveChannels, ].flat(); diff --git a/src/env.ts b/src/env.ts index 0a66798..b619677 100644 --- a/src/env.ts +++ b/src/env.ts @@ -41,6 +41,7 @@ export const config = { showcase: requireEnv('SHOWCASE_CHANNEL_ID'), showcaseLogs: requireEnv('SHOWCASE_LOG_CHANNEL_ID'), showcaseRules: requireEnv('SHOWCASE_RULES_CHANNEL_ID'), + spamDetection: requireEnv('SPAM_DETECTION_CHANNEL_ID'), archiveCategory: requireEnv('ARCHIVE_CATEGORY_ID'), }, onboarding: { diff --git a/src/features/moderation/cache-messages.ts b/src/features/cache-messages/index.ts similarity index 98% rename from src/features/moderation/cache-messages.ts rename to src/features/cache-messages/index.ts index 3fd1072..dcd14b8 100644 --- a/src/features/moderation/cache-messages.ts +++ b/src/features/cache-messages/index.ts @@ -4,7 +4,7 @@ import { PermissionsBitField, } from 'discord.js'; import { createSlashCommand } from '../../common/commands/create-commands.js'; -import { fetchAndCachePublicChannelsMessages } from '../../util/cache.js'; +import { fetchAndCachePublicChannelsMessages } from '../../util/channel-prefetch.js'; export default createSlashCommand({ data: { diff --git a/src/features/has-var/index.ts b/src/features/has-var/index.ts index cbe5f32..f8b066f 100644 --- a/src/features/has-var/index.ts +++ b/src/features/has-var/index.ts @@ -2,7 +2,7 @@ import { Events } from 'discord.js'; import ts from 'typescript'; import { createEvent } from '@/common/events/create-event.js'; import { MINUTE } from '../../constants/time.js'; -import { codeBlockRegex } from '../../util/message.js'; +import { codeBlockRegex } from '../../util/messages.js'; import { rateLimit } from '../../util/rate-limit.js'; const { canRun, reset } = rateLimit(5 * MINUTE); diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index 95c365c..331ea32 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -2,7 +2,7 @@ import { Events } from 'discord.js'; import { createEvent } from '@/common/events/create-event.js'; import { config } from '@/env.js'; import { initializeAdventScheduler } from '@/util/advent-scheduler.js'; -import { fetchAndCachePublicChannelsMessages } from '@/util/cache.js'; +import { fetchAndCachePublicChannelsMessages } from '@/util/channel-prefetch.js'; import { syncGuidesToChannel } from '@/util/post-guides.js'; import { leaveIfNotAllowedServer } from '@/util/server-guard.js'; import { syncArchiveCategoryChannels } from '../archive-channels/util.js'; diff --git a/src/features/report-message/index.ts b/src/features/report-message/index.ts new file mode 100644 index 0000000..2a5e23c --- /dev/null +++ b/src/features/report-message/index.ts @@ -0,0 +1,70 @@ +import { createMessageContextMenuCommand } from '@/common/commands/create-commands.js'; +import { config } from '@/env.js'; +import { ChannelType, Colors, EmbedBuilder, MessageFlags } from 'discord.js'; + +export const reportMessage = createMessageContextMenuCommand({ + data: { + name: 'Report to Moderators', + }, + execute: async (interaction) => { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral, + }); + + const guild = interaction.guild; + + if (!guild) { + await interaction.editReply({ + content: 'This can only be used in a server.', + }); + return; + } + + const targetMessage = interaction.targetMessage; + const reporter = interaction.user; + const channelId = config.channelIds.spamDetection; + const channel = guild.channels.cache.get(channelId); + + try { + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.editReply({ + content: 'Moderator channel not found or is not a text channel.', + }); + return; + } + + const jumpLink = targetMessage.url; + const authorTag = targetMessage.author.tag ?? 'Unknown'; + const authorId = targetMessage.author.id ?? 'Unknown'; + + const embed = new EmbedBuilder() + .setTitle('🚩 Message Report') + .setColor(Colors.DarkOrange) + .setTimestamp() + .setURL(jumpLink) + .addFields( + { name: 'Reporter', value: `<@${reporter.id}>`, inline: true }, + { + name: 'Message Link', + value: `[Jump to message](${jumpLink})`, + inline: true, + }, + { name: 'Message ID', value: targetMessage.id, inline: true }, + { name: 'Username', value: authorTag, inline: true }, + { name: 'User ID', value: authorId, inline: true }, + { name: 'Linked User', value: `<@${authorId}>`, inline: true } + ); + + await channel.send({ embeds: [embed] }); + + await interaction.editReply({ + content: 'Thanks. The message was reported to moderators.', + }); + } catch (error) { + console.error(error); + await interaction.editReply({ + content: 'Failed to report the message.', + }); + } + }, +}); diff --git a/src/features/spam-detection/actions.ts b/src/features/spam-detection/actions.ts new file mode 100644 index 0000000..cb0f359 --- /dev/null +++ b/src/features/spam-detection/actions.ts @@ -0,0 +1,115 @@ +import type { Channel, Message } from 'discord.js'; +import { cachedMessages } from '@/util/cache/recent-message-store.js'; +import { DAY, HOUR } from '../../constants/time.js'; +import { defaultLogFunction, type LogFunction } from './logs.js'; +import type { Rule } from './rules-config.js'; + +type ActionConfig = { + reason: string; + deleteMessages?: boolean; + muteDuration?: number; + log?: LogFunction; +}; + +const handleBulkDeleteMessages = async (messages: Message[]) => { + const messagesByChannel = new Map(); + for (const message of messages) { + if (!message.deletable || !message.inGuild()) { + continue; + } + if (!messagesByChannel.has(message.channelId)) { + messagesByChannel.set(message.channelId, [message.id]); + } else { + messagesByChannel.get(message.channelId)!.push(message.id); + } + } + + let deletedMessagesCount = 0; + + await Promise.allSettled( + Array.from(messagesByChannel.entries()).map(([channelId, messageIds]) => { + const channel = messages.find( + (message) => message.channelId === channelId + )?.channel; + if (!channel || channel.isDMBased()) { + return Promise.resolve(); + } + cachedMessages.bulkDeleteByIds(messageIds); + deletedMessagesCount += messageIds.length; + return channel.bulkDelete(messageIds, true); + }) + ); + + return deletedMessagesCount; +}; + +const handleAction = (config: ActionConfig) => { + return async (messages: Message[], rule: Rule, logChannel?: Channel) => { + const firstMessage = messages[0]; + const author = firstMessage.author; + + if (author === undefined) { + return; + } + + let muted = false; + if (config.muteDuration) { + try { + const guildMember = await firstMessage.guild?.members.fetch(author.id); + if (guildMember?.moderatable) { + await guildMember.timeout(config.muteDuration, config.reason); + muted = true; + } + } catch (error) { + console.error('Failed to mute user:', error); + } + } + + let deletedMessagesCount = 0; + + if (config.deleteMessages) { + deletedMessagesCount = await handleBulkDeleteMessages(messages); + } + + // Use custom log function if provided, otherwise use default + const logFunction = config.log || defaultLogFunction; + await logFunction({ + messages, + reason: config.reason, + logChannel, + deletedMessagesCount, + muteDuration: muted ? config.muteDuration : undefined, + rule, + }); + }; +}; + +export const handleBannedTagsAction = handleAction({ + reason: 'Banned Tag', + deleteMessages: true, + muteDuration: 1 * DAY, +}); + +export const handleDiscordInvitesAction = handleAction({ + reason: 'Discord Invite Link', + deleteMessages: true, + muteDuration: 1 * DAY, +}); + +export const handleSpoilerHackAction = handleAction({ + reason: 'Spoiler Tag Hack', + deleteMessages: true, + muteDuration: 12 * HOUR, +}); + +export const handleCrossPostingAction = handleAction({ + reason: 'Cross-posting', + deleteMessages: true, + muteDuration: 12 * HOUR, +}); + +export const handleHighFrequencyAction = handleAction({ + reason: 'High Frequency Messaging', + deleteMessages: true, + muteDuration: 12 * HOUR, +}); diff --git a/src/features/spam-detection/constants.ts b/src/features/spam-detection/constants.ts new file mode 100644 index 0000000..25955cd --- /dev/null +++ b/src/features/spam-detection/constants.ts @@ -0,0 +1,9 @@ +import { rules } from './rules-config.js'; + +export const MAX_RULE_TIMEFRAME = Math.max( + ...rules + .filter((rule) => rule.type !== 'contentBased') + .map((rule) => rule.timeframe) +); + +export const MESSAGE_SIMILARITY_THRESHOLD = 0.8; diff --git a/src/features/spam-detection/detectors.ts b/src/features/spam-detection/detectors.ts new file mode 100644 index 0000000..3fb3722 --- /dev/null +++ b/src/features/spam-detection/detectors.ts @@ -0,0 +1,53 @@ +import type { Message } from 'discord.js'; +import { + jaccardSimilarity, + replaceSpoilerHack, + stripCode, +} from '@/util/messages.js'; +import { MESSAGE_SIMILARITY_THRESHOLD } from './constants.js'; + +export const containsLink = (message: Message): boolean => { + const withoutCode = stripCode(message.content); + return withoutCode.includes('http://') || withoutCode.includes('https://'); +}; + +export const containsBannedTag = (message: Message): boolean => { + const withoutCode = stripCode(message.content); + return withoutCode.includes('@everyone') || withoutCode.includes('@here'); +}; + +export const containsDiscordInvite = (message: Message): boolean => { + const withoutCode = stripCode(message.content); + const keywords = [ + 'discord.gg/', + 'discord.com/invite/', + 'discordapp.com/invite/', + ]; + return keywords.some((keyword) => withoutCode.includes(keyword)); +}; + +export const containsSpoilerHack = (message: Message) => { + const withoutCode = stripCode(message.content); + + return withoutCode !== replaceSpoilerHack(withoutCode, ''); +}; + +export const isDuplicate = (message: Message, oldMessage: Message) => { + // cheaper comparison first + const a = message.content.toLowerCase().trim(); + const b = oldMessage.content.toLowerCase().trim(); + if (a === b) { + return true; + } + // followed by jaccard for catching reordered/slightly altered messages with high similarity + return jaccardSimilarity(a, b) > MESSAGE_SIMILARITY_THRESHOLD; +}; + +export const isCrossPost = (message: Message, oldMessage: Message) => { + return ( + message.channelId !== oldMessage.channelId && + isDuplicate(message, oldMessage) + ); +}; + +export const anyMessage = () => true; diff --git a/src/features/spam-detection/index.ts b/src/features/spam-detection/index.ts new file mode 100644 index 0000000..7d0cd49 --- /dev/null +++ b/src/features/spam-detection/index.ts @@ -0,0 +1,28 @@ +import { Events } from 'discord.js'; +import { cachedMessages } from '@/util/cache/recent-message-store.js'; +import { config } from '@/env.js'; +import { createEvent } from '@/common/events/create-event.js'; +import { checkRules } from './rules.js'; +import { isNormalUserMessage } from '@/util/messages.js'; + +export const spamDetection = createEvent( + { + name: Events.MessageCreate, + }, + async (message) => { + if (!isNormalUserMessage(message)) { + return; + } + const regularRole = message.guild?.roles.cache.get(config.roleIds.regular); + if ( + regularRole === undefined || + message.member === null || + message.member.roles.highest.position >= regularRole.position + ) { + return; + } + + cachedMessages.add(message); + await checkRules(message); + } +); diff --git a/src/features/spam-detection/logs.test.ts b/src/features/spam-detection/logs.test.ts new file mode 100644 index 0000000..332fde0 --- /dev/null +++ b/src/features/spam-detection/logs.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import type { Message } from 'discord.js'; +import { HOUR } from '../../constants/time.js'; +import { createLogTextContent, type LogFunctionOptions } from './logs.js'; +import type { ContentBasedRule } from './rules-config.js'; + +void describe('spam-detection/logs -> createLogTextContent', () => { + void it('should create log content for a content-based rule', () => { + // Mock options for a content-based rule + const options = { + rule: { + type: 'contentBased', + isBrokenBy: () => true, + action: async () => {}, + }, + messages: [ + { + content: 'This message contains a banned tag', + channelId: '123', + author: { id: '1' }, + }, + ] as Message[], + deletedMessagesCount: 1, + reason: 'Contains banned tag', + muteDuration: 1 * HOUR, + } satisfies LogFunctionOptions; + + const logContent = createLogTextContent(options); + // console.log(logContent); + + // Basic assertions to check if the log content includes expected information + assert(logContent.includes('**Rule Broken:** Contains banned tag')); + assert(logContent.includes('**User:** <@1>')); + assert(logContent.includes('**Flagged Message:**')); + assert(logContent.includes('This message contains a banned tag')); + assert(logContent.includes('**Channel:** <#123>')); + }); +}); diff --git a/src/features/spam-detection/logs.ts b/src/features/spam-detection/logs.ts new file mode 100644 index 0000000..99b1846 --- /dev/null +++ b/src/features/spam-detection/logs.ts @@ -0,0 +1,179 @@ +import { + type Channel, + Colors, + ContainerBuilder, + type Message, + MessageFlags, + SectionBuilder, + TextDisplayBuilder, + ThumbnailBuilder, +} from 'discord.js'; +import { timeToString } from '@/constants/time.js'; +import type { Rule } from './rules-config.js'; + +const makeLogMessageTitleAndContent = (title: string, content: string) => { + return `**${title}:** ${content}`; +}; + +const SPACER = `\n--------------------\n`; + +export type LogFunctionOptions = { + messages: Message[]; + reason: string; + deletedMessagesCount: number; + muteDuration?: number; + logChannel?: Channel; + rule: T; +}; +export type LogFunction = ( + options: LogFunctionOptions +) => Promise; + +export const createLogTextContent = ( + options: LogFunctionOptions +) => { + const content: string[] = []; + + content.push( + makeLogMessageTitleAndContent('Rule Broken', `${options.reason}\n`) + ); + content.push( + makeLogMessageTitleAndContent( + 'User', + `<@${options.messages[0].author.id}>\n` + ) + ); + + switch (options.rule.type) { + case 'contentBased': { + const flaggedMessage = options.messages[0]; + content.push( + makeLogMessageTitleAndContent( + 'Flagged Message', + `\`\n\n${flaggedMessage.content}\`\n` + ) + ); + content.push(SPACER); + content.push( + makeLogMessageTitleAndContent( + 'Channel', + `<#${flaggedMessage.channelId}>` + ) + ); + break; + } + case 'crossChannel': { + if (options.rule.isBrokenBy.name === 'isCrossPost') { + content.push( + `Posted in **${options.rule.channelCount}** channels within **${timeToString(options.rule.timeframe)} **\n` + ); + const flaggedMessage = options.messages[0]; + const affectedChannels = new Set( + options.messages.map((message) => message.channelId) + ); + const hasMessage = + flaggedMessage.content && flaggedMessage.content.trim().length > 0; + const hasAttachments = flaggedMessage.attachments.size > 0; + if (hasMessage) { + content.push( + makeLogMessageTitleAndContent( + 'Flagged Message', + `\n\n${flaggedMessage.content}\n` + ) + ); + } + if (hasAttachments) { + content.push( + makeLogMessageTitleAndContent( + 'Flagged Message', + `\n\n[Attachment: ${flaggedMessage.attachments.first()?.name}]\n` + ) + ); + } + if (!hasMessage && !hasAttachments) { + content.push( + makeLogMessageTitleAndContent( + 'Flagged Message', + `\n\n[No Text Content]\n` + ) + ); + } + content.push(SPACER); + content.push( + makeLogMessageTitleAndContent( + 'Channels Involved', + Array.from(affectedChannels) + .map((id) => `<#${id}>`) + .join(', ') + ) + ); + } + break; + } + case 'frequencyBased': { + content.push( + `Sent **${options.rule.frequency}** messages within **${timeToString(options.rule.timeframe)}**\n` + ); + const displayedMessages = options.messages.slice(0, 5); + const displayedCount = displayedMessages.length; + const remainingCount = options.messages.length - displayedCount; + + content.push(`**Messages Involved:**\n`); + content.push( + displayedMessages + .map((message) => { + const contentPreview = + message.content.length > 50 + ? `${message.content.slice(0, 47)}...` + : message.content; + return `- ${contentPreview}`; + }) + .join('\n') + ); + if (remainingCount > 0) { + content.push(`\n ...and ${remainingCount} more\n`); + } + break; + } + } + + content.push(SPACER); + content.push('**Action(s) Taken:**\n'); + if (options.deletedMessagesCount > 0) { + content.push( + `- Deleted ${options.deletedMessagesCount} message${options.deletedMessagesCount > 1 ? 's' : ''}\n` + ); + } + if (options.muteDuration) { + content.push(`- Muted user for ${timeToString(options.muteDuration)}\n`); + } + + return content.join(''); +}; + +export const defaultLogFunction: LogFunction = async (options) => { + if (!options.logChannel?.isSendable()) { + return; + } + + const content = createLogTextContent(options); + const textTextDisplayComponent = new TextDisplayBuilder().setContent(content); + + const sectionComponent = new SectionBuilder() + .addTextDisplayComponents(textTextDisplayComponent) + .setThumbnailAccessory( + new ThumbnailBuilder().setURL( + options.messages[0].author.displayAvatarURL() + ) + ); + + const containerComponent = new ContainerBuilder() + .addSectionComponents(sectionComponent) + .setAccentColor(Colors.Red); + + await options.logChannel.send({ + flags: MessageFlags.IsComponentsV2, + allowedMentions: { users: undefined }, + components: [containerComponent], + }); +}; diff --git a/src/features/spam-detection/rules-config.ts b/src/features/spam-detection/rules-config.ts new file mode 100644 index 0000000..a6460ae --- /dev/null +++ b/src/features/spam-detection/rules-config.ts @@ -0,0 +1,119 @@ +import type { Channel, Message } from 'discord.js'; +import { MINUTE, SECOND } from '../../constants/time.js'; +import { + handleBannedTagsAction, + handleCrossPostingAction, + handleDiscordInvitesAction, + handleHighFrequencyAction, + handleSpoilerHackAction, +} from './actions.js'; +import { + anyMessage, + containsBannedTag, + containsDiscordInvite, + containsSpoilerHack, + isCrossPost, +} from './detectors.js'; + +export type ContentBasedRule = { + isBrokenBy: (newMessage: Message) => boolean; + action: ( + messages: Message[], + rule: Rule, + logChannel?: Channel + ) => Promise; + type: 'contentBased'; +}; + +export type CrossChannelRule = { + isBrokenBy: (newMessage: Message, oldMessage: Message) => boolean; + timeframe: number; + channelCount: number; + action: ( + messages: Message[], + rule: Rule, + logChannel?: Channel + ) => Promise; + type: 'crossChannel'; +}; + +export type FrequencyBasedRule = { + timeframe: number; + frequency: number; + isBrokenBy: (newMessage: Message, oldMessage: Message) => boolean; + action: ( + messages: Message[], + rule: Rule, + logChannel?: Channel + ) => Promise; + type: 'frequencyBased'; +}; + +export type Rule = ContentBasedRule | CrossChannelRule | FrequencyBasedRule; + +export const rules: Rule[] = [ + { + type: 'contentBased', + isBrokenBy: containsBannedTag, + action: handleBannedTagsAction, + }, + { + type: 'contentBased', + isBrokenBy: containsDiscordInvite, + action: handleDiscordInvitesAction, + }, + { + type: 'contentBased', + isBrokenBy: containsSpoilerHack, + action: handleSpoilerHackAction, + }, + { + type: 'crossChannel', + isBrokenBy: isCrossPost, + timeframe: 15 * SECOND, + channelCount: 3, + action: handleCrossPostingAction, + }, + { + type: 'crossChannel', + isBrokenBy: isCrossPost, + timeframe: 25 * SECOND, + channelCount: 4, + action: handleCrossPostingAction, + }, + { + type: 'crossChannel', + isBrokenBy: isCrossPost, + timeframe: 40 * SECOND, + channelCount: 5, + action: handleCrossPostingAction, + }, + { + type: 'crossChannel', + isBrokenBy: isCrossPost, + timeframe: 1 * MINUTE, + channelCount: 6, + action: handleCrossPostingAction, + }, + { + type: 'crossChannel', + isBrokenBy: isCrossPost, + timeframe: 2 * MINUTE, + channelCount: 7, + action: handleCrossPostingAction, + }, + { + type: 'frequencyBased', + isBrokenBy: anyMessage, + timeframe: 3 * SECOND, + frequency: 15, + action: handleHighFrequencyAction, + }, + { + type: 'frequencyBased', + isBrokenBy: anyMessage, + timeframe: 6 * SECOND, + frequency: 20, + action: handleHighFrequencyAction, + }, +]; diff --git a/src/features/spam-detection/rules.ts b/src/features/spam-detection/rules.ts new file mode 100644 index 0000000..3a878b0 --- /dev/null +++ b/src/features/spam-detection/rules.ts @@ -0,0 +1,96 @@ +import type { Message } from 'discord.js'; +import { cachedMessages } from '@/util/cache/recent-message-store.js'; +import { config } from '../../env.js'; +import { MAX_RULE_TIMEFRAME } from './constants.js'; +import type { Rule } from './rules-config.js'; +import { rules } from './rules-config.js'; + +type CheckRuleOptions = { + newMessage: Message; + rule: Rule; + startTime: number; + userMessages: Message[]; +}; + +export async function checkRules(newMessage: Message): Promise { + const startTime = Date.now(); + const maxLookback = startTime - MAX_RULE_TIMEFRAME * 1.5; + const userMessages = cachedMessages.getMessagesInTimeRange( + newMessage.author.id, + maxLookback + ); + + for (const rule of rules) { + const result = checkRule({ + newMessage, + rule, + startTime, + userMessages, + }); + + if (result.broken) { + const logChannel = newMessage.client.channels.cache.get( + config.channelIds.spamDetection + ); + await rule.action(result.messages, rule, logChannel); + return; + } + } +} + +export const checkRule = ({ + newMessage, + rule, + startTime, + userMessages, +}: CheckRuleOptions): { + broken: boolean; + messages: Message[]; +} => { + switch (rule.type) { + case 'contentBased': { + const broken = rule.isBrokenBy(newMessage); + return { + broken, + messages: broken ? [newMessage] : [], + }; + } + case 'crossChannel': { + const recentMessages = userMessages.filter( + (msg) => msg.createdTimestamp >= startTime - rule.timeframe + ); + const violatingMessages = recentMessages.filter((msg) => + rule.isBrokenBy(newMessage, msg) + ); + const uniqueChannels = new Set( + violatingMessages.map((msg) => msg.channelId) + ); + uniqueChannels.add(newMessage.channelId); + + const broken = uniqueChannels.size >= rule.channelCount; + + return { + broken, + messages: broken ? [...violatingMessages, newMessage] : [], + }; + } + case 'frequencyBased': { + const recentMessages = userMessages.filter( + (msg) => msg.createdTimestamp >= startTime - rule.timeframe + ); + + const otherViolatingMessages = recentMessages.filter( + (msg) => msg.id !== newMessage.id && rule.isBrokenBy(newMessage, msg) + ); + + // Include the current message since it's part of the pattern + const allViolatingMessages = [newMessage, ...otherViolatingMessages]; + + const broken = allViolatingMessages.length >= rule.frequency; + return { + broken, + messages: broken ? allViolatingMessages : [], + }; + } + } +}; diff --git a/src/util/cache/recent-message-store.ts b/src/util/cache/recent-message-store.ts new file mode 100644 index 0000000..2ad401f --- /dev/null +++ b/src/util/cache/recent-message-store.ts @@ -0,0 +1,402 @@ +import type { Message } from 'discord.js'; +import { MAX_RULE_TIMEFRAME } from '@/features/spam-detection/constants.js'; + +// O(1) Priority Queue implementation for LRU eviction +class MinHeap { + private heap: Array<{ messageId: string; accessTime: number }> = []; + + private parent(index: number): number { + return Math.floor((index - 1) / 2); + } + + private leftChild(index: number): number { + return 2 * index + 1; + } + + private rightChild(index: number): number { + return 2 * index + 2; + } + + private swap(index: number, otherIndex: number): void { + [this.heap[index], this.heap[otherIndex]] = [ + this.heap[otherIndex], + this.heap[index], + ]; + } + + private heapifyUp(index: number): void { + while ( + index > 0 && + this.heap[this.parent(index)].accessTime > this.heap[index].accessTime + ) { + this.swap(index, this.parent(index)); + index = this.parent(index); + } + } + + private heapifyDown(index: number): void { + while (this.leftChild(index) < this.heap.length) { + let minChild = this.leftChild(index); + if ( + this.rightChild(index) < this.heap.length && + this.heap[this.rightChild(index)].accessTime < + this.heap[minChild].accessTime + ) { + minChild = this.rightChild(index); + } + + if (this.heap[index].accessTime <= this.heap[minChild].accessTime) { + break; + } + + this.swap(index, minChild); + index = minChild; + } + } + + push(messageId: string, accessTime: number): void { + this.heap.push({ messageId, accessTime }); + this.heapifyUp(this.heap.length - 1); + } + + pop(): { messageId: string; accessTime: number } | null { + if (this.heap.length === 0) { + return null; + } + + const min = this.heap[0]; + const last = this.heap.pop()!; + + if (this.heap.length > 0) { + this.heap[0] = last; + this.heapifyDown(0); + } + + return min; + } + + peek(): { messageId: string; accessTime: number } | null { + return this.heap.length > 0 ? this.heap[0] : null; + } + + size(): number { + return this.heap.length; + } + + clear(): void { + this.heap = []; + } + + // Remove specific item (O(n) but rarely used) + remove(messageId: string): boolean { + const index = this.heap.findIndex((item) => item.messageId === messageId); + if (index === -1) { + return false; + } + + const last = this.heap.pop()!; + if (index < this.heap.length) { + this.heap[index] = last; + + if ( + index > 0 && + this.heap[this.parent(index)].accessTime > this.heap[index].accessTime + ) { + this.heapifyUp(index); + } else { + this.heapifyDown(index); + } + } + return true; + } +} + +export class MessageCache { + private cache = new Map(); + private userMessages = new Map>(); // userId -> Set + private channelMessages = new Map>(); // channelId -> Set + private maxSize: number; + private lruHeap = new MinHeap(); + private accessTimes = new Map(); // messageId -> access timestamp + private nextAccessTime = 0; + private cleanupInterval: number; + + constructor(maxSize: number = 1000, cleanupInterval: number = 100) { + if (maxSize <= 0) { + throw new Error('maxSize must be positive'); + } + if (cleanupInterval <= 0) { + throw new Error('cleanupInterval must be positive'); + } + this.maxSize = maxSize; + this.cleanupInterval = cleanupInterval; + } + + add(message: Message): void { + if (!message || !message.id || !message.author || !message.channelId) { + throw new Error( + 'Invalid message: must be a valid Discord.js Message object' + ); + } + + const messageId = message.id; + const userId = message.author.id; + const channelId = message.channelId; + + // Remove oldest messages if we're at capacity - O(1) with priority queue + if (this.cache.size >= this.maxSize) { + this.evictOldest(); + } + + // Periodic time-based cleanup (every cleanupInterval messages) + if (this.cache.size % this.cleanupInterval === 0) { + this.cleanupOldMessages(MAX_RULE_TIMEFRAME); + } + + // Add to primary cache + this.cache.set(messageId, message); + + // Update secondary indexes - O(1) + if (!this.userMessages.has(userId)) { + this.userMessages.set(userId, new Set()); + } + this.userMessages.get(userId)!.add(messageId); + + if (!this.channelMessages.has(channelId)) { + this.channelMessages.set(channelId, new Set()); + } + this.channelMessages.get(channelId)!.add(messageId); + + // Update LRU tracking - O(log n) for heap operations + const accessTime = ++this.nextAccessTime; + this.accessTimes.set(messageId, accessTime); + this.lruHeap.push(messageId, accessTime); + } + + private evictOldest(): void { + // O(1) eviction with priority queue + while (this.lruHeap.size() > 0) { + const oldest = this.lruHeap.pop(); + if (!oldest) { + break; + } + + // Check if this entry is still valid (not already deleted) + if (this.accessTimes.get(oldest.messageId) === oldest.accessTime) { + this.delete(oldest.messageId); + break; + } + // If access times don't match, this is a stale entry, continue to next + } + } + + getStats(): { + size: number; + userCount: number; + channelCount: number; + heapSize: number; + } { + return { + size: this.cache.size, + userCount: this.userMessages.size, + channelCount: this.channelMessages.size, + heapSize: this.lruHeap.size(), + }; + } + + // Clean up messages older than MAX_RULE_TIMEFRAME + private cleanupOldMessages(maxRuleTimeframe: number): void { + const now = Date.now(); + const cutoffTime = now - maxRuleTimeframe; + const messagesToDelete: string[] = []; + + // Find messages older than cutoff + for (const [messageId, message] of this.cache) { + if (message.createdTimestamp < cutoffTime) { + messagesToDelete.push(messageId); + } + } + + // Bulk delete old messages + if (messagesToDelete.length > 0) { + console.log( + `Cleaning up ${messagesToDelete.length} old messages from cache (older than ${maxRuleTimeframe}ms)` + ); + this.bulkDeleteByIds(messagesToDelete); + } + } + + // PUBLIC: Force cleanup of old messages (can be called externally) + removeExpiredMessages(maxRuleTimeframe = MAX_RULE_TIMEFRAME): void { + if ( + typeof maxRuleTimeframe !== 'number' || + maxRuleTimeframe <= 0 || + Number.isNaN(maxRuleTimeframe) + ) { + throw new Error('Invalid maxRuleTimeframe: must be a positive number'); + } + this.cleanupOldMessages(maxRuleTimeframe); + } + + get(messageId: string): Message | undefined { + if (!messageId || typeof messageId !== 'string') { + throw new Error('Invalid messageId: must be a non-empty string'); + } + + // Update LRU on access - O(log n) for heap operations + if (this.cache.has(messageId)) { + const accessTime = ++this.nextAccessTime; + this.accessTimes.set(messageId, accessTime); + this.lruHeap.push(messageId, accessTime); + return this.cache.get(messageId); + } + return undefined; + } + + has(messageId: string): boolean { + if (!messageId || typeof messageId !== 'string') { + throw new Error('Invalid messageId: must be a non-empty string'); + } + return this.cache.has(messageId); + } + + delete(messageId: string): boolean { + if (!messageId || typeof messageId !== 'string') { + throw new Error('Invalid messageId: must be a non-empty string'); + } + + if (!this.cache.has(messageId)) { + return false; + } + + const message = this.cache.get(messageId)!; + const userId = message.author.id; + const channelId = message.channelId; + + // Remove from primary cache + this.cache.delete(messageId); + + // Remove from secondary indexes - O(1) + this.userMessages.get(userId)?.delete(messageId); + this.channelMessages.get(channelId)?.delete(messageId); + + // Clean up empty index sets + if (this.userMessages.get(userId)?.size === 0) { + this.userMessages.delete(userId); + } + if (this.channelMessages.get(channelId)?.size === 0) { + this.channelMessages.delete(channelId); + } + + // Remove from LRU tracking + this.accessTimes.delete(messageId); + // Note: We don't remove from heap immediately for performance + // Stale entries are handled during eviction + + return true; + } + + clear(): void { + this.cache.clear(); + this.userMessages.clear(); + this.channelMessages.clear(); + this.lruHeap.clear(); + this.accessTimes.clear(); + this.nextAccessTime = 0; + } + + size(): number { + return this.cache.size; + } + + // O(1) user message lookup using secondary index + getUserMessages(userId: string): Message[] { + if (!userId || typeof userId !== 'string') { + throw new Error('Invalid userId: must be a non-empty string'); + } + + const messageIds = this.userMessages.get(userId); + if (!messageIds) { + return []; + } + + return Array.from(messageIds) + .map((id) => this.cache.get(id)) + .filter((message): message is Message => message !== undefined); + } + + // O(1) channel message lookup using secondary index + getChannelMessages(channelId: string): Message[] { + if (!channelId || typeof channelId !== 'string') { + throw new Error('Invalid channelId: must be a non-empty string'); + } + + const messageIds = this.channelMessages.get(channelId); + if (!messageIds) { + return []; + } + + return Array.from(messageIds) + .map((id) => this.cache.get(id)) + .filter((msg): msg is Message => msg !== undefined); + } + + // O(k) where k is user's message count + getMessagesInTimeRange( + userId: string, + startTime: number, + endTime?: number + ): Message[] { + if (!userId || typeof userId !== 'string') { + throw new Error('Invalid userId: must be a non-empty string'); + } + if (typeof startTime !== 'number' || Number.isNaN(startTime)) { + throw new Error('Invalid startTime: must be a valid number'); + } + if ( + endTime !== undefined && + (typeof endTime !== 'number' || Number.isNaN(endTime)) + ) { + throw new Error('Invalid endTime: must be a valid number or undefined'); + } + if (endTime !== undefined && endTime < startTime) { + throw new Error( + 'Invalid time range: endTime must be greater than or equal to startTime' + ); + } + + const userMessageIds = this.userMessages.get(userId); + if (!userMessageIds) { + return []; + } + + return Array.from(userMessageIds) + .map((id) => this.cache.get(id)) + .filter( + (message): message is Message => + message !== undefined && + message.createdTimestamp >= startTime && + (endTime === undefined || message.createdTimestamp <= endTime) + ); + } + + bulkDeleteByIds(messageIds: string[]): void { + if (!Array.isArray(messageIds)) { + throw new Error('Invalid messageIds: must be an array'); + } + + for (const id of messageIds) { + if (typeof id !== 'string' || !id) { + throw new Error( + 'Invalid messageId in array: must be non-empty strings' + ); + } + } + + for (const id of messageIds) { + this.delete(id); + } + } +} + +export const cachedMessages = new MessageCache(5_000); diff --git a/src/util/cache.ts b/src/util/channel-prefetch.ts similarity index 95% rename from src/util/cache.ts rename to src/util/channel-prefetch.ts index 2ea348a..17f85d7 100644 --- a/src/util/cache.ts +++ b/src/util/channel-prefetch.ts @@ -2,7 +2,7 @@ import type { Guild } from 'discord.js'; import { getPublicChannels } from './channel.js'; const PER_CHANNEL_CACHE_LIMIT = 100; -export const cachedChannelsMap = new Set(); +const cachedChannelsMap = new Set(); export const fetchAndCachePublicChannelsMessages = async ( guild: Guild, diff --git a/src/util/message.ts b/src/util/message.ts deleted file mode 100644 index 866bf7b..0000000 --- a/src/util/message.ts +++ /dev/null @@ -1 +0,0 @@ -export const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; diff --git a/src/util/messages.test.ts b/src/util/messages.test.ts new file mode 100644 index 0000000..d181db6 --- /dev/null +++ b/src/util/messages.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { + replaceSpoilerHack, + stripCode, + stripEmoji, + jaccardSimilarity, +} from './messages.js'; + +void describe('utils/messages -> stripCode', () => { + void it('should remove inline code blocks', () => { + const input = 'This is a `code` block.'; + const expected = 'This is a block.'; + const actual = stripCode(input); + + assert.strictEqual(actual, expected); + }); + + void it('should remove multiple inline code blocks', () => { + const input = 'Here is `code1` and here is `code2`.'; + const expected = 'Here is and here is .'; + const actual = stripCode(input); + + assert.strictEqual(actual, expected); + }); + + void it('should handle strings without code blocks', () => { + const input = 'This string has no code blocks.'; + const expected = 'This string has no code blocks.'; + const actual = stripCode(input); + + assert.strictEqual(actual, expected); + }); + + void it('should remove code blocks', () => { + const input = '```function test() { return true; }```'; + const expected = ''; + const actual = stripCode(input); + + assert.strictEqual(actual, expected); + }); +}); + +void describe('utils/messages -> stripEmoji', () => { + void it('should remove emojis', () => { + const input = 'Hello :smile: world :custom_emoji:'; + const expected = 'Hello world '; + const actual = stripEmoji(input); + + assert.strictEqual(actual, expected); + }); + + void it('should handle strings without emojis', () => { + const input = 'This string has no emojis.'; + const expected = 'This string has no emojis.'; + const actual = stripEmoji(input); + + assert.strictEqual(actual, expected); + }); +}); + +void describe('utils/messages -> replaceSpoilerHack', () => { + void it('should replace spoiler hack sequences with the default replacement', () => { + const input = 'This is a spoiler ||\u200b|| and another ||\u200b||.'; + const expected = 'This is a spoiler [...] and another [...].'; + const actual = replaceSpoilerHack(input); + + assert.strictEqual(actual, expected); + }); + + void it('should replace spoiler hack sequences with a custom replacement', () => { + const input = 'Spoiler here ||\u200b||!'; + const expected = 'Spoiler here !'; + const actual = replaceSpoilerHack(input, ''); + + assert.strictEqual(actual, expected); + }); +}); + +void describe('jaccardSimilarity - crosspost detection', () => { + void it('catches identical self-promotion spam', () => { + const msg1 = + 'Check out my new portfolio website! Built with React and Tailwind'; + const msg2 = + 'Check out my new portfolio website! Built with React and Tailwind'; + const actual = jaccardSimilarity(msg1, msg2); + + assert.strictEqual(actual, 1); + }); + + void it('catches copy-paste spam with minor punctuation differences', () => { + const msg1 = 'hey guys check out my new website!'; + const msg2 = 'hey guys, check out my new website'; + const actual = jaccardSimilarity(msg1, msg2); + + assert.strictEqual(actual, 1); + }); + + void it('catches reordered messages', () => { + const msg1 = + 'I just launched my SaaS app! Check it out and let me know what you think'; + const msg2 = + 'Check it out and let me know what you think! I just launched my SaaS app'; + const actual = jaccardSimilarity(msg1, msg2); + assert.strictEqual(actual, 1); + }); + + void it('does not flag similar but different questions', () => { + const msg1 = 'How do I center a div in CSS?'; + const msg2 = 'How do I align a div to the right in CSS?'; + const actual = jaccardSimilarity(msg1, msg2); // 0.5833333333333334 + + assert.ok(actual > 0.5 && actual < 0.8); + }); +}); diff --git a/src/util/messages.ts b/src/util/messages.ts new file mode 100644 index 0000000..0b85d03 --- /dev/null +++ b/src/util/messages.ts @@ -0,0 +1,52 @@ +export const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; +import { type Message, MessageType, type PartialMessage } from 'discord.js'; + +export type GuildMessage = Message; + +export const isNormalUserMessage = ( + message: Message | PartialMessage +): message is Message => { + const { author, system, type } = message; + + return ( + message.inGuild() && + !system && + author !== null && + !author.bot && + !author.system && + (type === MessageType.Default || type === MessageType.Reply) + ); +}; + +export const stripCode = (content: string): string => + content.replace(/`[^`]*`/g, ''); + +export const stripEmoji = (content: string): string => + content.replace(/:\w+:/g, ''); + +export function replaceSpoilerHack( + messageContent: string | null, + replacement = '[...]' +) { + return (messageContent ?? '').replace(/(\|\|\u200b\|\|)+/g, replacement); +} + +// https://en.wikipedia.org/wiki/Jaccard_index +export function jaccardSimilarity(text1: string, text2: string): number { + const words1 = new Set(normalizeText(text1)); + const words2 = new Set(normalizeText(text2)); + + const intersection = words1.intersection(words2); + const union = words1.union(words2); + + return union.size === 0 ? 0 : intersection.size / union.size; +} + +const normalizeText = (text: string) => { + return text + .toLowerCase() + .replace(/[^\w\s]/g, '') // Remove punctuation & symbols + .trim() + .split(/\s+/) + .filter(Boolean); +};