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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 2 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json
4 changes: 3 additions & 1 deletion src/common/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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';
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<string, Command>(
[
Expand All @@ -20,6 +21,7 @@ export const commands = new Map<string, Command>(
publicGuidesCommand,
createShowcaseCommand,
sendShowcasePinnedMessage,
reportMessage,
]
.flat()
.map((command) => [command.data.name, command])
Expand Down
2 changes: 2 additions & 0 deletions src/common/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ 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[] = [
readyEvent,
guildCreateEvent,
hasVarEvent,
interactionCreateEvent,
spamDetection,
archiveChannels,
].flat();
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Comment thread
michal-skraburski marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/features/has-var/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/features/ready/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
70 changes: 70 additions & 0 deletions src/features/report-message/index.ts
Original file line number Diff line number Diff line change
@@ -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.',
});
}
},
});
115 changes: 115 additions & 0 deletions src/features/spam-detection/actions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();
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,
});
9 changes: 9 additions & 0 deletions src/features/spam-detection/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
53 changes: 53 additions & 0 deletions src/features/spam-detection/detectors.ts
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions src/features/spam-detection/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
);
Loading