Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor listener methods #692

Merged
merged 8 commits into from
Jan 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions deno-runtime/handlers/app/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ import handleOnDisable from './handleOnDisable.ts';
import handleOnUninstall from './handleOnUninstall.ts';
import handleOnPreSettingUpdate from './handleOnPreSettingUpdate.ts';
import handleOnSettingUpdated from './handleOnSettingUpdated.ts';
import handleListener from "../listener/handler.ts";

export default async function handleApp(method: string, params: unknown): Promise<Defined | JsonRpcError> {
const [, appMethod] = method.split(':');

if (appMethod.startsWith('check') || appMethod.startsWith('execute')) {
return handleListener(method, params);
}

try {
switch (appMethod) {
case 'construct':
Expand Down
123 changes: 123 additions & 0 deletions deno-runtime/handlers/listener/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Defined, JsonRpcError } from 'jsonrpc-lite';
import type { App } from '@rocket.chat/apps-engine/definition/App.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 { AppsEngineException as _AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.ts';

import { AppObjectRegistry } from '../../AppObjectRegistry.ts';
import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender.ts';
import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender.ts';
import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder.ts';
import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder.ts';
import { AppAccessors, AppAccessorsInstance } from '../../lib/accessors/mod.ts';
import { require } from '../../lib/require.ts';
import createRoom from '../../lib/roomFactory.ts';

const { AppsEngineException } = require('@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.js') as {
AppsEngineException: typeof _AppsEngineException;
};

export default async function handleListener(method: string, params: unknown): Promise<Defined | JsonRpcError> {
const [, evtInterface] = method.split(':');

const app = AppObjectRegistry.get<App>('app');

const eventExecutor = app?.[evtInterface as keyof App];

if (typeof eventExecutor !== 'function') {
return JsonRpcError.methodNotFound({
message: 'Invalid event interface called on app',
});
}

if (!Array.isArray(params) || params.length < 1 || params.length > 2) {
return JsonRpcError.invalidParams(null);
}

try {
const args = parseArgs({ AppAccessorsInstance }, evtInterface, params);
return await (eventExecutor as (...args: unknown[]) => Promise<Defined>).apply(app, args);
} catch (e) {
if (e instanceof JsonRpcError) {
return e;
}

if (e instanceof AppsEngineException) {
return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name });
}

return JsonRpcError.internalError({ message: e.message });
}
}

export function parseArgs(deps: { AppAccessorsInstance: AppAccessors }, evtMethod: string, params: unknown[]): unknown[] {
const { AppAccessorsInstance } = deps;
/**
* param1 is the context for the event handler execution
* param2 is an optional extra content that some hanlers require
*/
const [param1, param2] = params as [unknown, unknown];

if (!param1) {
throw JsonRpcError.invalidParams(null);
}

let context = param1;

if (evtMethod.endsWith('RoomUserJoined') || evtMethod.endsWith('RoomUserLeave')) {
(context as Record<string, unknown>).room = createRoom((context as Record<string, unknown>).room as IRoom, AppAccessorsInstance.getSenderFn());
} else if (evtMethod.includes('PreRoom')) {
context = createRoom(context as IRoom, AppAccessorsInstance.getSenderFn());
}

const args: unknown[] = [context, AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()];

// "check" events will only go this far - (context, reader, http)
if (evtMethod.startsWith('check')) {
// "checkPostMessageDeleted" has an extra param - (context, reader, http, extraContext)
if (param2) {
args.push(param2);
}

return args;
}

// From this point on, all events will require (reader, http, persistence) injected
args.push(AppAccessorsInstance.getPersistence());

// "extend" events have an additional "Extender" param - (context, extender, reader, http, persistence)
if (evtMethod.endsWith('Extend')) {
if (evtMethod.includes('Message')) {
args.splice(1, 0, new MessageExtender(param1 as IMessage));
} else if (evtMethod.includes('Room')) {
args.splice(1, 0, new RoomExtender(param1 as IRoom));
}

return args;
}

// "Modify" events have an additional "Builder" param - (context, builder, reader, http, persistence)
if (evtMethod.endsWith('Modify')) {
if (evtMethod.includes('Message')) {
args.splice(1, 0, new MessageBuilder(param1 as IMessage));
} else if (evtMethod.includes('Room')) {
args.splice(1, 0, new RoomBuilder(param1 as IRoom));
}

return args;
}

// From this point on, all events will require (reader, http, persistence, modifier) injected
args.push(AppAccessorsInstance.getModifier());

// This guy gets an extra one
if (evtMethod === 'executePostMessageDeleted') {
if (!param2) {
throw JsonRpcError.invalidParams(null);
}

args.push(param2);
}

return args;
}
31 changes: 11 additions & 20 deletions deno-runtime/handlers/slashcommand-handler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
import { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand.ts';
import { Defined, JsonRpcError } from 'jsonrpc-lite';

import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts';
import type { 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";
import { require } from '../lib/require.ts';
import createRoom from '../lib/roomFactory.ts';

// 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],
});
},
}),
}),
});
const { SlashCommandContext } = require('@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.js') as {
SlashCommandContext: typeof _SlashCommandContext;
};

export default async function slashCommandHandler(call: string, params: unknown): Promise<JsonRpcError | Defined> {
const [, commandName, method] = call.split(':');
Expand Down Expand Up @@ -71,7 +62,7 @@ export function handleExecutor(deps: { AppAccessorsInstance: AppAccessors }, com

const context = new SlashCommandContext(
sender as _SlashCommandContext['sender'],
new Room(room, getMockAppManager(deps.AppAccessorsInstance.getSenderFn())),
createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()),
args as _SlashCommandContext['params'],
threadId as _SlashCommandContext['threadId'],
triggerId as _SlashCommandContext['triggerId'],
Expand Down Expand Up @@ -104,7 +95,7 @@ export function handlePreviewItem(deps: { AppAccessorsInstance: AppAccessors },

const context = new SlashCommandContext(
sender as _SlashCommandContext['sender'],
new Room(room, getMockAppManager(deps.AppAccessorsInstance.getSenderFn())),
createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()),
args as _SlashCommandContext['params'],
threadId as _SlashCommandContext['threadId'],
triggerId as _SlashCommandContext['triggerId'],
Expand Down
181 changes: 181 additions & 0 deletions deno-runtime/handlers/tests/listener-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// deno-lint-ignore-file no-explicit-any
import { assertEquals, assertInstanceOf, assertObjectMatch } from 'https://deno.land/[email protected]/assert/mod.ts';
import { describe, it } from 'https://deno.land/[email protected]/testing/bdd.ts';

import { parseArgs } from '../listener/handler.ts';
import { AppAccessors } from '../../lib/accessors/mod.ts';
import { Room } from '../../lib/roomFactory.ts';
import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender.ts';
import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender.ts';
import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder.ts';
import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder.ts';

describe('handlers > listeners', () => {
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;

it('correctly parses the arguments for a request to trigger the "checkPreMessageSentPrevent" method', () => {
const evtMethod = 'checkPreMessageSentPrevent';
// For the 'checkPreMessageSentPrevent' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 3);
assertEquals(params[0], { __type: 'context' });
assertEquals(params[1], { __type: 'reader' });
assertEquals(params[2], { __type: 'http' });
});

it('correctly parses the arguments for a request to trigger the "checkPostMessageDeleted" method', () => {
const evtMethod = 'checkPostMessageDeleted';
// For the 'checkPostMessageDeleted' method, the context will be a message in a real scenario,
// and the extraContext will provide further information such the user who deleted the message
const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 4);
assertEquals(params[0], { __type: 'context' });
assertEquals(params[1], { __type: 'reader' });
assertEquals(params[2], { __type: 'http' });
assertEquals(params[3], { __type: 'extraContext' });
});

it('correctly parses the arguments for a request to trigger the "checkPreRoomCreateExtend" method', () => {
const evtMethod = 'checkPreRoomCreateExtend';
// For the 'checkPreRoomCreateExtend' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 3);

assertInstanceOf(params[0], Room);
assertEquals(params[1], { __type: 'reader' });
assertEquals(params[2], { __type: 'http' });
});

it('correctly parses the arguments for a request to trigger the "executePreMessageSentExtend" method', () => {
const evtMethod = 'executePreMessageSentExtend';
// For the 'executePreMessageSentExtend' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 5);
// Instantiating the MessageExtender might modify the original object, so we need to assert it matches instead of equals
assertObjectMatch(params[0] as Record<string, unknown>, {
__type: 'context',
});
assertInstanceOf(params[1], MessageExtender);
assertEquals(params[2], { __type: 'reader' });
assertEquals(params[3], { __type: 'http' });
assertEquals(params[4], { __type: 'persistence' });
});

it('correctly parses the arguments for a request to trigger the "executePreRoomCreateExtend" method', () => {
const evtMethod = 'executePreRoomCreateExtend';
// For the 'executePreRoomCreateExtend' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 5);
// Instantiating the RoomExtender might modify the original object, so we need to assert it matches instead of equals
assertObjectMatch(params[0] as Record<string, unknown>, {
__type: 'context',
});
assertInstanceOf(params[1], RoomExtender);
assertEquals(params[2], { __type: 'reader' });
assertEquals(params[3], { __type: 'http' });
assertEquals(params[4], { __type: 'persistence' });
});

it('correctly parses the arguments for a request to trigger the "executePreMessageSentModify" method', () => {
const evtMethod = 'executePreMessageSentModify';
// For the 'executePreMessageSentModify' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 5);
// Instantiating the MessageBuilder might modify the original object, so we need to assert it matches instead of equals
assertObjectMatch(params[0] as Record<string, unknown>, {
__type: 'context',
});
assertInstanceOf(params[1], MessageBuilder);
assertEquals(params[2], { __type: 'reader' });
assertEquals(params[3], { __type: 'http' });
assertEquals(params[4], { __type: 'persistence' });
});

it('correctly parses the arguments for a request to trigger the "executePreRoomCreateModify" method', () => {
const evtMethod = 'executePreRoomCreateModify';
// For the 'executePreRoomCreateModify' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 5);
// Instantiating the RoomBuilder might modify the original object, so we need to assert it matches instead of equals
assertObjectMatch(params[0] as Record<string, unknown>, {
__type: 'context',
});
assertInstanceOf(params[1], RoomBuilder);
assertEquals(params[2], { __type: 'reader' });
assertEquals(params[3], { __type: 'http' });
assertEquals(params[4], { __type: 'persistence' });
});

it('correctly parses the arguments for a request to trigger the "executePostRoomUserJoined" method', () => {
const evtMethod = 'executePostRoomUserJoined';
// For the 'executePostRoomUserJoined' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context', room: { __type: 'room' } }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 5);
assertInstanceOf((params[0] as any).room, Room);
assertEquals(params[1], { __type: 'reader' });
assertEquals(params[2], { __type: 'http' });
assertEquals(params[3], { __type: 'persistence' });
assertEquals(params[4], { __type: 'modifier' });
});

it('correctly parses the arguments for a request to trigger the "executePostRoomUserLeave" method', () => {
const evtMethod = 'executePostRoomUserLeave';
// For the 'executePostRoomUserLeave' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context', room: { __type: 'room' } }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 5);
assertInstanceOf((params[0] as any).room, Room);
assertEquals(params[1], { __type: 'reader' });
assertEquals(params[2], { __type: 'http' });
assertEquals(params[3], { __type: 'persistence' });
assertEquals(params[4], { __type: 'modifier' });
});

it('correctly parses the arguments for a request to trigger the "executePostMessageDeleted" method', () => {
const evtMethod = 'executePostMessageDeleted';
// For the 'executePostMessageDeleted' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }];

const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);

assertEquals(params.length, 6);
assertEquals(params[0], { __type: 'context' });
assertEquals(params[1], { __type: 'reader' });
assertEquals(params[2], { __type: 'http' });
assertEquals(params[3], { __type: 'persistence' });
assertEquals(params[4], { __type: 'modifier' });
assertEquals(params[5], { __type: 'extraContext' });
});
});
Loading
Loading