diff --git a/AppsSpamMonitorApp.ts b/AppsSpamMonitorApp.ts index 5689a4a..0224c35 100644 --- a/AppsSpamMonitorApp.ts +++ b/AppsSpamMonitorApp.ts @@ -19,6 +19,12 @@ import { } 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'; +import { + IUIKitResponse, + UIKitBlockInteractionContext, + UIKitViewSubmitInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; +import { IUIKitInteractionHandler } from '@rocket.chat/apps-engine/definition/uikit/IUIKitActionHandler'; import { SpamProcessor } from './src/core/spamProcessor'; import { MessageCache } from './src/core/cache/messageCache'; import { SpamMonitorCommand } from './src/commands/commandUtilities'; @@ -35,10 +41,15 @@ import { MS_PER_DAY, MS_PER_SECOND, } from './src/constants/config'; +import { ViewSubmitHandler } from './src/handlers/viewSubmitHandler'; +import { BlockActionHandler } from './src/handlers/blockActionHandler'; export class AppsSpamMonitorApp extends App - implements IPreMessageSentPrevent, IPostMessageSent + implements + IPreMessageSentPrevent, + IPostMessageSent, + IUIKitInteractionHandler { private processor: SpamProcessor | null = null; private cache: MessageCache; @@ -63,9 +74,8 @@ export class AppsSpamMonitorApp configuration.settings.provideSetting(setting), ), ); - await configuration.slashCommands.provideSlashCommand( - new SpamMonitorCommand(), + new SpamMonitorCommand(this.getID()), ); } @@ -178,7 +188,6 @@ export class AppsSpamMonitorApp private async loadSettings(env: IEnvironmentRead): Promise { const settings = env.getSettings(); - const [ monitoringWindowDays, slidingWindowSeconds, @@ -213,9 +222,7 @@ export class AppsSpamMonitorApp _read: IRead, _http: IHttp, ): Promise { - if (!message.sender || !message.room || !message.text) { - return false; - } + if (!message.sender || !message.room || !message.text) return false; return message.room.type !== RoomType.DIRECT_MESSAGE; } @@ -225,9 +232,7 @@ export class AppsSpamMonitorApp _http: IHttp, persistence: IPersistence, ): Promise { - if (!message.sender || !message.room) { - return false; - } + if (!message.sender || !message.room) return false; try { const { restricted } = await UserStatusStore.isRestricted( read, @@ -246,9 +251,8 @@ export class AppsSpamMonitorApp _read: IRead, _http: IHttp, ): Promise { - if (!message.text || message.room.type === RoomType.DIRECT_MESSAGE) { + if (!message.text || message.room.type === RoomType.DIRECT_MESSAGE) return false; - } return true; } @@ -259,33 +263,60 @@ export class AppsSpamMonitorApp persistence: IPersistence, modify: IModify, ): Promise { - if (!message.sender || !message.room) { - return; - } - + if (!message.sender || !message.room) return; const sender = await read.getUserReader().getById(message.sender.id); - if (!sender || !this.processor?.isNewUser(sender)) { - return; - } - + if (!sender || !this.processor?.isNewUser(sender)) return; try { 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 }, + { + levelChanged: result.levelChanged, + }, ); } } catch (err) { this.getLogger().error('[antispam] Error in analyzeMessage:', err); } } + + public async executeBlockActionHandler( + context: UIKitBlockInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise { + return new BlockActionHandler( + read, + http, + persistence, + modify, + context, + this.getID(), + ).handle(); + } + public async executeViewSubmitHandler( + context: UIKitViewSubmitInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise { + return new ViewSubmitHandler( + read, + http, + persistence, + modify, + context, + ).handle(); + } } diff --git a/app.json b/app.json index 894a792..40620fe 100644 --- a/app.json +++ b/app.json @@ -18,7 +18,8 @@ "description": "Automatically detects and flags spam from new users before it reaches Rocket.Chats community", "implements": [ "IPreMessageSentPrevent", - "IPostMessageSent" + "IPostMessageSent", + "IUIKitInteractionHandler" ], "permissions": [ { diff --git a/src/commands/commandUtilities.ts b/src/commands/commandUtilities.ts index c38afed..210f6b2 100644 --- a/src/commands/commandUtilities.ts +++ b/src/commands/commandUtilities.ts @@ -12,13 +12,17 @@ import { SpamMonitorHandler } from '../handlers/handler'; import { SpamMonitorParam } from '../enums/commandUtilities'; import { slashCommandHelp, slashNotifications } from '../enums/notifications'; import { ADMIN_CHANNEL_NAME } from '../constants/config'; +import { RoomInteractionStorage } from '../persistence/roomInteraction'; export class SpamMonitorCommand implements ISlashCommand { public command = 'spammonitor'; public i18nDescription = 'SpamMonitor_Command_Description'; - public i18nParamsExample = 'list all | list timeout | list '; + public i18nParamsExample = + 'list all | list timeout | list | manage '; public providesPreview = false; + constructor(private readonly appId: string) {} + public async executor( context: SlashCommandContext, read: IRead, @@ -28,6 +32,15 @@ export class SpamMonitorCommand implements ISlashCommand { ): Promise { const sender = context.getSender(); const room = context.getRoom(); + const triggerId = context.getTriggerId(); + + const roomInteractionStorage = new RoomInteractionStorage( + persistence, + read.getPersistenceReader(), + sender.id, + ); + roomInteractionStorage.storeInteractionRoomId(room.id); + const handler = new SpamMonitorHandler( sender, room, @@ -35,6 +48,7 @@ export class SpamMonitorCommand implements ISlashCommand { modify, http, persistence, + this.appId, ); if (room.slugifiedName !== ADMIN_CHANNEL_NAME) { @@ -51,25 +65,40 @@ export class SpamMonitorCommand implements ISlashCommand { } const [subcommand, ...rest] = context.getArguments(); - if (subcommand?.toLowerCase() !== SpamMonitorParam.LIST) { - await handler.sendNotification(slashCommandHelp.HELP); - return; - } - const filter = rest.join(' ').toLowerCase().trim(); - switch (filter) { - case SpamMonitorParam.ALL: - case '': - await handler.listAll(); - break; - case SpamMonitorParam.TIMEOUT: - await handler.listTimeout(); + switch (subcommand?.toLowerCase()) { + case SpamMonitorParam.LIST: { + const filter = rest.join(' ').toLowerCase().trim(); + switch (filter) { + case SpamMonitorParam.ALL: + case '': + await handler.listAll(); + break; + case SpamMonitorParam.TIMEOUT: + await handler.listTimeout(); + break; + case SpamMonitorParam.ADMIN_REVIEW: + await handler.listAdminReview(); + break; + default: + await handler.listByLevel(filter); + break; + } break; - case SpamMonitorParam.ADMIN_REVIEW: - await handler.listAdminReview(); + } + case SpamMonitorParam.MANAGE: { + const username = rest[0]?.replace(/^@/, '').trim(); + if (!triggerId) { + await handler.sendNotification( + slashNotifications.MANAGE_MISSING_USERNAME, + ); + return; + } + await handler.manageUser(username, triggerId); break; + } default: - await handler.listByLevel(filter); + await handler.sendNotification(slashCommandHelp.HELP); break; } } diff --git a/src/handlers/blockActionHandler.ts b/src/handlers/blockActionHandler.ts new file mode 100644 index 0000000..7961e6c --- /dev/null +++ b/src/handlers/blockActionHandler.ts @@ -0,0 +1,107 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { + IUIKitResponse, + UIKitBlockInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; +import { buildConfirmActionModal } from '../modals/confirmationModal'; +import { buildManageUserModal } from '../modals/manageUsers'; +import { RoomInteractionStorage } from '../persistence/roomInteraction'; +import { UserStatusStore } from '../persistence/userStatusStore'; +import { + ACTIONS_REQUIRING_CONFIRM, + CONFIRM_TO_ACTION, + ManageUserActionId, +} from '../enums/modals/manageUsers'; + +export class BlockActionHandler { + constructor( + private readonly read: IRead, + private readonly http: IHttp, + private readonly persistence: IPersistence, + private readonly modify: IModify, + private readonly context: UIKitBlockInteractionContext, + private readonly appId: string, + ) {} + + public async handle(): Promise { + const { actionId, value, user, triggerId } = + this.context.getInteractionData(); + const roomStorage = new RoomInteractionStorage( + this.persistence, + this.read.getPersistenceReader(), + user.id, + ); + + if (actionId === ManageUserActionId.OPEN_MANAGE_MODAL) { + if (!value || !triggerId) { + return this.context.getInteractionResponder().successResponse(); + } + // value format: "::" + const [intent, userId] = value.split('::'); + + if (intent === ManageUserActionId.OPEN_MANAGE_MODAL) { + const targetUser = await this.read + .getUserReader() + .getById(userId); + if (!targetUser) { + return this.context + .getInteractionResponder() + .successResponse(); + } + + const record = await UserStatusStore.get(this.read, userId); + if (!record) { + return this.context + .getInteractionResponder() + .successResponse(); + } + + const modal = buildManageUserModal(record, this.appId); + await this.modify + .getUiController() + .openSurfaceView(modal, { triggerId }, user); + + return this.context.getInteractionResponder().successResponse(); + } + + return this.context.getInteractionResponder().successResponse(); + } + + if (!ACTIONS_REQUIRING_CONFIRM.has(actionId)) { + return this.context.getInteractionResponder().successResponse(); + } + + if (!value || !triggerId) { + return this.context.getInteractionResponder().successResponse(); + } + + const roomId = await roomStorage.getInteractionRoomId(); + if (!roomId) { + return this.context.getInteractionResponder().successResponse(); + } + + const targetUser = await this.read.getUserReader().getById(value); + if (!targetUser) { + return this.context.getInteractionResponder().successResponse(); + } + + const realAction = CONFIRM_TO_ACTION[actionId]; + const modal = buildConfirmActionModal( + realAction, + targetUser.id, + targetUser.username, + this.appId, + roomId, + ); + await this.modify + .getUiController() + .openSurfaceView(modal, { triggerId }, user); + + return this.context.getInteractionResponder().successResponse(); + } +} diff --git a/src/handlers/handler.ts b/src/handlers/handler.ts index 2a1ca95..026e486 100644 --- a/src/handlers/handler.ts +++ b/src/handlers/handler.ts @@ -12,7 +12,14 @@ import { UserSpamRecord, } from '../definition/spamlevel'; import { UserStatusStore } from '../persistence/userStatusStore'; -import { slashCommandHelp, slashNotifications } from '../enums/notifications'; +import { slashNotifications } from '../enums/notifications'; +import { buildManageUserModal } from '../modals/manageUsers'; +import { + LIST_OVERFLOW_BLOCK_ID, + ManageUserActionId, +} from '../enums/modals/manageUsers'; +import { TextObjectType } from '@rocket.chat/ui-kit'; + export class SpamMonitorHandler { constructor( private readonly sender: IUser, @@ -21,6 +28,7 @@ export class SpamMonitorHandler { private readonly modify: IModify, private readonly http: IHttp, private readonly persis: IPersistence, + private readonly appId: string, ) {} private async notify(text: string): Promise { @@ -61,7 +69,9 @@ export class SpamMonitorHandler { ); } - private formatRow(r: UserSpamRecord): string { + private buildUserRowBlock( + r: UserSpamRecord, + ): import('@rocket.chat/ui-kit').Block { const label = SPAMMING_LEVEL_LABELS[r.spammingLevel] ?? String(r.spammingLevel); const now = Date.now(); @@ -72,7 +82,30 @@ export class SpamMonitorHandler { .slice(0, 16) .replace('T', ' ')} UTC` : ''; - return `@${r.username} — *${label}* ${cooldownStr}`; + + return { + type: 'section' as const, + text: { + type: TextObjectType.MRKDWN, + text: `@${r.username} — *${label}*${cooldownStr}`, + }, + accessory: { + type: 'overflow' as const, + appId: this.appId, + blockId: LIST_OVERFLOW_BLOCK_ID, + actionId: ManageUserActionId.OPEN_MANAGE_MODAL, + options: [ + { + value: `${ManageUserActionId.OPEN_MANAGE_MODAL}::${r.userId}`, + text: { + type: TextObjectType.PLAIN_TEXT, + text: 'Manage user', + emoji: true, + }, + }, + ], + }, + }; } private async renderList( @@ -87,9 +120,29 @@ export class SpamMonitorHandler { } const summary = this.buildSummary(allRecords); - const rows = records.map((r) => this.formatRow(r)).join('\n'); - await this.notify(`${summary}\n\n*${title}*\n${rows}`); + + const headerBlock = { + type: 'section' as const, + text: { + type: TextObjectType.MRKDWN, + text: `${summary}\n\n*${title}*`, + }, + }; + + const userBlocks = records.map((r) => this.buildUserRowBlock(r)); + const allBlocks = [headerBlock, ...userBlocks]; + + const msg = this.modify + .getCreator() + .startMessage() + .setRoom(this.room) + .setBlocks(allBlocks as any); + + await this.modify + .getNotifier() + .notifyUser(this.sender, msg.getMessage()); } + private async listByLevelEnum( level: SpammingLevel, title: string, @@ -105,6 +158,7 @@ export class SpamMonitorHandler { slashNotifications.NO_FLAGGED_USERS_FILTER(title), ); } + public async listAll(): Promise { const records = await UserStatusStore.getAll(this.read); const sorted = [...records].sort((a, b) => @@ -154,7 +208,6 @@ export class SpamMonitorHandler { const filtered = records .filter((r) => r.cooldownUntil > 0 && now < r.cooldownUntil) .sort((a, b) => a.cooldownUntil - b.cooldownUntil); - await this.renderList( filtered, 'Users in Active Timeout', @@ -166,4 +219,34 @@ export class SpamMonitorHandler { public async sendNotification(text: string): Promise { await this.notify(text); } + public async manageUser( + username: string, + triggerId: string, + ): Promise { + if (!username) { + await this.notify(slashNotifications.MANAGE_MISSING_USERNAME); + return; + } + + const targetUser = await this.read + .getUserReader() + .getByUsername(username); + + if (!targetUser) { + await this.notify(slashNotifications.USER_NOT_FOUND(username)); + return; + } + + const record = await UserStatusStore.get(this.read, targetUser.id); + + if (!record) { + await this.notify(slashNotifications.USER_NOT_FOUND(username)); + return; + } + + const modal = buildManageUserModal(record, this.appId); + await this.modify + .getUiController() + .openSurfaceView(modal, { triggerId }, this.sender); + } } diff --git a/src/handlers/viewSubmitHandler.ts b/src/handlers/viewSubmitHandler.ts new file mode 100644 index 0000000..1720192 --- /dev/null +++ b/src/handlers/viewSubmitHandler.ts @@ -0,0 +1,183 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { + IUIKitResponse, + UIKitViewSubmitInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { UserStatusStore } from '../persistence/userStatusStore'; +import { SPAMMING_LEVEL_LABELS } from '../definition/spamlevel'; +import { + ManageUserActionId, + CONFIRM_ACTION_MODAL_ID, +} from '../enums/modals/manageUsers'; +import { sendNotification } from '../lib/utils/notifications'; +import { RoomInteractionStorage } from '../persistence/roomInteraction'; +import { AdminActionMessages } from '../lib/translations/locals/en'; + +export class ViewSubmitHandler { + constructor( + private readonly read: IRead, + private readonly http: IHttp, + private readonly persistence: IPersistence, + private readonly modify: IModify, + private readonly context: UIKitViewSubmitInteractionContext, + ) {} + + public async handle(): Promise { + const { view, user } = this.context.getInteractionData(); + + if (!view.id.startsWith(CONFIRM_ACTION_MODAL_ID)) { + return this.context.getInteractionResponder().successResponse(); + } + + // view.id format: confirm_action_modal:::::: + const parts = view.id.split('::'); + if (parts.length !== 4) { + return this.context.getInteractionResponder().successResponse(); + } + + const [, realAction, userId, roomId] = parts; + + const targetUser = await this.read.getUserReader().getById(userId); + if (!targetUser) { + return this.context.getInteractionResponder().successResponse(); + } + const notifyRoom = roomId + ? await this.read.getRoomReader().getById(roomId) + : null; + if (!notifyRoom) { + const roomStorage = new RoomInteractionStorage( + this.persistence, + this.read.getPersistenceReader(), + user.id, + ); + const fallbackRoomId = await roomStorage.getInteractionRoomId(); + const fallbackRoom = fallbackRoomId + ? await this.read.getRoomReader().getById(fallbackRoomId) + : null; + + if (!fallbackRoom) { + console.error( + `[ViewSubmitHandler] Could not resolve notify room. viewId roomId=${roomId}`, + ); + return this.context.getInteractionResponder().successResponse(); + } + + await this.executeAction( + realAction as ManageUserActionId, + targetUser, + user, + fallbackRoom, + ); + return this.context.getInteractionResponder().successResponse(); + } + + await this.executeAction( + realAction as ManageUserActionId, + targetUser, + user, + notifyRoom, + ); + + return this.context.getInteractionResponder().successResponse(); + } + + private async executeAction( + action: ManageUserActionId, + targetUser: IUser, + admin: IUser, + room: IRoom, + ): Promise { + const notify = async (message: string) => { + await sendNotification(this.read, this.modify, admin, room, { + message, + }); + }; + + switch (action) { + case ManageUserActionId.VOUCH: + await UserStatusStore.vouch( + this.persistence, + targetUser.id, + targetUser.username, + admin.username, + ); + await notify( + AdminActionMessages.vouch( + targetUser.username, + admin.username, + ), + ); + break; + + case ManageUserActionId.RESET_COOLDOWN: + await UserStatusStore.resetCooldown( + this.read, + this.persistence, + targetUser.id, + ); + await notify( + AdminActionMessages.resetCooldown( + targetUser.username, + admin.username, + ), + ); + break; + + case ManageUserActionId.RESET_LEVEL_DOWN: { + const before = await UserStatusStore.get( + this.read, + targetUser.id, + ); + await UserStatusStore.resetLevelDown( + this.read, + this.persistence, + targetUser.id, + ); + const after = await UserStatusStore.get( + this.read, + targetUser.id, + ); + const beforeLabel = + before?.spammingLevel !== undefined + ? (SPAMMING_LEVEL_LABELS[before.spammingLevel] ?? + String(before.spammingLevel)) + : 'Unknown'; + const afterLabel = + after?.spammingLevel !== undefined + ? (SPAMMING_LEVEL_LABELS[after.spammingLevel] ?? + String(after.spammingLevel)) + : 'Clean'; + await notify( + AdminActionMessages.resetLevelDown( + targetUser.username, + admin.username, + beforeLabel, + afterLabel, + ), + ); + break; + } + + case ManageUserActionId.RESET_LEVEL_CLEAN: + await UserStatusStore.resetLevelClean( + this.read, + this.persistence, + targetUser.id, + ); + await notify( + AdminActionMessages.resetLevelClean( + targetUser.username, + admin.username, + ), + ); + break; + } + } +} diff --git a/src/lib/translations/locals/en.ts b/src/lib/translations/locals/en.ts index c36ddbf..a57e0ed 100644 --- a/src/lib/translations/locals/en.ts +++ b/src/lib/translations/locals/en.ts @@ -44,7 +44,6 @@ 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.`,