Skip to content
Open
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
68 changes: 67 additions & 1 deletion AppsSpamMonitorApp.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,78 @@
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';
import { SpamConfig } from './src/definition/spamProcessor';

// Hardcoded defaults — settings will be introduced in a follow-up PR
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;
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<void> {
this.cache = new MessageCache();
this.processor = new SpamProcessor(this.cache, DEFAULT_CONFIG);
await super.initialize(configurationExtend, environmentRead);
}

protected async extendConfiguration(
configuration: IConfigurationExtend,
): Promise<void> {
await configuration.slashCommands.provideSlashCommand(
new SpamMonitorCommand(),
);
}

public async executePostMessageSent(
message: IMessage,
read: IRead,
_http: IHttp,
persistence: IPersistence,
_modify: IModify,
): Promise<void> {
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);
}
}
}
4 changes: 3 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
54 changes: 54 additions & 0 deletions src/commands/commandUtilities.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.sendNoPermission();
return;
}
Comment thread
not-meet marked this conversation as resolved.
const [subcommand] = context.getArguments();

switch (subcommand?.toLowerCase()) {
case SpamMonitorParam.LIST:
await handler.listFlaggedUsers();
break;
default:
await handler.sendHelp();
break;
}
}
}
243 changes: 243 additions & 0 deletions src/core/spamProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
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, SpamConfig } from '../definition/spamProcessor';

export class SpamProcessor {
constructor(
private readonly cache: MessageCache,
private config: SpamConfig,
) {}

public isNewUser(user: IUser): boolean {
if (!user.createdAt) {
return false;
}
return (
Date.now() - new Date(user.createdAt).getTime() <
this.config.monitoringWindowMs
);
}

public updateConfig(config: SpamConfig): void {
this.config = { ...config };
}

public async analyzeMessage(
message: IMessage,
read: IRead,
persistence: IPersistence,
): Promise<AnalysisResult> {
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<AnalysisResult> => {
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.config.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.config.slidingWindowMs,
(a, b) =>
this.cosineSimilarity(this.tokenize(a), this.tokenize(b)),
simThreshold,
);
if (fuzzyChannels >= this.config.crossChannelThreshold) {
return flag('polymorphic-spam');
}
}

// Gate 3 — Cross-channel exact
const crossCount = this.cache.crossChannelCount(
userId,
hash,
roomId,
this.config.slidingWindowMs,
);
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.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.config.crossChannelThreshold &&
rate2m >= this.config.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<string, number> {
const freq = new Map<string, number>();
for (const token of text.split(' ')) {
if (token.length >= 3) {
freq.set(token, (freq.get(token) ?? 0) + 1);
}
}
return freq;
}

private cosineSimilarity(
a: Map<string, number>,
b: Map<string, number>,
): 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 };
}
}
Loading