From 8212e444cae8bb4b6072d956d7904e238d6cd093 Mon Sep 17 00:00:00 2001 From: Dnouv Date: Fri, 7 Mar 2025 19:15:36 +0530 Subject: [PATCH 1/2] add settings, modal for send, modal submit handler --- KokoApp.ts | 9 +++ actions/KokoSend.ts | 123 ++++++++++++++++++++++++++++++++++++++ commands/KokoCommand.ts | 4 ++ commands/Send.ts | 61 +++++++++++++++++++ i18n/en.json | 4 +- modals/SendModal.ts | 128 ++++++++++++++++++++++++++++++++++++++++ settings.ts | 9 +++ 7 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 actions/KokoSend.ts create mode 100644 commands/Send.ts create mode 100644 modals/SendModal.ts diff --git a/KokoApp.ts b/KokoApp.ts index e89c585..be06ce5 100644 --- a/KokoApp.ts +++ b/KokoApp.ts @@ -39,6 +39,7 @@ import { praiseModal } from './modals/PraiseModal'; import { questionModal } from './modals/QuestionModal'; import { valuesModal } from './modals/ValuesModal'; import { settings } from './settings'; +import { KokoSend } from './actions/KokoSend'; export class KokoApp extends App implements IUIKitInteractionHandler { /** @@ -116,6 +117,11 @@ export class KokoApp extends App implements IUIKitInteractionHandler { */ public kokoWellness: KokoWellness; + /** + * The send message mechanism + */ + public kokoSend: KokoSend; + /** * Members cache */ @@ -138,6 +144,8 @@ export class KokoApp extends App implements IUIKitInteractionHandler { return this.kokoQuestion.submit({ context, modify, read, persistence }); case 'values': return this.kokoValues.submit({ context, modify, read, persistence, http }); + case 'send': + return this.kokoSend.submit({ context, modify, read, persistence, http }); } return { success: true, @@ -178,6 +186,7 @@ export class KokoApp extends App implements IUIKitInteractionHandler { this.kokoOneOnOne = new KokoOneOnOne(this); this.kokoWellness = new KokoWellness(this); this.kokoValues = new KokoValues(this); + this.kokoSend = new KokoSend(this); await this.extendConfiguration(configurationExtend); } diff --git a/actions/KokoSend.ts b/actions/KokoSend.ts new file mode 100644 index 0000000..421aeb7 --- /dev/null +++ b/actions/KokoSend.ts @@ -0,0 +1,123 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { UIKitViewSubmitInteractionContext } from "@rocket.chat/apps-engine/definition/uikit"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; + +import { KokoApp } from "../KokoApp"; +import { getDirect, notifyUser, sendMessage } from "../lib/helpers"; +import { messageSubmittedModal } from "../modals/SendModal"; + +export class KokoSend { + constructor(private readonly app: KokoApp) {} + + /** + * Handles the submit action for the Send Message modal + * Sends a message to the specified room + */ + public async submit({ + context, + modify, + read, + persistence, + http, + }: { + context: UIKitViewSubmitInteractionContext; + modify: IModify; + read: IRead; + persistence: IPersistence; + http: IHttp; + }) { + const data = context.getInteractionData(); + let roomToSend: IRoom | undefined; + + // Extract data from the modal submission + const { send } = data.view.state as any; + const messageToSend = send?.["message"]; + const roomNameInput = send?.["room"]; + const roomNameUser = send?.["user"]; + + // Validate inputs + const errors = {} as any; + + if (!messageToSend || messageToSend.trim().length === 0) { + errors["message"] = "Please enter a message to send"; + } + + const roomName = + roomNameInput?.trim() || roomNameUser?.trim() || undefined; + if (!roomName) { + errors["room"] = "Please enter a room name"; + } else if (roomName.startsWith("#")) { + // Check if the room exists + const room = await read + .getRoomReader() + .getByName(roomName.replace("#", "")); + if (!room) { + errors["room"] = `Room "${roomName}" not found`; + } else { + roomToSend = room; + } + } else if (roomName.startsWith("@")) { + // Check if the user exists + const user = await read + .getUserReader() + .getByUsername(roomName.replace("@", "")); + + if (!user) { + errors["user"] = `User "${roomName}" not found`; + } else { + roomToSend = await getDirect( + this.app, + read, + modify, + roomName.replace("@", "") + ); + } + } + + // Return errors if validation fails + if (Object.keys(errors).length > 0) { + return context.getInteractionResponder().viewErrorResponse({ + viewId: data.view.id, + errors, + }); + } + + // Send the message + await sendMessage(this.app, modify, roomToSend as IRoom, messageToSend); + + // Show confirmation modal + const modal = await messageSubmittedModal({ read, modify, data }); + return context.getInteractionResponder().updateModalViewResponse(modal); + } + + /** + * Gets the direct message room between the user and the bot + * + * @param {IRead} read - The IRead instance + * @param {IUser} user - The user to get the direct message room for + * @param {IRoom} room - The room to get the direct message room for + * @return {Promise} - The direct message room + * @throws {Error} - If the direct message room is not found + */ + private async getDirectRoom( + read: IRead, + user: IUser, + room: IRoom + ): Promise { + // const directRoom = await getDirect(this.app, read, user, room); + + // // Check if the direct room exists + // if (!directRoom) { + // throw new Error(`Direct message room not found`); + // } + + // return directRoom; + return room; + } +} diff --git a/commands/KokoCommand.ts b/commands/KokoCommand.ts index 177a441..5ed3d6e 100644 --- a/commands/KokoCommand.ts +++ b/commands/KokoCommand.ts @@ -8,6 +8,7 @@ import { processHelpCommand } from './Help'; import { processOneOnOneCommand } from './OneOnOne'; import { processPraiseCommand } from './Praise'; import { processQuestionCommand } from './Question'; +import { processSendCommand } from './Send'; export class KokoCommand implements ISlashCommand { public command = 'koko'; @@ -56,6 +57,9 @@ export class KokoCommand implements ISlashCommand { case this.CommandEnum.Cancel: await processCancelCommand(this.app, context, read, modify, persistence); break; + case this.CommandEnum.Send: + await processSendCommand(this.app, context, read, modify, persistence, params); + break; default: await processHelpCommand(this.app, context, read, modify); } diff --git a/commands/Send.ts b/commands/Send.ts new file mode 100644 index 0000000..25f5694 --- /dev/null +++ b/commands/Send.ts @@ -0,0 +1,61 @@ +import { + IRead, + IModify, + IPersistence, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { SlashCommandContext } from "@rocket.chat/apps-engine/definition/slashcommands"; +import { KokoApp } from "../KokoApp"; +import { sendModal } from "../modals/SendModal"; +import { notifyUser } from "../lib/helpers"; + +/** + * Process a send command with format: send [roomName] [message] + * @param {string[]} args - Array of command arguments + * @returns {boolean} - True if command was processed successfully + */ +export const processSendCommand = async ( + app: KokoApp, + context: SlashCommandContext, + read: IRead, + modify: IModify, + persistence: IPersistence, + args: string[] +) => { + // Check if room name is provided + if (args.length < 1) { + await notifyUser( + app, + modify, + context.getRoom(), + context.getSender(), + "Please provide a room name." + ); + return false; + } + + const roomName = args[0]; + + const triggerId = context.getTriggerId(); + if (triggerId) { + try { + const modal = await sendModal({ + app, + read, + modify, + data: { user: context.getSender(), roomName }, + }); + await modify + .getUiController() + .openModalView(modal, { triggerId }, context.getSender()); + } catch (error) { + console.log(error); + app.getLogger().error( + `Error opening Send modal: ${ + error?.message + }, stringify: ${JSON.stringify(error, null, 2)}` + ); + } + } + + return true; +}; diff --git a/i18n/en.json b/i18n/en.json index 9e84c16..ddcae12 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -10,5 +10,7 @@ "Koko_Post_Answers_Room_Name": "Answers Room Name", "Koko_Post_Answers_Room_Name_Description": "The room name for Koko to post answers to", "AllStars_Webhook": "AllStars Webhook", - "AllStars_Webhook_Description": "Webhook to add Rocket.Chat AllStars points" + "AllStars_Webhook_Description": "Webhook to add Rocket.Chat AllStars points", + "Access_Roles": "Access Roles", + "Access_Roles_Description": "Roles that can manage Koko. Case sensitive, comma separated" } diff --git a/modals/SendModal.ts b/modals/SendModal.ts new file mode 100644 index 0000000..852882f --- /dev/null +++ b/modals/SendModal.ts @@ -0,0 +1,128 @@ +import { IModify, IRead } from "@rocket.chat/apps-engine/definition/accessors"; +import { + BlockElementType, + IMultiStaticSelectElement, + TextObjectType, +} from "@rocket.chat/apps-engine/definition/uikit/blocks"; +import { IUIKitModalViewParam } from "@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder"; + +import { KokoApp } from "../KokoApp"; +import { getMembers } from "../lib/helpers"; + +const UI_ID = "send"; + +export async function sendModal({ + app, + data, + read, + modify, +}: { + app: KokoApp; + data; + read: IRead; + modify: IModify; +}): Promise { + const viewId = UI_ID; + const block = modify.getCreator().getBlockBuilder(); + const { roomName }: { roomName: string } = data; + + if (roomName.startsWith("#")) { + block.addInputBlock({ + blockId: UI_ID, + element: block.newPlainTextInputElement({ + actionId: "room", + initialValue: roomName, + multiline: false, + }), + label: { + type: TextObjectType.PLAINTEXT, + text: "Send to Channel", + emoji: true, + }, + }); + } + if (roomName.startsWith("@")) { + block.addInputBlock({ + blockId: UI_ID, + element: block.newPlainTextInputElement({ + actionId: "user", + initialValue: roomName, + multiline: false, + }), + label: { + type: TextObjectType.PLAINTEXT, + text: "Send to User", + emoji: true, + }, + }); + } + + block.addInputBlock({ + blockId: UI_ID, + element: block.newPlainTextInputElement({ + actionId: "message", + multiline: true, + }), + label: { + type: TextObjectType.PLAINTEXT, + text: `What would you like to send?`, + emoji: true, + }, + }); + + return { + id: viewId, + title: { + type: TextObjectType.PLAINTEXT, + text: "Send Message", + }, + submit: block.newButtonElement({ + text: { + type: TextObjectType.PLAINTEXT, + text: "Send", + }, + }), + close: block.newButtonElement({ + text: { + type: TextObjectType.PLAINTEXT, + text: "Cancel", + }, + }), + blocks: block.getBlocks(), + }; +} + +export async function messageSubmittedModal({ + read, + modify, + data, +}: { + read: IRead; + modify: IModify; + data; +}): Promise { + const viewId = UI_ID; + const block = modify.getCreator().getBlockBuilder(); + + block.addSectionBlock({ + text: { + type: TextObjectType.PLAINTEXT, + text: "Your message has been sent successfully.", + }, + }); + + return { + id: viewId, + title: { + type: TextObjectType.PLAINTEXT, + text: "Message Sent", + }, + close: block.newButtonElement({ + text: { + type: TextObjectType.PLAINTEXT, + text: "Close", + }, + }), + blocks: block.getBlocks(), + }; +} diff --git a/settings.ts b/settings.ts index 352c9b7..95f6097 100644 --- a/settings.ts +++ b/settings.ts @@ -45,4 +45,13 @@ export const settings: Array = [ i18nLabel: 'AllStars_Webhook', i18nDescription: 'AllStars_Webhook_Description', }, + { + id: 'Access_Roles', + type: SettingType.STRING, + packageValue: '', + required: false, + public: false, + i18nLabel: 'Access_Roles', + i18nDescription: 'Access_Roles_Description', + } ]; From 8648caf6d557d7dc192741bbbad99807beb4730d Mon Sep 17 00:00:00 2001 From: Dnouv Date: Fri, 7 Mar 2025 19:24:44 +0530 Subject: [PATCH 2/2] fail fast, better validation, logging --- actions/KokoSend.ts | 155 ++++++++++++++++++++++++-------------------- 1 file changed, 85 insertions(+), 70 deletions(-) diff --git a/actions/KokoSend.ts b/actions/KokoSend.ts index 421aeb7..80adb72 100644 --- a/actions/KokoSend.ts +++ b/actions/KokoSend.ts @@ -6,10 +6,9 @@ import { } from "@rocket.chat/apps-engine/definition/accessors"; import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; import { UIKitViewSubmitInteractionContext } from "@rocket.chat/apps-engine/definition/uikit"; -import { IUser } from "@rocket.chat/apps-engine/definition/users"; import { KokoApp } from "../KokoApp"; -import { getDirect, notifyUser, sendMessage } from "../lib/helpers"; +import { getDirect, sendMessage } from "../lib/helpers"; import { messageSubmittedModal } from "../modals/SendModal"; export class KokoSend { @@ -17,7 +16,7 @@ export class KokoSend { /** * Handles the submit action for the Send Message modal - * Sends a message to the specified room + * Validates input and sends message to the specified room or user */ public async submit({ context, @@ -33,91 +32,107 @@ export class KokoSend { http: IHttp; }) { const data = context.getInteractionData(); - let roomToSend: IRoom | undefined; - - // Extract data from the modal submission const { send } = data.view.state as any; + + // Extract and validate message content const messageToSend = send?.["message"]; + if (!messageToSend?.trim()) { + return context.getInteractionResponder().viewErrorResponse({ + viewId: data.view.id, + errors: { + message: "Please enter a message to send", + }, + }); + } + + // Determine target room (channel or user) const roomNameInput = send?.["room"]; const roomNameUser = send?.["user"]; + const roomName = roomNameInput?.trim() || roomNameUser?.trim(); - // Validate inputs - const errors = {} as any; - - if (!messageToSend || messageToSend.trim().length === 0) { - errors["message"] = "Please enter a message to send"; + // Validate room name + if (!roomName) { + return context.getInteractionResponder().viewErrorResponse({ + viewId: data.view.id, + errors: { + room: "Please enter a valid room or user name", + }, + }); } - const roomName = - roomNameInput?.trim() || roomNameUser?.trim() || undefined; - if (!roomName) { - errors["room"] = "Please enter a room name"; - } else if (roomName.startsWith("#")) { - // Check if the room exists - const room = await read - .getRoomReader() - .getByName(roomName.replace("#", "")); - if (!room) { - errors["room"] = `Room "${roomName}" not found`; - } else { - roomToSend = room; - } - } else if (roomName.startsWith("@")) { - // Check if the user exists - const user = await read - .getUserReader() - .getByUsername(roomName.replace("@", "")); - - if (!user) { - errors["user"] = `User "${roomName}" not found`; - } else { - roomToSend = await getDirect( - this.app, - read, - modify, - roomName.replace("@", "") - ); + // Find target room + try { + const targetRoom = await this.resolveTargetRoom( + read, + modify, + roomName + ); + + if (!targetRoom) { + const fieldName = roomName.startsWith("@") ? "user" : "room"; + const entityType = roomName.startsWith("@") ? "User" : "Room"; + + return context.getInteractionResponder().viewErrorResponse({ + viewId: data.view.id, + errors: { + [fieldName]: `${entityType} "${roomName}" not found. Please check the name and try again.`, + }, + }); } - } - // Return errors if validation fails - if (Object.keys(errors).length > 0) { + // Send the message + await sendMessage(this.app, modify, targetRoom, messageToSend); + + // Show confirmation modal + const modal = await messageSubmittedModal({ read, modify, data }); + return context + .getInteractionResponder() + .updateModalViewResponse(modal); + } catch (error) { + // Handle errors during room resolution or message sending + this.app + .getLogger() + .error(`Error in send command: ${error.message}`); + return context.getInteractionResponder().viewErrorResponse({ viewId: data.view.id, - errors, + errors: { + message: + "An error occurred while sending your message. Please try again.", + }, }); } - - // Send the message - await sendMessage(this.app, modify, roomToSend as IRoom, messageToSend); - - // Show confirmation modal - const modal = await messageSubmittedModal({ read, modify, data }); - return context.getInteractionResponder().updateModalViewResponse(modal); } /** - * Gets the direct message room between the user and the bot + * Resolves a room name or username to an actual room object * - * @param {IRead} read - The IRead instance - * @param {IUser} user - The user to get the direct message room for - * @param {IRoom} room - The room to get the direct message room for - * @return {Promise} - The direct message room - * @throws {Error} - If the direct message room is not found + * @param {IRead} read - The read accessor + * @param {IModify} modify - The modify accessor + * @param {string} target - The target room name or username (with # or @ prefix) + * @returns {Promise} The resolved room or undefined if not found */ - private async getDirectRoom( + private async resolveTargetRoom( read: IRead, - user: IUser, - room: IRoom - ): Promise { - // const directRoom = await getDirect(this.app, read, user, room); - - // // Check if the direct room exists - // if (!directRoom) { - // throw new Error(`Direct message room not found`); - // } - - // return directRoom; - return room; + modify: IModify, + target: string + ): Promise { + // Handle channel + if (target.startsWith("#")) { + const roomName = target.substring(1); // Remove # prefix + return read.getRoomReader().getByName(roomName); + } + + // Handle user + if (target.startsWith("@")) { + const username = target.substring(1); // Remove @ prefix + const user = await read.getUserReader().getByUsername(username); + + if (user) { + return getDirect(this.app, read, modify, username); + } + } + + return undefined; } }