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
6 changes: 6 additions & 0 deletions src/definition/confirmationModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ConfirmMeta {
title: string;
description: string;
confirmLabel: string;
danger?: boolean;
}
8 changes: 8 additions & 0 deletions src/definition/spamlevel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export const NEXT_LEVEL: Partial<Record<SpammingLevel, SpammingLevel>> = {
[SpammingLevel.Suspended]: SpammingLevel.AdminReview,
};

export const PREV_LEVEL: Partial<Record<SpammingLevel, SpammingLevel>> = {
[SpammingLevel.Monitored]: SpammingLevel.Clean,
[SpammingLevel.Restricted]: SpammingLevel.Monitored,
[SpammingLevel.Suspended]: SpammingLevel.Restricted,
[SpammingLevel.AdminReview]: SpammingLevel.Suspended,
};

export const COOLDOWN_DURATIONS: Record<SpammingLevel, number> = {
[SpammingLevel.Clean]: 0,
[SpammingLevel.Monitored]: 0,
Expand All @@ -45,5 +52,6 @@ export interface UserSpamRecord {
lastEscalation: number;
totalFlags: number;
flagsAtLevel: number;
vouched?: boolean;
vouchedBy?: string;
}
1 change: 1 addition & 0 deletions src/enums/commandUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export enum SpamMonitorParam {
ALL = 'all',
TIMEOUT = 'timeout',
ADMIN_REVIEW = 'review',
MANAGE = 'manage',
}
46 changes: 46 additions & 0 deletions src/enums/modals/manageUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export enum ManageUserActionId {
VOUCH = 'manage_user_vouch',
RESET_COOLDOWN = 'manage_user_reset_cooldown',
RESET_LEVEL_DOWN = 'manage_user_reset_level_down',
RESET_LEVEL_CLEAN = 'manage_user_reset_level_clean',
CONFIRM_VOUCH = 'confirm_vouch',
CONFIRM_RESET_COOLDOWN = 'confirm_reset_cooldown',
CONFIRM_RESET_LEVEL_DOWN = 'confirm_reset_level_down',
CONFIRM_RESET_LEVEL_CLEAN = 'confirm_reset_level_clean',
OPEN_MANAGE_MODAL = 'list_open_manage_modal',
}

export const MANAGE_USER_MODAL_ID = 'manage_user_modal';
export const LIST_OVERFLOW_BLOCK_ID = 'spam_list_overflow';
export const CONFIRM_ACTION_MODAL_ID = 'confirm_action_modal';

// overflow-menu action ids that should open a confirmation modal.
export const ACTIONS_REQUIRING_CONFIRM = new Set<string>([
ManageUserActionId.CONFIRM_VOUCH,
ManageUserActionId.CONFIRM_RESET_COOLDOWN,
ManageUserActionId.CONFIRM_RESET_LEVEL_DOWN,
ManageUserActionId.CONFIRM_RESET_LEVEL_CLEAN,
]);

export const CONFIRM_TO_ACTION: Record<string, ManageUserActionId> = {
[ManageUserActionId.CONFIRM_VOUCH]: ManageUserActionId.VOUCH,
[ManageUserActionId.CONFIRM_RESET_COOLDOWN]:
ManageUserActionId.RESET_COOLDOWN,
[ManageUserActionId.CONFIRM_RESET_LEVEL_DOWN]:
ManageUserActionId.RESET_LEVEL_DOWN,
[ManageUserActionId.CONFIRM_RESET_LEVEL_CLEAN]:
ManageUserActionId.RESET_LEVEL_CLEAN,
};

export const BlockId = {
USER_INFO: 'manage_user_info',
DETAILS: 'manage_user_details',
DIVIDER: 'manage_user_divider',
ACTIONS_HEADER: 'manage_user_actions_header',
ACTIONS: 'manage_user_actions',
CLOSE: 'manage_user_close',

CONFIRM_TARGET: 'confirm_action_target',
CONFIRM_SUBMIT: 'confirm_action_submit',
CONFIRM_CLOSE: 'confirm_action_close',
} as const;
6 changes: 5 additions & 1 deletion src/enums/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ export const slashNotifications = {
ADMIN_CHANNEL_ONLY: 'This command can only be used in the admin channel.',
NO_FLAGGED_USERS_FILTER: (filter: string) =>
`No flagged users found for filter: *${filter}*.`,
USER_NOT_FOUND: (username: string) =>
`User *@${username}* not found or has no spam record.`,
MANAGE_MISSING_USERNAME: 'Usage: `/spammonitor manage <username>`',
};

export const slashCommandHelp = {
HELP:
'*SpamMonitor commands*\n' +
'`/spammonitor list all` — all flagged users, highest level first\n' +
'`/spammonitor list timeout` — users currently in an active cooldown\n' +
'`/spammonitor list <Level>` — users at a specific level e.g. `list review` for admin review users',
'`/spammonitor list <Level>` — users at a specific level e.g. `list review` for admin review users\n' +
'`/spammonitor manage <username>` — open admin controls for a flagged user',
};
47 changes: 47 additions & 0 deletions src/lib/translations/locals/en.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ConfirmMeta } from '../../../definition/confirmationModal';
import { SpammingLevel } from '../../../definition/spamlevel';
import { ManageUserActionId } from '../../../enums/modals/manageUsers';

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

Expand Down Expand Up @@ -42,3 +44,48 @@ export const AdminChannelMessages = {
`**SpamMonitor uninstalled.**\n\n` +
`The \`#${channelName}\` channel has been removed.`,
};

export const AdminActionMessages = {
vouch: (targetUsername: string, adminUsername: string) =>
`@${targetUsername} vouched successfully by @${adminUsername} — now fully exempt from spam monitoring.`,
resetCooldown: (targetUsername: string, adminUsername: string) =>
`Cooldown for @${targetUsername} reset successfully by @${adminUsername}.`,
resetLevelDown: (
targetUsername: string,
adminUsername: string,
beforeLabel: string,
afterLabel: string,
) =>
`Spam level for @${targetUsername} reduced from *${beforeLabel}* → *${afterLabel}* successfully by @${adminUsername}.`,
resetLevelClean: (targetUsername: string, adminUsername: string) =>
`@${targetUsername} reset to *Clean* successfully by @${adminUsername}.`,
};

export const ConfirmActionMeta: Partial<
Record<ManageUserActionId, ConfirmMeta>
> = {
[ManageUserActionId.VOUCH]: {
title: 'Vouch for User',
description:
'This will mark the user as *trusted* and fully exempt them from spam monitoring.',
confirmLabel: 'Confirm Vouch',
},
[ManageUserActionId.RESET_COOLDOWN]: {
title: 'Reset Cooldown',
description:
'This will immediately lift the active cooldown/timeout for this user.',
confirmLabel: 'Reset Cooldown',
},
[ManageUserActionId.RESET_LEVEL_DOWN]: {
title: 'Level Down',
description: 'This will reduce the spam level by one step.',
confirmLabel: 'Level Down',
},
[ManageUserActionId.RESET_LEVEL_CLEAN]: {
title: 'Reset to Clean',
description:
'This will immediately reset the spam level to *Clean*, removing all restrictions.',
confirmLabel: 'Reset to Clean',
danger: true,
},
};
13 changes: 13 additions & 0 deletions src/lib/utils/messageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,16 @@ export function buildMessage(record: UserSpamRecord): string | null {
const duration = formatDuration(COOLDOWN_DURATIONS[record.spammingLevel]);
return fn(record.username, duration);
}
export function formatCooldown(cooldownUntil: number): string {
const now = Date.now();
if (!cooldownUntil || now >= cooldownUntil) return 'None';
const remainingMs = cooldownUntil - now;
const minutes = Math.floor(remainingMs / 60000);
const seconds = Math.floor((remainingMs % 60000) / 1000);
return `${minutes}m ${seconds}s remaining`;
}

export function formatDate(ts: number): string {
if (!ts) return 'Never';
return new Date(ts).toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
}
27 changes: 27 additions & 0 deletions src/lib/utils/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IRead, IModify } 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';

export async function sendNotification(
read: IRead,
modify: IModify,
user: IUser,
room: IRoom,
content: { message?: string },
): Promise<void> {
const appUser = (await read.getUserReader().getAppUser()) as IUser;
const { message } = content;

const messageBuilder = modify
.getCreator()
.startMessage()
.setSender(appUser)
.setRoom(room)
.setGroupable(false);

if (message) {
messageBuilder.setText(message);
}

return read.getNotifier().notifyUser(user, messageBuilder.getMessage());
}
39 changes: 39 additions & 0 deletions src/persistence/roomInteraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
IPersistence,
IPersistenceRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import {
RocketChatAssociationModel,
RocketChatAssociationRecord,
} from '@rocket.chat/apps-engine/definition/metadata';

export class RoomInteractionStorage {
constructor(
private readonly persistence: IPersistence,
private readonly persistenceRead: IPersistenceRead,
private readonly userId: string,
) {}

public async storeInteractionRoomId(roomId: string): Promise<void> {
const association = new RocketChatAssociationRecord(
RocketChatAssociationModel.USER,
`${this.userId}#RoomId`,
);
await this.persistence.updateByAssociation(
association,
{ roomId },
true,
);
}

public async getInteractionRoomId(): Promise<string> {
const association = new RocketChatAssociationRecord(
RocketChatAssociationModel.USER,
`${this.userId}#RoomId`,
);
const [result] = (await this.persistenceRead.readByAssociation(
association,
)) as Array<{ roomId: string }>;
return result?.roomId;
}
}
68 changes: 68 additions & 0 deletions src/persistence/userStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
COOLDOWN_DURATIONS,
ESCALATION_THRESHOLDS,
NEXT_LEVEL,
PREV_LEVEL,
SpammingLevel,
UserSpamRecord,
} from '../definition/spamlevel';
Expand Down Expand Up @@ -145,6 +146,8 @@ export class UserStatusStore {
return { restricted: false, record: null };
}

if (record.vouched) return { restricted: false, record };

if (record.spammingLevel === SpammingLevel.AdminReview) {
return { restricted: true, record };
}
Expand All @@ -161,4 +164,69 @@ export class UserStatusStore {

return { restricted: false, record };
}

public static async vouch(
persistence: IPersistence,
userId: string,
username: string,
adminUsername: string,
): Promise<void> {
const record: UserSpamRecord = {
userId,
username,
spammingLevel: SpammingLevel.Clean,
cooldownUntil: 0,
lastEscalation: 0,
totalFlags: 0,
flagsAtLevel: 0,
vouched: true,
vouchedBy: adminUsername,
};
await UserStatusStore.save(persistence, userId, record);
}

public static async resetCooldown(
read: IRead,
persistence: IPersistence,
userId: string,
): Promise<void> {
const existing = await UserStatusStore.get(read, userId);
if (!existing) return;
await UserStatusStore.save(persistence, userId, {
...existing,
cooldownUntil: 0,
});
}

public static async resetLevelClean(
read: IRead,
persistence: IPersistence,
userId: string,
): Promise<void> {
const existing = await UserStatusStore.get(read, userId);
if (!existing) return;
await UserStatusStore.save(persistence, userId, {
...existing,
spammingLevel: SpammingLevel.Clean,
cooldownUntil: 0,
flagsAtLevel: 0,
});
}

public static async resetLevelDown(
read: IRead,
persistence: IPersistence,
userId: string,
): Promise<void> {
const existing = await UserStatusStore.get(read, userId);
if (!existing) return;
const prevLevel =
PREV_LEVEL[existing.spammingLevel] ?? SpammingLevel.Clean;
await UserStatusStore.save(persistence, userId, {
...existing,
spammingLevel: prevLevel,
cooldownUntil: 0,
flagsAtLevel: 0,
});
}
}