import { Apps } from '@rocket.chat/apps'; import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import objectPath from 'object-path'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; import { slashCommands } from '../../lib/utils/slashCommand'; import { executeSlashCommandPreview } from '../../meteor-methods/messages/executeSlashCommandPreview'; import { getSlashCommandPreviews } from '../../meteor-methods/messages/getSlashCommandPreviews'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../lib/getPaginationItems'; type CommandsGetParams = { command: string }; const CommandsGetParamsSchema = { type: 'object', properties: { command: { type: 'string' }, }, required: ['command'], additionalProperties: false, }; const isCommandsGetParams = ajvQuery.compile(CommandsGetParamsSchema); const commandsListResponseSchema = ajv.compile<{ commands: SlashCommand[]; appsLoaded: boolean; offset: number; count: number; total: number; }>({ type: 'object', properties: { commands: { type: 'array', items: { $ref: '#/components/schemas/SlashCommand' }, }, appsLoaded: { type: 'boolean' }, offset: { type: 'number' }, count: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'], additionalProperties: false, }); type CommandsListParams = { offset?: number; count?: number; sort?: string; query?: string; fields?: string; }; const isCommandsListParams = ajvQuery.compile({ type: 'object', properties: { offset: { type: 'number', nullable: true }, count: { type: 'number', nullable: true }, sort: { type: 'string', nullable: true }, query: { type: 'string', nullable: true }, fields: { type: 'string', nullable: true }, }, additionalProperties: false, }); const commandsEndpoints = API.v1 .get( 'commands.get', { authRequired: true, query: isCommandsGetParams, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile<{ command: Pick; success: true; }>({ type: 'object', properties: { command: { type: 'object', properties: { clientOnly: { type: 'boolean' }, command: { type: 'string' }, description: { type: 'string' }, params: { type: 'string' }, providesPreview: { type: 'boolean' }, }, required: ['command', 'providesPreview'], additionalProperties: false, }, success: { type: 'boolean', enum: [true], }, }, required: ['command', 'success'], additionalProperties: false, }), }, }, async function action() { const params = this.queryParams; const cmd = slashCommands.commands[params.command.toLowerCase()]; if (!cmd) { return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); } return API.v1.success({ command: { command: cmd.command, description: cmd.description, params: cmd.params, clientOnly: cmd.clientOnly, providesPreview: cmd.providesPreview, }, }); }, ) .get( 'commands.list', { authRequired: true, query: isCommandsListParams, response: { 200: commandsListResponseSchema, 202: commandsListResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { if (!Apps.self?.isLoaded()) { return API.v1.success( { commands: [], appsLoaded: false as const, offset: 0, count: 0, total: 0, success: true as const, }, 202, ); } const { offset, count } = await getPaginationItems(this.queryParams); const { sort, query } = await this.parseJsonQuery(); let commands = Object.values(slashCommands.commands); if (query?.command) { commands = commands.filter((command) => command.command === query.command); } const totalCount = commands.length; return API.v1.success({ commands: processQueryOptionsOnResult(commands, { sort: sort || { name: 1 }, skip: offset, limit: count, }), appsLoaded: true as const, offset, count: commands.length, total: totalCount, }); }, ); /* @deprecated */ const processQueryOptionsOnResult = , F extends keyof T>( result: T[], options: { fields?: { [key in F]?: 1 | 0; }; sort?: { [key: string]: 1 | -1; }; limit?: number; skip?: number; } = {}, ): Pick[] => { if (result === undefined || result === null) { return []; } if (Array.isArray(result)) { if (options.sort) { result = result.sort((a, b) => { let r = 0; for (const field in options.sort) { if (options.sort.hasOwnProperty(field)) { const direction = options.sort[field]; let valueA; let valueB; if (field.indexOf('.') > -1) { valueA = objectPath.get(a, field); valueB = objectPath.get(b, field); } else { valueA = a[field]; valueB = b[field]; } if (valueA > valueB) { r = direction; break; } if (valueA < valueB) { r = -direction; break; } } } return r; }); } if (typeof options.skip === 'number') { result.splice(0, options.skip); } if (typeof options.limit === 'number' && options.limit !== 0) { result.splice(options.limit); } } const fieldsToRemove: F[] = []; const fieldsToGet: F[] = []; if (options.fields) { for (const field in Object.keys(options.fields)) { if (options.fields.hasOwnProperty(field as F)) { if (options.fields[field as F] === 0) { fieldsToRemove.push(field as F); } else if (options.fields[field as F] === 1) { fieldsToGet.push(field as F); } } } } if (fieldsToGet.length > 0 && fieldsToGet.indexOf('_id' as F) === -1) { fieldsToGet.push('_id' as F); } const pickFields = (obj: T, fields: F[]): Pick => { const picked: Partial = {}; fields.forEach((field: F) => { if (String(field).indexOf('.') !== -1) { objectPath.set(picked, String(field), objectPath.get(obj, String(field))); } else { picked[field] = obj[field]; } }); return picked as Pick; }; if (fieldsToRemove.length > 0 && fieldsToGet.length > 0) { console.warn("Can't mix remove and get fields"); fieldsToRemove.splice(0, fieldsToRemove.length); } if (fieldsToRemove.length > 0 || fieldsToGet.length > 0) { return result.map((record) => { if (fieldsToRemove.length > 0) { return Object.fromEntries(Object.entries(record).filter(([key]) => !fieldsToRemove.includes(key as F))) as Pick; } return pickFields(record, fieldsToGet); }); } return result; }; const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({ type: 'object', properties: { command: { type: 'string', minLength: 1 }, params: { type: 'string', nullable: true }, roomId: { type: 'string', minLength: 1 }, tmid: { type: 'string', nullable: true }, triggerId: { type: 'string', nullable: true }, }, required: ['command', 'roomId'], additionalProperties: false, }); const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({ type: 'object', properties: { result: {}, success: { type: 'boolean', enum: [true] }, }, required: ['success'], additionalProperties: true, }); const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({ type: 'object', properties: { command: { type: 'string', minLength: 1 }, params: { type: 'string', nullable: true }, roomId: { type: 'string', minLength: 1 }, }, required: ['command', 'roomId'], additionalProperties: false, }); const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record | null }>({ type: 'object', properties: { preview: { type: 'object', nullable: true }, success: { type: 'boolean', enum: [true] }, }, required: ['success'], additionalProperties: false, }); const isCommandsPreviewPostProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string; previewItem: SlashCommandPreviewItem; }>({ type: 'object', properties: { command: { type: 'string', minLength: 1 }, params: { type: 'string', nullable: true }, roomId: { type: 'string', minLength: 1 }, tmid: { type: 'string', nullable: true }, triggerId: { type: 'string', nullable: true }, previewItem: { type: 'object', properties: { id: { type: 'string' }, type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] }, value: { type: 'string' }, }, required: ['id', 'type', 'value'], additionalProperties: false, }, }, required: ['command', 'roomId', 'previewItem'], additionalProperties: false, }); const commandsPreviewPostResponseSchema = ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true] }, }, required: ['success'], additionalProperties: false, }); // Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' } API.v1.post( 'commands.run', { authRequired: true, body: isCommandsRunProps, response: { 200: commandsRunResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, }, }, async function action() { const body = this.bodyParams; const cmd = body.command.toLowerCase(); if (!slashCommands.commands[cmd]) { return API.v1.failure('The command provided does not exist (or is disabled).'); } if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { return API.v1.forbidden(); } const params = body.params ? body.params : ''; if (body.tmid) { const thread = await Messages.findOneById(body.tmid); if (thread?.rid !== body.roomId) { return API.v1.failure('Invalid thread.'); } } const message = { _id: Random.id(), rid: body.roomId, msg: `/${cmd} ${params}`, ...(body.tmid && { tmid: body.tmid }), }; const { triggerId } = body; const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId }); return API.v1.success({ result }); }, ); // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' API.v1.get( 'commands.preview', { authRequired: true, query: isCommandsPreviewGetProps, response: { 200: commandsPreviewGetResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, }, }, async function action() { const query = this.queryParams; const cmd = query.command.toLowerCase(); if (!slashCommands.commands[cmd]) { return API.v1.failure('The command provided does not exist (or is disabled).'); } if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { return API.v1.forbidden(); } const params = query.params ? query.params : ''; const preview = await getSlashCommandPreviews({ cmd, params, msg: { rid: query.roomId }, userId: this.userId, }); return API.v1.success({ preview: preview ?? null }); }, ); // Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } } API.v1.post( 'commands.preview', { authRequired: true, body: isCommandsPreviewPostProps, response: { 200: commandsPreviewPostResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, }, }, async function action() { const body = this.bodyParams; const cmd = body.command.toLowerCase(); if (!slashCommands.commands[cmd]) { return API.v1.failure('The command provided does not exist (or is disabled).'); } if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { return API.v1.forbidden(); } const { params = '' } = body; if (body.tmid) { const thread = await Messages.findOneById(body.tmid); if (thread?.rid !== body.roomId) { return API.v1.failure('Invalid thread.'); } } const msg = { rid: body.roomId, ...(body.tmid && { tmid: body.tmid }), }; await executeSlashCommandPreview( { cmd, params, msg, triggerId: body.triggerId, }, body.previewItem, this.userId, ); return API.v1.success(); }, ); export type CommandsEndpoints = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface interface Endpoints extends CommandsEndpoints {} }