diff --git a/src/definition/confirmationModal.ts b/src/definition/confirmationModal.ts new file mode 100644 index 0000000..bcac7cb --- /dev/null +++ b/src/definition/confirmationModal.ts @@ -0,0 +1,6 @@ +export interface ConfirmMeta { + title: string; + description: string; + confirmLabel: string; + danger?: boolean; +} diff --git a/src/definition/spamlevel.ts b/src/definition/spamlevel.ts index a9d644b..a6b747f 100644 --- a/src/definition/spamlevel.ts +++ b/src/definition/spamlevel.ts @@ -29,6 +29,13 @@ export const NEXT_LEVEL: Partial> = { [SpammingLevel.Suspended]: SpammingLevel.AdminReview, }; +export const PREV_LEVEL: Partial> = { + [SpammingLevel.Monitored]: SpammingLevel.Clean, + [SpammingLevel.Restricted]: SpammingLevel.Monitored, + [SpammingLevel.Suspended]: SpammingLevel.Restricted, + [SpammingLevel.AdminReview]: SpammingLevel.Suspended, +}; + export const COOLDOWN_DURATIONS: Record = { [SpammingLevel.Clean]: 0, [SpammingLevel.Monitored]: 0, @@ -45,5 +52,6 @@ export interface UserSpamRecord { lastEscalation: number; totalFlags: number; flagsAtLevel: number; + vouched?: boolean; vouchedBy?: string; } diff --git a/src/enums/commandUtilities.ts b/src/enums/commandUtilities.ts index f5d4719..457e2a2 100644 --- a/src/enums/commandUtilities.ts +++ b/src/enums/commandUtilities.ts @@ -3,4 +3,5 @@ export enum SpamMonitorParam { ALL = 'all', TIMEOUT = 'timeout', ADMIN_REVIEW = 'review', + MANAGE = 'manage', } diff --git a/src/enums/modals/manageUsers.ts b/src/enums/modals/manageUsers.ts new file mode 100644 index 0000000..f6c025e --- /dev/null +++ b/src/enums/modals/manageUsers.ts @@ -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([ + ManageUserActionId.CONFIRM_VOUCH, + ManageUserActionId.CONFIRM_RESET_COOLDOWN, + ManageUserActionId.CONFIRM_RESET_LEVEL_DOWN, + ManageUserActionId.CONFIRM_RESET_LEVEL_CLEAN, +]); + +export const CONFIRM_TO_ACTION: Record = { + [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; diff --git a/src/enums/notifications.ts b/src/enums/notifications.ts index 6d389c7..b14ba18 100644 --- a/src/enums/notifications.ts +++ b/src/enums/notifications.ts @@ -4,6 +4,9 @@ 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 `', }; export const slashCommandHelp = { @@ -11,5 +14,6 @@ export const slashCommandHelp = { '*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 ` — users at a specific level e.g. `list review` for admin review users', + '`/spammonitor list ` — users at a specific level e.g. `list review` for admin review users\n' + + '`/spammonitor manage ` — open admin controls for a flagged user', }; diff --git a/src/lib/translations/locals/en.ts b/src/lib/translations/locals/en.ts index f666836..c36ddbf 100644 --- a/src/lib/translations/locals/en.ts +++ b/src/lib/translations/locals/en.ts @@ -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; @@ -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.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, + }, +}; diff --git a/src/lib/utils/messageUtils.ts b/src/lib/utils/messageUtils.ts index f06f5b2..b3f35ef 100644 --- a/src/lib/utils/messageUtils.ts +++ b/src/lib/utils/messageUtils.ts @@ -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'; +} diff --git a/src/lib/utils/notifications.ts b/src/lib/utils/notifications.ts new file mode 100644 index 0000000..b866680 --- /dev/null +++ b/src/lib/utils/notifications.ts @@ -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 { + 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()); +} diff --git a/src/persistence/roomInteraction.ts b/src/persistence/roomInteraction.ts new file mode 100644 index 0000000..d3ddc24 --- /dev/null +++ b/src/persistence/roomInteraction.ts @@ -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 { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${this.userId}#RoomId`, + ); + await this.persistence.updateByAssociation( + association, + { roomId }, + true, + ); + } + + public async getInteractionRoomId(): Promise { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${this.userId}#RoomId`, + ); + const [result] = (await this.persistenceRead.readByAssociation( + association, + )) as Array<{ roomId: string }>; + return result?.roomId; + } +} diff --git a/src/persistence/userStatusStore.ts b/src/persistence/userStatusStore.ts index 653787c..37c85c9 100644 --- a/src/persistence/userStatusStore.ts +++ b/src/persistence/userStatusStore.ts @@ -10,6 +10,7 @@ import { COOLDOWN_DURATIONS, ESCALATION_THRESHOLDS, NEXT_LEVEL, + PREV_LEVEL, SpammingLevel, UserSpamRecord, } from '../definition/spamlevel'; @@ -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 }; } @@ -161,4 +164,69 @@ export class UserStatusStore { return { restricted: false, record }; } + + public static async vouch( + persistence: IPersistence, + userId: string, + username: string, + adminUsername: string, + ): Promise { + 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 { + 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 { + 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 { + 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, + }); + } }