diff --git a/src/features/spam-detection/actions.ts b/src/features/spam-detection/actions.ts index cb0f359..a3011a9 100644 --- a/src/features/spam-detection/actions.ts +++ b/src/features/spam-detection/actions.ts @@ -2,6 +2,7 @@ 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 { finishModeration, startModeration } from './moderation-state.js'; import type { Rule } from './rules-config.js'; type ActionConfig = { @@ -11,6 +12,17 @@ type ActionConfig = { log?: LogFunction; }; +export const deleteMessageDuringModeration = async ( + message: Message +): Promise => { + if (!message.deletable) { + return; + } + + cachedMessages.delete(message.id); + await message.delete(); +}; + const handleBulkDeleteMessages = async (messages: Message[]) => { const messagesByChannel = new Map(); for (const message of messages) { @@ -52,35 +64,41 @@ const handleAction = (config: ActionConfig) => { 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; + startModeration(author.id); + try { + 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); } - } catch (error) { - console.error('Failed to mute user:', error); } - } - let deletedMessagesCount = 0; + let deletedMessagesCount = 0; - if (config.deleteMessages) { - deletedMessagesCount = await handleBulkDeleteMessages(messages); - } + 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, - }); + const logFunction = config.log || defaultLogFunction; + await logFunction({ + messages, + reason: config.reason, + logChannel, + deletedMessagesCount, + muteDuration: muted ? config.muteDuration : undefined, + rule, + }); + } finally { + finishModeration(author.id); + } }; }; diff --git a/src/features/spam-detection/index.ts b/src/features/spam-detection/index.ts index 7d0cd49..62e4aa8 100644 --- a/src/features/spam-detection/index.ts +++ b/src/features/spam-detection/index.ts @@ -2,6 +2,8 @@ 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 { deleteMessageDuringModeration } from './actions.js'; +import { isUserBeingModerated } from './moderation-state.js'; import { checkRules } from './rules.js'; import { isNormalUserMessage } from '@/util/messages.js'; @@ -13,6 +15,12 @@ export const spamDetection = createEvent( if (!isNormalUserMessage(message)) { return; } + + if (isUserBeingModerated(message.author.id)) { + await deleteMessageDuringModeration(message); + return; + } + const regularRole = message.guild?.roles.cache.get(config.roleIds.regular); if ( regularRole === undefined || diff --git a/src/features/spam-detection/logs.test.ts b/src/features/spam-detection/logs.test.ts index 01c418b..83cf666 100644 --- a/src/features/spam-detection/logs.test.ts +++ b/src/features/spam-detection/logs.test.ts @@ -3,7 +3,7 @@ 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'; +import type { ContentBasedRule, CrossChannelRule } from './rules-config.js'; const options = { rule: { @@ -23,6 +23,39 @@ const options = { muteDuration: 1 * HOUR, } satisfies LogFunctionOptions; +const crossChannelOptions = { + rule: { + type: 'crossChannel', + isBrokenBy: () => true, + logType: 'crossPost', + timeframe: 15_000, + channelCount: 3, + action: async () => {}, + }, + messages: [ + { + content: 'Repeated message', + channelId: '123', + author: { id: '1' }, + attachments: { size: 0 }, + }, + { + content: 'Repeated message', + channelId: '456', + author: { id: '1' }, + attachments: { size: 0 }, + }, + { + content: 'Repeated message', + channelId: '789', + author: { id: '1' }, + attachments: { size: 0 }, + }, + ] as Message[], + deletedMessagesCount: 3, + reason: 'Cross-posting', +} satisfies LogFunctionOptions; + void describe('spam-detection/logs -> createLogTextContent', () => { void it('should create log content for a content-based rule', () => { const logContent = createLogTextContent(options); @@ -33,4 +66,16 @@ void describe('spam-detection/logs -> createLogTextContent', () => { assert(logContent.includes('This message contains a banned tag')); assert(logContent.includes('**Channel:** <#123>')); }); + + void it('should create log content for a cross-channel rule', () => { + const logContent = createLogTextContent(crossChannelOptions); + + assert(logContent.includes('Posted in **3** channels within')); + assert(logContent.includes('15 seconds')); + assert(logContent.includes('**Flagged Message:**')); + assert(logContent.includes('Repeated message')); + assert( + logContent.includes('**Channels Involved:** <#123>, <#456>, <#789>') + ); + }); }); diff --git a/src/features/spam-detection/logs.ts b/src/features/spam-detection/logs.ts index 99b1846..4cb1b60 100644 --- a/src/features/spam-detection/logs.ts +++ b/src/features/spam-detection/logs.ts @@ -63,7 +63,7 @@ export const createLogTextContent = ( break; } case 'crossChannel': { - if (options.rule.isBrokenBy.name === 'isCrossPost') { + if (options.rule.logType === 'crossPost') { content.push( `Posted in **${options.rule.channelCount}** channels within **${timeToString(options.rule.timeframe)} **\n` ); diff --git a/src/features/spam-detection/moderation-state.test.ts b/src/features/spam-detection/moderation-state.test.ts new file mode 100644 index 0000000..f7726f8 --- /dev/null +++ b/src/features/spam-detection/moderation-state.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { + finishModeration, + isUserBeingModerated, + startModeration, +} from './moderation-state.js'; + +void describe('spam-detection', () => { + void describe('moderation-state', () => { + void it('tracks a user until all active moderation actions finish', () => { + startModeration('user-1'); + startModeration('user-1'); + + finishModeration('user-1'); + assert(isUserBeingModerated('user-1')); + + finishModeration('user-1'); + assert(!isUserBeingModerated('user-1')); + }); + }); +}); diff --git a/src/features/spam-detection/moderation-state.ts b/src/features/spam-detection/moderation-state.ts new file mode 100644 index 0000000..e007ff3 --- /dev/null +++ b/src/features/spam-detection/moderation-state.ts @@ -0,0 +1,19 @@ +const moderationCounts = new Map(); + +export const isUserBeingModerated = (userId: string): boolean => { + return moderationCounts.has(userId); +}; + +export const startModeration = (userId: string): void => { + moderationCounts.set(userId, (moderationCounts.get(userId) ?? 0) + 1); +}; + +export const finishModeration = (userId: string): void => { + const moderationCount = moderationCounts.get(userId); + if (moderationCount === undefined || moderationCount === 1) { + moderationCounts.delete(userId); + return; + } + + moderationCounts.set(userId, moderationCount - 1); +}; diff --git a/src/features/spam-detection/rules-config.ts b/src/features/spam-detection/rules-config.ts index a6460ae..6c7b810 100644 --- a/src/features/spam-detection/rules-config.ts +++ b/src/features/spam-detection/rules-config.ts @@ -27,6 +27,7 @@ export type ContentBasedRule = { export type CrossChannelRule = { isBrokenBy: (newMessage: Message, oldMessage: Message) => boolean; + logType?: 'crossPost'; timeframe: number; channelCount: number; action: ( @@ -70,6 +71,7 @@ export const rules: Rule[] = [ { type: 'crossChannel', isBrokenBy: isCrossPost, + logType: 'crossPost', timeframe: 15 * SECOND, channelCount: 3, action: handleCrossPostingAction, @@ -77,6 +79,7 @@ export const rules: Rule[] = [ { type: 'crossChannel', isBrokenBy: isCrossPost, + logType: 'crossPost', timeframe: 25 * SECOND, channelCount: 4, action: handleCrossPostingAction, @@ -84,6 +87,7 @@ export const rules: Rule[] = [ { type: 'crossChannel', isBrokenBy: isCrossPost, + logType: 'crossPost', timeframe: 40 * SECOND, channelCount: 5, action: handleCrossPostingAction, @@ -91,6 +95,7 @@ export const rules: Rule[] = [ { type: 'crossChannel', isBrokenBy: isCrossPost, + logType: 'crossPost', timeframe: 1 * MINUTE, channelCount: 6, action: handleCrossPostingAction, @@ -98,6 +103,7 @@ export const rules: Rule[] = [ { type: 'crossChannel', isBrokenBy: isCrossPost, + logType: 'crossPost', timeframe: 2 * MINUTE, channelCount: 7, action: handleCrossPostingAction,