Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/migrate-livechat-openapi-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-typings": patch
"@rocket.chat/rest-typings": patch
---

Migrates livechat/config, livechat/webhook.test, and livechat/integrations.settings API endpoints to the OpenAPI chained route definition pattern with AJV response validation and shared $ref schemas.
44 changes: 40 additions & 4 deletions apps/meteor/app/livechat/imports/server/rest/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { schemas } from '@rocket.chat/core-typings';
import { ajv, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';

import { API } from '../../../../api/server';
import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass';
import { findIntegrationSettings } from '../../../server/api/lib/integrations';

API.v1.addRoute(
// Register ISetting schema for $ref resolution (livechat loads before api/server/ajv.ts)
const iSettingSchema = schemas.components?.schemas?.ISetting;
if (iSettingSchema && !ajv.getSchema('#/components/schemas/ISetting')) {
ajv.addSchema(iSettingSchema, '#/components/schemas/ISetting');
}

const livechatIntegrationsEndpoints = API.v1.get(
'livechat/integrations.settings',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
{
async get() {
return API.v1.success(await findIntegrationSettings());
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
query: undefined,
response: {
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<{ settings: ISetting[]; success: boolean }>({
type: 'object',
properties: {
settings: {
type: 'array',
items: { $ref: '#/components/schemas/ISetting' },
},
success: { type: 'boolean', enum: [true] },
},
required: ['settings', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
return API.v1.success(await findIntegrationSettings());
},
);

type LivechatIntegrationsEndpoints = ExtractRoutesFromAPI<typeof livechatIntegrationsEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatIntegrationsEndpoints {}
}
107 changes: 83 additions & 24 deletions apps/meteor/app/livechat/server/api/v1/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,80 @@
import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings';
import type { ILivechatAgent, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { schemas } from '@rocket.chat/core-typings';
import { ajv, GETLivechatConfigRouting, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
import mem from 'mem';

import { API } from '../../../../api/server';
import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass';
import { settings as serverSettings } from '../../../../settings/server';
import { settings as serverSettings } from '../../../../settings/server/index';
import { RoutingManager } from '../../lib/RoutingManager';
import { online } from '../../lib/service-status';
import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from '../lib/livechat';

const schemaComponents = schemas.components?.schemas;
(['IOmnichannelRoom', 'ILivechatAgent', 'ILivechatVisitor'] as const).forEach((key) => {
const schema = schemaComponents?.[key];
if (schema && !ajv.getSchema(`#/components/schemas/${key}`)) {
ajv.addSchema(schema, `#/components/schemas/${key}`);
}
});

type GETLivechatConfigParams = {
token?: string;
department?: string;
businessUnit?: string;
};

const GETLivechatConfigParamsSchema = {
type: 'object',
properties: {
token: {
type: 'string',
nullable: true,
},
department: {
type: 'string',
nullable: true,
},
businessUnit: {
type: 'string',
nullable: true,
},
},
additionalProperties: false,
};

const isGETLivechatConfigParams = ajv.compile<GETLivechatConfigParams>(GETLivechatConfigParamsSchema);

const cachedSettings = mem(settings, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 1000, cacheKey: JSON.stringify });

API.v1.addRoute(
'livechat/config',
{ validateParams: isGETLivechatConfigParams },
{
async get() {
const livechatConfigEndpoints = API.v1
.get(
'livechat/config',
{
query: isGETLivechatConfigParams,
response: {
200: ajv.compile<{
config: { [k: string]: string | boolean } & { room?: IOmnichannelRoom; agent?: ILivechatAgent };
success: boolean;
}>({
type: 'object',
properties: {
config: {
type: 'object',
properties: {
room: { $ref: '#/components/schemas/IOmnichannelRoom' },
agent: { $ref: '#/components/schemas/ILivechatAgent' },
},
additionalProperties: true,
},
success: { type: 'boolean', enum: [true] },
},
required: ['config', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
const enabled = serverSettings.get<boolean>('Livechat_enabled');

if (!enabled) {
Expand All @@ -35,30 +95,29 @@ API.v1.addRoute(
const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]);

return API.v1.success({
config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) },
config: { ...config, online: status, ...extraInfo, ...(room && { room }), ...(agent && { agent }) },
});
},
},
);

const livechatConfigEndpoints = API.v1.get(
'livechat/config/routing',
{
response: {
200: GETLivechatConfigRouting,
)
.get(
'livechat/config/routing',
{
authRequired: true,
response: {
200: GETLivechatConfigRouting,
401: validateUnauthorizedErrorResponse,
},
},
authRequired: true,
},
async function action() {
const config = RoutingManager.getConfig();
async function action() {
const config = RoutingManager.getConfig();

return API.v1.success({ config });
},
);
return API.v1.success({ config });
},
);

type LivechatConfigEndpoints = ExtractRoutesFromAPI<typeof livechatConfigEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatConfigEndpoints {}
}
162 changes: 91 additions & 71 deletions apps/meteor/app/livechat/server/api/v1/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,95 +1,115 @@
import { Logger } from '@rocket.chat/logger';
import { ajv, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { API } from '../../../../api/server';
import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass';
import { settings } from '../../../../settings/server';

const logger = new Logger('WebhookTest');

API.v1.addRoute(
const livechatWebhookEndpoints = API.v1.post(
'livechat/webhook.test',
{ authRequired: true, permissionsRequired: ['view-livechat-webhooks'] },
{
async post() {
const sampleData = {
type: 'LivechatSession',
_id: 'fasd6f5a4sd6f8a4sdf',
label: 'title',
topic: 'asiodojf',
createdAt: new Date(),
lastMessageAt: new Date(),
tags: ['tag1', 'tag2', 'tag3'],
authRequired: true,
permissionsRequired: ['view-livechat-webhooks'],
response: {
401: validateUnauthorizedErrorResponse,
200: ajv.compile<{ success: boolean }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
},
},
async function action() {
const sampleData = {
type: 'LivechatSession',
_id: 'fasd6f5a4sd6f8a4sdf',
label: 'title',
topic: 'asiodojf',
createdAt: new Date(),
lastMessageAt: new Date(),
tags: ['tag1', 'tag2', 'tag3'],
customFields: {
productId: '123456',
},
visitor: {
_id: '',
name: 'visitor name',
username: 'visitor-username',
department: 'department',
email: 'email@address.com',
phone: '192873192873',
ip: '123.456.7.89',
browser: 'Chrome',
os: 'Linux',
customFields: {
productId: '123456',
customerId: '123456',
},
visitor: {
_id: '',
name: 'visitor name',
},
agent: {
_id: 'asdf89as6df8',
username: 'agent.username',
name: 'Agent Name',
email: 'agent@email.com',
},
messages: [
{
username: 'visitor-username',
department: 'department',
email: 'email@address.com',
phone: '192873192873',
ip: '123.456.7.89',
browser: 'Chrome',
os: 'Linux',
customFields: {
customerId: '123456',
},
msg: 'message content',
ts: new Date(),
},
agent: {
_id: 'asdf89as6df8',
{
username: 'agent.username',
name: 'Agent Name',
email: 'agent@email.com',
agentId: 'asdf89as6df8',
msg: 'message content from agent',
ts: new Date(),
},
messages: [
{
username: 'visitor-username',
msg: 'message content',
ts: new Date(),
},
{
username: 'agent.username',
agentId: 'asdf89as6df8',
msg: 'message content from agent',
ts: new Date(),
},
],
};
const options = {
method: 'POST',
headers: {
'X-RocketChat-Livechat-Token': settings.get<string>('Livechat_secret_token'),
'Accept': 'application/json',
},
body: sampleData,
// SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
size: 10 * 1024 * 1024,
} as ExtendedFetchOptions;

const webhookUrl = settings.get<string>('Livechat_webhookUrl');
],
};
const options = {
method: 'POST',
headers: {
'X-RocketChat-Livechat-Token': settings.get<string>('Livechat_secret_token'),
'Accept': 'application/json',
},
body: sampleData,
ignoreSsrfValidation: true,
size: 10 * 1024 * 1024,
} as ExtendedFetchOptions;

if (!webhookUrl) {
return API.v1.failure('Webhook_URL_not_set');
}
const webhookUrl = settings.get<string>('Livechat_webhookUrl');

try {
logger.debug({ msg: 'Testing webhook', webhookUrl });
const request = await fetch(webhookUrl, options);
const response = await request.text();
if (!webhookUrl) {
return API.v1.failure('Webhook_URL_not_set');
}

logger.debug({ msg: 'Webhook response', response });
if (request.status === 200) {
return API.v1.success();
}
try {
logger.debug({ msg: 'Testing webhook', host: new URL(webhookUrl).host });
const request = await fetch(webhookUrl, options);
await request.text();

throw new Error('Invalid status code');
} catch (error) {
logger.error({ msg: 'Error testing webhook', err: error });
throw new Error('error-invalid-webhook-response');
logger.debug({ msg: 'Webhook response', status: request.status });
if (request.status === 200) {
return API.v1.success();
}
},

throw new Error('Invalid status code');
} catch (error) {
logger.error({ msg: 'Error testing webhook', err: error });
throw new Error('error-invalid-webhook-response');
}
},
);

type LivechatWebhookEndpoints = ExtractRoutesFromAPI<typeof livechatWebhookEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatWebhookEndpoints {}
}
Loading