From d49601db16e89ae532e9f75e12b4d2add39dacb8 Mon Sep 17 00:00:00 2001 From: not-meet Date: Sat, 6 Jun 2026 04:12:15 +0530 Subject: [PATCH] [feat]: added pre message sent prevent and restrictions manager --- AppsSpamMonitorApp.ts | 63 ++++++++++++++++++++++++-- app.json | 1 + src/core/restrictionsManager.ts | 72 ++++++++++++++++++++++++++++++ src/definition/spamlevel.ts | 6 +-- src/lib/translations/locals/en.ts | 15 +++++++ src/lib/utils/messageUtils.ts | 24 ++++++++++ src/persistence/userStatusStore.ts | 27 +++++++++++ 7 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/core/restrictionsManager.ts create mode 100644 src/lib/translations/locals/en.ts create mode 100644 src/lib/utils/messageUtils.ts diff --git a/AppsSpamMonitorApp.ts b/AppsSpamMonitorApp.ts index b6965e2..60336a6 100644 --- a/AppsSpamMonitorApp.ts +++ b/AppsSpamMonitorApp.ts @@ -13,6 +13,7 @@ import { App } from '@rocket.chat/apps-engine/definition/App'; import { IMessage, IPostMessageSent, + IPreMessageSentPrevent, } from '@rocket.chat/apps-engine/definition/messages'; import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import { ISetting } from '@rocket.chat/apps-engine/definition/settings'; @@ -22,10 +23,16 @@ import { SpamMonitorCommand } from './src/commands/commandUtilities'; import { APP_SETTINGS } from './src/config/settings'; import { AppSetting } from './src/enums/settings'; import { SpamConfig } from './src/definition/spamProcessor'; +import { RestrictionManager } from './src/core/restrictionsManager'; +import { UserStatusStore } from './src/persistence/userStatusStore'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; const MS_PER_DAY = 86_400_000; const MS_PER_SECOND = 1000; -export class AppsSpamMonitorApp extends App implements IPostMessageSent { +export class AppsSpamMonitorApp + extends App + implements IPreMessageSentPrevent, IPostMessageSent +{ private processor: SpamProcessor | null = null; private cache: MessageCache; @@ -104,12 +111,48 @@ export class AppsSpamMonitorApp extends App implements IPostMessageSent { } } + public async checkPreMessageSentPrevent( + message: IMessage, + _read: IRead, + _http: IHttp, + ): Promise { + if (!message.text || message.room.type === RoomType.DIRECT_MESSAGE) { + return false; + } + return true; + } + + public async executePreMessageSentPrevent( + message: IMessage, + read: IRead, + _http: IHttp, + persistence: IPersistence, + ): Promise { + const { restricted } = await UserStatusStore.isRestricted( + read, + persistence, + message.sender.id, + ); + return restricted; + } + + public async checkPostMessageSent( + message: IMessage, + _read: IRead, + _http: IHttp, + ): Promise { + if (!message.text || message.room.type === RoomType.DIRECT_MESSAGE) { + return false; + } + return true; + } + public async executePostMessageSent( message: IMessage, read: IRead, _http: IHttp, persistence: IPersistence, - _modify: IModify, + modify: IModify, ): Promise { if (!message.sender || !message.room) { return; @@ -121,7 +164,21 @@ export class AppsSpamMonitorApp extends App implements IPostMessageSent { } try { - await this.processor.analyzeMessage(message, read, persistence); + const result = await this.processor.analyzeMessage( + message, + read, + persistence, + ); + + if (result?.flagged && result.record && result.levelChanged) { + await RestrictionManager.applyAction( + read, + modify, + sender, + result.record, + { levelChanged: result.levelChanged }, + ); + } } catch (err) { this.getLogger().error('[antispam] Error in analyzeMessage:', err); } diff --git a/app.json b/app.json index 23a206f..0c8d4a0 100644 --- a/app.json +++ b/app.json @@ -17,6 +17,7 @@ "classFile": "AppsSpamMonitorApp.ts", "description": "Automatically detects and flags spam from new users before it reaches Rocket.Chats community", "implements": [ + "IPreMessageSentPrevent", "IPostMessageSent" ] } \ No newline at end of file diff --git a/src/core/restrictionsManager.ts b/src/core/restrictionsManager.ts new file mode 100644 index 0000000..ea4e10d --- /dev/null +++ b/src/core/restrictionsManager.ts @@ -0,0 +1,72 @@ +import { IModify, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { IRoom, RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { SpammingLevel, UserSpamRecord } from '../definition/spamlevel'; +import { buildMessage } from '../lib/utils/messageUtils'; + +export class RestrictionManager { + public static async dmUser( + read: IRead, + modify: IModify, + targetUser: IUser, + text: string, + ): Promise { + const appUser = await read.getUserReader().getAppUser(); + if (!appUser) { + return; + } + + let room: IRoom | undefined = (await read + .getRoomReader() + .getDirectByUsernames([ + appUser.username, + targetUser.username, + ])) as any; + + if (!room) { + const roomBuilder = modify + .getCreator() + .startRoom() + .setType(RoomType.DIRECT_MESSAGE) + .setCreator(appUser) + .setMembersToBeAddedByUsernames([ + appUser.username, + targetUser.username, + ]); + const roomId = await modify.getCreator().finish(roomBuilder); + room = await read.getRoomReader().getById(roomId); + } + + if (!room) { + return; + } + + const msg = modify + .getCreator() + .startMessage() + .setSender(appUser) + .setRoom(room) + .setText(text); + await modify.getCreator().finish(msg); + } + + public static async applyAction( + read: IRead, + modify: IModify, + user: IUser, + record: UserSpamRecord, + options: { levelChanged?: boolean } = {}, + ): Promise { + const { levelChanged = true } = options; + if (!levelChanged || record.spammingLevel === SpammingLevel.Clean) { + return; + } + + const message = buildMessage(record); + if (!message) { + return; + } + + await RestrictionManager.dmUser(read, modify, user, message); + } +} diff --git a/src/definition/spamlevel.ts b/src/definition/spamlevel.ts index 26b363a..a9d644b 100644 --- a/src/definition/spamlevel.ts +++ b/src/definition/spamlevel.ts @@ -31,9 +31,9 @@ export const NEXT_LEVEL: Partial> = { export const COOLDOWN_DURATIONS: Record = { [SpammingLevel.Clean]: 0, - [SpammingLevel.Monitored]: 5 * 60 * 1000, - [SpammingLevel.Restricted]: 30 * 60 * 1000, - [SpammingLevel.Suspended]: 24 * 60 * 60 * 1000, + [SpammingLevel.Monitored]: 0, + [SpammingLevel.Restricted]: 2 * 60 * 1000, + [SpammingLevel.Suspended]: 12 * 60 * 1000, [SpammingLevel.AdminReview]: 0, }; diff --git a/src/lib/translations/locals/en.ts b/src/lib/translations/locals/en.ts new file mode 100644 index 0000000..627d1ad --- /dev/null +++ b/src/lib/translations/locals/en.ts @@ -0,0 +1,15 @@ +import { SpammingLevel } from '../../../definition/spamlevel'; + +export type NotifyFn = (username: string, duration: string) => string; + +export const Messages: Record = { + [SpammingLevel.Clean]: null, + [SpammingLevel.Monitored]: (username) => + `Hey @${username}, we noticed some unusual activity from your account.\n\nYour messages are being monitored. Please slow down and avoid sending repeated or identical messages across multiple channels.\n\nIf this continues, further restrictions may be applied.`, + [SpammingLevel.Restricted]: (username, duration) => + `@${username}, your account has been placed on a cooldown for ${duration}.\n\nYou will not be able to send messages during this period. This was triggered by repeated flagged behaviour.\n\nThe restriction will lift automatically once the cooldown expires.`, + [SpammingLevel.Suspended]: (username, duration) => + `@${username}, your account has been suspended from sending messages for ${duration}.\n\nThis is due to continued spam-like behaviour after prior warnings. Your messages will be blocked until the suspension period ends.\n\nIf you believe this is a mistake, please contact an administrator.`, + [SpammingLevel.AdminReview]: (username) => + `@${username}, your account has been flagged for admin review.\n\nYou are currently restricted from sending messages until an administrator reviews your account and lifts the restriction.\n\nPlease reach out to an admin directly if you need immediate assistance.`, +}; diff --git a/src/lib/utils/messageUtils.ts b/src/lib/utils/messageUtils.ts new file mode 100644 index 0000000..f06f5b2 --- /dev/null +++ b/src/lib/utils/messageUtils.ts @@ -0,0 +1,24 @@ +import { COOLDOWN_DURATIONS, UserSpamRecord } from '../../definition/spamlevel'; +import { Messages } from '../translations/locals/en'; + +function formatDuration(ms: number): string { + if (ms <= 0) return ''; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (hours > 0 && minutes > 0) { + return `${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`; + } + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''}`; + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''}`; + return `${totalSeconds} second${totalSeconds > 1 ? 's' : ''}`; +} + +export function buildMessage(record: UserSpamRecord): string | null { + const fn = Messages[record.spammingLevel]; + if (!fn) { + return null; + } + const duration = formatDuration(COOLDOWN_DURATIONS[record.spammingLevel]); + return fn(record.username, duration); +} diff --git a/src/persistence/userStatusStore.ts b/src/persistence/userStatusStore.ts index 7fb1830..653787c 100644 --- a/src/persistence/userStatusStore.ts +++ b/src/persistence/userStatusStore.ts @@ -134,4 +134,31 @@ export class UserStatusStore { typeof r.flagsAtLevel === 'number' ); } + + public static async isRestricted( + read: IRead, + persistence: IPersistence, + userId: string, + ): Promise<{ restricted: boolean; record: UserSpamRecord | null }> { + const record = await UserStatusStore.get(read, userId); + if (!record) { + return { restricted: false, record: null }; + } + + if (record.spammingLevel === SpammingLevel.AdminReview) { + return { restricted: true, record }; + } + + if (record.cooldownUntil > 0 && Date.now() < record.cooldownUntil) { + return { restricted: true, record }; + } + + if (record.cooldownUntil > 0 && Date.now() >= record.cooldownUntil) { + const lifted: UserSpamRecord = { ...record, cooldownUntil: 0 }; + await UserStatusStore.save(persistence, userId, lifted); + return { restricted: false, record: lifted }; + } + + return { restricted: false, record }; + } }