Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
522 lines
13 KiB
TypeScript
522 lines
13 KiB
TypeScript
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<CommandsGetParams>(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<CommandsListParams>({
|
|
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<SlashCommand, 'clientOnly' | 'command' | 'description' | 'params' | 'providesPreview'>;
|
|
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 = <T extends { _id?: string } & Record<string, any>, F extends keyof T>(
|
|
result: T[],
|
|
options: {
|
|
fields?: {
|
|
[key in F]?: 1 | 0;
|
|
};
|
|
sort?: {
|
|
[key: string]: 1 | -1;
|
|
};
|
|
limit?: number;
|
|
skip?: number;
|
|
} = {},
|
|
): Pick<T, F>[] => {
|
|
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 = <F extends keyof T>(obj: T, fields: F[]): Pick<T, F> => {
|
|
const picked: Partial<T> = {};
|
|
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<T, F>;
|
|
};
|
|
|
|
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<T, F>;
|
|
}
|
|
|
|
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<string, unknown> | 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<void>({
|
|
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<typeof commandsEndpoints>;
|
|
|
|
declare module '@rocket.chat/rest-typings' {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
|
interface Endpoints extends CommandsEndpoints {}
|
|
}
|