From 513f258b7384055f1c76d40f253f4aab4118e15c Mon Sep 17 00:00:00 2001 From: not-meet Date: Sun, 24 May 2026 05:18:00 +0530 Subject: [PATCH 1/2] [feat]:added spamprocessor class, slash list command --- AppsSpamMonitorApp.ts | 72 ++++++++- app.json | 4 +- src/commands/commandUtilities.ts | 54 +++++++ src/core/spamProcessor.ts | 248 +++++++++++++++++++++++++++++ src/definition/spamProcessor.ts | 8 + src/enums/commandUtilities.ts | 3 + src/enums/notifications.ts | 9 ++ src/handlers/handler.ts | 75 +++++++++ src/persistence/userStatusStore.ts | 16 +- 9 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 src/commands/commandUtilities.ts create mode 100644 src/core/spamProcessor.ts create mode 100644 src/definition/spamProcessor.ts create mode 100644 src/enums/commandUtilities.ts create mode 100644 src/enums/notifications.ts create mode 100644 src/handlers/handler.ts diff --git a/AppsSpamMonitorApp.ts b/AppsSpamMonitorApp.ts index 4771278..de846ab 100644 --- a/AppsSpamMonitorApp.ts +++ b/AppsSpamMonitorApp.ts @@ -1,12 +1,82 @@ import { IAppAccessors, + IConfigurationExtend, + IEnvironmentRead, + IHttp, ILogger, + IModify, + IPersistence, + IRead, } from '@rocket.chat/apps-engine/definition/accessors'; import { App } from '@rocket.chat/apps-engine/definition/App'; +import { + IMessage, + IPostMessageSent, +} from '@rocket.chat/apps-engine/definition/messages'; import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { SpamProcessor } from './src/core/spamProcessor'; +import { MessageCache } from './src/core/cache/messageCache'; +import { SpamMonitorCommand } from './src/commands/commandUtilities'; + +// Hardcoded defaults — settings will be introduced in a follow-up PR +const MONITORING_WINDOW_MS = 42 * 24 * 60 * 60 * 1000; // 42 days +const SLIDING_WINDOW_MS = 5 * 60 * 1000; // 5 min +const CROSS_CHANNEL_THRESHOLD = 3; +const RATE_SHORT_BURST = 5; +const RATE_SUSTAINED = 12; + +export class AppsSpamMonitorApp extends App implements IPostMessageSent { + private processor: SpamProcessor; + private cache: MessageCache; -export class AppsSpamMonitorApp extends App { constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { super(info, logger, accessors); } + + public async initialize( + configurationExtend: IConfigurationExtend, + environmentRead: IEnvironmentRead, + ): Promise { + this.cache = new MessageCache(); + this.processor = new SpamProcessor( + this.cache, + MONITORING_WINDOW_MS, + SLIDING_WINDOW_MS, + CROSS_CHANNEL_THRESHOLD, + RATE_SHORT_BURST, + RATE_SUSTAINED, + ); + await super.initialize(configurationExtend, environmentRead); + } + + protected async extendConfiguration( + configuration: IConfigurationExtend, + ): Promise { + await configuration.slashCommands.provideSlashCommand( + new SpamMonitorCommand(), + ); + } + + public async executePostMessageSent( + message: IMessage, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise { + if (!message.sender || !message.room) { + return; + } + + const sender = await read.getUserReader().getById(message.sender.id); + if (!sender || !this.processor.isNewUser(sender)) { + return; + } + + try { + await this.processor.analyzeMessage(message, read, persistence); + } catch (err) { + this.getLogger().error('[antispam] Error in analyzeMessage:', err); + } + } } diff --git a/app.json b/app.json index ac4fdaf..89062d4 100644 --- a/app.json +++ b/app.json @@ -12,5 +12,7 @@ "nameSlug": "appsspammonitor", "classFile": "AppsSpamMonitorApp.ts", "description": "Automatically detects and flags spam from new users before it reaches Rocket.Chats community", - "implements": [] + "implements": [ + "IPostMessageSent" + ] } \ No newline at end of file diff --git a/src/commands/commandUtilities.ts b/src/commands/commandUtilities.ts new file mode 100644 index 0000000..c4ba200 --- /dev/null +++ b/src/commands/commandUtilities.ts @@ -0,0 +1,54 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { + ISlashCommand, + SlashCommandContext, +} from '@rocket.chat/apps-engine/definition/slashcommands'; +import { SpamMonitorHandler } from '../handlers/handler'; +import { SpamMonitorParam } from '../enums/commandUtilities'; + +export class SpamMonitorCommand implements ISlashCommand { + public command = 'spammonitor'; + public i18nDescription = 'SpamMonitor_Command_Description'; + public i18nParamsExample = 'list'; + public providesPreview = false; + + public async executor( + context: SlashCommandContext, + read: IRead, + modify: IModify, + http: IHttp, + persistence: IPersistence, + ): Promise { + const sender = context.getSender(); + const room = context.getRoom(); + + const handler = new SpamMonitorHandler( + sender, + room, + read, + modify, + http, + persistence, + ); + const roles = sender.roles || []; + if (!roles.includes('admin')) { + await handler.sendHelp(); + return; + } + const [subcommand] = context.getArguments(); + + switch (subcommand?.toLowerCase()) { + case SpamMonitorParam.LIST: + await handler.listFlaggedUsers(); + break; + default: + await handler.sendNoPermission(); + break; + } + } +} diff --git a/src/core/spamProcessor.ts b/src/core/spamProcessor.ts new file mode 100644 index 0000000..fdd337e --- /dev/null +++ b/src/core/spamProcessor.ts @@ -0,0 +1,248 @@ +import { createHash } from 'crypto'; +import { + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { MessageCache } from './cache/messageCache'; +import { UserStatusStore } from '../persistence/userStatusStore'; +import { AnalysisResult } from '../definition/spamProcessor'; + +export class SpamProcessor { + constructor( + private readonly cache: MessageCache, + private readonly monitoringWindowMs: number, + private readonly slidingWindowMs: number, + private readonly crossChannelThreshold: number, + private readonly rateShortBurst: number = 5, + private readonly rateSustained: number = 12, + ) {} + + public isNewUser(user: IUser): boolean { + if (!user.createdAt) { + return false; + } + return ( + Date.now() - new Date(user.createdAt).getTime() < + this.monitoringWindowMs + ); + } + + public updateConfig( + monitoringWindowMs: number, + slidingWindowMs: number, + crossChannelThreshold: number, + rateShortBurst: number, + rateSustained: number, + ): void { + (this as any).monitoringWindowMs = monitoringWindowMs; + (this as any).slidingWindowMs = slidingWindowMs; + (this as any).crossChannelThreshold = crossChannelThreshold; + (this as any).rateShortBurst = rateShortBurst; + (this as any).rateSustained = rateSustained; + } + + public async analyzeMessage( + message: IMessage, + read: IRead, + persistence: IPersistence, + ): Promise { + const text = message.text || ''; + const userId = message.sender.id; + const username = message.sender.username; + const roomId = message.room.id; + const messageId = message.id; + + const normalized = this.normalize(text); + const hash = this.hashText(normalized); + const { hasUrl, domains } = this.extractUrlInfo(text); + + this.cache.trackMessage(userId); + + const existing = await UserStatusStore.get(read, userId); + const prevLevel = existing?.spammingLevel ?? 0; + + // Edit-awareness + if (messageId && this.cache.isEditedMessage(userId, messageId)) { + this.cache.add( + userId, + hash, + roomId, + normalized, + hasUrl, + domains, + messageId, + ); + return { + flagged: false, + levelChanged: false, + trigger: 'edit', + record: null, + }; + } + + const flag = async (trigger: string): Promise => { + const record = await UserStatusStore.escalate( + read, + persistence, + userId, + username, + ); + this.cache.add( + userId, + hash, + roomId, + normalized, + hasUrl, + domains, + messageId, + ); + const levelChanged = record.spammingLevel > prevLevel; + return { flagged: true, levelChanged, trigger, record }; + }; + + // Gate 1 — Exact duplicate + if (this.cache.hasExactDuplicate(userId, hash, this.slidingWindowMs)) { + return flag('duplicate'); + } + + // Gate 2 — Fuzzy / polymorphic + if (normalized.length >= 10) { + const tokenCount = normalized + .split(' ') + .filter((t) => t.length >= 3).length; + const simThreshold = + tokenCount < 5 ? 0.85 : tokenCount < 8 ? 0.8 : 0.75; + const fuzzyChannels = this.cache.getFuzzyChannels( + userId, + normalized, + roomId, + this.slidingWindowMs, + (a, b) => + this.cosineSimilarity(this.tokenize(a), this.tokenize(b)), + simThreshold, + ); + if (fuzzyChannels >= this.crossChannelThreshold) { + return flag('polymorphic-spam'); + } + } + + // Gate 3 — Cross-channel exact + const crossCount = this.cache.crossChannelCount( + userId, + hash, + roomId, + this.slidingWindowMs, + ); + if (crossCount >= this.crossChannelThreshold) { + return flag('cross-channel'); + } + + // Gate 4 — Rate flood + const rate30s = this.cache.getMessageRate(userId, 30_000); + const rate2m = this.cache.getMessageRate(userId, 120_000); + if (rate30s >= this.rateShortBurst || rate2m >= this.rateSustained) { + return flag('rate-flood'); + } + + // Gate 5 — Room spread + const roomSpread = this.cache.getDistinctRooms(userId, 120_000); + if ( + roomSpread >= this.crossChannelThreshold && + rate2m >= this.crossChannelThreshold + ) { + return flag('room-spread'); + } + + // Gate 6 — URL spam + if (hasUrl) { + const urlCount = this.cache.getUrlMessageCount(userId, 120_000); + if (urlCount >= 3 && roomSpread >= 2) { + return flag('url-spam'); + } + } + + this.cache.add( + userId, + hash, + roomId, + normalized, + hasUrl, + domains, + messageId, + ); + + return { + flagged: false, + levelChanged: false, + trigger: 'none', + record: existing, + }; + } + + // Text utilities + + private normalize(text: string): string { + return text + .toLowerCase() + .replace(/[\u200B-\u200F\u2060-\u206F]/g, '') + .replace(/[\u{1F300}-\u{1FFFF}]/gu, '') + .replace(/https?:\/\/\S+/g, '') + .replace(/[.,!?;:()\-#@]/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + private tokenize(text: string): Map { + const freq = new Map(); + for (const token of text.split(' ')) { + if (token.length >= 3) { + freq.set(token, (freq.get(token) ?? 0) + 1); + } + } + return freq; + } + + private cosineSimilarity( + a: Map, + b: Map, + ): number { + if (a.size === 0 || b.size === 0) { + return 0; + } + let dot = 0, + normA = 0, + normB = 0; + for (const [k, v] of a) { + dot += v * (b.get(k) ?? 0); + normA += v * v; + } + for (const [, v] of b) { + normB += v * v; + } + if (normA === 0 || normB === 0) { + return 0; + } + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); + } + + private hashText(text: string): string { + return createHash('sha256') + .update(text.toLowerCase().trim().replace(/\s+/g, ' ')) + .digest('hex'); + } + + private extractUrlInfo(text: string): { + hasUrl: boolean; + domains: string[]; + } { + const urlRegex = /https?:\/\/([^\/\s]+)/g; + const domains: string[] = []; + let match: RegExpExecArray | null; + while ((match = urlRegex.exec(text)) !== null) { + domains.push(match[1].toLowerCase()); + } + return { hasUrl: domains.length > 0, domains }; + } +} diff --git a/src/definition/spamProcessor.ts b/src/definition/spamProcessor.ts new file mode 100644 index 0000000..46508f6 --- /dev/null +++ b/src/definition/spamProcessor.ts @@ -0,0 +1,8 @@ +import { UserSpamRecord } from './spamlevel'; + +export interface AnalysisResult { + flagged: boolean; + levelChanged: boolean; + trigger: string; + record: UserSpamRecord | null; +} diff --git a/src/enums/commandUtilities.ts b/src/enums/commandUtilities.ts new file mode 100644 index 0000000..7d911f7 --- /dev/null +++ b/src/enums/commandUtilities.ts @@ -0,0 +1,3 @@ +export enum SpamMonitorParam { + LIST = 'list', +} diff --git a/src/enums/notifications.ts b/src/enums/notifications.ts new file mode 100644 index 0000000..2b43300 --- /dev/null +++ b/src/enums/notifications.ts @@ -0,0 +1,9 @@ +export const slashNotifications = { + NO_FLAGGED_USERS: 'No flagged users at this time.', + NO_PERMISSION: 'You do not have permission to use this command.', +}; +export const slashCommandHelp = { + HELP: + '*SpamMonitor commands*\n' + + '`/spammonitor list` — list all flagged users and their current spam level', +}; diff --git a/src/handlers/handler.ts b/src/handlers/handler.ts new file mode 100644 index 0000000..445f70e --- /dev/null +++ b/src/handlers/handler.ts @@ -0,0 +1,75 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { SPAMMING_LEVEL_LABELS } from '../definition/spamlevel'; +import { UserStatusStore } from '../persistence/userStatusStore'; +import { slashCommandHelp, slashNotifications } from '../enums/notifications'; + +export class SpamMonitorHandler { + constructor( + private readonly sender: IUser, + private readonly room: IRoom, + private readonly read: IRead, + private readonly modify: IModify, + private readonly http: IHttp, + private readonly persis: IPersistence, + ) {} + + private async notify(text: string): Promise { + const msg = this.modify + .getNotifier() + .notifyUser( + this.sender, + this.modify + .getCreator() + .startMessage() + .setRoom(this.room) + .setText(text) + .getMessage(), + ); + await msg; + } + + public async listFlaggedUsers(): Promise { + const records = await UserStatusStore.getAll(this.read); + + if (!records.length) { + await this.notify(slashNotifications.NO_FLAGGED_USERS); + return; + } + + records.sort((a, b) => { + if (b.spammingLevel !== a.spammingLevel) { + return b.spammingLevel - a.spammingLevel; + } + return a.username.localeCompare(b.username); + }); + + const lines = records.map((r) => { + const label = + SPAMMING_LEVEL_LABELS[r.spammingLevel] ?? + String(r.spammingLevel); + const cooldownStr = + r.cooldownUntil > 0 && Date.now() < r.cooldownUntil + ? ` | cooldown until ${new Date(r.cooldownUntil).toISOString().slice(0, 16).replace('T', ' ')}` + : ''; + return `@${r.username} — **${label}** (${r.totalFlags} flag${r.totalFlags === 1 ? '' : 's'})${cooldownStr}`; + }); + + const header = `*Flagged Users (${records.length})*\n`; + await this.notify(header + lines.join('\n')); + } + + public async sendHelp(): Promise { + await this.notify(slashCommandHelp.HELP); + } + + public async sendNoPermission(): Promise { + await this.notify(slashNotifications.NO_PERMISSION); + } +} diff --git a/src/persistence/userStatusStore.ts b/src/persistence/userStatusStore.ts index 43b820a..7fb1830 100644 --- a/src/persistence/userStatusStore.ts +++ b/src/persistence/userStatusStore.ts @@ -28,6 +28,12 @@ export class UserStatusStore { ), ]; } + private static scopeAssoc(): RocketChatAssociationRecord { + return new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + ASSOC_SCOPE, + ); + } public static async get( read: IRead, @@ -41,6 +47,15 @@ export class UserStatusStore { return rows[0] as UserSpamRecord; } + public static async getAll(read: IRead): Promise { + const rows = await read + .getPersistenceReader() + .readByAssociation(UserStatusStore.scopeAssoc()); + return (rows as unknown[]) + .filter(UserStatusStore.isValidRecord) + .filter((r) => r.spammingLevel > SpammingLevel.Clean); + } + public static async save( persistence: IPersistence, userId: string, @@ -52,7 +67,6 @@ export class UserStatusStore { true, ); } - public static async escalate( read: IRead, persistence: IPersistence, From 1a39d8c62aeac2a363fdeefeb4b0a73124e16f55 Mon Sep 17 00:00:00 2001 From: not-meet Date: Wed, 27 May 2026 00:44:00 +0530 Subject: [PATCH 2/2] [fix]: address premission/help notification and SpamProcessor config mutability --- AppsSpamMonitorApp.ts | 26 +++++++---------- src/commands/commandUtilities.ts | 4 +-- src/core/spamProcessor.ts | 49 ++++++++++++++------------------ src/definition/spamProcessor.ts | 7 +++++ 4 files changed, 42 insertions(+), 44 deletions(-) diff --git a/AppsSpamMonitorApp.ts b/AppsSpamMonitorApp.ts index de846ab..b67fe0a 100644 --- a/AppsSpamMonitorApp.ts +++ b/AppsSpamMonitorApp.ts @@ -17,13 +17,16 @@ import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import { SpamProcessor } from './src/core/spamProcessor'; import { MessageCache } from './src/core/cache/messageCache'; import { SpamMonitorCommand } from './src/commands/commandUtilities'; +import { SpamConfig } from './src/definition/spamProcessor'; // Hardcoded defaults — settings will be introduced in a follow-up PR -const MONITORING_WINDOW_MS = 42 * 24 * 60 * 60 * 1000; // 42 days -const SLIDING_WINDOW_MS = 5 * 60 * 1000; // 5 min -const CROSS_CHANNEL_THRESHOLD = 3; -const RATE_SHORT_BURST = 5; -const RATE_SUSTAINED = 12; +const DEFAULT_CONFIG: SpamConfig = { + monitoringWindowMs: 42 * 24 * 60 * 60 * 1000, // 42 days + slidingWindowMs: 5 * 60 * 1000, // 5 min + crossChannelThreshold: 3, + rateShortBurst: 5, + rateSustained: 12, +}; export class AppsSpamMonitorApp extends App implements IPostMessageSent { private processor: SpamProcessor; @@ -38,14 +41,7 @@ export class AppsSpamMonitorApp extends App implements IPostMessageSent { environmentRead: IEnvironmentRead, ): Promise { this.cache = new MessageCache(); - this.processor = new SpamProcessor( - this.cache, - MONITORING_WINDOW_MS, - SLIDING_WINDOW_MS, - CROSS_CHANNEL_THRESHOLD, - RATE_SHORT_BURST, - RATE_SUSTAINED, - ); + this.processor = new SpamProcessor(this.cache, DEFAULT_CONFIG); await super.initialize(configurationExtend, environmentRead); } @@ -60,9 +56,9 @@ export class AppsSpamMonitorApp extends App implements IPostMessageSent { public async executePostMessageSent( message: IMessage, read: IRead, - http: IHttp, + _http: IHttp, persistence: IPersistence, - modify: IModify, + _modify: IModify, ): Promise { if (!message.sender || !message.room) { return; diff --git a/src/commands/commandUtilities.ts b/src/commands/commandUtilities.ts index c4ba200..68f53be 100644 --- a/src/commands/commandUtilities.ts +++ b/src/commands/commandUtilities.ts @@ -37,7 +37,7 @@ export class SpamMonitorCommand implements ISlashCommand { ); const roles = sender.roles || []; if (!roles.includes('admin')) { - await handler.sendHelp(); + await handler.sendNoPermission(); return; } const [subcommand] = context.getArguments(); @@ -47,7 +47,7 @@ export class SpamMonitorCommand implements ISlashCommand { await handler.listFlaggedUsers(); break; default: - await handler.sendNoPermission(); + await handler.sendHelp(); break; } } diff --git a/src/core/spamProcessor.ts b/src/core/spamProcessor.ts index fdd337e..dc4773e 100644 --- a/src/core/spamProcessor.ts +++ b/src/core/spamProcessor.ts @@ -7,16 +7,12 @@ import { IMessage } from '@rocket.chat/apps-engine/definition/messages'; import { IUser } from '@rocket.chat/apps-engine/definition/users'; import { MessageCache } from './cache/messageCache'; import { UserStatusStore } from '../persistence/userStatusStore'; -import { AnalysisResult } from '../definition/spamProcessor'; +import { AnalysisResult, SpamConfig } from '../definition/spamProcessor'; export class SpamProcessor { constructor( private readonly cache: MessageCache, - private readonly monitoringWindowMs: number, - private readonly slidingWindowMs: number, - private readonly crossChannelThreshold: number, - private readonly rateShortBurst: number = 5, - private readonly rateSustained: number = 12, + private config: SpamConfig, ) {} public isNewUser(user: IUser): boolean { @@ -25,22 +21,12 @@ export class SpamProcessor { } return ( Date.now() - new Date(user.createdAt).getTime() < - this.monitoringWindowMs + this.config.monitoringWindowMs ); } - public updateConfig( - monitoringWindowMs: number, - slidingWindowMs: number, - crossChannelThreshold: number, - rateShortBurst: number, - rateSustained: number, - ): void { - (this as any).monitoringWindowMs = monitoringWindowMs; - (this as any).slidingWindowMs = slidingWindowMs; - (this as any).crossChannelThreshold = crossChannelThreshold; - (this as any).rateShortBurst = rateShortBurst; - (this as any).rateSustained = rateSustained; + public updateConfig(config: SpamConfig): void { + this.config = { ...config }; } public async analyzeMessage( @@ -103,7 +89,13 @@ export class SpamProcessor { }; // Gate 1 — Exact duplicate - if (this.cache.hasExactDuplicate(userId, hash, this.slidingWindowMs)) { + if ( + this.cache.hasExactDuplicate( + userId, + hash, + this.config.slidingWindowMs, + ) + ) { return flag('duplicate'); } @@ -118,12 +110,12 @@ export class SpamProcessor { userId, normalized, roomId, - this.slidingWindowMs, + this.config.slidingWindowMs, (a, b) => this.cosineSimilarity(this.tokenize(a), this.tokenize(b)), simThreshold, ); - if (fuzzyChannels >= this.crossChannelThreshold) { + if (fuzzyChannels >= this.config.crossChannelThreshold) { return flag('polymorphic-spam'); } } @@ -133,24 +125,27 @@ export class SpamProcessor { userId, hash, roomId, - this.slidingWindowMs, + this.config.slidingWindowMs, ); - if (crossCount >= this.crossChannelThreshold) { + if (crossCount >= this.config.crossChannelThreshold) { return flag('cross-channel'); } // Gate 4 — Rate flood const rate30s = this.cache.getMessageRate(userId, 30_000); const rate2m = this.cache.getMessageRate(userId, 120_000); - if (rate30s >= this.rateShortBurst || rate2m >= this.rateSustained) { + if ( + rate30s >= this.config.rateShortBurst || + rate2m >= this.config.rateSustained + ) { return flag('rate-flood'); } // Gate 5 — Room spread const roomSpread = this.cache.getDistinctRooms(userId, 120_000); if ( - roomSpread >= this.crossChannelThreshold && - rate2m >= this.crossChannelThreshold + roomSpread >= this.config.crossChannelThreshold && + rate2m >= this.config.crossChannelThreshold ) { return flag('room-spread'); } diff --git a/src/definition/spamProcessor.ts b/src/definition/spamProcessor.ts index 46508f6..635ea17 100644 --- a/src/definition/spamProcessor.ts +++ b/src/definition/spamProcessor.ts @@ -6,3 +6,10 @@ export interface AnalysisResult { trigger: string; record: UserSpamRecord | null; } +export interface SpamConfig { + monitoringWindowMs: number; + slidingWindowMs: number; + crossChannelThreshold: number; + rateShortBurst: number; + rateSustained: number; +}