Files
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

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 {}
}