From 092f19b0d2b786c42bb84f5e5b5378bb8842cc03 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 5 Dec 2023 10:18:15 -0300 Subject: [PATCH] Modify Accessors (#680) --- deno-runtime/deno.jsonc | 4 +- deno-runtime/deno.lock | 73 +++-- deno-runtime/lib/accessors/BlockBuilder.ts | 208 ++++++++++++ .../lib/accessors/DiscussionBuilder.ts | 53 +++ .../lib/accessors/LivechatMessageBuilder.ts | 198 ++++++++++++ deno-runtime/lib/accessors/MessageBuilder.ts | 226 +++++++++++++ deno-runtime/lib/accessors/RoomBuilder.ts | 157 +++++++++ deno-runtime/lib/accessors/UserBuilder.ts | 75 +++++ .../lib/accessors/VideoConferenceBuilder.ts | 79 +++++ deno-runtime/lib/accessors/mod.ts | 17 +- .../lib/accessors/modify/ModifyCreator.ts | 306 ++++++++++++++++++ .../{_test.ts => tests/AppAccessors.test.ts} | 59 ++-- .../lib/accessors/tests/ModifyCreator.test.ts | 106 ++++++ deno-runtime/lib/messenger.ts | 1 + src/server/runtime/AppsEngineDenoRuntime.ts | 51 ++- .../DenoRuntimeSubprocessController.spec.ts | 116 ++++++- tests/test-data/bridges/userBridge.ts | 21 +- 17 files changed, 1671 insertions(+), 79 deletions(-) create mode 100644 deno-runtime/lib/accessors/BlockBuilder.ts create mode 100644 deno-runtime/lib/accessors/DiscussionBuilder.ts create mode 100644 deno-runtime/lib/accessors/LivechatMessageBuilder.ts create mode 100644 deno-runtime/lib/accessors/MessageBuilder.ts create mode 100644 deno-runtime/lib/accessors/RoomBuilder.ts create mode 100644 deno-runtime/lib/accessors/UserBuilder.ts create mode 100644 deno-runtime/lib/accessors/VideoConferenceBuilder.ts create mode 100644 deno-runtime/lib/accessors/modify/ModifyCreator.ts rename deno-runtime/lib/accessors/{_test.ts => tests/AppAccessors.test.ts} (73%) create mode 100644 deno-runtime/lib/accessors/tests/ModifyCreator.test.ts diff --git a/deno-runtime/deno.jsonc b/deno-runtime/deno.jsonc index 60edf305a..6950e16e5 100644 --- a/deno-runtime/deno.jsonc +++ b/deno-runtime/deno.jsonc @@ -1,9 +1,11 @@ { "imports": { "@rocket.chat/apps-engine/": "./../src/", + "@rocket.chat/ui-kit": "npm:@rocket.chat/ui-kit@^0.31.22", "acorn": "npm:acorn@8.10.0", "acorn-walk": "npm:acorn-walk@8.2.0", - "astring": "npm:astring@1.8.6" + "astring": "npm:astring@1.8.6", "jsonrpc-lite": "npm:jsonrpc-lite@2.2.0", + "uuid": "npm:uuid@8.3.2", } } diff --git a/deno-runtime/deno.lock b/deno-runtime/deno.lock index 5c46ee52e..d168f8f0c 100644 --- a/deno-runtime/deno.lock +++ b/deno-runtime/deno.lock @@ -1,5 +1,47 @@ { - "version": "2", + "version": "3", + "packages": { + "specifiers": { + "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0": "npm:acorn@8.10.0", + "npm:astring@1.8.6": "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", + "npm:uuid@8.3.2": "npm:uuid@8.3.2" + }, + "npm": { + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", + "dependencies": {} + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": { + "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" + } + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dependencies": {} + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dependencies": {} + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dependencies": {} + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", + "dependencies": {} + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dependencies": {} + } + } + }, "remote": { "https://deno.land/std@0.203.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", "https://deno.land/std@0.203.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", @@ -34,32 +76,7 @@ "https://deno.land/std@0.203.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", "https://deno.land/std@0.203.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", "https://deno.land/std@0.203.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", - "https://deno.land/std@0.203.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769" - }, - "npm": { - "specifiers": { - "acorn-walk@8.2.0": "acorn-walk@8.2.0", - "acorn@8.10.0": "acorn@8.10.0", - "astring@1.8.6": "astring@1.8.6", - "jsonrpc-lite@2.2.0": "jsonrpc-lite@2.2.0" - }, - "packages": { - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dependencies": {} - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dependencies": {} - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "dependencies": {} - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", - "dependencies": {} - } - } + "https://deno.land/std@0.203.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", + "https://deno.land/std@0.203.0/testing/mock.ts": "6576b4aa55ee20b1990d656a78fff83599e190948c00e9f25a7f3ac5e9d6492d" } } diff --git a/deno-runtime/lib/accessors/BlockBuilder.ts b/deno-runtime/lib/accessors/BlockBuilder.ts new file mode 100644 index 000000000..52cd58025 --- /dev/null +++ b/deno-runtime/lib/accessors/BlockBuilder.ts @@ -0,0 +1,208 @@ +import { v1 as uuid } from 'uuid'; + +import { + BlockType, + IActionsBlock, + IBlock, + IConditionalBlock, + IConditionalBlockFilters, + IContextBlock, + IImageBlock, + IInputBlock, + ISectionBlock, +} from "@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.ts"; +import type { + IBlockElement, + IButtonElement, + IImageElement, + IInputElement, + IInteractiveElement, + IMultiStaticSelectElement, + IOverflowMenuElement, + IPlainTextInputElement, + ISelectElement, + IStaticSelectElement, +} from '@rocket.chat/apps-engine/definition/uikit/blocks/Elements.ts'; +import { BlockElementType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Elements.ts'; +import type { ITextObject } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects.ts'; +import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects.ts'; +import { AppObjectRegistry } from "../../AppObjectRegistry.ts"; + +type BlockFunctionParameter = Omit; +type ElementFunctionParameter = T extends IInteractiveElement + ? Omit | Partial> + : Omit; + +type SectionBlockParam = BlockFunctionParameter; +type ImageBlockParam = BlockFunctionParameter; +type ActionsBlockParam = BlockFunctionParameter; +type ContextBlockParam = BlockFunctionParameter; +type InputBlockParam = BlockFunctionParameter; + +type ButtonElementParam = ElementFunctionParameter; +type ImageElementParam = ElementFunctionParameter; +type OverflowMenuElementParam = ElementFunctionParameter; +type PlainTextInputElementParam = ElementFunctionParameter; +type StaticSelectElementParam = ElementFunctionParameter; +type MultiStaticSelectElementParam = ElementFunctionParameter; + +/** + * @deprecated please prefer the rocket.chat/ui-kit components + */ +export class BlockBuilder { + private readonly blocks: Array; + private readonly appId: string; + + constructor() { + this.blocks = []; + this.appId = String(AppObjectRegistry.get('appId')); + } + + public addSectionBlock(block: SectionBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.SECTION, ...block } as ISectionBlock); + + return this; + } + + public addImageBlock(block: ImageBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.IMAGE, ...block } as IImageBlock); + + return this; + } + + public addDividerBlock(): BlockBuilder { + this.addBlock({ type: BlockType.DIVIDER }); + + return this; + } + + public addActionsBlock(block: ActionsBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.ACTIONS, ...block } as IActionsBlock); + + return this; + } + + public addContextBlock(block: ContextBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.CONTEXT, ...block } as IContextBlock); + + return this; + } + + public addInputBlock(block: InputBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.INPUT, ...block } as IInputBlock); + + return this; + } + + public addConditionalBlock(innerBlocks: BlockBuilder | Array, condition?: IConditionalBlockFilters): BlockBuilder { + const render = innerBlocks instanceof BlockBuilder ? innerBlocks.getBlocks() : innerBlocks; + + this.addBlock({ type: BlockType.CONDITIONAL, render, when: condition } as IConditionalBlock); + + return this; + } + + public getBlocks() { + return this.blocks; + } + + public newPlainTextObject(text: string, emoji = false): ITextObject { + return { + type: TextObjectType.PLAINTEXT, + text, + emoji, + }; + } + + public newMarkdownTextObject(text: string): ITextObject { + return { + type: TextObjectType.MARKDOWN, + text, + }; + } + + public newButtonElement(info: ButtonElementParam): IButtonElement { + return this.newInteractiveElement({ + type: BlockElementType.BUTTON, + ...info, + } as IButtonElement); + } + + public newImageElement(info: ImageElementParam): IImageElement { + return { + type: BlockElementType.IMAGE, + ...info, + }; + } + + public newOverflowMenuElement(info: OverflowMenuElementParam): IOverflowMenuElement { + return this.newInteractiveElement({ + type: BlockElementType.OVERFLOW_MENU, + ...info, + } as IOverflowMenuElement); + } + + public newPlainTextInputElement(info: PlainTextInputElementParam): IPlainTextInputElement { + return this.newInputElement({ + type: BlockElementType.PLAIN_TEXT_INPUT, + ...info, + } as IPlainTextInputElement); + } + + public newStaticSelectElement(info: StaticSelectElementParam): IStaticSelectElement { + return this.newSelectElement({ + type: BlockElementType.STATIC_SELECT, + ...info, + } as IStaticSelectElement); + } + + public newMultiStaticElement(info: MultiStaticSelectElementParam): IMultiStaticSelectElement { + return this.newSelectElement({ + type: BlockElementType.MULTI_STATIC_SELECT, + ...info, + } as IMultiStaticSelectElement); + } + + private newInteractiveElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private newInputElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private newSelectElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private addBlock(block: IBlock): void { + if (!block.blockId) { + block.blockId = this.generateBlockId(); + } + + block.appId = this.appId; + + this.blocks.push(block); + } + + private generateBlockId(): string { + return uuid(); + } + + private generateActionId(): string { + return uuid(); + } + +} diff --git a/deno-runtime/lib/accessors/DiscussionBuilder.ts b/deno-runtime/lib/accessors/DiscussionBuilder.ts new file mode 100644 index 000000000..4b07c2536 --- /dev/null +++ b/deno-runtime/lib/accessors/DiscussionBuilder.ts @@ -0,0 +1,53 @@ +import type { IDiscussionBuilder as _IDiscussionBuilder } from "@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder.ts"; +import type { IMessage } from "@rocket.chat/apps-engine/definition/messages/IMessage.ts"; +import type { IRoom } from "@rocket.chat/apps-engine/definition/rooms/IRoom.ts"; + +import { RocketChatAssociationModel } from "@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts"; +import { RoomType } from "@rocket.chat/apps-engine/definition/rooms/RoomType.ts"; + +import { RoomBuilder } from "./RoomBuilder.ts"; +import { IRoomBuilder } from "@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts"; + +export interface IDiscussionBuilder extends _IDiscussionBuilder, IRoomBuilder {} + +export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { + public kind: RocketChatAssociationModel.DISCUSSION; + + private reply?: string; + + private parentMessage?: IMessage; + + constructor(data?: Partial) { + super(data); + this.kind = RocketChatAssociationModel.DISCUSSION; + this.room.type = RoomType.PRIVATE_GROUP; + } + + public setParentRoom(parentRoom: IRoom): IDiscussionBuilder { + this.room.parentRoom = parentRoom; + return this; + } + + public getParentRoom(): IRoom { + return this.room.parentRoom!; + } + + public setReply(reply: string): IDiscussionBuilder { + this.reply = reply; + return this; + } + + public getReply(): string { + return this.reply!; + } + + public setParentMessage(parentMessage: IMessage): IDiscussionBuilder { + this.parentMessage = parentMessage; + return this; + } + + public getParentMessage(): IMessage { + return this.parentMessage!; + } +} + diff --git a/deno-runtime/lib/accessors/LivechatMessageBuilder.ts b/deno-runtime/lib/accessors/LivechatMessageBuilder.ts new file mode 100644 index 000000000..b90081e23 --- /dev/null +++ b/deno-runtime/lib/accessors/LivechatMessageBuilder.ts @@ -0,0 +1,198 @@ +import { RocketChatAssociationModel } from "@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts"; +import { RoomType } from "@rocket.chat/apps-engine/definition/rooms/RoomType.ts"; + +import type { ILivechatMessageBuilder } from "@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder.ts"; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { ILivechatMessage as EngineLivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage.ts'; +import type { IVisitor } from "@rocket.chat/apps-engine/definition/livechat/IVisitor.ts"; +import type { IMessageBuilder } from "@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts"; + +import { MessageBuilder } from "./MessageBuilder.ts"; + +export interface ILivechatMessage extends EngineLivechatMessage, IMessage {} + +export class LivechatMessageBuilder implements ILivechatMessageBuilder { + public kind: RocketChatAssociationModel.LIVECHAT_MESSAGE; + + private msg: ILivechatMessage; + + constructor(message?: ILivechatMessage) { + this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE; + this.msg = message || ({} as ILivechatMessage); + } + + public setData(data: ILivechatMessage): ILivechatMessageBuilder { + delete data.id; + this.msg = data; + + return this; + } + + public setRoom(room: IRoom): ILivechatMessageBuilder { + this.msg.room = room; + return this; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): ILivechatMessageBuilder { + this.msg.sender = sender; + delete this.msg.visitor; + + return this; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): ILivechatMessageBuilder { + this.msg.text = text; + return this; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): ILivechatMessageBuilder { + this.msg.emoji = emoji; + return this; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): ILivechatMessageBuilder { + this.msg.alias = alias; + return this; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this; + } + + public setAttachments(attachments: Array): ILivechatMessageBuilder { + this.msg.attachments = attachments; + return this; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this; + } + + public removeAttachment(position: number): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this; + } + + public setEditor(user: IUser): ILivechatMessageBuilder { + this.msg.editor = user; + return this; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): ILivechatMessageBuilder { + this.msg.groupable = groupable; + return this; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder { + this.msg.parseUrls = parseUrls; + return this; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public setToken(token: string): ILivechatMessageBuilder { + this.msg.token = token; + return this; + } + + public getToken(): string { + return this.msg.token!; + } + + public setVisitor(visitor: IVisitor): ILivechatMessageBuilder { + this.msg.visitor = visitor; + delete this.msg.sender; + + return this; + } + + public getVisitor(): IVisitor { + return this.msg.visitor; + } + + public getMessage(): ILivechatMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + if (this.msg.room.type !== RoomType.LIVE_CHAT) { + throw new Error('The room is not a Livechat room'); + } + + return this.msg; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(this.msg as IMessage); + } +} + diff --git a/deno-runtime/lib/accessors/MessageBuilder.ts b/deno-runtime/lib/accessors/MessageBuilder.ts new file mode 100644 index 000000000..f8bebbcc4 --- /dev/null +++ b/deno-runtime/lib/accessors/MessageBuilder.ts @@ -0,0 +1,226 @@ +import { Block } from '@rocket.chat/ui-kit'; + +import { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; +import { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import { IBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.ts'; +import { BlockBuilder } from "./BlockBuilder.ts"; + +export class MessageBuilder implements IMessageBuilder { + public kind: RocketChatAssociationModel.MESSAGE; + + private msg: IMessage; + + constructor(message?: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + this.msg = message || ({} as IMessage); + } + + public setData(data: IMessage): IMessageBuilder { + delete data.id; + this.msg = data; + + return this as IMessageBuilder; + } + + public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder { + this.msg = data; + this.msg.editor = editor; + this.msg.editedAt = new Date(); + + return this as IMessageBuilder; + } + + public setThreadId(threadId: string): IMessageBuilder { + this.msg.threadId = threadId; + + return this as IMessageBuilder; + } + + public getThreadId(): string { + return this.msg.threadId!; + } + + public setRoom(room: IRoom): IMessageBuilder { + this.msg.room = room; + return this as IMessageBuilder; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): IMessageBuilder { + this.msg.sender = sender; + return this as IMessageBuilder; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): IMessageBuilder { + this.msg.text = text; + return this as IMessageBuilder; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): IMessageBuilder { + this.msg.emoji = emoji; + return this as IMessageBuilder; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): IMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this as IMessageBuilder; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): IMessageBuilder { + this.msg.alias = alias; + return this as IMessageBuilder; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this as IMessageBuilder; + } + + public setAttachments(attachments: Array): IMessageBuilder { + this.msg.attachments = attachments; + return this as IMessageBuilder; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this as IMessageBuilder; + } + + public removeAttachment(position: number): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this as IMessageBuilder; + } + + public setEditor(user: IUser): IMessageBuilder { + this.msg.editor = user; + return this as IMessageBuilder; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): IMessageBuilder { + this.msg.groupable = groupable; + return this as IMessageBuilder; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): IMessageBuilder { + this.msg.parseUrls = parseUrls; + return this as IMessageBuilder; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public getMessage(): IMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + return this.msg; + } + + public addBlocks(blocks: BlockBuilder | Array) { + if (!Array.isArray(this.msg.blocks)) { + this.msg.blocks = []; + } + + if (blocks instanceof BlockBuilder) { + this.msg.blocks.push(...blocks.getBlocks()); + } else { + this.msg.blocks.push(...blocks); + } + + return this as IMessageBuilder; + } + + public setBlocks(blocks: BlockBuilder | Array) { + if (blocks instanceof BlockBuilder) { + this.msg.blocks = blocks.getBlocks(); + } else { + this.msg.blocks = blocks; + } + + return this as IMessageBuilder; + } + + public getBlocks() { + return this.msg.blocks!; + } + + public addCustomField(key: string, value: unknown): IMessageBuilder { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + return this as IMessageBuilder; + } +} diff --git a/deno-runtime/lib/accessors/RoomBuilder.ts b/deno-runtime/lib/accessors/RoomBuilder.ts new file mode 100644 index 000000000..19b634b37 --- /dev/null +++ b/deno-runtime/lib/accessors/RoomBuilder.ts @@ -0,0 +1,157 @@ +import { IRoomBuilder } from "@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts"; +import { RocketChatAssociationModel } from "@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms/IRoom.ts"; +import { RoomType } from "@rocket.chat/apps-engine/definition/rooms/RoomType.ts"; +import { IUser } from "@rocket.chat/apps-engine/definition/users/IUser.ts"; + +export class RoomBuilder implements IRoomBuilder { + public kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION; + + protected room: IRoom; + + private members: Array; + + constructor(data?: Partial) { + this.kind = RocketChatAssociationModel.ROOM; + this.room = (data || { customFields: {} }) as IRoom; + this.members = []; + } + + public setData(data: Partial): IRoomBuilder { + delete data.id; + this.room = data as IRoom; + + return this; + } + + public setDisplayName(name: string): IRoomBuilder { + this.room.displayName = name; + return this; + } + + public getDisplayName(): string { + return this.room.displayName!; + } + + public setSlugifiedName(name: string): IRoomBuilder { + this.room.slugifiedName = name; + return this; + } + + public getSlugifiedName(): string { + return this.room.slugifiedName; + } + + public setType(type: RoomType): IRoomBuilder { + this.room.type = type; + return this; + } + + public getType(): RoomType { + return this.room.type; + } + + public setCreator(creator: IUser): IRoomBuilder { + this.room.creator = creator; + return this; + } + + public getCreator(): IUser { + return this.room.creator; + } + + /** + * @deprecated + */ + public addUsername(username: string): IRoomBuilder { + this.addMemberToBeAddedByUsername(username); + return this; + } + + /** + * @deprecated + */ + public setUsernames(usernames: Array): IRoomBuilder { + this.setMembersToBeAddedByUsernames(usernames); + return this; + } + + /** + * @deprecated + */ + public getUsernames(): Array { + const usernames = this.getMembersToBeAddedUsernames(); + if (usernames && usernames.length > 0) { + return usernames; + } + return this.room.usernames || []; + } + + public addMemberToBeAddedByUsername(username: string): IRoomBuilder { + this.members.push(username); + return this; + } + + public setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder { + this.members = usernames; + return this; + } + + public getMembersToBeAddedUsernames(): Array { + return this.members; + } + + public setDefault(isDefault: boolean): IRoomBuilder { + this.room.isDefault = isDefault; + return this; + } + + public getIsDefault(): boolean { + return this.room.isDefault!; + } + + public setReadOnly(isReadOnly: boolean): IRoomBuilder { + this.room.isReadOnly = isReadOnly; + return this; + } + + public getIsReadOnly(): boolean { + return this.room.isReadOnly!; + } + + public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder { + this.room.displaySystemMessages = displaySystemMessages; + return this; + } + + public getDisplayingOfSystemMessages(): boolean { + return this.room.displaySystemMessages!; + } + + public addCustomField(key: string, value: object): IRoomBuilder { + if (typeof this.room.customFields !== 'object') { + this.room.customFields = {}; + } + + this.room.customFields[key] = value; + return this; + } + + public setCustomFields(fields: { [key: string]: object }): IRoomBuilder { + this.room.customFields = fields; + return this; + } + + public getCustomFields(): { [key: string]: object } { + return this.room.customFields!; + } + + public getUserIds(): Array { + return this.room.userIds!; + } + + public getRoom(): IRoom { + return this.room; + } +} + diff --git a/deno-runtime/lib/accessors/UserBuilder.ts b/deno-runtime/lib/accessors/UserBuilder.ts new file mode 100644 index 000000000..05fcc3d1f --- /dev/null +++ b/deno-runtime/lib/accessors/UserBuilder.ts @@ -0,0 +1,75 @@ +import { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder.ts'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings.ts'; +import { IUserEmail } from '@rocket.chat/apps-engine/definition/users/IUserEmail.ts'; + +export class UserBuilder implements IUserBuilder { + public kind: RocketChatAssociationModel.USER; + + private user: Partial; + + constructor(user?: Partial) { + this.kind = RocketChatAssociationModel.USER; + this.user = user || ({} as Partial); + } + + public setData(data: Partial): IUserBuilder { + delete data.id; + this.user = data; + + return this; + } + + public setEmails(emails: Array): IUserBuilder { + this.user.emails = emails; + return this; + } + + public getEmails(): Array { + return this.user.emails!; + } + + public setDisplayName(name: string): IUserBuilder { + this.user.name = name; + return this; + } + + public getDisplayName(): string { + return this.user.name!; + } + + public setUsername(username: string): IUserBuilder { + this.user.username = username; + return this; + } + + public getUsername(): string { + return this.user.username!; + } + + public setRoles(roles: Array): IUserBuilder { + this.user.roles = roles; + return this; + } + + public getRoles(): Array { + return this.user.roles!; + } + + public getSettings(): Partial { + return this.user.settings; + } + + public getUser(): Partial { + if (!this.user.username) { + throw new Error('The "username" property is required.'); + } + + if (!this.user.name) { + throw new Error('The "name" property is required.'); + } + + return this.user; + } +} diff --git a/deno-runtime/lib/accessors/VideoConferenceBuilder.ts b/deno-runtime/lib/accessors/VideoConferenceBuilder.ts new file mode 100644 index 000000000..2b7af6c6a --- /dev/null +++ b/deno-runtime/lib/accessors/VideoConferenceBuilder.ts @@ -0,0 +1,79 @@ +import { IVideoConferenceBuilder } from "@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder.ts"; +import { RocketChatAssociationModel } from "@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts"; +import type { IGroupVideoConference } from "@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts"; + +export type AppVideoConference = Pick & { + createdBy: IGroupVideoConference['createdBy']['_id']; +}; + + +export class VideoConferenceBuilder implements IVideoConferenceBuilder { + public kind: RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE; + + protected call: AppVideoConference; + + constructor(data?: Partial) { + this.call = (data || {}) as AppVideoConference; + } + + public setData(data: Partial): IVideoConferenceBuilder { + this.call = { + rid: data.rid!, + createdBy: data.createdBy, + providerName: data.providerName!, + title: data.title!, + }; + + return this; + } + + public setRoomId(rid: string): IVideoConferenceBuilder { + this.call.rid = rid; + return this; + } + + public getRoomId(): string { + return this.call.rid; + } + + public setCreatedBy(userId: string): IVideoConferenceBuilder { + this.call.createdBy = userId; + return this; + } + + public getCreatedBy(): string { + return this.call.createdBy; + } + + public setProviderName(userId: string): IVideoConferenceBuilder { + this.call.providerName = userId; + return this; + } + + public getProviderName(): string { + return this.call.providerName; + } + + public setProviderData(data: Record | undefined): IVideoConferenceBuilder { + this.call.providerData = data; + return this; + } + + public getProviderData(): Record { + return this.call.providerData!; + } + + public setTitle(userId: string): IVideoConferenceBuilder { + this.call.title = userId; + return this; + } + + public getTitle(): string { + return this.call.title; + } + + public getVideoConference(): AppVideoConference { + return this.call; + } +} + diff --git a/deno-runtime/lib/accessors/mod.ts b/deno-runtime/lib/accessors/mod.ts index 85a9a9d3d..78b970cb4 100644 --- a/deno-runtime/lib/accessors/mod.ts +++ b/deno-runtime/lib/accessors/mod.ts @@ -15,6 +15,7 @@ import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/vid import * as Messenger from '../messenger.ts'; import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { ModifyCreator } from "./modify/ModifyCreator.ts"; const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const; @@ -28,13 +29,14 @@ export class AppAccessors { private modifier?: IModify; private persistence?: IPersistence; private http?: IHttp; + private creator?: ModifyCreator; private proxify: (namespace: string) => T; - constructor(senderFn: typeof Messenger.sendRequest) { + constructor(private readonly senderFn: typeof Messenger.sendRequest) { this.proxify = (namespace: string): T => new Proxy( - { __kind: namespace }, + { __kind: `accessor:${namespace}` }, { get: (_target: unknown, prop: string) => @@ -193,7 +195,8 @@ export class AppAccessors { public getModifier() { if (!this.modifier) { this.modifier = { - getCreator: () => this.proxify('getModifier:getCreator'), // can't be proxy + // getCreator: () => this.proxify('getModifier:getCreator'), // can't be proxy + getCreator: this.getCreator.bind(this), getUpdater: () => this.proxify('getModifier:getUpdater'), // can't be proxy getDeleter: () => this.proxify('getModifier:getDeleter'), getExtender: () => this.proxify('getModifier:getExtender'), // can't be proxy @@ -223,6 +226,14 @@ export class AppAccessors { return this.http; } + + private getCreator() { + if (!this.creator) { + this.creator = new ModifyCreator(this.senderFn); + } + + return this.creator; + } } export const AppAccessorsInstance = new AppAccessors(Messenger.sendRequest); diff --git a/deno-runtime/lib/accessors/modify/ModifyCreator.ts b/deno-runtime/lib/accessors/modify/ModifyCreator.ts new file mode 100644 index 000000000..42614f228 --- /dev/null +++ b/deno-runtime/lib/accessors/modify/ModifyCreator.ts @@ -0,0 +1,306 @@ +import { createRequire } from 'node:module'; + +import type { IModifyCreator } from '@rocket.chat/apps-engine/definition/accessors/IModifyCreator.ts'; +import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors/IUploadCreator.ts'; +import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser.ts'; + +import { UserType } from '@rocket.chat/apps-engine/definition/users/UserType.ts'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; +import { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; +import { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder.ts'; +import { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder.ts'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder.ts'; + +import { BlockBuilder } from '../BlockBuilder.ts'; +import { MessageBuilder } from '../MessageBuilder.ts'; +import { DiscussionBuilder, IDiscussionBuilder } from '../DiscussionBuilder.ts'; +import { ILivechatMessage, LivechatMessageBuilder } from '../LivechatMessageBuilder.ts'; +import { RoomBuilder } from '../RoomBuilder.ts'; +import { UserBuilder } from '../UserBuilder.ts'; +import { AppVideoConference, VideoConferenceBuilder } from '../VideoConferenceBuilder.ts'; +import * as Messenger from '../../messenger.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; + +const require = createRequire(import.meta.url); + +// @deno-types="../../../../server/misc/UIHelper.d.ts" +const UIHelper = require(import.meta.resolve('@rocket.chat/apps-engine/server/misc/UIHelper.js').replace('file://', '').replace('src/', '')); + +export class ModifyCreator implements IModifyCreator { + constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + + getLivechatCreator(): ILivechatCreator { + return new Proxy( + { __kind: 'getLivechatCreator' }, + { + get: (_target: unknown, prop: string) => { + if (prop === 'createToken') { + return () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + } + + return (...params: unknown[]) => + this.senderFn({ + method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, + params, + }); + }, + }, + ) as ILivechatCreator; + } + + getUploadCreator(): IUploadCreator { + return new Proxy( + { __kind: 'getUploadCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + this.senderFn({ + method: `accessor:getModifier:getCreator:getUploadCreator:${prop}`, + params, + }), + }, + ) as IUploadCreator; + } + + getBlockBuilder() { + return new BlockBuilder(); + } + + startMessage(data?: IMessage) { + if (data) { + delete data.id; + } + + return new MessageBuilder(data); + } + + startLivechatMessage(data?: ILivechatMessage) { + if (data) { + delete data.id; + } + + return new LivechatMessageBuilder(data); + } + + startRoom(data?: IRoom) { + if (data) { + // @ts-ignore - this has been imported from the Apps-Engine + delete data.id; + } + + return new RoomBuilder(data); + } + + startDiscussion(data?: Partial) { + if (data) { + delete data.id; + } + + return new DiscussionBuilder(data); + } + + startVideoConference(data?: Partial) { + return new VideoConferenceBuilder(data); + } + + startBotUser(data?: Partial) { + if (data) { + delete data.id; + + const { roles } = data; + + if (roles?.length) { + const hasRole = roles + .map((role: string) => role.toLocaleLowerCase()) + .some((role: string) => role === 'admin' || role === 'owner' || role === 'moderator'); + + if (hasRole) { + throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.'); + } + } + + if (!data.type) { + data.type = UserType.BOT; + } + } + + return new UserBuilder(data); + } + + public finish( + builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder, + ): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as IMessageBuilder); + case RocketChatAssociationModel.LIVECHAT_MESSAGE: + return this._finishLivechatMessage(builder as ILivechatMessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as IRoomBuilder); + case RocketChatAssociationModel.DISCUSSION: + return this._finishDiscussion(builder as IDiscussionBuilder); + case RocketChatAssociationModel.VIDEO_CONFERENCE: + return this._finishVideoConference(builder as IVideoConferenceBuilder); + case RocketChatAssociationModel.USER: + return this._finishUser(builder as IUserBuilder); + default: + throw new Error('Invalid builder passed to the ModifyCreator.finish function.'); + } + } + + private async _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + delete result.id; + + if (!result.sender || !result.sender.id) { + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doGetAppUser', + params: ['APP_ID'], + }); + + const appUser = response.result; + + if (!appUser) { + throw new Error('Invalid sender assigned to the message.'); + } + + result.sender = appUser; + } + + if (result.blocks?.length) { + // Can we move this elsewhere? This AppObjectRegistry usage doesn't really belong here, but where? + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('appId')); + } + + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doCreate', + params: [result, AppObjectRegistry.get('appId')], + }); + + return String(response.result); + } + + private async _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise { + if (builder.getSender() && !builder.getVisitor()) { + return this._finishMessage(builder.getMessageBuilder()); + } + + const result = builder.getMessage(); + delete result.id; + + if (!result.token && (!result.visitor || !result.visitor.token)) { + throw new Error('Invalid visitor sending the message'); + } + + result.token = result.visitor ? result.visitor.token : result.token; + + const response = await this.senderFn({ + method: 'bridges:getLivechatBridge:doCreateMessage', + params: [result, AppObjectRegistry.get('appId')], + }); + + return String(response.result); + } + + private async _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + delete result.id; + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator || !result.creator.id) { + throw new Error('Invalid creator assigned to the room.'); + } + } + + if (result.type !== RoomType.DIRECT_MESSAGE) { + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.slugifiedName || !result.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName || !result.displayName.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreate', + params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('appId')], + }); + + return String(response.result); + } + + private async _finishDiscussion(builder: IDiscussionBuilder): Promise { + const room = builder.getRoom(); + delete room.id; + + if (!room.creator || !room.creator.id) { + throw new Error('Invalid creator assigned to the discussion.'); + } + + if (!room.slugifiedName || !room.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the discussion.'); + } + + if (!room.displayName || !room.displayName.trim()) { + throw new Error('Invalid displayName assigned to the discussion.'); + } + + if (!room.parentRoom || !room.parentRoom.id) { + throw new Error('Invalid parentRoom assigned to the discussion.'); + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreateDiscussion', + params: [room, builder.getParentMessage(), builder.getReply(), builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('appId')], + }); + + return String(response.result); + } + + private async _finishVideoConference(builder: IVideoConferenceBuilder): Promise { + const videoConference = builder.getVideoConference(); + + if (!videoConference.createdBy) { + throw new Error('Invalid creator assigned to the video conference.'); + } + + if (!videoConference.providerName?.trim()) { + throw new Error('Invalid provider name assigned to the video conference.'); + } + + if (!videoConference.rid) { + throw new Error('Invalid roomId assigned to the video conference.'); + } + + const response = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doCreate', + params: [videoConference, AppObjectRegistry.get('appId')], + }); + + return String(response.result); + } + + private async _finishUser(builder: IUserBuilder): Promise { + const user = builder.getUser(); + + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doCreate', + params: [user, AppObjectRegistry.get('appId')], + }); + + return String(response.result); + } +} diff --git a/deno-runtime/lib/accessors/_test.ts b/deno-runtime/lib/accessors/tests/AppAccessors.test.ts similarity index 73% rename from deno-runtime/lib/accessors/_test.ts rename to deno-runtime/lib/accessors/tests/AppAccessors.test.ts index b650b9c90..d32cfb6d6 100644 --- a/deno-runtime/lib/accessors/_test.ts +++ b/deno-runtime/lib/accessors/tests/AppAccessors.test.ts @@ -1,25 +1,30 @@ -import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { assertEquals } from "https://deno.land/std@0.203.0/assert/assert_equals.ts"; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/assert_equals.ts'; -import { AppAccessors } from "./mod.ts"; -import { AppObjectRegistry } from "../../AppObjectRegistry.ts"; +import { AppAccessors } from '../mod.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; describe('AppAccessors', () => { let appAccessors: AppAccessors; - const senderFn = (r: object) => Promise.resolve({ - id: Math.random().toString(36).substring(2), - jsonrpc: '2.0', - result: r, - serialize() { - return JSON.stringify(this); - } - }); + const senderFn = (r: object) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); beforeEach(() => { appAccessors = new AppAccessors(senderFn); AppObjectRegistry.clear(); }); + afterAll(() => { + AppObjectRegistry.clear(); + }); + it('creates the correct format for IRead calls', async () => { const roomRead = appAccessors.getReader().getRoomReader(); const room = await roomRead.getById('123'); @@ -70,12 +75,14 @@ describe('AppAccessors', () => { }); assertEquals(command.result, { - params: [{ - command: 'test', - i18nDescription: 'test', - i18nParamsExample: 'test', - providesPreview: true, - }], + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], method: 'accessor:getConfigurationModify:slashCommands:modifySlashCommand', }); }); @@ -90,7 +97,7 @@ describe('AppAccessors', () => { providesPreview: true, executor() { return Promise.resolve(); - } + }, }; const result = await configExtend.slashCommands.provideSlashCommand(slashcommand); @@ -102,12 +109,14 @@ describe('AppAccessors', () => { assertEquals(result.result, { method: 'accessor:getConfigurationExtend:slashCommands:provideSlashCommand', - params: [{ - command: 'test', - i18nDescription: 'test', - i18nParamsExample: 'test', - providesPreview: true, - }], + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], }); }); }); diff --git a/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts b/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts new file mode 100644 index 000000000..c8b2fccbb --- /dev/null +++ b/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts @@ -0,0 +1,106 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assert, assertEquals, assertNotInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyCreator } from '../modify/ModifyCreator.ts'; + +describe('ModifyCreator', () => { + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('appId', 'deno-test'); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('sends the correct payload in the request to create a message', async () => { + const spying = spy(senderFn); + const modifyCreator = new ModifyCreator(spying); + const messageBuilder = modifyCreator.startMessage(); + + // Importing types from the Apps-Engine is problematic, so we'll go with `any` here + messageBuilder + .setRoom({ id: '123' } as any) + .setSender({ id: '456' } as any) + .setText('Hello World') + .setUsernameAlias('alias') + .setAvatarUrl('https://avatars.com/123'); + + // We can't get a legitimate return value here, so we ignore it + // but we need to know that the request sent was well formed + await modifyCreator.finish(messageBuilder); + + assertSpyCall(spying, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doCreate', + params: [ + { + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + alias: 'alias', + avatarUrl: 'https://avatars.com/123', + }, + 'deno-test', + ], + }, + ], + }); + }); + + it('sends the correct payload in the request to upload a buffer', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = await modifyCreator.getUploadCreator().uploadBuffer(new Uint8Array([1, 2, 3, 4]), 'text/plain'); + + assertEquals(result.result, { + method: 'accessor:getModifier:getCreator:getUploadCreator:uploadBuffer', + params: [new Uint8Array([1, 2, 3, 4]), 'text/plain'], + }); + }); + + it('sends the correct payload in the request to create a visitor', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = (await modifyCreator.getLivechatCreator().createVisitor({ + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + })) as any; // We modified the send function so it changed the original return type of the function + + assertEquals(result.result, { + method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', + params: [ + { + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + ], + }); + }); + + // This test is important because if we return a promise we break API compatibility + it('does not return a promise for calls of the createToken() method of the LivechatCreator', () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = modifyCreator.getLivechatCreator().createToken(); + + assertNotInstanceOf(result, Promise); + assert(typeof result === 'string', `Expected "${result}" to be of type "string", but got "${typeof result}"`); + }); +}); diff --git a/deno-runtime/lib/messenger.ts b/deno-runtime/lib/messenger.ts index 1b8951726..9d3448209 100644 --- a/deno-runtime/lib/messenger.ts +++ b/deno-runtime/lib/messenger.ts @@ -86,6 +86,7 @@ export async function sendRequest(requestDescriptor: RequestDescriptor): Promise await send(request); + // TODO: add timeout to this return new Promise((resolve, reject) => { const handler = (event: Event) => { if (event instanceof ErrorEvent) { diff --git a/src/server/runtime/AppsEngineDenoRuntime.ts b/src/server/runtime/AppsEngineDenoRuntime.ts index 2a9fe2e0c..af9125ec0 100644 --- a/src/server/runtime/AppsEngineDenoRuntime.ts +++ b/src/server/runtime/AppsEngineDenoRuntime.ts @@ -6,6 +6,7 @@ import * as jsonrpc from 'jsonrpc-lite'; import type { AppAccessorManager, AppApiManager } from '../managers'; import type { AppManager } from '../AppManager'; +import type { AppBridges } from '../bridges'; export type AppRuntimeParams = { appId: string; @@ -20,10 +21,18 @@ const ALLOWED_ACCESSOR_METHODS = [ 'getReader', 'getPersistence', 'getHttp', + 'getModifier', ] as Array< keyof Pick< AppAccessorManager, - 'getConfigurationExtend' | 'getEnvironmentRead' | 'getEnvironmentWrite' | 'getConfigurationModify' | 'getReader' | 'getPersistence' | 'getHttp' + | 'getConfigurationExtend' + | 'getEnvironmentRead' + | 'getEnvironmentWrite' + | 'getConfigurationModify' + | 'getReader' + | 'getPersistence' + | 'getHttp' + | 'getModifier' > >; @@ -63,6 +72,8 @@ export class DenoRuntimeSubprocessController extends EventEmitter { private readonly api: AppApiManager; + private readonly bridges: AppBridges; + // We need to keep the appSource around in case the Deno process needs to be restarted constructor(private readonly appId: string, private readonly appSource: string, manager: AppManager) { super(); @@ -83,6 +94,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter { this.accessors = manager.getAccessorManager(); this.api = manager.getApiManager(); + this.bridges = manager.getBridges(); } emit(eventName: string | symbol, ...args: any[]): boolean { @@ -234,6 +246,33 @@ export class DenoRuntimeSubprocessController extends EventEmitter { return jsonrpc.success(id, result); } + private async handleBridgeMessage({ payload: { method, id, params } }: jsonrpc.IParsedObjectRequest): Promise { + const [bridgeName, bridgeMethod] = method.substring(8).split(':'); + + const bridge = this.bridges[bridgeName as keyof typeof this.bridges]; + + if (!bridgeMethod.startsWith('do') || typeof bridge !== 'function' || !Array.isArray(params)) { + throw new Error('Invalid bridge request'); + } + + const bridgeInstance = bridge.call(this.bridges); + + const methodRef = bridgeInstance[bridgeMethod as keyof typeof bridge] as unknown; + + if (typeof methodRef !== 'function') { + throw new Error('Invalid bridge request'); + } + + const result = await methodRef.apply( + bridgeInstance, + // Should the protocol expect the placeholder APP_ID value or should the Deno process send the actual appId? + // If we do not expect the APP_ID, the Deno process will be able to impersonate other apps, potentially + params.map((value: unknown) => (value === 'APP_ID' ? this.appId : value)), + ); + + return jsonrpc.success(id, result); + } + private async handleIncomingMessage(message: jsonrpc.IParsedObjectNotification | jsonrpc.IParsedObjectRequest): Promise { const { method } = message.payload; @@ -241,6 +280,16 @@ export class DenoRuntimeSubprocessController extends EventEmitter { const result = await this.handleAccessorMessage(message as jsonrpc.IParsedObjectRequest); this.deno.stdin.write(result.serialize()); + + return; + } + + if (method.startsWith('bridge:')) { + const result = await this.handleBridgeMessage(message as jsonrpc.IParsedObjectRequest); + + this.deno.stdin.write(result.serialize()); + + return; } switch (method) { diff --git a/tests/server/runtime/DenoRuntimeSubprocessController.spec.ts b/tests/server/runtime/DenoRuntimeSubprocessController.spec.ts index bc8205c7a..20b538cec 100644 --- a/tests/server/runtime/DenoRuntimeSubprocessController.spec.ts +++ b/tests/server/runtime/DenoRuntimeSubprocessController.spec.ts @@ -1,9 +1,10 @@ -import { TestFixture, Setup, SetupFixture, Expect, AsyncTest } from 'alsatian'; +import { TestFixture, Setup, SetupFixture, Expect, AsyncTest, SpyOn, Any } from 'alsatian'; import { AppAccessorManager, AppApiManager } from '../../../src/server/managers'; import { TestData, TestInfastructureSetup } from '../../test-data/utilities'; import { DenoRuntimeSubprocessController } from '../../../src/server/runtime/AppsEngineDenoRuntime'; import type { AppManager } from '../../../src/server/AppManager'; +import { UserStatusConnection, UserType } from '../../../src/definition/users'; @TestFixture('DenoRuntimeSubprocessController') export class DenuRuntimeSubprocessControllerTestFixture { @@ -36,6 +37,8 @@ export class DenuRuntimeSubprocessControllerTestFixture { @AsyncTest('correctly identifies a call to the HTTP accessor') public async testHttpAccessor() { + const spy = SpyOn(this.manager.getBridges().getHttpBridge(), 'doCall'); + // eslint-disable-next-line const r = await this.controller['handleAccessorMessage']({ type: 'request' as any, @@ -48,6 +51,14 @@ export class DenuRuntimeSubprocessControllerTestFixture { }, }); + Expect(this.manager.getBridges().getHttpBridge().doCall).toHaveBeenCalledWith( + Any(Object).thatMatches({ + appId: 'deno-controller', + method: 'get', + url: 'https://google.com', + }), + ); + Expect(r.result).toEqual({ method: 'get', url: 'https://google.com', @@ -55,10 +66,33 @@ export class DenuRuntimeSubprocessControllerTestFixture { statusCode: 200, headers: {}, }); + + spy.restore(); } @AsyncTest('correctly identifies a call to the IRead accessor') public async testIReadAccessor() { + const spy = SpyOn(this.manager.getBridges().getUserBridge(), 'doGetByUsername'); + + spy.andReturn( + Promise.resolve({ + id: 'id', + username: 'rocket.cat', + isEnabled: true, + emails: [], + name: 'name', + roles: [], + type: UserType.USER, + active: true, + utcOffset: 0, + status: 'offline', + statusConnection: UserStatusConnection.OFFLINE, + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + // eslint-disable-next-line const { id, result } = await this.controller['handleAccessorMessage']({ type: 'request' as any, @@ -71,9 +105,10 @@ export class DenuRuntimeSubprocessControllerTestFixture { }, }); + Expect(this.manager.getBridges().getUserBridge().doGetByUsername).toHaveBeenCalledWith('rocket.cat', 'deno-controller'); + Expect(id).toBe('test'); Expect((result as any).username).toEqual('rocket.cat'); - Expect((result as any).appId).toEqual('deno-controller'); } @AsyncTest('correctly identifies a call to the IEnvironmentReader accessor via IRead') @@ -93,4 +128,81 @@ export class DenuRuntimeSubprocessControllerTestFixture { Expect(id).toBe('requestId'); Expect((result as any).id).toEqual('setting test id'); } + + @AsyncTest('correctly identifies a call to create a visitor via the LivechatCreator') + public async testLivechatCreator() { + const spy = SpyOn(this.manager.getBridges().getLivechatBridge(), 'doCreateVisitor'); + + spy.andReturn(Promise.resolve('random id')); + + // eslint-disable-next-line + const { id, result } = await this.controller['handleAccessorMessage']({ + type: 'request' as any, + payload: { + jsonrpc: '2.0', + id: 'requestId', + method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', + params: [ + { + id: 'random id', + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + ], + serialize: () => '', + }, + }); + + // Making sure `handleAccessorMessage` correctly identified which accessor it should resolve to + // and that it passed the correct arguments to the bridge method + Expect(this.manager.getBridges().getLivechatBridge().doCreateVisitor).toHaveBeenCalledWith( + Any(Object).thatMatches({ + id: 'random id', + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }), + 'deno-controller', + ); + + Expect(id).toBe('requestId'); + Expect(result).toEqual('random id'); + + spy.restore(); + } + + @AsyncTest('correctly identifies a call to the message bridge') + public async testMessageBridge() { + const spy = SpyOn(this.manager.getBridges().getMessageBridge(), 'doCreate'); + + spy.andReturn(Promise.resolve('random-message-id')); + + const messageParam = { + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + alias: 'alias', + avatarUrl: 'https://avatars.com/123', + }; + + // eslint-disable-next-line + const { id, result } = await this.controller['handleBridgeMessage']({ + type: 'request' as any, + payload: { + jsonrpc: '2.0', + id: 'requestId', + method: 'bridges:getMessageBridge:doCreate', + params: [messageParam, 'APP_ID'], + serialize: () => '', + }, + }); + + Expect(this.manager.getBridges().getMessageBridge().doCreate).toHaveBeenCalledWith(messageParam, 'deno-controller'); + + Expect(id).toBe('requestId'); + Expect(result).toEqual('random-message-id'); + + spy.restore(); + } } diff --git a/tests/test-data/bridges/userBridge.ts b/tests/test-data/bridges/userBridge.ts index 79cf75b75..c9399f5b6 100644 --- a/tests/test-data/bridges/userBridge.ts +++ b/tests/test-data/bridges/userBridge.ts @@ -1,5 +1,4 @@ -import type { IUser } from '../../../src/definition/users'; -import { UserStatusConnection, UserType } from '../../../src/definition/users'; +import type { IUser, UserType } from '../../../src/definition/users'; import { UserBridge } from '../../../src/server/bridges'; export class TestsUserBridge extends UserBridge { @@ -8,23 +7,7 @@ export class TestsUserBridge extends UserBridge { } public getByUsername(username: string, appId: string): Promise { - return Promise.resolve({ - id: 'id', - username, - isEnabled: true, - emails: [], - name: 'name', - roles: [], - type: UserType.USER, - active: true, - appId, - utcOffset: 0, - status: 'offline', - statusConnection: UserStatusConnection.OFFLINE, - lastLoginAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }); + throw new Error('Method not implemented.'); } public create(user: Partial): Promise {