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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 42 additions & 24 deletions src/features/spam-detection/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -11,6 +12,17 @@ type ActionConfig = {
log?: LogFunction;
};

export const deleteMessageDuringModeration = async (
message: Message
): Promise<void> => {
if (!message.deletable) {
return;
}

cachedMessages.delete(message.id);
await message.delete();
};

const handleBulkDeleteMessages = async (messages: Message[]) => {
const messagesByChannel = new Map<string, string[]>();
for (const message of messages) {
Expand Down Expand Up @@ -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);
}
};
};

Expand Down
8 changes: 8 additions & 0 deletions src/features/spam-detection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 ||
Expand Down
47 changes: 46 additions & 1 deletion src/features/spam-detection/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -23,6 +23,39 @@ const options = {
muteDuration: 1 * HOUR,
} satisfies LogFunctionOptions<ContentBasedRule>;

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<CrossChannelRule>;

void describe('spam-detection/logs -> createLogTextContent', () => {
void it('should create log content for a content-based rule', () => {
const logContent = createLogTextContent(options);
Expand All @@ -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>')
);
});
});
2 changes: 1 addition & 1 deletion src/features/spam-detection/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const createLogTextContent = <T extends Rule>(
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`
);
Expand Down
22 changes: 22 additions & 0 deletions src/features/spam-detection/moderation-state.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
});
19 changes: 19 additions & 0 deletions src/features/spam-detection/moderation-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const moderationCounts = new Map<string, number>();

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);
};
6 changes: 6 additions & 0 deletions src/features/spam-detection/rules-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type ContentBasedRule = {

export type CrossChannelRule = {
isBrokenBy: (newMessage: Message, oldMessage: Message) => boolean;
logType?: 'crossPost';
timeframe: number;
channelCount: number;
action: (
Expand Down Expand Up @@ -70,34 +71,39 @@ export const rules: Rule[] = [
{
type: 'crossChannel',
isBrokenBy: isCrossPost,
logType: 'crossPost',
timeframe: 15 * SECOND,
channelCount: 3,
action: handleCrossPostingAction,
},
{
type: 'crossChannel',
isBrokenBy: isCrossPost,
logType: 'crossPost',
timeframe: 25 * SECOND,
channelCount: 4,
action: handleCrossPostingAction,
},
{
type: 'crossChannel',
isBrokenBy: isCrossPost,
logType: 'crossPost',
timeframe: 40 * SECOND,
channelCount: 5,
action: handleCrossPostingAction,
},
{
type: 'crossChannel',
isBrokenBy: isCrossPost,
logType: 'crossPost',
timeframe: 1 * MINUTE,
channelCount: 6,
action: handleCrossPostingAction,
},
{
type: 'crossChannel',
isBrokenBy: isCrossPost,
logType: 'crossPost',
timeframe: 2 * MINUTE,
channelCount: 7,
action: handleCrossPostingAction,
Expand Down
Loading