diff --git a/src/services/MessageProcessor.ts b/src/services/MessageProcessor.ts index 2961abc04..50429d0cf 100644 --- a/src/services/MessageProcessor.ts +++ b/src/services/MessageProcessor.ts @@ -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); diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 170b92851..ad33ebde3 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -20,7 +20,8 @@ export const enum RedisKeys { broadcasts = 'broadcasts', messageReverse = 'messageReverse', Hub = 'hub', - Spam = 'spam', + SpamBucket = 'spamBucket', + Spam = 'spam', // FIXME: remove DevAnnouncement = 'DevAnnouncement', } @@ -81,7 +82,6 @@ export default { }, Channels: { - goal: '906460473065615403', inviteLogs: '1246117516099457146', }, diff --git a/src/utils/checkSpam.ts b/src/utils/checkSpam.ts new file mode 100644 index 000000000..2a9291c58 --- /dev/null +++ b/src/utils/checkSpam.ts @@ -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 { + 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. + } +} diff --git a/src/utils/network/runChecks.ts b/src/utils/network/runChecks.ts index ae6207dd8..5f0249ad6 100644 --- a/src/utils/network/runChecks.ts +++ b/src/utils/network/runChecks.ts @@ -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; @@ -50,7 +51,7 @@ const checks: CheckFunction[] = [ checkBanAndBlacklist, checkAntiSwear, checkHubLock, - checkSpam, + checkSpamContent, checkNewUser, checkMessageLength, checkInviteLinks, @@ -85,16 +86,16 @@ export const runChecks = async ( totalHubConnections: number; attachmentURL?: string | null; }, -): Promise => { +): Promise => { 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( @@ -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 }; @@ -144,39 +145,38 @@ function checkLinks(message: Message, opts: CheckFunctionOpts): CheckResul return { passed: true }; } -async function checkSpam(message: Message, opts: CheckFunctionOpts): Promise { +async function checkSpamContent( + message: Message, + opts: CheckFunctionOpts, +): Promise { 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 };