-
Notifications
You must be signed in to change notification settings - Fork 118
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor slashcommand execution to new Runtime API (#686)
- Loading branch information
Showing
15 changed files
with
403 additions
and
81 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,5 +7,6 @@ | |
"astring": "npm:[email protected]", | ||
"jsonrpc-lite": "npm:[email protected]", | ||
"uuid": "npm:[email protected]", | ||
"stack-trace": "npm:[email protected]", | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
155
deno-runtime/handlers/tests/slashcommand-handler.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.