diff --git a/dev/docker/docker-compose.yml b/dev/docker/docker-compose.yml index 0a4297f7..d2782193 100644 --- a/dev/docker/docker-compose.yml +++ b/dev/docker/docker-compose.yml @@ -18,7 +18,8 @@ services: - 8000:8000 image: steelcityamir/safe-content-ai:latest - redis: - image: redis + # redis with multithread support + keydb: + image: eqalpha/keydb ports: - 127.0.0.1:6379:6379 diff --git a/src/core/BaseClient.ts b/src/core/BaseClient.ts index b0f6f8a1..a247fa6b 100644 --- a/src/core/BaseClient.ts +++ b/src/core/BaseClient.ts @@ -1,14 +1,15 @@ import Constants from '#main/config/Constants.js'; import type BaseCommand from '#main/core/BaseCommand.js'; import type { InteractionFunction } from '#main/decorators/Interaction.js'; +import AntiSpamManager from '#main/managers/AntiSpamManager.js'; import UserDbManager from '#main/managers/UserDbManager.js'; import CooldownService from '#main/modules/CooldownService.js'; import EventLoader from '#main/modules/Loaders/EventLoader.js'; import Scheduler from '#main/modules/SchedulerService.js'; +import type { RemoveMethods } from '#types/index.d.ts'; import { loadCommandFiles, loadInteractions } from '#utils/CommandUtils.js'; import { loadLocales } from '#utils/Locale.js'; import { resolveEval } from '#utils/Utils.js'; -import type { RemoveMethods } from '#types/index.d.ts'; import { ClusterClient, getInfo } from 'discord-hybrid-sharding'; import { type Guild, @@ -38,6 +39,11 @@ export default class InterChatClient extends Client { readonly commandCooldowns = new CooldownService(); public readonly commands = new Collection(); public readonly interactions = new Collection(); + public readonly antiSpamManager = new AntiSpamManager({ + spamThreshold: 4, + timeWindow: 5000, + spamCountExpirySecs: 60, + }); constructor() { super({ diff --git a/src/managers/AntiSpamManager.ts b/src/managers/AntiSpamManager.ts new file mode 100644 index 00000000..45be1044 --- /dev/null +++ b/src/managers/AntiSpamManager.ts @@ -0,0 +1,76 @@ +import getRedis from '#main/utils/Redis.js'; +import { Message } from 'discord.js'; +import type { Redis } from 'ioredis'; + +export default class AntiSpamManager { + private config: SpamConfig; + private redis: Redis; + + constructor(config: SpamConfig, redis = getRedis()) { + this.redis = redis; + this.config = config; + } + + public async handleMessage(message: Message): Promise { + const userId = message.author.id; + const currentTime = Date.now(); + const key = `spam:${userId}`; + + const userInfo = await this.getUserInfo(key); + + if (currentTime - userInfo.lastMessage < this.config.timeWindow) { + userInfo.messageCount++; + if (userInfo.messageCount >= this.config.spamThreshold) { + userInfo.lastMessage = currentTime; + await this.incrementSpamCount(message.author.id); + await this.setUserInfo(key, userInfo); + return userInfo; + } + } + else { + userInfo.messageCount = 1; + } + + userInfo.lastMessage = currentTime; + await this.setUserInfo(key, userInfo); + } + + private async getUserInfo(key: string): Promise { + const data = await this.redis.hgetall(key); + return { + messageCount: parseInt(data.messageCount || '0', 10), + lastMessage: parseInt(data.lastMessage || '0', 10), + }; + } + + private async setUserInfo(key: string, info: UserMessageInfo): Promise { + await this.redis.hmset(key, { + messageCount: info.messageCount.toString(), + lastMessage: info.lastMessage.toString(), + }); + await this.redis.expire(key, this.config.timeWindow / 1000); + } + + private async incrementSpamCount(userId: string): Promise { + const key = `spamcount:${userId}`; + await this.redis.incr(key); + await this.redis.expire(key, this.config.spamCountExpirySecs); + } + + public async getSpamCount(userId: string): Promise { + const key = `spamcount:${userId}`; + const count = await this.redis.get(key); + return parseInt(count || '0', 10); + } +} + +interface UserMessageInfo { + messageCount: number; + lastMessage: number; +} + +interface SpamConfig { + spamThreshold: number; + timeWindow: number; + spamCountExpirySecs: number; +} diff --git a/src/modules/NSFWDetection.ts b/src/modules/NSFWDetection.ts index 9b8d3eff..99eda536 100644 --- a/src/modules/NSFWDetection.ts +++ b/src/modules/NSFWDetection.ts @@ -15,7 +15,7 @@ export const analyzeImageForNSFW = async (imageURL: string): Promise - prediction.is_nsfw && prediction.confidence_percentage >= minConfidence; +export const isImageUnsafe = ( + prediction: predictionType | undefined, + minConfidence = 80, +): boolean => Boolean(prediction?.is_nsfw && prediction.confidence_percentage >= minConfidence); diff --git a/src/types/index.d.ts b/src/types/index.d.ts index b4e9b140..2fe058f2 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,8 +1,9 @@ import BaseCommand from '#main/core/BaseCommand.js'; import { InteractionFunction } from '#main/decorators/Interaction.ts'; +import AntiSpamManager from '#main/managers/AntiSpamManager.js'; +import UserDbManager from '#main/managers/UserDbManager.js'; import CooldownService from '#main/modules/CooldownService.js'; import Scheduler from '#main/modules/SchedulerService.js'; -import UserDbManager from '#main/managers/UserDbManager.js'; import { ClusterClient } from 'discord-hybrid-sharding'; import { Collection, @@ -31,6 +32,7 @@ declare module 'discord.js' { readonly reactionCooldowns: Collection; readonly cluster: ClusterClient; readonly userManager: UserDbManager; + readonly antiSpamManager: AntiSpamManager; fetchGuild(guildId: Snowflake): Promise | undefined>; getScheduler(): Scheduler; diff --git a/src/utils/network/antiSpam.ts b/src/utils/network/antiSpam.ts deleted file mode 100644 index 2742a3d2..00000000 --- a/src/utils/network/antiSpam.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Collection, User } from 'discord.js'; - -interface AntiSpamUserOpts { - timestamps: number[]; - infractions: number; -} - -export const antiSpamMap = new Collection(); - -const WINDOW_SIZE = 5000; -const MAX_STORE = 3; - -/** - * Sets spam timers for a given user. - * @param userId - The ID of the user to set spam timers for. - * @returns void - */ -export const setSpamTimers = (user: User) => { - const five_min = 60 * 5000; - const userInCol = antiSpamMap.get(user.id); - const scheduler = user.client.getScheduler(); - const lastMsgTimestamp = userInCol?.timestamps[userInCol.timestamps.length - 1]; - - if (lastMsgTimestamp && Date.now() - five_min <= lastMsgTimestamp) { - scheduler.stopTask(`removeFromCol_${user.id}`); - } - - scheduler.addRecurringTask(`removeFromCol_${user.id}`, five_min, () => { - antiSpamMap.delete(user.id); - }); -}; - -/** - * Runs the anti-spam mechanism for a given user. - * @param author - The user to run the anti-spam mechanism for. - * @param maxInfractions - The maximum number of infractions before the user is blacklisted. - * @returns The user's anti-spam data if they have reached the maximum number of infractions, otherwise undefined. - */ -export const runAntiSpam = (author: User, maxInfractions = MAX_STORE) => { - const userInCol = antiSpamMap.get(author.id); - const currentTimestamp = Date.now(); - - if (!userInCol) { - antiSpamMap.set(author.id, { - timestamps: [currentTimestamp], - infractions: 0, - }); - setSpamTimers(author); - return null; - } - - // resetting count as it is assumed they will be blacklisted right after - if (userInCol.infractions >= maxInfractions) { - antiSpamMap.delete(author.id); - return userInCol; - } - - const { timestamps } = userInCol; - - // Check if all the timestamps are within the window - if (timestamps.length === MAX_STORE) { - const [oldestTimestamp] = timestamps; - const isWithinWindow = currentTimestamp - oldestTimestamp <= WINDOW_SIZE; - - antiSpamMap.set(author.id, { - timestamps: [...timestamps.slice(1), currentTimestamp], - infractions: isWithinWindow ? userInCol.infractions + 1 : userInCol.infractions, - }); - setSpamTimers(author); - if (isWithinWindow) return userInCol; - } - else { - antiSpamMap.set(author.id, { - timestamps: [...timestamps, currentTimestamp], - infractions: userInCol.infractions, - }); - } - return null; -}; diff --git a/src/utils/network/runChecks.ts b/src/utils/network/runChecks.ts index a4d99249..f077fefa 100644 --- a/src/utils/network/runChecks.ts +++ b/src/utils/network/runChecks.ts @@ -3,20 +3,18 @@ import BlacklistManager from '#main/managers/BlacklistManager.js'; import HubSettingsManager from '#main/managers/HubSettingsManager.js'; import UserInfractionManager from '#main/managers/InfractionManager/UserInfractionManager.js'; import { analyzeImageForNSFW, isImageUnsafe } from '#main/modules/NSFWDetection.js'; +import { sendBlacklistNotif } from '#main/utils/moderation/blacklistUtils.js'; import db from '#utils/Db.js'; import { isHubMod } from '#utils/hub/utils.js'; -import { logBlacklist } from '#utils/HubLogger/ModLogs.js'; import logProfanity from '#utils/HubLogger/Profanity.js'; import { supportedLocaleCodes, t } from '#utils/Locale.js'; -import { sendBlacklistNotif } from '#utils/moderation/blacklistUtils.js'; +import { createRegexFromWords } from '#utils/moderation/blockedWords.js'; import { sendWelcomeMsg } from '#utils/network/helpers.js'; import { check as checkProfanity } from '#utils/ProfanityUtils.js'; import { containsInviteLinks, replaceLinks } from '#utils/Utils.js'; import { Hub, MessageBlockList } from '@prisma/client'; import { stripIndents } from 'common-tags'; import { Awaitable, EmbedBuilder, Message } from 'discord.js'; -import { runAntiSpam } from './antiSpam.js'; -import { createRegexFromWords } from '#utils/moderation/blockedWords.js'; interface CheckResult { passed: boolean; @@ -142,26 +140,30 @@ function checkLinks(message: Message, opts: CheckFunctionOpts): CheckResul async function checkSpam(message: Message, opts: CheckFunctionOpts): Promise { const { settings, hub } = opts; - const antiSpamResult = runAntiSpam(message.author, 3); - if (settings.getSetting('SpamFilter') && antiSpamResult && antiSpamResult.infractions >= 3) { - 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(new UserInfractionManager(target.id)); - await blacklistManager.addBlacklist({ hubId: hub.id, reason, expiresAt, moderatorId: mod.id }); - - await logBlacklist(hub.id, message.client, { target, mod, reason, expiresAt }).catch( - () => null, - ); - - await sendBlacklistNotif('user', message.client, { - target, - hubId: hub.id, - expiresAt, - reason, - }).catch(() => null); + const result = await message.client.antiSpamManager.handleMessage(message); + if (settings.getSetting('SpamFilter') && result) { + if (result.messageCount >= 6) { + const expiresAt = new Date(Date.now() + 60 * 5000); + const reason = 'Auto-blacklisted for spamminag.'; + const target = message.author; + const mod = message.client.user; + + const blacklistManager = new BlacklistManager(new UserInfractionManager(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(emojis.timeout).catch(() => null); return { passed: false }; @@ -260,7 +262,7 @@ async function checkNSFW(message: Message, opts: CheckFunctionOpts): Promi const { attachmentURL } = opts; if (attachmentURL && Constants.Regex.StaticImageUrl.test(attachmentURL)) { const predictions = await analyzeImageForNSFW(attachmentURL); - if (predictions.length > 0 && isImageUnsafe(predictions[0])) { + if (isImageUnsafe(predictions.at(0))) { const nsfwEmbed = new EmbedBuilder() .setColor(Constants.Colors.invisible) .setDescription(