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
63 changes: 60 additions & 3 deletions AppsSpamMonitorApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -104,12 +111,48 @@ export class AppsSpamMonitorApp extends App implements IPostMessageSent {
}
}

public async checkPreMessageSentPrevent(
message: IMessage,
_read: IRead,
_http: IHttp,
): Promise<boolean> {
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<boolean> {
const { restricted } = await UserStatusStore.isRestricted(
read,
persistence,
message.sender.id,
);
return restricted;
}

public async checkPostMessageSent(
message: IMessage,
_read: IRead,
_http: IHttp,
): Promise<boolean> {
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<void> {
if (!message.sender || !message.room) {
return;
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
72 changes: 72 additions & 0 deletions src/core/restrictionsManager.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
}
}
6 changes: 3 additions & 3 deletions src/definition/spamlevel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ export const NEXT_LEVEL: Partial<Record<SpammingLevel, SpammingLevel>> = {

export const COOLDOWN_DURATIONS: Record<SpammingLevel, number> = {
[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,
};

Expand Down
15 changes: 15 additions & 0 deletions src/lib/translations/locals/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SpammingLevel } from '../../../definition/spamlevel';

export type NotifyFn = (username: string, duration: string) => string;

export const Messages: Record<SpammingLevel, NotifyFn | null> = {
[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.`,
};
24 changes: 24 additions & 0 deletions src/lib/utils/messageUtils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
27 changes: 27 additions & 0 deletions src/persistence/userStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}