Skip to content

Commit

Permalink
Refactor slashcommand execution to new Runtime API (#686)
Browse files Browse the repository at this point in the history
  • Loading branch information
d-gubert authored Dec 20, 2023
1 parent 6f9da6f commit 30d41d4
Show file tree
Hide file tree
Showing 15 changed files with 403 additions and 81 deletions.
1 change: 1 addition & 0 deletions deno-runtime/deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"astring": "npm:[email protected]",
"jsonrpc-lite": "npm:[email protected]",
"uuid": "npm:[email protected]",
"stack-trace": "npm:[email protected]",
}
}
1 change: 1 addition & 0 deletions deno-runtime/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions deno-runtime/handlers/slashcommand-handler.ts
Original file line number Diff line number Diff line change
@@ -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<JsonRpcError | Defined> {
const [, commandName, method] = call.split(':');

const command = AppObjectRegistry.get<ISlashCommand>(`slashcommand:${commandName}`);

if (!command) {
return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000);
}

let result: Awaited<ReturnType<typeof handleExecutor>> | Awaited<ReturnType<typeof handlePreviewItem>>;

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<string, unknown>;

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<string, unknown>, Record<string, unknown>];

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(),
);
}
155 changes: 155 additions & 0 deletions deno-runtime/handlers/tests/slashcommand-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// deno-lint-ignore-file no-explicit-any
import { assertInstanceOf, assertEquals } from 'https://deno.land/[email protected]/assert/mod.ts';
import { beforeEach, describe, it } from 'https://deno.land/[email protected]/testing/bdd.ts';
import { spy } from "https://deno.land/[email protected]/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<void> {},
};

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<void> {},
// deno-lint-ignore no-unused-vars
async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise<void> {},
// deno-lint-ignore no-unused-vars
async executePreviewItem(previewItem: any,context: any, read: any, modify: any, http: any, persis: any): Promise<void> {},
};

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<void> {},
// deno-lint-ignore no-unused-vars
async executePreviewItem(previewItem: any,context: any, read: any, modify: any, http: any, persis: any): Promise<void> {},
};

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();
});
});
4 changes: 4 additions & 0 deletions deno-runtime/lib/accessors/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export class AppAccessors {
) as T;
}

public getSenderFn() {
return this.senderFn;
}

public getEnvironmentRead(): IEnvironmentRead {
if (!this.environmentRead) {
this.environmentRead = {
Expand Down
36 changes: 22 additions & 14 deletions deno-runtime/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -16,7 +27,7 @@ type Entry = {
method: string;
timestamp: Date;
args: Array<unknown>;
}
};

interface ILoggerStorageEntry {
appId: string;
Expand All @@ -29,40 +40,38 @@ interface ILoggerStorageEntry {
}

export class Logger {
private appId: string;
private entries: Array<Entry>;
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<unknown>): void {
this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args)
this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args);
}

public info(...args: Array<unknown>): void {
this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args)
this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args);
}

public log(...args: Array<unknown>): void {
this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args)
this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args);
}

public warning(...args: Array<unknown>): void {
this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args)
this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args);
}

public error(...args: Array<unknown>): void {
this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args)
this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args);
}

public success(...args: Array<unknown>): 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<unknown>): void {
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 30d41d4

Please sign in to comment.