Skip to content
This repository was archived by the owner on Oct 9, 2025. It is now read-only.
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
17 changes: 7 additions & 10 deletions src/services/MessageProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,14 @@ export class MessageProcessor {
}

const attachmentURL = await this.broadcastService.resolveAttachmentURL(message);
const checker = await runChecks(message, hub, {
userData,
settings: hub.settings,
attachmentURL,
totalHubConnections: hubConnections.length + 1,
});

if (
!(await runChecks(message, hub, {
userData,
settings: hub.settings,
attachmentURL,
totalHubConnections: hubConnections.length + 1,
}))
) {
return { handled: false };
}
if (!checker.passed) return { handled: false };;

message.channel.sendTyping().catch(() => null);

Expand Down
4 changes: 2 additions & 2 deletions src/utils/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export const enum RedisKeys {
broadcasts = 'broadcasts',
messageReverse = 'messageReverse',
Hub = 'hub',
Spam = 'spam',
SpamBucket = 'spamBucket',
Spam = 'spam', // FIXME: remove
DevAnnouncement = 'DevAnnouncement',
}

Expand Down Expand Up @@ -81,7 +82,6 @@ export default {
},

Channels: {
goal: '906460473065615403',
inviteLogs: '1246117516099457146',
},

Expand Down
51 changes: 51 additions & 0 deletions src/utils/checkSpam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { RedisKeys } from '#src/utils/Constants.js';
import getRedis from '#src/utils/Redis.js';

// Token Bucket parameters.
const MAX_TOKENS = 5; // Maximum tokens per bucket.
const REFILL_RATE = 0.5; // Tokens per second.
const COST_PER_MESSAGE = 1; // Each message costs 1 token.

// Interface for our bucket data.
interface TokenBucket {
tokens: number;
last: number; // Timestamp in milliseconds.
}

/**
* Checks whether a message from a user is allowed based on the token bucket.
* @returns `true` if allowed, `false` if considered spam.
*/
export async function checkSpam(userId: string): Promise<boolean> {
const redis = getRedis();
const now = Date.now();
const bucketKey = `${RedisKeys.SpamBucket}:${userId}`;

const bucketData = await redis.get(bucketKey);
let bucket: TokenBucket;

if (!bucketData) {
// No bucket exists for this user; initialize with a full bucket.
bucket = { tokens: MAX_TOKENS, last: now };
}
else {
bucket = JSON.parse(bucketData) as TokenBucket;
// Calculate how many tokens to add since the last update.
const deltaSeconds = (now - bucket.last) / 1000;
bucket.tokens = Math.min(MAX_TOKENS, bucket.tokens + deltaSeconds * REFILL_RATE);
bucket.last = now;
}

// Check if there are enough tokens to send a message.
if (bucket.tokens < COST_PER_MESSAGE) {
// Update the bucket in Redis with an expiry.
await redis.set(bucketKey, JSON.stringify(bucket), 'EX', Math.ceil(MAX_TOKENS / REFILL_RATE));
return false; // Not enough tokens: message is spam.
}
else {
// Deduct the cost for the message.
bucket.tokens -= COST_PER_MESSAGE;
await redis.set(bucketKey, JSON.stringify(bucket), 'EX', Math.ceil(MAX_TOKENS / REFILL_RATE));
return true; // Message is allowed.
}
}
72 changes: 36 additions & 36 deletions src/utils/network/runChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import Constants from '#utils/Constants.js';
import { t } from '#utils/Locale.js';
import { containsInviteLinks, fetchUserLocale, replaceLinks } from '#utils/Utils.js';
import { checkBlockedWords } from '#src/utils/network/antiSwearChecks.js';
import { checkSpam } from '#src/utils/checkSpam.js';

export interface CheckResult {
passed: boolean;
Expand All @@ -50,7 +51,7 @@ const checks: CheckFunction[] = [
checkBanAndBlacklist,
checkAntiSwear,
checkHubLock,
checkSpam,
checkSpamContent,
checkNewUser,
checkMessageLength,
checkInviteLinks,
Expand Down Expand Up @@ -85,16 +86,16 @@ export const runChecks = async (
totalHubConnections: number;
attachmentURL?: string | null;
},
): Promise<boolean> => {
): Promise<CheckResult> => {
for (const check of checks) {
const result = await check(message, { ...opts, hub });
if (!result.passed) {
if (result.reason) await replyToMsg(message, { content: result.reason });
return false;
return { passed: false };
}
}

return true;
return { passed: true };
};

async function checkAntiSwear(
Expand Down Expand Up @@ -125,7 +126,7 @@ async function checkHubLock(
return {
passed: false,
reason:
'This hub\'s chat has been locked. Only moderators can send messages. Please check back later as this may be temporary.',
'This hub is currently locked. Only moderators can send messages until the lock is removed.',
};
}
return { passed: true };
Expand All @@ -144,39 +145,38 @@ function checkLinks(message: Message<true>, opts: CheckFunctionOpts): CheckResul
return { passed: true };
}

async function checkSpam(message: Message<true>, opts: CheckFunctionOpts): Promise<CheckResult> {
async function checkSpamContent(
message: Message<true>,
opts: CheckFunctionOpts,
): Promise<CheckResult> {
const { settings, hub } = opts;
const result = await message.client.antiSpamManager.handleMessage(message);

if (settings.has('SpamFilter') && result) {
if (result.messageCount >= 6) {
const expiresAt = new Date(Date.now() + 60 * 5000);
const reason = 'Auto-blacklisted for spamming.';
const target = message.author;
const mod = message.client.user;

const blacklistManager = new BlacklistManager('user', target.id);
await blacklistManager.addBlacklist({
hubId: hub.id,
reason,
expiresAt,
moderatorId: mod.id,
});

await blacklistManager.log(hub.id, message.client, {
mod,
reason,
expiresAt,
});
await sendBlacklistNotif('user', message.client, {
target,
hubId: hub.id,
expiresAt,
reason,
}).catch(() => null);
}
const allowed = await checkSpam(message.author.id);

if (!allowed) {
message.react(getEmoji('timeout', message.client)).catch(() => null);
if (!settings.has('SpamFilter')) return { passed: false };

const expiresAt = new Date(Date.now() + 60 * 10_000);
const reason = 'Auto-blacklisted for spamming.';
const target = message.author;
const mod = message.client.user;

const blacklistManager = new BlacklistManager('user', target.id);
await blacklistManager.addBlacklist({
hubId: hub.id,
reason,
expiresAt,
moderatorId: mod.id,
});

await blacklistManager.log(hub.id, message.client, { mod, reason, expiresAt });
await sendBlacklistNotif('user', message.client, {
target,
hubId: hub.id,
expiresAt,
reason,
}).catch(() => null);

await message.react(getEmoji('timeout', message.client)).catch(() => null);
return { passed: false };
}
return { passed: true };
Expand Down