Skip to content
This repository was archived by the owner on Oct 9, 2025. It is now read-only.
Merged
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
5 changes: 3 additions & 2 deletions dev/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion src/core/BaseClient.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -38,6 +39,11 @@ export default class InterChatClient extends Client {
readonly commandCooldowns = new CooldownService();
public readonly commands = new Collection<string, BaseCommand>();
public readonly interactions = new Collection<string, InteractionFunction>();
public readonly antiSpamManager = new AntiSpamManager({
spamThreshold: 4,
timeWindow: 5000,
spamCountExpirySecs: 60,
});

constructor() {
super({
Expand Down
76 changes: 76 additions & 0 deletions src/managers/AntiSpamManager.ts
Original file line number Diff line number Diff line change
@@ -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<UserMessageInfo | undefined> {
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<UserMessageInfo> {
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<void> {
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<void> {
const key = `spamcount:${userId}`;
await this.redis.incr(key);
await this.redis.expire(key, this.config.spamCountExpirySecs);
}

public async getSpamCount(userId: string): Promise<number> {
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;
}
8 changes: 5 additions & 3 deletions src/modules/NSFWDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const analyzeImageForNSFW = async (imageURL: string): Promise<predictionT
});

const data = await res.json();
if (res.status !== 200) throw new Error(`Failed to analyze image: ${data}`);
if (res.status !== 200) throw new Error('Failed to analyze image:', data);
return data;
};

Expand All @@ -24,5 +24,7 @@ export const analyzeImageForNSFW = async (imageURL: string): Promise<predictionT
* @param predictions The predictions to check
* @returns Whether the predictions are unsafe
*/
export const isImageUnsafe = (prediction: predictionType, minConfidence = 90): boolean =>
prediction.is_nsfw && prediction.confidence_percentage >= minConfidence;
export const isImageUnsafe = (
prediction: predictionType | undefined,
minConfidence = 80,
): boolean => Boolean(prediction?.is_nsfw && prediction.confidence_percentage >= minConfidence);
4 changes: 3 additions & 1 deletion src/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -31,6 +32,7 @@ declare module 'discord.js' {
readonly reactionCooldowns: Collection<string, number>;
readonly cluster: ClusterClient<Client>;
readonly userManager: UserDbManager;
readonly antiSpamManager: AntiSpamManager;

fetchGuild(guildId: Snowflake): Promise<RemoveMethods<Guild> | undefined>;
getScheduler(): Scheduler;
Expand Down
79 changes: 0 additions & 79 deletions src/utils/network/antiSpam.ts

This file was deleted.

52 changes: 27 additions & 25 deletions src/utils/network/runChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,26 +140,30 @@ function checkLinks(message: Message<true>, opts: CheckFunctionOpts): CheckResul

async function checkSpam(message: Message<true>, opts: CheckFunctionOpts): Promise<CheckResult> {
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 };
Expand Down Expand Up @@ -260,7 +262,7 @@ async function checkNSFW(message: Message<true>, 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(
Expand Down
Loading