diff --git a/deno-runtime/deno.jsonc b/deno-runtime/deno.jsonc index 6950e16e5..7cb74c6cb 100644 --- a/deno-runtime/deno.jsonc +++ b/deno-runtime/deno.jsonc @@ -7,5 +7,6 @@ "astring": "npm:astring@1.8.6", "jsonrpc-lite": "npm:jsonrpc-lite@2.2.0", "uuid": "npm:uuid@8.3.2", + "stack-trace": "npm:stack-trace@0.0.10", } } diff --git a/deno-runtime/deno.lock b/deno-runtime/deno.lock index 6f892ee7f..a8c0e6d32 100644 --- a/deno-runtime/deno.lock +++ b/deno-runtime/deno.lock @@ -8,6 +8,7 @@ "npm:astring@1.8.6": "npm:astring@1.8.6", "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", "npm:stack-trace": "npm:stack-trace@0.0.10", + "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", "npm:uuid@8.3.2": "npm:uuid@8.3.2" }, "npm": { diff --git a/deno-runtime/handlers/slashcommand-handler.ts b/deno-runtime/handlers/slashcommand-handler.ts new file mode 100644 index 000000000..0609d7fbb --- /dev/null +++ b/deno-runtime/handlers/slashcommand-handler.ts @@ -0,0 +1,121 @@ +import { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand.ts'; +import { SlashCommandContext as _SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.ts'; +import { Room as _Room } from '@rocket.chat/apps-engine/server/rooms/Room.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { require } from '../lib/require.ts'; +import { AppAccessors, AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { Defined, JsonRpcError } from "jsonrpc-lite"; + +// For some reason Deno couldn't understand the typecast to the original interfaces and said it wasn't a constructor type +const { SlashCommandContext } = require('@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.js') as { SlashCommandContext: typeof _SlashCommandContext }; +const { Room } = require('@rocket.chat/apps-engine/server/rooms/Room.js') as { Room: typeof _Room } ; + +const getMockAppManager = (senderFn: AppAccessors['senderFn']) => ({ + getBridges: () => ({ + getInternalBridge: () => ({ + doGetUsernamesOfRoomById: (roomId: string) => { + senderFn({ + method: 'bridges:getInternalBridge:doGetUsernamesOfRoomById', + params: [roomId], + }); + }, + }), + }), +}); + +export default async function slashCommandHandler(call: string, params: unknown): Promise { + const [, commandName, method] = call.split(':'); + + const command = AppObjectRegistry.get(`slashcommand:${commandName}`); + + if (!command) { + return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000); + } + + let result: Awaited> | Awaited>; + + try { + if (method === 'executor' || method === 'previewer') { + result = await handleExecutor({ AppAccessorsInstance }, command, method, params); + } else if (method === 'executePreviewItem') { + result = await handlePreviewItem({ AppAccessorsInstance }, command, params); + } else { + return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000); + } + } catch (error) { + return new JsonRpcError(error.message, -32000); + } + + return result; +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param method The method that is being executed + * @param params The parameters that are being passed to the method + */ +export function handleExecutor(deps: { AppAccessorsInstance: AppAccessors }, command: ISlashCommand, method: 'executor' | 'previewer', params: unknown) { + const executor = command[method]; + + if (typeof executor !== 'function') { + throw new Error(`Method ${method} not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const { sender, room, params: args, threadId, triggerId } = params[0] as Record; + + const context = new SlashCommandContext( + sender as _SlashCommandContext['sender'], + new Room(room, getMockAppManager(deps.AppAccessorsInstance.getSenderFn())), + args as _SlashCommandContext['params'], + threadId as _SlashCommandContext['threadId'], + triggerId as _SlashCommandContext['triggerId'], + ); + + return executor.apply(command, [ + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ]); +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param params The parameters that are being passed to the method + */ +export function handlePreviewItem(deps: { AppAccessorsInstance: AppAccessors }, command: ISlashCommand, params: unknown) { + if (typeof command.executePreviewItem !== 'function') { + throw new Error(`Method not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const [previewItem, { sender, room, params: args, threadId, triggerId }] = params as [Record, Record]; + + const context = new SlashCommandContext( + sender as _SlashCommandContext['sender'], + new Room(room, getMockAppManager(deps.AppAccessorsInstance.getSenderFn())), + args as _SlashCommandContext['params'], + threadId as _SlashCommandContext['threadId'], + triggerId as _SlashCommandContext['triggerId'], + ); + + return command.executePreviewItem( + previewItem, + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ); +} diff --git a/deno-runtime/handlers/tests/slashcommand-handler.test.ts b/deno-runtime/handlers/tests/slashcommand-handler.test.ts new file mode 100644 index 000000000..40fb5a397 --- /dev/null +++ b/deno-runtime/handlers/tests/slashcommand-handler.test.ts @@ -0,0 +1,155 @@ +// deno-lint-ignore-file no-explicit-any +import { assertInstanceOf, assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from "https://deno.land/std@0.203.0/testing/mock.ts"; +import { Room as _Room } from '@rocket.chat/apps-engine/server/rooms/Room.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import { handleExecutor, handlePreviewItem } from '../slashcommand-handler.ts'; +import { require } from '../../lib/require.ts'; + +const { Room } = require('@rocket.chat/apps-engine/server/rooms/Room.js') as { Room: typeof _Room } ; + +describe('handlers > slashcommand', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + const mockCommandExecutorOnly = { + command: 'executor-only', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: false, + // deno-lint-ignore no-unused-vars + async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + const mockCommandExecutorAndPreview = { + command: 'executor-and-preview', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + // deno-lint-ignore no-unused-vars + async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async executePreviewItem(previewItem: any,context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + const mockCommandPreviewWithNoExecutor = { + command: 'preview-with-no-executor', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + // deno-lint-ignore no-unused-vars + async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async executePreviewItem(previewItem: any,context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('slashcommand:executor-only', mockCommandExecutorOnly); + AppObjectRegistry.set('slashcommand:executor-and-preview', mockCommandExecutorAndPreview); + AppObjectRegistry.set('slashcommand:preview-with-no-executor', mockCommandPreviewWithNoExecutor); + }); + + it('correctly handles execution of a slash command', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = spy(mockCommandExecutorOnly, 'executor'); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors }, mockCommandExecutorOnly, 'executor', [mockContext]); + + const context = _spy.calls[0].args[0]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); + + it('correctly handles execution of a slash command previewer', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = spy(mockCommandExecutorAndPreview, 'previewer'); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors }, mockCommandExecutorAndPreview, 'previewer', [mockContext]); + + const context = _spy.calls[0].args[0]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); + + it('correctly handles execution of a slash command preview item executor', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const mockPreviewItem = { + id: 'previewItemId', + type: 'image', + value: 'https://example.com/image.png', + }; + + const _spy = spy(mockCommandExecutorAndPreview, 'executePreviewItem'); + + await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors }, mockCommandExecutorAndPreview, [mockPreviewItem, mockContext]); + + const context = _spy.calls[0].args[1]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[5], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); +}); diff --git a/deno-runtime/lib/accessors/mod.ts b/deno-runtime/lib/accessors/mod.ts index 95ac7f7d6..f3ea5b6c1 100644 --- a/deno-runtime/lib/accessors/mod.ts +++ b/deno-runtime/lib/accessors/mod.ts @@ -52,6 +52,10 @@ export class AppAccessors { ) as T; } + public getSenderFn() { + return this.senderFn; + } + public getEnvironmentRead(): IEnvironmentRead { if (!this.environmentRead) { this.environmentRead = { diff --git a/deno-runtime/lib/logger.ts b/deno-runtime/lib/logger.ts index bf84a176f..94248ecaa 100644 --- a/deno-runtime/lib/logger.ts +++ b/deno-runtime/lib/logger.ts @@ -1,5 +1,16 @@ -import * as stackTrace from 'npm:stack-trace' -import { StackFrame } from 'npm:stack-trace' +import stackTrace from 'stack-trace'; +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; + +export interface StackFrame { + getTypeName(): string; + getFunctionName(): string; + getMethodName(): string; + getFileName(): string; + getLineNumber(): number; + getColumnNumber(): number; + isNative(): boolean; + isConstructor(): boolean; +} enum LogMessageSeverity { DEBUG = 'debug', @@ -16,7 +27,7 @@ type Entry = { method: string; timestamp: Date; args: Array; -} +}; interface ILoggerStorageEntry { appId: string; @@ -29,40 +40,38 @@ interface ILoggerStorageEntry { } export class Logger { - private appId: string; private entries: Array; private start: Date; private method: string; - constructor(method: string, appId: string) { - this.appId = appId; + constructor(method: string) { this.method = method; this.entries = []; this.start = new Date(); } public debug(...args: Array): void { - this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args) + this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args); } public info(...args: Array): void { - this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args) + this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args); } public log(...args: Array): void { - this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args) + this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args); } public warning(...args: Array): void { - this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args) + this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args); } public error(...args: Array): void { - this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args) + this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args); } public success(...args: Array): void { - this.addEntry(LogMessageSeverity.SUCCESS, this.getStack(stackTrace.get()), ...args) + this.addEntry(LogMessageSeverity.SUCCESS, this.getStack(stackTrace.get()), ...args); } private addEntry(severity: LogMessageSeverity, caller: string, ...items: Array): void { @@ -78,7 +87,6 @@ export class Logger { } const str = JSON.stringify(args, null, 2); return str ? JSON.parse(str) : str; // force call toJSON to prevent circular references - }); this.entries.push({ @@ -122,7 +130,7 @@ export class Logger { public getLogs(): ILoggerStorageEntry { return { - appId: this.appId, + appId: AppObjectRegistry.get('id')!, method: this.method, entries: this.entries, startTime: this.start, diff --git a/deno-runtime/lib/require.ts b/deno-runtime/lib/require.ts new file mode 100644 index 000000000..3288ecf67 --- /dev/null +++ b/deno-runtime/lib/require.ts @@ -0,0 +1,14 @@ +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +export const require = (mod: string) => { + // When we try to import something from the apps-engine, we resolve the path using import maps from Deno + // However, the import maps are configured to look at the source folder for typescript files, but during + // runtime those files are not available + if (mod.startsWith('@rocket.chat/apps-engine')) { + mod = import.meta.resolve(mod).replace('file://', '').replace('src/', ''); + } + + return _require(mod); +} diff --git a/deno-runtime/lib/tests/logger.test.ts b/deno-runtime/lib/tests/logger.test.ts index 2f275e30c..23b915492 100644 --- a/deno-runtime/lib/tests/logger.test.ts +++ b/deno-runtime/lib/tests/logger.test.ts @@ -4,7 +4,7 @@ import { Logger } from "../logger.ts"; describe('Logger', () => { it('getLogs should return an array of entries', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.info('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -12,7 +12,7 @@ describe('Logger', () => { }) it('should be able to add entries of different severity', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.info('test'); logger.debug('test'); logger.error('test'); @@ -24,7 +24,7 @@ describe('Logger', () => { }) it('should be able to add an info entry', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.info('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -34,7 +34,7 @@ describe('Logger', () => { }); it('should be able to add an debug entry', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.debug('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -44,7 +44,7 @@ describe('Logger', () => { }); it('should be able to add an error entry', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.error('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -54,7 +54,7 @@ describe('Logger', () => { }); it('should be able to add an success entry', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.success('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -64,7 +64,7 @@ describe('Logger', () => { }); it('should be able to add an warning entry', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.warning('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -74,7 +74,7 @@ describe('Logger', () => { }); it('should be able to add an log entry', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.log('test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -84,7 +84,7 @@ describe('Logger', () => { }); it('should be able to add an entry with multiple arguments', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.log('test', 'test', 'test'); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); @@ -96,7 +96,7 @@ describe('Logger', () => { }); it('should be able to add an entry with multiple arguments of different types', () => { - const logger = new Logger('test', 'test'); + const logger = new Logger('test'); logger.log('test', 1, true, { foo: 'bar' }); const logs = logger.getLogs(); assertEquals(logs.entries.length, 1); diff --git a/deno-runtime/lib/tests/messenger.test.ts b/deno-runtime/lib/tests/messenger.test.ts index 588618bfa..ef30176f5 100644 --- a/deno-runtime/lib/tests/messenger.test.ts +++ b/deno-runtime/lib/tests/messenger.test.ts @@ -9,7 +9,8 @@ import { Logger } from '../logger.ts'; describe('Messenger', () => { beforeEach(() => { AppObjectRegistry.clear(); - AppObjectRegistry.set('logger', new Logger('test', 'test')); + AppObjectRegistry.set('logger', new Logger('test')); + AppObjectRegistry.set('id', 'test'); Messenger.Transport.selectTransport('noop'); }); diff --git a/deno-runtime/main.ts b/deno-runtime/main.ts index 36f5c2887..10eb3faab 100644 --- a/deno-runtime/main.ts +++ b/deno-runtime/main.ts @@ -8,16 +8,16 @@ if (!Deno.args.includes('--subprocess')) { Deno.exit(1001); } -import { createRequire } from 'node:module'; import { sanitizeDeprecatedUsage } from './lib/sanitizeDeprecatedUsage.ts'; import { AppAccessorsInstance } from './lib/accessors/mod.ts'; import * as Messenger from './lib/messenger.ts'; import { AppObjectRegistry } from './AppObjectRegistry.ts'; -import { Logger } from "./lib/logger.ts"; +import { Logger } from './lib/logger.ts'; +import { require } from './lib/require.ts'; import type { IParseAppPackageResult } from '@rocket.chat/apps-engine/server/compiler/IParseAppPackageResult.ts'; - -const require = createRequire(import.meta.url); +import slashcommandHandler from './handlers/slashcommand-handler.ts'; +import { JsonRpcError } from "jsonrpc-lite"; const ALLOWED_NATIVE_MODULES = ['path', 'url', 'crypto', 'buffer', 'stream', 'net', 'http', 'https', 'zlib', 'util', 'punycode', 'os', 'querystring']; const ALLOWED_EXTERNAL_MODULES = ['uuid']; @@ -55,6 +55,7 @@ function wrapAppCode(code: string): (require: (module: string) => unknown) => Pr } async function handlInitializeApp(appPackage: IParseAppPackageResult): Promise { + AppObjectRegistry.set('id', appPackage.info.id); const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]); const require = buildRequire(); @@ -93,7 +94,6 @@ async function handlInitializeApp(appPackage: IParseAppPackageResult): Promise { @@ -104,13 +104,11 @@ async function handleRequest({ type, payload }: Messenger.JsonRpcRequest): Promi const { id, method, params } = payload; - const appId: string = method === 'construct' ? (params as Array)[0] : AppObjectRegistry.get('id') as string; - - const logger = new Logger(method, appId); + const logger = new Logger(method); AppObjectRegistry.set('logger', logger); - switch (method) { - case 'app:construct': { + switch (true) { + case method.includes('app:construct'): { const [appPackage] = params as [IParseAppPackageResult]; if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) { @@ -119,9 +117,21 @@ async function handleRequest({ type, payload }: Messenger.JsonRpcRequest): Promi await handlInitializeApp(appPackage); - Messenger.successResponse({ id, result: 'logs should go here as a response' }); + Messenger.successResponse({ + id, + result: 'logs should go here as a response', + }); break; } + case method.includes('slashcommand:'): { + const result = await slashcommandHandler(method, params); + + if (result instanceof JsonRpcError) { + return Messenger.errorResponse({ id, error: result }); + } + + return Messenger.successResponse({ id, result }); + } default: { Messenger.errorResponse({ error: { message: 'Method not found', code: -32601 }, @@ -162,7 +172,7 @@ async function main() { JSONRPCMessage = Messenger.parseMessage(message); } catch (error) { if (Messenger.isErrorResponse(error)) { - await Messenger.send(error); + await Messenger.Transport.send(error); } else { await Messenger.sendParseError(); } diff --git a/src/server/ProxiedApp.ts b/src/server/ProxiedApp.ts index 007966e25..69c84a50e 100644 --- a/src/server/ProxiedApp.ts +++ b/src/server/ProxiedApp.ts @@ -24,6 +24,10 @@ export class ProxiedApp implements IApp { return this.manager.getRuntime(); } + public getDenoRuntime(): DenoRuntimeSubprocessController { + return this.appRuntime; + } + public getStorageItem(): IAppStorageItem { return this.storageItem; } diff --git a/src/server/managers/AppSlashCommand.ts b/src/server/managers/AppSlashCommand.ts index 7e849fd63..a6c7cd757 100644 --- a/src/server/managers/AppSlashCommand.ts +++ b/src/server/managers/AppSlashCommand.ts @@ -1,7 +1,6 @@ import { AppMethod } from '../../definition/metadata'; import type { ISlashCommand, ISlashCommandPreview, ISlashCommandPreviewItem, SlashCommandContext } from '../../definition/slashcommands'; import type { ProxiedApp } from '../ProxiedApp'; -import { AppConsole } from '../logging'; import type { AppLogStorage } from '../storage'; import type { AppAccessorManager } from './AppAccessorManager'; @@ -59,42 +58,24 @@ export class AppSlashCommand { private async runTheCode( method: AppMethod._COMMAND_EXECUTOR | AppMethod._COMMAND_PREVIEWER | AppMethod._COMMAND_PREVIEW_EXECUTOR, - logStorage: AppLogStorage, - accessors: AppAccessorManager, + _logStorage: AppLogStorage, + _accessors: AppAccessorManager, context: SlashCommandContext, runContextArgs: Array, ): Promise { const { command } = this.slashCommand; - // Ensure the slash command has the property before going on - if (typeof this.slashCommand[method] !== 'function') { - return; - } - - const logger = this.app.setupLogger(method); - logger.debug(`${command}'s ${method} is being executed...`, context); - try { - const runCode = `module.exports = slashCommand.${method}.apply(slashCommand, args)`; - const result = await this.app.getRuntime().runInSandbox(runCode, { - slashCommand: this.slashCommand, - args: [ - ...runContextArgs, - context, - accessors.getReader(this.app.getID()), - accessors.getModifier(this.app.getID()), - accessors.getHttp(this.app.getID()), - accessors.getPersistence(this.app.getID()), - ], + const result = await this.app.getDenoRuntime().sendRequest({ + method: `slashcommand:${command}:${method}`, + params: [...runContextArgs, context], }); - logger.debug(`${command}'s ${method} was successfully executed.`); - return result; + return result as void | ISlashCommandPreview; } catch (e) { - logger.error(e); - logger.debug(`${command}'s ${method} was unsuccessful.`); - } finally { - await logStorage.storeEntries(AppConsole.toStorageEntry(this.app.getID(), logger)); + // @TODO this needs to be revisited + console.error(e); + throw e; } } } diff --git a/src/server/managers/AppSlashCommandManager.ts b/src/server/managers/AppSlashCommandManager.ts index bc8940932..4378308ff 100644 --- a/src/server/managers/AppSlashCommandManager.ts +++ b/src/server/managers/AppSlashCommandManager.ts @@ -375,7 +375,7 @@ export class AppSlashCommandManager { return result; } - public async executePreview(command: string, previewItem: ISlashCommandPreviewItem, context: SlashCommandContext): Promise { + public async executePreview(command: string, previewItem: ISlashCommandPreviewItem, context: SlashCommandContext): Promise { const cmd = command.toLowerCase().trim(); if (!this.shouldCommandFunctionsRun(cmd)) { diff --git a/src/server/runtime/AppsEngineDenoRuntime.ts b/src/server/runtime/AppsEngineDenoRuntime.ts index 46b4e49d5..5cbef43bf 100644 --- a/src/server/runtime/AppsEngineDenoRuntime.ts +++ b/src/server/runtime/AppsEngineDenoRuntime.ts @@ -10,6 +10,7 @@ import type { AppBridges } from '../bridges'; import type { IParseAppPackageResult } from '../compiler'; import type { AppStatus } from '../../definition/AppStatus'; import type { AppAccessorManager, AppApiManager } from '../managers'; +import type { ILoggerStorageEntry } from '../logging'; export const ALLOWED_ACCESSOR_METHODS = [ 'getConfigurationExtend', @@ -133,26 +134,38 @@ export class DenoRuntimeSubprocessController extends EventEmitter { this.deno.stdin.write(jsonrpc.request(id, message.method, message.params).serialize()); - return this.waitForResult(id); + return this.waitForResponse(id); } private waitUntilReady(): Promise { return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => reject(new Error('Timeout: app process not ready')), this.options.timeout); + if (this.state === 'ready') { + clearTimeout(timeoutId); return resolve(); } - this.once('ready', resolve); - - setTimeout(() => reject(new Error('Timeout: app process not ready')), this.options.timeout); + this.once('ready', () => { + clearTimeout(timeoutId); + return resolve(); + }); }); } - private waitForResult(id: string): Promise { + private waitForResponse(id: string): Promise { return new Promise((resolve, reject) => { - this.once(`result:${id}`, (result: unknown[]) => resolve(result)); + const timeoutId = setTimeout(() => reject(new Error('Request timed out')), this.options.timeout); + + this.once(`result:${id}`, (result: unknown, error: jsonrpc.IParsedObjectError['payload']['error']) => { + clearTimeout(timeoutId); - setTimeout(() => reject(new Error('Request timed out')), this.options.timeout); + if (error) { + reject(error); + } + + resolve(result); + }); }); } @@ -313,19 +326,22 @@ export class DenoRuntimeSubprocessController extends EventEmitter { private async handleResultMessage(message: jsonrpc.IParsedObjectError | jsonrpc.IParsedObjectSuccess): Promise { const { id } = message.payload; - let param; + let result: unknown; + let error: jsonrpc.IParsedObjectError['payload']['error'] | undefined; + let logs: ILoggerStorageEntry; if (message.type === 'success') { - param = message.payload.result; - const { value, logs } = param as any; - param = value; - - this.logStorage.storeEntries(logs); + const params = message.payload.result as { value: unknown; logs: ILoggerStorageEntry }; + result = params.value; + logs = params.logs as ILoggerStorageEntry; } else { - param = message.payload.error; + error = message.payload.error; + logs = message.payload.error.data?.logs as ILoggerStorageEntry; } - this.emit(`result:${id}`, param); + this.logStorage.storeEntries(logs); + + this.emit(`result:${id}`, result, error); } private async parseOutput(chunk: Buffer): Promise { diff --git a/tests/server/managers/AppSlashCommandManager.spec.ts b/tests/server/managers/AppSlashCommandManager.spec.ts index b8e06557e..a2f8ef5e0 100644 --- a/tests/server/managers/AppSlashCommandManager.spec.ts +++ b/tests/server/managers/AppSlashCommandManager.spec.ts @@ -20,6 +20,7 @@ import type { ProxiedApp } from '../../../src/server/ProxiedApp'; import { Room } from '../../../src/server/rooms/Room'; import type { AppsEngineRuntime } from '../../../src/server/runtime/AppsEngineRuntime'; import type { AppLogStorage } from '../../../src/server/storage'; +import type { DenoRuntimeSubprocessController } from '../../../src/server/runtime/AppsEngineDenoRuntime'; export class AppSlashCommandManagerTestFixture { public static doThrow = false; @@ -42,6 +43,11 @@ export class AppSlashCommandManagerTestFixture { getRuntime() { return {} as AppsEngineRuntime; }, + getDenoRuntime() { + return { + sendRequest: () => {}, + } as unknown as DenoRuntimeSubprocessController; + }, getID() { return 'testing'; },