Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b4f364a
mig: add message-cache from moderation-tool and rename both cache fil…
michal-skraburski Aug 16, 2026
e2bbe93
feat: spam-detection, see desc
michal-skraburski Aug 16, 2026
d25aad9
fix: stray console log rm
michal-skraburski Aug 16, 2026
614653a
mig: add reportMessage feat from moderation tool
michal-skraburski Aug 16, 2026
7867113
feat: move report-spam into report-message
michal-skraburski Aug 16, 2026
cc77fca
fix: enable spam-detection & reportMessage
michal-skraburski Aug 16, 2026
aa13e3d
Merge branch 'main' into moderation-migrate
michal-skraburski Aug 16, 2026
8c5bdfd
Update .env.test
michal-skraburski Aug 16, 2026
c964088
Update .env.production
michal-skraburski Aug 16, 2026
c7859d5
fix: rename moderation/cache-messages to features/cache-messages
michal-skraburski Aug 16, 2026
4052f88
fix: remove redudant comments
michal-skraburski Aug 16, 2026
0ea9ab7
Update src/util/cache/channel-prefetch.ts
michal-skraburski Aug 16, 2026
d19e559
fix: merge 3 commits, rm redudant comments, rename moderation/cache-m…
michal-skraburski Aug 16, 2026
13fc65d
fix: rename channel-prefetch one level down
michal-skraburski Aug 16, 2026
28b951d
fix: remove comments + hoist options in log.test.ts
michal-skraburski Aug 26, 2026
721676c
Merge branch 'main' into moderation-migrate
michal-skraburski Aug 26, 2026
359e355
fix: remove comments + hoist options in log.test.ts
michal-skraburski Aug 26, 2026
0f27486
fix: remove comments in log.test.ts
michal-skraburski Aug 26, 2026
c4a3304
fix: update messages.test.ts to use better vitest format
michal-skraburski Aug 26, 2026
36879d0
Merge remote-tracking branch 'refs/remotes/origin/moderation-migrate'…
michal-skraburski Aug 26, 2026
3831169
refactor: move text utils to text.ts
hmd-ali Aug 27, 2026
392a0ea
Merge branch 'main' into moderation-migrate
hmd-ali Aug 27, 2026
05dd462
refactor: rename variables to accommodate to id-length lint rule
hmd-ali Aug 27, 2026
58bdec3
refactor: remove comment
hmd-ali Aug 27, 2026
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 @@ -31,4 +32,4 @@ TAG_ACCESS_ROLE_ID=your-tag-access-role-id
GUIDES_TRACKER_PATH=guides-tracker.json
ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json

DATABASE_URL=test.db
DATABASE_URL=test.db
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';
import { tagCommand } from '@/features/tags/index.js';
import { botOptionsCommand } from '@/features/bot-options/index.js';

Expand All @@ -22,6 +23,7 @@ export const commands = new Map<string, Command>(
publicGuidesCommand,
createShowcaseCommand,
sendShowcasePinnedMessage,
reportMessage,
tagCommand,
botOptionsCommand,
]
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,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';
import { tagReceivedEvent } from '@/features/tags/tag-received.js';
import { reactionAddEvent } from '@/features/reactions/index.js';
Expand All @@ -13,6 +14,7 @@ export const events: DiscordEvent[] = [
guildCreateEvent,
hasVarEvent,
interactionCreateEvent,
spamDetection,
archiveChannels,
tagReceivedEvent,
reactionAddEvent,
Expand Down
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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/text.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;
51 changes: 51 additions & 0 deletions src/features/spam-detection/detectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Message } from 'discord.js';
import { jaccardSimilarity } from '@/util/text.js';
import { replaceSpoilerHack } from '@/util/text.js';
import { stripCode } from '@/util/text.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));
};
Comment thread
wiktoriavh marked this conversation as resolved.

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 content = message.content.toLowerCase().trim();
const oldContent = oldMessage.content.toLowerCase().trim();
if (content === oldContent) {
return true;
}
// followed by jaccard for catching reordered/slightly altered messages with high similarity
return jaccardSimilarity(content, oldContent) > 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
Loading