snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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 1s
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 1s
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import type { IOutgoingIntegration, IIntegration } from '@rocket.chat/core-typings';
|
||||
import { Integrations, IntegrationHistory } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import notifications from '../../notifications/core/lib/Notifications';
|
||||
import { triggerHandler } from '../lib/triggerHandler';
|
||||
|
||||
export const clearIntegrationHistoryMethod = async (userId: string, integrationId: string): Promise<void> => {
|
||||
let integration: IIntegration | null = null;
|
||||
|
||||
if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) {
|
||||
integration = await Integrations.findOneById<IIntegration>(integrationId);
|
||||
} else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) {
|
||||
integration = await Integrations.findOne<IIntegration>({
|
||||
'_id': integrationId,
|
||||
'_createdBy._id': userId,
|
||||
});
|
||||
} else {
|
||||
throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' });
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'clearIntegrationHistory' });
|
||||
}
|
||||
|
||||
await IntegrationHistory.removeByIntegrationId(integrationId);
|
||||
|
||||
notifications.streamIntegrationHistory.emit(integrationId, { type: 'removed', id: integrationId });
|
||||
};
|
||||
|
||||
export const replayOutgoingIntegrationMethod = async (
|
||||
userId: string,
|
||||
{ integrationId, historyId }: { integrationId: string; historyId: string },
|
||||
): Promise<void> => {
|
||||
let integration: IOutgoingIntegration | null = null;
|
||||
|
||||
if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) {
|
||||
integration = await Integrations.findOneById<IOutgoingIntegration>(integrationId);
|
||||
} else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) {
|
||||
const found = await Integrations.findOne<IOutgoingIntegration>({
|
||||
'_id': integrationId,
|
||||
'_createdBy._id': userId,
|
||||
});
|
||||
|
||||
if (found && 'event' in found) {
|
||||
integration = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'replayOutgoingIntegration' });
|
||||
}
|
||||
|
||||
const history = await IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId);
|
||||
|
||||
if (!history) {
|
||||
throw new Meteor.Error('error-invalid-integration-history', 'Invalid Integration History', { method: 'replayOutgoingIntegration' });
|
||||
}
|
||||
|
||||
await triggerHandler.replay(integration, history);
|
||||
};
|
||||
399
apps/meteor/server/lib/integrations/lib/ScriptEngine.ts
Normal file
399
apps/meteor/server/lib/integrations/lib/ScriptEngine.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import type {
|
||||
IUser,
|
||||
IRoom,
|
||||
IMessage,
|
||||
IOutgoingIntegration,
|
||||
IIncomingIntegration,
|
||||
IIntegration,
|
||||
IIntegrationHistory,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
import type { serverFetch } from '@rocket.chat/server-fetch';
|
||||
import { wrapExceptions } from '@rocket.chat/tools';
|
||||
|
||||
import { incomingLogger, outgoingLogger } from '../logger';
|
||||
import type { IScriptClass, CompiledScript } from './definition';
|
||||
import { updateHistory } from './updateHistory';
|
||||
|
||||
type OutgoingRequestBaseData = {
|
||||
token: IOutgoingIntegration['token'];
|
||||
bot: boolean;
|
||||
trigger_word: string;
|
||||
};
|
||||
|
||||
type OutgoingRequestSendMessageData = OutgoingRequestBaseData & {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
message_id: string;
|
||||
timestamp: Date;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
text: string;
|
||||
siteUrl: string;
|
||||
alias?: string;
|
||||
bot?: boolean;
|
||||
isEdited?: true;
|
||||
tmid?: string;
|
||||
};
|
||||
|
||||
type OutgoingRequestUploadedFileData = OutgoingRequestBaseData & {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
message_id: string;
|
||||
timestamp: Date;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
text: string;
|
||||
|
||||
user: IUser;
|
||||
room: IRoom;
|
||||
message: IMessage;
|
||||
|
||||
alias?: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
|
||||
type OutgoingRequestRoomCreatedData = OutgoingRequestBaseData & {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
timestamp: Date;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
owner: IUser;
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
type OutgoingRequestRoomData = OutgoingRequestBaseData & {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
timestamp: Date;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
owner: IUser;
|
||||
room: IRoom;
|
||||
bot?: boolean;
|
||||
};
|
||||
|
||||
type OutgoingRequestUserCreatedData = OutgoingRequestBaseData & {
|
||||
timestamp: Date;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user: IUser;
|
||||
bot?: boolean;
|
||||
};
|
||||
|
||||
export type OutgoingRequestData =
|
||||
| OutgoingRequestSendMessageData
|
||||
| OutgoingRequestUploadedFileData
|
||||
| OutgoingRequestRoomCreatedData
|
||||
| OutgoingRequestRoomData
|
||||
| OutgoingRequestUserCreatedData;
|
||||
|
||||
type OutgoingRequest = {
|
||||
params: Record<never, never>;
|
||||
method: 'POST';
|
||||
url: string;
|
||||
data: OutgoingRequestData;
|
||||
auth: undefined;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
type OutgoingRequestFromScript = {
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
message?: {
|
||||
text?: string;
|
||||
channel?: string;
|
||||
attachments?: {
|
||||
color?: string;
|
||||
author_name?: string;
|
||||
author_link?: string;
|
||||
author_icon?: string;
|
||||
title?: string;
|
||||
title_link?: string;
|
||||
text?: string;
|
||||
fields?: {
|
||||
title?: string;
|
||||
value?: string;
|
||||
short?: boolean;
|
||||
}[];
|
||||
image_url?: string;
|
||||
thumb_url?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
auth?: string;
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
type OutgoingRequestContext = {
|
||||
integration: IOutgoingIntegration;
|
||||
data: OutgoingRequestData;
|
||||
historyId: IIntegrationHistory['_id'];
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ProcessedOutgoingRequest = OutgoingRequest | OutgoingRequestFromScript;
|
||||
|
||||
type OutgoingResponseContext = {
|
||||
integration: IOutgoingIntegration;
|
||||
request: ProcessedOutgoingRequest;
|
||||
response: Awaited<ReturnType<typeof serverFetch>>;
|
||||
content: string;
|
||||
historyId: IIntegrationHistory['_id'];
|
||||
};
|
||||
|
||||
type IncomingIntegrationRequest = {
|
||||
url: {
|
||||
hash: string | null | undefined;
|
||||
search: string | null | undefined;
|
||||
query: Record<string, any>;
|
||||
pathname: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
};
|
||||
url_raw: string;
|
||||
url_params: Record<string, string>;
|
||||
content: Record<string, any>;
|
||||
content_raw: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, any>;
|
||||
user: Pick<Required<IUser>, '_id' | 'name' | 'username'>;
|
||||
};
|
||||
|
||||
export abstract class IntegrationScriptEngine<IsIncoming extends boolean> {
|
||||
protected compiledScripts: Record<IIntegration['_id'], CompiledScript>;
|
||||
|
||||
public get disabled(): boolean {
|
||||
return this.isDisabled();
|
||||
}
|
||||
|
||||
public get incoming(): IsIncoming {
|
||||
return this.isIncoming;
|
||||
}
|
||||
|
||||
constructor(private isIncoming: IsIncoming) {
|
||||
this.compiledScripts = {};
|
||||
}
|
||||
|
||||
public integrationHasValidScript(integration: IIntegration): boolean {
|
||||
return Boolean(!this.disabled && integration.scriptEnabled && integration.scriptCompiled && integration.scriptCompiled.trim() !== '');
|
||||
}
|
||||
|
||||
// PrepareOutgoingRequest will execute a script to build the request object that will be used for the actual integration request
|
||||
// It may also return a message object to be sent to the room where the integration was triggered
|
||||
public async prepareOutgoingRequest({ integration, data, historyId, url }: OutgoingRequestContext): Promise<ProcessedOutgoingRequest> {
|
||||
const request: OutgoingRequest = {
|
||||
params: {},
|
||||
method: 'POST',
|
||||
url,
|
||||
data,
|
||||
auth: undefined,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36',
|
||||
},
|
||||
};
|
||||
|
||||
if (!(await this.hasScriptAndMethod(integration, 'prepare_outgoing_request'))) {
|
||||
return request;
|
||||
}
|
||||
|
||||
return this.executeOutgoingScript(integration, 'prepare_outgoing_request', { request }, historyId);
|
||||
}
|
||||
|
||||
public async processOutgoingResponse({
|
||||
integration,
|
||||
request,
|
||||
response,
|
||||
content,
|
||||
historyId,
|
||||
}: OutgoingResponseContext): Promise<string | false | undefined> {
|
||||
if (!(await this.hasScriptAndMethod(integration, 'process_outgoing_response'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
request,
|
||||
response: {
|
||||
error: null,
|
||||
status_code: response.status,
|
||||
content,
|
||||
content_raw: content,
|
||||
headers: Object.fromEntries(response.headers),
|
||||
},
|
||||
};
|
||||
|
||||
const scriptResult = await this.executeOutgoingScript(integration, 'process_outgoing_response', sandbox, historyId);
|
||||
|
||||
if (scriptResult === false) {
|
||||
return scriptResult;
|
||||
}
|
||||
|
||||
if (scriptResult?.content) {
|
||||
return scriptResult.content;
|
||||
}
|
||||
}
|
||||
|
||||
public async processIncomingRequest({
|
||||
integration,
|
||||
request,
|
||||
}: {
|
||||
integration: IIncomingIntegration;
|
||||
request: IncomingIntegrationRequest;
|
||||
}): Promise<any> {
|
||||
return this.executeIncomingScript(integration, 'process_incoming_request', { request });
|
||||
}
|
||||
|
||||
protected get logger(): ReturnType<Logger['section']> {
|
||||
if (this.isIncoming) {
|
||||
return incomingLogger;
|
||||
}
|
||||
|
||||
return outgoingLogger;
|
||||
}
|
||||
|
||||
protected async executeOutgoingScript(
|
||||
integration: IOutgoingIntegration,
|
||||
method: keyof IScriptClass,
|
||||
params: Record<string, any>,
|
||||
historyId: IIntegrationHistory['_id'],
|
||||
): Promise<any> {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = await wrapExceptions(() => this.getIntegrationScript(integration)).suppress((e: any) =>
|
||||
updateHistory({
|
||||
historyId,
|
||||
step: 'execute-script-getting-script',
|
||||
error: true,
|
||||
errorStack: e,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!script[method]) {
|
||||
this.logger.error({
|
||||
msg: 'Script method not found in the Integration',
|
||||
method,
|
||||
integration: integration.name,
|
||||
});
|
||||
await updateHistory({ historyId, step: `execute-script-no-method-${method}` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateHistory({ historyId, step: `execute-script-before-running-${method}` });
|
||||
|
||||
const result = await this.runScriptMethod({
|
||||
integrationId: integration._id,
|
||||
script,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
this.logger.debug({
|
||||
msg: 'Script method result of the Integration',
|
||||
method,
|
||||
integration: integration.name,
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: `execute-script-error-running-${method}`,
|
||||
error: true,
|
||||
errorStack: err.stack.replace(/^/gm, ' '),
|
||||
});
|
||||
this.logger.error({
|
||||
msg: 'Error running Script in the Integration',
|
||||
integration: integration.name,
|
||||
err,
|
||||
});
|
||||
this.logger.debug({
|
||||
msg: 'Error running Script in the Integration',
|
||||
integration: integration.name,
|
||||
script: integration.scriptCompiled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeIncomingScript(
|
||||
integration: IIncomingIntegration,
|
||||
method: keyof IScriptClass,
|
||||
params: Record<string, any>,
|
||||
): Promise<any> {
|
||||
if (!this.integrationHasValidScript(integration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = await wrapExceptions(() => this.getIntegrationScript(integration)).catch((e) => {
|
||||
this.logger.error({
|
||||
msg: 'Error getting Integration script',
|
||||
integration: integration.name,
|
||||
err: e,
|
||||
});
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (!script[method]) {
|
||||
this.logger.error({
|
||||
msg: 'Script method not found in the Integration',
|
||||
method,
|
||||
integration: integration.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return wrapExceptions(() =>
|
||||
this.runScriptMethod({
|
||||
integrationId: integration._id,
|
||||
script,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
).catch((err: any) => {
|
||||
this.logger.error({
|
||||
msg: 'Error running Script in Trigger',
|
||||
integration: integration.name,
|
||||
script: integration.scriptCompiled,
|
||||
err,
|
||||
});
|
||||
throw new Error('error-running-script');
|
||||
});
|
||||
}
|
||||
|
||||
protected async hasScriptAndMethod(integration: IIntegration, method: keyof IScriptClass): Promise<boolean> {
|
||||
const script = await this.getScriptSafely(integration);
|
||||
return typeof script?.[method] === 'function';
|
||||
}
|
||||
|
||||
protected async getScriptSafely(integration: IIntegration): Promise<Partial<IScriptClass> | undefined> {
|
||||
if (this.disabled || integration.scriptEnabled !== true || !integration.scriptCompiled || integration.scriptCompiled.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
return wrapExceptions(() => this.getIntegrationScript(integration)).suppress();
|
||||
}
|
||||
|
||||
protected abstract isDisabled(): boolean;
|
||||
|
||||
protected abstract runScriptMethod({
|
||||
integrationId,
|
||||
script,
|
||||
method,
|
||||
params,
|
||||
}: {
|
||||
integrationId: IIntegration['_id'];
|
||||
script: IScriptClass;
|
||||
method: keyof IScriptClass;
|
||||
params: Record<string, any>;
|
||||
}): Promise<any>;
|
||||
|
||||
protected abstract getIntegrationScript(integration: IIntegration): Promise<Partial<IScriptClass>>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { transformSync } from '@babel/core';
|
||||
import presetEnv from '@babel/preset-env';
|
||||
|
||||
/**
|
||||
* Compile or validate a user-supplied integration script for storage in
|
||||
* `scriptCompiled`.
|
||||
*
|
||||
* When `transpile` is `true` (the default, controlled by each integration's
|
||||
* `skipTranspile` flag), the script is transpiled with `@babel/core +
|
||||
* @babel/preset-env` — the historical behavior. When `false`, the script is
|
||||
* validated with Node's built-in `vm.Script` and stored as-is, matching the
|
||||
* 9.0.0 default where Babel transpilation is removed entirely.
|
||||
*
|
||||
* Integration scripts run inside `isolated-vm`, which embeds modern V8 and
|
||||
* handles ES2023+ natively. The transpilation only exists to preserve the
|
||||
* sloppy-mode semantics (implicit globals in class methods, `this` in nested
|
||||
* functions, etc.) that early scripts relied on. Admins can flip
|
||||
* `skipTranspile: true` per integration to test strict-mode compatibility
|
||||
* before the 9.0.0 upgrade.
|
||||
*
|
||||
* Returns `{ script }` on success or `{ error }` with the same
|
||||
* `{ name, message, stack }` shape persisted in `scriptError`.
|
||||
*/
|
||||
export function compileIntegrationScript(
|
||||
script: string,
|
||||
{ transpile }: { transpile: boolean },
|
||||
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
|
||||
if (!transpile) {
|
||||
return validateOnly(script);
|
||||
}
|
||||
|
||||
return transpileWithBabel(script);
|
||||
}
|
||||
|
||||
function validateOnly(
|
||||
script: string,
|
||||
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
|
||||
try {
|
||||
new vm.Script(`(function(){${script}})`);
|
||||
return { script };
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
const { name, message, stack } = e;
|
||||
return { error: { name, message, stack } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function transpileWithBabel(
|
||||
script: string,
|
||||
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
|
||||
try {
|
||||
const result = transformSync(script, {
|
||||
presets: [presetEnv],
|
||||
compact: true,
|
||||
minified: true,
|
||||
comments: false,
|
||||
});
|
||||
|
||||
return { script: result?.code ?? script };
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
const { name, message, stack } = e;
|
||||
return { error: { name, message, stack } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
24
apps/meteor/server/lib/integrations/lib/definition.ts
Normal file
24
apps/meteor/server/lib/integrations/lib/definition.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { IIntegration, IOutgoingIntegration } from '@rocket.chat/core-typings';
|
||||
|
||||
export interface IScriptClass {
|
||||
prepare_outgoing_request?: (params: Record<string, any>) => any;
|
||||
process_outgoing_response?: (params: Record<string, any>) => any;
|
||||
process_incoming_request?: (params: Record<string, any>) => any;
|
||||
}
|
||||
|
||||
export type FullScriptClass = Required<IScriptClass>;
|
||||
|
||||
export type CompiledScript = {
|
||||
script: Partial<IScriptClass>;
|
||||
store: Record<string, any>;
|
||||
_updatedAt: IIntegration['_updatedAt'];
|
||||
};
|
||||
|
||||
export type CompatibilityScriptResult = IScriptClass & {
|
||||
availableFunctions: (keyof IScriptClass)[];
|
||||
};
|
||||
|
||||
export const isOutgoingIntegration = (
|
||||
integration: Partial<IIntegration>,
|
||||
): integration is IOutgoingIntegration & { type: 'webhook-outgoing' } =>
|
||||
integration.type === 'webhook-outgoing' && 'event' in integration && integration.event !== undefined;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { serverFetch as fetch, Response } from '@rocket.chat/server-fetch';
|
||||
import ivm, { type Context } from 'isolated-vm';
|
||||
|
||||
import * as s from '../../../../../lib/utils/stringUtils';
|
||||
|
||||
const proxyObject = (obj: Record<string, any>, forbiddenKeys: string[] = []): Record<string, any> => {
|
||||
return copyObject({
|
||||
isProxy: true,
|
||||
get: (key: string) => {
|
||||
if (forbiddenKeys.includes(key)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = obj[key];
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return new ivm.Reference(async (...args: any[]) => {
|
||||
const result = (obj[key] as any)(...args);
|
||||
|
||||
if (result && result instanceof Promise) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const awaitedResult = await result;
|
||||
resolve(makeTransferable(awaitedResult));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return makeTransferable(result);
|
||||
});
|
||||
}
|
||||
|
||||
return makeTransferable(value);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyObject = (obj: Record<string, any> | any[]): Record<string, any> | any[] => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((data) => copyData(data));
|
||||
}
|
||||
|
||||
if (obj instanceof Response) {
|
||||
return proxyObject(obj, ['clone']);
|
||||
}
|
||||
|
||||
if (isSemiTransferable(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj[Symbol.iterator as any] === 'function') {
|
||||
return copyObject(Array.from(obj as any));
|
||||
}
|
||||
|
||||
if (obj instanceof EventEmitter) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
keys.map((key) => {
|
||||
const data = obj[key];
|
||||
|
||||
if (typeof data === 'function') {
|
||||
return [key, new ivm.Callback((...args: any[]) => obj[key](...args))];
|
||||
}
|
||||
|
||||
return [key, copyData(data)];
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Transferable data can be passed to isolates directly
|
||||
const isTransferable = (data: any): data is ivm.Transferable => {
|
||||
const dataType = typeof data;
|
||||
|
||||
if (data === ivm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['null', 'undefined', 'string', 'number', 'boolean', 'function'].includes(dataType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dataType !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
data instanceof ivm.Isolate ||
|
||||
data instanceof ivm.Context ||
|
||||
data instanceof ivm.Script ||
|
||||
data instanceof ivm.ExternalCopy ||
|
||||
data instanceof ivm.Callback ||
|
||||
data instanceof ivm.Reference
|
||||
);
|
||||
};
|
||||
|
||||
// Semi-transferable data can be copied with an ivm.ExternalCopy without needing any manipulation.
|
||||
const isSemiTransferable = (data: any) => data instanceof ArrayBuffer;
|
||||
|
||||
const copyData = <T extends ivm.Transferable | Record<string, any> | any[]>(data: T) => (isTransferable(data) ? data : copyObject(data));
|
||||
const makeTransferable = (data: any) => (isTransferable(data) ? data : new ivm.ExternalCopy(copyObject(data)).copyInto());
|
||||
|
||||
export const buildSandbox = (context: Context) => {
|
||||
const { global: jail } = context;
|
||||
jail.setSync('global', jail.derefInto());
|
||||
jail.setSync('ivm', ivm);
|
||||
|
||||
jail.setSync('s', makeTransferable(s));
|
||||
jail.setSync('console', makeTransferable(console));
|
||||
|
||||
jail.setSync(
|
||||
'serverFetch',
|
||||
new ivm.Reference(async (url: string, options?: any, allowSelfSignedCerts?: boolean) => {
|
||||
// SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here.
|
||||
const result = await fetch(url, { ...(options ?? {}), ignoreSsrfValidation: true }, allowSelfSignedCerts);
|
||||
return makeTransferable(result);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
export const getCompatibilityScript = (customScript?: string) => `
|
||||
const Store = (function() {
|
||||
const store = {};
|
||||
return {
|
||||
set(key, val) {
|
||||
store[key] = val;
|
||||
return val;
|
||||
},
|
||||
get(key) {
|
||||
return store[key];
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
const reproxy = (reference) => {
|
||||
return new Proxy(reference, {
|
||||
get(target, p, receiver) {
|
||||
if (target !== reference || p === 'then') {
|
||||
return Reflect.get(target, p, receiver);
|
||||
}
|
||||
|
||||
const data = reference.get(p);
|
||||
|
||||
if (typeof data === 'object' && data instanceof ivm.Reference && data.typeof === 'function') {
|
||||
return (...args) => data.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } });
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//url, options, allowSelfSignedCertificate
|
||||
const fetch = async (...args) => {
|
||||
const result = await serverFetch.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } });
|
||||
|
||||
if (result && typeof result === 'object' && result.isProxy) {
|
||||
return reproxy(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
${customScript}
|
||||
|
||||
(function() {
|
||||
const instance = new Script();
|
||||
|
||||
const functions = {
|
||||
...(typeof instance['prepare_outgoing_request'] === 'function' ? { prepare_outgoing_request : (...args) => instance.prepare_outgoing_request(...args) } : {}),
|
||||
...(typeof instance['process_outgoing_response'] === 'function' ? { process_outgoing_response : (...args) => instance.process_outgoing_response(...args) } : {}),
|
||||
...(typeof instance['process_incoming_request'] === 'function' ? { process_incoming_request : (...args) => instance.process_incoming_request(...args) } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
...functions,
|
||||
availableFunctions: Object.keys(functions),
|
||||
}
|
||||
})();
|
||||
`;
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { IIntegration, ValueOf } from '@rocket.chat/core-typings';
|
||||
import { pick } from '@rocket.chat/tools';
|
||||
import ivm, { type Reference } from 'isolated-vm';
|
||||
|
||||
import { IntegrationScriptEngine } from '../ScriptEngine';
|
||||
import type { IScriptClass, CompatibilityScriptResult, FullScriptClass } from '../definition';
|
||||
import { buildSandbox } from './buildSandbox';
|
||||
import { getCompatibilityScript } from './getCompatibilityScript';
|
||||
|
||||
const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'ivm'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase());
|
||||
|
||||
export class IsolatedVMScriptEngine<IsIncoming extends boolean> extends IntegrationScriptEngine<IsIncoming> {
|
||||
protected isDisabled(): boolean {
|
||||
return DISABLE_INTEGRATION_SCRIPTS;
|
||||
}
|
||||
|
||||
protected async callScriptFunction(
|
||||
scriptReference: Reference<ValueOf<IScriptClass>>,
|
||||
...params: Parameters<ValueOf<FullScriptClass>>
|
||||
): Promise<any> {
|
||||
return scriptReference.applySync(undefined, params, {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true, promise: true },
|
||||
});
|
||||
}
|
||||
|
||||
protected async runScriptMethod({
|
||||
script,
|
||||
method,
|
||||
params,
|
||||
}: {
|
||||
integrationId: IIntegration['_id'];
|
||||
script: Partial<IScriptClass>;
|
||||
method: keyof IScriptClass;
|
||||
params: Record<string, any>;
|
||||
}): Promise<any> {
|
||||
const fn = script[method];
|
||||
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error('integration-method-not-found');
|
||||
}
|
||||
|
||||
return fn(params);
|
||||
}
|
||||
|
||||
protected async getIntegrationScript(integration: IIntegration): Promise<Partial<IScriptClass>> {
|
||||
if (this.disabled) {
|
||||
throw new Error('integration-scripts-disabled');
|
||||
}
|
||||
|
||||
const compiledScript = this.compiledScripts[integration._id];
|
||||
if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) {
|
||||
return compiledScript.script;
|
||||
}
|
||||
|
||||
const script = integration.scriptCompiled;
|
||||
try {
|
||||
this.logger.info({ msg: 'Will evaluate the integration script', integration: pick(integration, 'name', '_id') });
|
||||
this.logger.debug({ script });
|
||||
|
||||
const isolate = new ivm.Isolate({ memoryLimit: 8 });
|
||||
|
||||
const ivmScript = await isolate.compileScript(getCompatibilityScript(script));
|
||||
|
||||
const ivmContext = isolate.createContextSync();
|
||||
buildSandbox(ivmContext);
|
||||
|
||||
const ivmResult: Reference<CompatibilityScriptResult> = await ivmScript.run(ivmContext, {
|
||||
reference: true,
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
const availableFunctions = await ivmResult.get('availableFunctions', { copy: true });
|
||||
const scriptFunctions = Object.fromEntries(
|
||||
availableFunctions.map((functionName) => {
|
||||
const fnReference = ivmResult.getSync(functionName, { reference: true });
|
||||
return [functionName, (...params: Parameters<ValueOf<FullScriptClass>>) => this.callScriptFunction(fnReference, ...params)];
|
||||
}),
|
||||
) as Partial<IScriptClass>;
|
||||
|
||||
this.compiledScripts[integration._id] = {
|
||||
script: scriptFunctions,
|
||||
store: {},
|
||||
_updatedAt: integration._updatedAt,
|
||||
};
|
||||
|
||||
return scriptFunctions;
|
||||
} catch (err: any) {
|
||||
this.logger.error({
|
||||
msg: 'Error evaluating integration script',
|
||||
integration: integration.name,
|
||||
script,
|
||||
err,
|
||||
});
|
||||
|
||||
throw new Error('error-evaluating-script');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { DeepWritable } from '@rocket.chat/core-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
export const mountIntegrationQueryBasedOnPermissions = async (userId: string) => {
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('You must provide the userId to the "mountIntegrationQueryBasedOnPermissions" function.');
|
||||
}
|
||||
const canViewAllOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations');
|
||||
const canViewAllIncomingIntegrations = await hasPermissionAsync(userId, 'manage-incoming-integrations');
|
||||
const canViewOnlyOwnOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-own-outgoing-integrations');
|
||||
const canViewOnlyOwnIncomingIntegrations = await hasPermissionAsync(userId, 'manage-own-incoming-integrations');
|
||||
|
||||
const query: DeepWritable<Filter<any>> = {};
|
||||
|
||||
if (canViewAllOutgoingIntegrations && canViewAllIncomingIntegrations) {
|
||||
return query;
|
||||
}
|
||||
if (canViewOnlyOwnOutgoingIntegrations && canViewOnlyOwnIncomingIntegrations) {
|
||||
return { '_createdBy._id': userId };
|
||||
}
|
||||
query.$or = [];
|
||||
|
||||
if (canViewAllOutgoingIntegrations) {
|
||||
query.$or.push({ type: 'webhook-outgoing' });
|
||||
}
|
||||
if (canViewAllIncomingIntegrations) {
|
||||
query.$or.push({ type: 'webhook-incoming' });
|
||||
}
|
||||
if (canViewOnlyOwnOutgoingIntegrations) {
|
||||
query.$or.push({ '_createdBy._id': userId, 'type': 'webhook-outgoing' });
|
||||
}
|
||||
if (canViewOnlyOwnIncomingIntegrations) {
|
||||
query.$or.push({ '_createdBy._id': userId, 'type': 'webhook-incoming' });
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
export const mountIntegrationHistoryQueryBasedOnPermissions = async (userId: string, integrationId: string) => {
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('You must provide the userId to the "mountIntegrationHistoryQueryBasedOnPermissions" fucntion.');
|
||||
}
|
||||
if (!integrationId) {
|
||||
throw new Meteor.Error('You must provide the integrationId to the "mountIntegrationHistoryQueryBasedOnPermissions" fucntion.');
|
||||
}
|
||||
|
||||
const canViewOnlyOwnOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-own-outgoing-integrations');
|
||||
const canViewAllOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations');
|
||||
if (!canViewAllOutgoingIntegrations && canViewOnlyOwnOutgoingIntegrations) {
|
||||
return { 'integration._id': integrationId, 'integration._createdBy._id': userId };
|
||||
}
|
||||
return { 'integration._id': integrationId };
|
||||
};
|
||||
840
apps/meteor/server/lib/integrations/lib/triggerHandler.ts
Normal file
840
apps/meteor/server/lib/integrations/lib/triggerHandler.ts
Normal file
@@ -0,0 +1,840 @@
|
||||
import type {
|
||||
IIntegrationHistory,
|
||||
IMessage,
|
||||
IOutgoingIntegration,
|
||||
IUser,
|
||||
OutgoingIntegrationEvent,
|
||||
IRoom,
|
||||
RequiredField,
|
||||
AtLeast,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { Integrations, Users, Rooms, Messages } from '@rocket.chat/models';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
import { wrapExceptions } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import _ from 'underscore';
|
||||
|
||||
import type { OutgoingRequestData } from './ScriptEngine';
|
||||
import { IsolatedVMScriptEngine } from './isolated-vm/isolated-vm';
|
||||
import { updateHistory } from './updateHistory';
|
||||
import { outgoingEvents } from '../../../../app/integrations/lib/outgoingEvents';
|
||||
import { settings } from '../../../settings';
|
||||
import { processWebhookMessage } from '../../messages/processWebhookMessage';
|
||||
import { notifyOnIntegrationChangedById } from '../../notifyListener';
|
||||
import { getRoomByNameOrIdWithOptionToJoin } from '../../rooms/getRoomByNameOrIdWithOptionToJoin';
|
||||
import { outgoingLogger } from '../logger';
|
||||
|
||||
type Trigger = Record<string, Record<string, any>>;
|
||||
|
||||
type MessageWithEditedAt = IMessage & { editedAt?: Date };
|
||||
type ArgumentsObject = {
|
||||
event?: OutgoingIntegrationEvent;
|
||||
message?: MessageWithEditedAt;
|
||||
room?: IRoom;
|
||||
owner?: IUser;
|
||||
user?: IUser;
|
||||
};
|
||||
type IntegrationData = {
|
||||
token?: string;
|
||||
bot: boolean;
|
||||
trigger_word?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
message_id?: string;
|
||||
timestamp?: Date;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
text?: string;
|
||||
siteUrl?: string;
|
||||
alias?: string;
|
||||
isEdited?: boolean;
|
||||
tmid?: string;
|
||||
user?: Partial<IUser>;
|
||||
room?: IRoom;
|
||||
message?: IMessage;
|
||||
owner?: Partial<IUser>;
|
||||
};
|
||||
|
||||
class RocketChatIntegrationHandler {
|
||||
private successResults: number[];
|
||||
|
||||
private triggers: Trigger;
|
||||
|
||||
private ivmEngine: IsolatedVMScriptEngine<false>;
|
||||
|
||||
constructor() {
|
||||
this.successResults = [200, 201, 202];
|
||||
this.triggers = {};
|
||||
this.ivmEngine = new IsolatedVMScriptEngine(false);
|
||||
}
|
||||
|
||||
addIntegration(record: IOutgoingIntegration): void {
|
||||
outgoingLogger.debug({ msg: 'Adding integration', integrationName: record.name, event: record.event });
|
||||
let channels = [];
|
||||
if (record.event && !outgoingEvents[record.event].use.channel) {
|
||||
outgoingLogger.debug('The integration doesnt rely on channels.');
|
||||
// We don't use any channels, so it's special ;)
|
||||
channels = ['__any'];
|
||||
} else if (_.isEmpty(record.channel)) {
|
||||
outgoingLogger.debug('The integration had an empty channel property, so it is going on all the public channels.');
|
||||
channels = ['all_public_channels'];
|
||||
} else {
|
||||
outgoingLogger.debug({ msg: 'The integration is going on these channels', channels: record.channel });
|
||||
channels = ([] as string[]).concat(record.channel);
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
if (!this.triggers[channel]) {
|
||||
this.triggers[channel] = {};
|
||||
}
|
||||
|
||||
this.triggers[channel][record._id] = record;
|
||||
}
|
||||
}
|
||||
|
||||
getEngine(_integration: any): IsolatedVMScriptEngine<false> {
|
||||
return this.ivmEngine;
|
||||
}
|
||||
|
||||
removeIntegration(record: AtLeast<IOutgoingIntegration, '_id'>): void {
|
||||
for (const trigger of Object.values(this.triggers)) {
|
||||
delete trigger[record._id];
|
||||
}
|
||||
}
|
||||
|
||||
isTriggerEnabled(trigger: IOutgoingIntegration): boolean {
|
||||
for (const trig of Object.values(this.triggers)) {
|
||||
if (trig[trigger._id]) {
|
||||
return trig[trigger._id].enabled;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trigger is the trigger, nameOrId is a string which is used to try and find a room, room is a room, message is a message, and data contains "user_name" if trigger.impersonateUser is truthful.
|
||||
async sendMessage({
|
||||
trigger,
|
||||
nameOrId = '',
|
||||
room,
|
||||
message,
|
||||
data,
|
||||
}: {
|
||||
trigger: IOutgoingIntegration;
|
||||
nameOrId?: string;
|
||||
room?: IRoom;
|
||||
message: { channel: string; bot?: Record<string, any>; message: Partial<IMessage> };
|
||||
data: IntegrationData;
|
||||
}): Promise<{ channel: string; message: Partial<IMessage> }[] | undefined> {
|
||||
let user: IUser | null = null;
|
||||
// Try to find the user who we are impersonating
|
||||
if (trigger.impersonateUser) {
|
||||
user = await Users.findOneByUsernameIgnoringCase(data.user_name);
|
||||
}
|
||||
|
||||
// If they don't exist (aka the trigger didn't contain a user) then we set the user based upon the
|
||||
// configured username for the integration since this is required at all times.
|
||||
if (!user) {
|
||||
user = await Users.findOneByUsernameIgnoringCase(trigger.username);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
outgoingLogger.error({ msg: 'Integration user not found', username: trigger.username });
|
||||
return;
|
||||
}
|
||||
|
||||
let tmpRoom;
|
||||
if (nameOrId || trigger.targetRoom || message.channel) {
|
||||
tmpRoom =
|
||||
(await getRoomByNameOrIdWithOptionToJoin({
|
||||
user,
|
||||
nameOrId: nameOrId || message.channel || trigger.targetRoom || '',
|
||||
errorOnEmpty: false,
|
||||
})) || room;
|
||||
} else {
|
||||
tmpRoom = room;
|
||||
}
|
||||
|
||||
// If no room could be found, we won't be sending any messages but we'll warn in the logs
|
||||
if (!tmpRoom) {
|
||||
outgoingLogger.warn({
|
||||
msg: 'The Integration doesnt have a room configured nor did it provide a room to send the message to.',
|
||||
integrationName: trigger.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
outgoingLogger.debug({ msg: 'Found room for integration', integrationName: trigger.name, roomName: tmpRoom.name, roomType: tmpRoom.t });
|
||||
|
||||
message.bot = { i: trigger._id };
|
||||
|
||||
const defaultValues: {
|
||||
alias: string;
|
||||
avatar: string;
|
||||
emoji: string;
|
||||
channel: string;
|
||||
} = {
|
||||
alias: trigger.alias || '',
|
||||
avatar: trigger.avatar || '',
|
||||
emoji: trigger.emoji || '',
|
||||
channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`,
|
||||
};
|
||||
|
||||
return processWebhookMessage(message, user as RequiredField<IUser, 'username'>, defaultValues);
|
||||
}
|
||||
|
||||
eventNameArgumentsToObject(...args: unknown[]) {
|
||||
const argObject: ArgumentsObject = {
|
||||
event: args[0] as OutgoingIntegrationEvent | undefined,
|
||||
};
|
||||
|
||||
switch (argObject.event) {
|
||||
case 'sendMessage':
|
||||
if (args.length >= 3) {
|
||||
argObject.message = args[1] as IMessage;
|
||||
argObject.room = args[2] as IRoom;
|
||||
}
|
||||
break;
|
||||
case 'fileUploaded':
|
||||
if (args.length >= 2) {
|
||||
const arghhh: Record<string, any> = args[1] as Record<string, any>;
|
||||
argObject.user = arghhh.user as IUser;
|
||||
argObject.room = arghhh.room as IRoom;
|
||||
argObject.message = arghhh.message as IMessage;
|
||||
}
|
||||
break;
|
||||
case 'roomArchived':
|
||||
if (args.length >= 3) {
|
||||
argObject.room = args[1] as IRoom;
|
||||
argObject.user = args[2] as IUser;
|
||||
}
|
||||
break;
|
||||
case 'roomCreated':
|
||||
if (args.length >= 3) {
|
||||
argObject.owner = args[1] as IUser;
|
||||
argObject.room = args[2] as IRoom;
|
||||
}
|
||||
break;
|
||||
case 'roomJoined':
|
||||
if (args.length >= 3) {
|
||||
argObject.user = args[1] as IUser;
|
||||
argObject.room = args[2] as IRoom;
|
||||
}
|
||||
break;
|
||||
case 'roomLeft':
|
||||
if (args.length >= 3) {
|
||||
argObject.user = (args[1] as { user: IUser })?.user;
|
||||
argObject.room = args[2] as IRoom;
|
||||
}
|
||||
break;
|
||||
case 'userCreated':
|
||||
if (args.length >= 2) {
|
||||
argObject.user = args[1] as IUser;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
outgoingLogger.warn({ msg: 'Unhandled trigger event', event: argObject.event });
|
||||
argObject.event = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
outgoingLogger.debug({
|
||||
msg: 'Got the event arguments for the event',
|
||||
event: argObject.event,
|
||||
messageId: argObject.message?._id,
|
||||
roomId: argObject.room?._id,
|
||||
userId: argObject.user?._id || argObject.owner?._id,
|
||||
});
|
||||
|
||||
return argObject;
|
||||
}
|
||||
|
||||
mapEventArgsToData(data: IntegrationData, { event, message, room, owner, user }: ArgumentsObject) {
|
||||
/* The "services" field contains sensitive information such as
|
||||
the user's password hash. To prevent this information from being
|
||||
sent to the webhook, we're checking and removing it by destructuring
|
||||
the user and owner objects while discarding the "services" field.
|
||||
*/
|
||||
|
||||
const omitServicesField = (object: IUser) => {
|
||||
const { services, ...objectWithoutServicesField } = object;
|
||||
return objectWithoutServicesField;
|
||||
};
|
||||
|
||||
const userWithoutServicesField = user?.services ? omitServicesField(user) : user;
|
||||
const ownerWithoutServicesField = owner?.services ? omitServicesField(owner) : owner;
|
||||
|
||||
if (!room || !message) {
|
||||
outgoingLogger.warn({ msg: 'Integration called without room or message', event });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case 'sendMessage':
|
||||
data.channel_id = room._id;
|
||||
data.channel_name = room.name;
|
||||
data.message_id = message._id;
|
||||
data.timestamp = message.ts;
|
||||
data.user_id = message.u._id;
|
||||
data.user_name = message.u.username;
|
||||
data.text = message.msg;
|
||||
data.siteUrl = settings.get('Site_Url');
|
||||
|
||||
if (message.alias) {
|
||||
data.alias = message.alias;
|
||||
}
|
||||
|
||||
if (message.bot) {
|
||||
data.bot = Boolean(message.bot); // TODO: need to double check this, since it makes no sense
|
||||
}
|
||||
|
||||
if (message.editedAt) {
|
||||
data.isEdited = true;
|
||||
}
|
||||
|
||||
if (message.tmid) {
|
||||
data.tmid = message.tmid;
|
||||
}
|
||||
break;
|
||||
case 'fileUploaded':
|
||||
data.channel_id = room._id;
|
||||
data.channel_name = room.name;
|
||||
data.message_id = message._id;
|
||||
data.timestamp = message.ts;
|
||||
data.user_id = message.u._id;
|
||||
data.user_name = message.u.username;
|
||||
data.text = message.msg;
|
||||
data.user = userWithoutServicesField;
|
||||
data.room = room;
|
||||
data.message = message;
|
||||
|
||||
if (message.alias) {
|
||||
data.alias = message.alias;
|
||||
}
|
||||
|
||||
if (message.bot) {
|
||||
data.bot = Boolean(message.bot); // TODO: need to double check this, since it makes no sense
|
||||
}
|
||||
break;
|
||||
case 'roomCreated':
|
||||
if (!owner) {
|
||||
outgoingLogger.warn({ msg: 'Integration called without owner data', event });
|
||||
return;
|
||||
}
|
||||
data.channel_id = room._id;
|
||||
data.channel_name = room.name;
|
||||
data.timestamp = room.ts;
|
||||
data.user_id = owner._id;
|
||||
data.user_name = owner.username;
|
||||
data.owner = ownerWithoutServicesField;
|
||||
data.room = room;
|
||||
break;
|
||||
case 'roomArchived':
|
||||
case 'roomJoined':
|
||||
case 'roomLeft':
|
||||
if (!user) {
|
||||
outgoingLogger.warn({ msg: 'Integration called without owner data', event });
|
||||
return;
|
||||
}
|
||||
data.timestamp = new Date();
|
||||
data.channel_id = room._id;
|
||||
data.channel_name = room.name;
|
||||
data.user_id = user._id;
|
||||
data.user_name = user.username;
|
||||
data.user = userWithoutServicesField;
|
||||
data.room = room;
|
||||
|
||||
if (user.type === 'bot') {
|
||||
data.bot = true;
|
||||
}
|
||||
break;
|
||||
case 'userCreated':
|
||||
if (!user) {
|
||||
outgoingLogger.warn({ msg: 'Integration called without owner data', event });
|
||||
return;
|
||||
}
|
||||
data.timestamp = user.createdAt;
|
||||
data.user_id = user._id;
|
||||
data.user_name = user.username;
|
||||
data.user = userWithoutServicesField;
|
||||
|
||||
if (user.type === 'bot') {
|
||||
data.bot = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getTriggersToExecute(room?: IRoom, message?: MessageWithEditedAt) {
|
||||
const triggersToExecute = new Set<IOutgoingIntegration>();
|
||||
if (room) {
|
||||
switch (room.t) {
|
||||
case 'd':
|
||||
if (this.triggers.all_direct_messages) {
|
||||
for (const trigger of Object.values(this.triggers.all_direct_messages)) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
room.uids
|
||||
?.filter((uid) => this.triggers[`@${uid}`])
|
||||
.forEach((uid) => {
|
||||
for (const trigger of Object.values(this.triggers[`@${uid}`])) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
});
|
||||
|
||||
room.usernames
|
||||
?.filter((username) => username !== message?.u?.username && this.triggers[`@${username}`])
|
||||
.forEach((username) => {
|
||||
for (const trigger of Object.values(this.triggers[`@${username}`])) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'c':
|
||||
if (this.triggers.all_public_channels) {
|
||||
for (const trigger of Object.values(this.triggers.all_public_channels)) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.triggers[`#${room._id}`]) {
|
||||
for (const trigger of Object.values(this.triggers[`#${room._id}`])) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
if (room._id !== room.name && this.triggers[`#${room.name}`]) {
|
||||
for (const trigger of Object.values(this.triggers[`#${room.name}`])) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.triggers.all_private_groups) {
|
||||
for (const trigger of Object.values(this.triggers.all_private_groups)) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.triggers[`#${room._id}`]) {
|
||||
for (const trigger of Object.values(this.triggers[`#${room._id}`])) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
if (room._id !== room.name && this.triggers[`#${room.name}`]) {
|
||||
for (const trigger of Object.values(this.triggers[`#${room.name}`])) {
|
||||
triggersToExecute.add(trigger);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...triggersToExecute];
|
||||
}
|
||||
|
||||
async executeTriggers(...args: unknown[]) {
|
||||
outgoingLogger.debug({ msg: 'Execute Trigger:', arg: args[0] });
|
||||
|
||||
const argObject = this.eventNameArgumentsToObject(...args);
|
||||
const { event, message, room } = argObject;
|
||||
|
||||
// Each type of event should have an event and a room attached, otherwise we
|
||||
// wouldn't know how to handle the trigger nor would we have anywhere to send the
|
||||
// result of the integration
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
outgoingLogger.debug({ msg: 'Starting search for triggers for room', roomId: room ? room._id : '__any' });
|
||||
|
||||
const triggersToExecute = this.getTriggersToExecute(room, message);
|
||||
|
||||
if (this.triggers.__any) {
|
||||
// For outgoing integration which don't rely on rooms.
|
||||
for (const trigger of Object.values(this.triggers.__any)) {
|
||||
triggersToExecute.push(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
outgoingLogger.debug({ msg: 'Found triggers to iterate over', triggerCount: triggersToExecute.length, event });
|
||||
|
||||
for (const triggerToExecute of triggersToExecute) {
|
||||
outgoingLogger.debug({
|
||||
msg: 'Checking trigger execution eligibility',
|
||||
triggerName: triggerToExecute.name,
|
||||
isEnabled: triggerToExecute.enabled,
|
||||
triggerEvent: triggerToExecute.event,
|
||||
});
|
||||
if (triggerToExecute.enabled === true && triggerToExecute.event === event) {
|
||||
await this.executeTrigger(triggerToExecute, argObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async executeTrigger(trigger: IOutgoingIntegration, argObject: ArgumentsObject) {
|
||||
if (!trigger.urls) {
|
||||
return;
|
||||
}
|
||||
for (const url of trigger.urls) {
|
||||
await this.executeTriggerUrl(url, trigger, argObject, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that any errors thrown by the script engine will contibue to be compatible with Meteor.Error
|
||||
async wrapScriptEngineCall(getter: () => Promise<any>) {
|
||||
return wrapExceptions(getter).catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
throw new Meteor.Error(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async executeTriggerUrl(url: string, trigger: IOutgoingIntegration, { event, message, room, owner, user }: ArgumentsObject, tries = 0) {
|
||||
if (!this.isTriggerEnabled(trigger)) {
|
||||
outgoingLogger.warn({ msg: 'Trigger is not enabled', triggerName: trigger.name, tries });
|
||||
return;
|
||||
}
|
||||
|
||||
outgoingLogger.debug({ msg: 'Starting to execute trigger', triggerName: trigger.name, triggerId: trigger._id });
|
||||
|
||||
let word;
|
||||
// Not all triggers/events support triggerWords
|
||||
if (event && outgoingEvents[event].use.triggerWords) {
|
||||
if (trigger.triggerWords && trigger.triggerWords.length > 0) {
|
||||
for (const triggerWord of trigger.triggerWords) {
|
||||
if (!trigger.triggerWordAnywhere && message?.msg.indexOf(triggerWord) === 0) {
|
||||
word = triggerWord;
|
||||
break;
|
||||
} else if (trigger.triggerWordAnywhere && message?.msg.includes(triggerWord)) {
|
||||
word = triggerWord;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if there are triggerWords but none match
|
||||
if (!word) {
|
||||
outgoingLogger.debug({ msg: 'Trigger word not found', triggerName: trigger.name });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message && message.editedAt && !trigger.runOnEdits) {
|
||||
outgoingLogger.debug({ msg: 'Trigger run on edits disabled and message was edited', triggerName: trigger.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const historyId = await updateHistory({
|
||||
step: 'start-execute-trigger-url',
|
||||
integration: trigger,
|
||||
event,
|
||||
historyId: '',
|
||||
});
|
||||
|
||||
const data: IntegrationData = {
|
||||
token: trigger.token,
|
||||
bot: false,
|
||||
};
|
||||
|
||||
if (word) {
|
||||
data.trigger_word = word;
|
||||
}
|
||||
|
||||
this.mapEventArgsToData(data, { event, message, room, owner, user });
|
||||
await updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word });
|
||||
|
||||
outgoingLogger.info({ msg: 'Will be executing integration', integrationName: trigger.name, url });
|
||||
outgoingLogger.debug({ data });
|
||||
|
||||
const scriptEngine = this.getEngine(trigger);
|
||||
|
||||
const opts = await this.wrapScriptEngineCall(() =>
|
||||
scriptEngine.prepareOutgoingRequest({
|
||||
integration: trigger,
|
||||
data: data as OutgoingRequestData,
|
||||
url,
|
||||
historyId,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true });
|
||||
|
||||
if (!opts) {
|
||||
await updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.message) {
|
||||
const prepareMessage = await this.sendMessage({ trigger, room, message: opts.message, data });
|
||||
if (!prepareMessage) {
|
||||
await updateHistory({ historyId, step: 'after-prepare-send-message-failed', finished: true });
|
||||
return;
|
||||
}
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'after-prepare-send-message',
|
||||
prepareSentMessage: prepareMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (!opts.url || !opts.method) {
|
||||
await updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// based on HTTP.call implementation
|
||||
if (opts.auth) {
|
||||
if (opts.auth.indexOf(':') < 0) {
|
||||
throw new Error('auth option should be of the form "username:password"');
|
||||
}
|
||||
|
||||
const base64 = Buffer.from(opts.auth, 'ascii').toString('base64');
|
||||
opts.headers.Authorization = `Basic ${base64}`;
|
||||
}
|
||||
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'pre-http-call',
|
||||
url: opts.url,
|
||||
httpCallData: opts.data,
|
||||
});
|
||||
|
||||
if (opts.data) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
fetch(
|
||||
opts.url,
|
||||
{
|
||||
method: opts.method,
|
||||
headers: opts.headers,
|
||||
...(opts.timeout && { timeout: opts.timeout }),
|
||||
...(opts.data && { body: opts.data }),
|
||||
// SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here.
|
||||
ignoreSsrfValidation: true,
|
||||
size: 10 * 1024 * 1024,
|
||||
},
|
||||
settings.get('Allow_Invalid_SelfSigned_Certs'),
|
||||
)
|
||||
.then(async (res) => {
|
||||
const content = await res.text();
|
||||
if (!content) {
|
||||
outgoingLogger.warn({ msg: 'Integration result is empty', integrationName: trigger.name, url });
|
||||
} else {
|
||||
outgoingLogger.info({ msg: 'Integration HTTP status', integrationName: trigger.name, url, status: res.status });
|
||||
}
|
||||
|
||||
const data = (() => {
|
||||
const contentType = (res.headers.get('content-type') || '').split(';')[0];
|
||||
if (!['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].includes(contentType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'after-http-call',
|
||||
httpError: null,
|
||||
httpResult: content,
|
||||
});
|
||||
|
||||
const responseContent = await this.wrapScriptEngineCall(() =>
|
||||
scriptEngine.processOutgoingResponse({
|
||||
integration: trigger,
|
||||
request: opts,
|
||||
response: res,
|
||||
content,
|
||||
historyId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (responseContent) {
|
||||
const resultMessage = await this.sendMessage({
|
||||
trigger,
|
||||
room,
|
||||
message: responseContent,
|
||||
data,
|
||||
});
|
||||
|
||||
if (!resultMessage) {
|
||||
await updateHistory({ historyId, step: 'after-process-send-message-failed', finished: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'after-process-send-message',
|
||||
processSentMessage: resultMessage,
|
||||
finished: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseContent === false) {
|
||||
await updateHistory({ historyId, step: 'after-process-false-result', finished: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// if the result contained nothing or wasn't a successful statusCode
|
||||
if (!content || !this.successResults.includes(res.status)) {
|
||||
if (content) {
|
||||
outgoingLogger.error({
|
||||
msg: 'Error for integration request',
|
||||
integrationName: trigger.name,
|
||||
url,
|
||||
result: content,
|
||||
});
|
||||
|
||||
if (res.status === 410) {
|
||||
await updateHistory({ historyId, step: 'after-process-http-status-410', error: true });
|
||||
outgoingLogger.error({ msg: 'Disabling integration due to 410 (Gone) status', integrationName: trigger.name });
|
||||
await Integrations.updateOne({ _id: trigger._id }, { $set: { enabled: false } });
|
||||
void notifyOnIntegrationChangedById(trigger._id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 500) {
|
||||
await updateHistory({ historyId, step: 'after-process-http-status-500', error: true });
|
||||
outgoingLogger.error({
|
||||
msg: 'Error 500 for integration request',
|
||||
integrationName: trigger.name,
|
||||
url,
|
||||
content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.retryFailedCalls && trigger.retryCount) {
|
||||
if (tries < trigger.retryCount && trigger.retryDelay) {
|
||||
await updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` });
|
||||
|
||||
let waitTime;
|
||||
|
||||
switch (trigger.retryDelay) {
|
||||
case 'powers-of-ten':
|
||||
// Try again in 0.1s, 1s, 10s, 1m40s, 16m40s, 2h46m40s, 27h46m40s, etc
|
||||
waitTime = Math.pow(10, tries + 2);
|
||||
break;
|
||||
case 'powers-of-two':
|
||||
// 2 seconds, 4 seconds, 8 seconds
|
||||
waitTime = Math.pow(2, tries + 1) * 1000;
|
||||
break;
|
||||
case 'increments-of-two':
|
||||
// 2 second, 4 seconds, 6 seconds, etc
|
||||
waitTime = (tries + 1) * 2 * 1000;
|
||||
break;
|
||||
default:
|
||||
const er = new Error("The integration's retryDelay setting is invalid.");
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'failed-and-retry-delay-is-invalid',
|
||||
error: true,
|
||||
errorStack: er.stack,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
outgoingLogger.info({ msg: 'Retrying integration', integrationName: trigger.name, url, waitTime });
|
||||
setTimeout(() => {
|
||||
void this.executeTriggerUrl(url, trigger, { event, message, room, owner, user }, tries + 1);
|
||||
}, waitTime);
|
||||
} else {
|
||||
await updateHistory({ historyId, step: 'too-many-retries', error: true });
|
||||
}
|
||||
} else {
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'failed-and-not-configured-to-retry',
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// process outgoing webhook response as a new message
|
||||
if (content && this.successResults.includes(res.status)) {
|
||||
if (data?.text || data?.attachments) {
|
||||
const resultMsg = await this.sendMessage({ trigger, room, message: data, data });
|
||||
if (!resultMsg) {
|
||||
await updateHistory({ historyId, step: 'after-http-call-send-message-failed', finished: true });
|
||||
return;
|
||||
}
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'url-response-sent-message',
|
||||
resultMessage: resultMsg,
|
||||
finished: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
outgoingLogger.error({ err });
|
||||
await updateHistory({
|
||||
historyId,
|
||||
step: 'after-http-call',
|
||||
httpError: err,
|
||||
httpResult: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async replay(integration: IOutgoingIntegration, history: IIntegrationHistory) {
|
||||
if (integration?.type !== 'webhook-outgoing') {
|
||||
throw new Meteor.Error('integration-type-must-be-outgoing', 'The integration type to replay must be an outgoing webhook.');
|
||||
}
|
||||
|
||||
if (!history?.data) {
|
||||
throw new Meteor.Error('history-data-must-be-defined', 'The history data must be defined to replay an integration.');
|
||||
}
|
||||
|
||||
const { event } = history;
|
||||
let owner;
|
||||
let message;
|
||||
let room;
|
||||
let user;
|
||||
|
||||
if (history.data.owner?._id) {
|
||||
owner = await Users.findOneById(history.data.owner._id);
|
||||
}
|
||||
if (history.data.message_id) {
|
||||
message = await Messages.findOneById(history.data.message_id);
|
||||
}
|
||||
if (history.data.channel_id) {
|
||||
room = await Rooms.findOneById(history.data.channel_id);
|
||||
}
|
||||
if (history.data.user_id) {
|
||||
user = await Users.findOneById(history.data.user_id);
|
||||
}
|
||||
|
||||
if (!history.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.executeTriggerUrl(history.url, integration, {
|
||||
event,
|
||||
message: message || undefined,
|
||||
room: room || undefined,
|
||||
owner: owner || undefined,
|
||||
user: user || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
const triggerHandler = new RocketChatIntegrationHandler();
|
||||
export { triggerHandler };
|
||||
105
apps/meteor/server/lib/integrations/lib/updateHistory.ts
Normal file
105
apps/meteor/server/lib/integrations/lib/updateHistory.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { IIntegrationHistory, OutgoingIntegrationEvent, IIntegration, IMessage, AtLeast } from '@rocket.chat/core-typings';
|
||||
import { IntegrationHistory } from '@rocket.chat/models';
|
||||
|
||||
import { omit } from '../../../../lib/utils/omit';
|
||||
import { notifyOnIntegrationHistoryChangedById, notifyOnIntegrationHistoryChanged } from '../../notifyListener';
|
||||
|
||||
export const updateHistory = async ({
|
||||
historyId,
|
||||
step,
|
||||
integration,
|
||||
event,
|
||||
data,
|
||||
triggerWord,
|
||||
ranPrepareScript,
|
||||
prepareSentMessage,
|
||||
processSentMessage,
|
||||
resultMessage,
|
||||
finished,
|
||||
url,
|
||||
httpCallData,
|
||||
httpError,
|
||||
httpResult,
|
||||
error,
|
||||
errorStack,
|
||||
}: {
|
||||
historyId: IIntegrationHistory['_id'];
|
||||
step: IIntegrationHistory['step'];
|
||||
integration?: IIntegration;
|
||||
event?: OutgoingIntegrationEvent;
|
||||
triggerWord?: string;
|
||||
ranPrepareScript?: boolean;
|
||||
prepareSentMessage?: { channel: string; message: Partial<IMessage> }[];
|
||||
processSentMessage?: { channel: string; message: Partial<IMessage> }[];
|
||||
resultMessage?: { channel: string; message: Partial<IMessage> }[];
|
||||
finished?: boolean;
|
||||
url?: string;
|
||||
httpCallData?: Record<string, any>; // ProcessedOutgoingRequest.data
|
||||
httpError?: any; // null or whatever error type `fetch` may throw
|
||||
httpResult?: string | null;
|
||||
|
||||
error?: boolean;
|
||||
errorStack?: any; // Error | Error['stack']
|
||||
|
||||
data?: Record<string, any>;
|
||||
}) => {
|
||||
const { user: userData, room: roomData, ...fullData } = data || {};
|
||||
|
||||
const history: AtLeast<IIntegrationHistory, 'type' | 'step'> = {
|
||||
type: 'outgoing-webhook',
|
||||
step,
|
||||
|
||||
// Usually is only added on initial insert
|
||||
...(integration ? { integration } : {}),
|
||||
// Usually is only added on initial insert
|
||||
...(event ? { event } : {}),
|
||||
...(fullData
|
||||
? {
|
||||
data: {
|
||||
...fullData,
|
||||
...(userData ? { user: omit(userData, 'services') } : {}),
|
||||
...(roomData ? { room: roomData } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(triggerWord ? { triggerWord } : {}),
|
||||
...(typeof ranPrepareScript !== 'undefined' ? { ranPrepareScript } : {}),
|
||||
...(prepareSentMessage ? { prepareSentMessage } : {}),
|
||||
...(processSentMessage ? { processSentMessage } : {}),
|
||||
...(resultMessage ? { resultMessage } : {}),
|
||||
...(typeof finished !== 'undefined' ? { finished } : {}),
|
||||
...(url ? { url } : {}),
|
||||
...(typeof httpCallData !== 'undefined' ? { httpCallData } : {}),
|
||||
...(httpError ? { httpError } : {}),
|
||||
...(typeof httpResult !== 'undefined' ? { httpResult: JSON.stringify(httpResult, null, 2) } : {}),
|
||||
...(typeof error !== 'undefined' ? { error } : {}),
|
||||
...(typeof errorStack !== 'undefined' ? { errorStack } : {}),
|
||||
};
|
||||
|
||||
if (historyId) {
|
||||
// Projecting just integration field to comply with existing listener behaviour
|
||||
const integrationHistory = await IntegrationHistory.updateById(historyId, history, { projection: { 'integration._id': 1 } });
|
||||
if (!integrationHistory) {
|
||||
throw new Error('error-updating-integration-history');
|
||||
}
|
||||
void notifyOnIntegrationHistoryChanged(integrationHistory, 'updated', history);
|
||||
return historyId;
|
||||
}
|
||||
|
||||
// Can't create a new history without there being an integration
|
||||
if (!history.integration) {
|
||||
throw new Error('error-invalid-integration');
|
||||
}
|
||||
|
||||
// TODO: Had to force type cast here because of function's signature
|
||||
// It would be easier if we separate into create and update functions
|
||||
const { insertedId } = await IntegrationHistory.create(history as IIntegrationHistory);
|
||||
|
||||
if (!insertedId) {
|
||||
throw new Error('error-creating-integration-history');
|
||||
}
|
||||
|
||||
void notifyOnIntegrationHistoryChangedById(insertedId, 'inserted');
|
||||
|
||||
return insertedId;
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { compileIntegrationScript } from './compileIntegrationScript';
|
||||
import { isScriptEngineFrozen } from './validateScriptEngine';
|
||||
import { outgoingEvents } from '../../../../app/integrations/lib/outgoingEvents';
|
||||
import { parseCSV } from '../../../../lib/utils/parseCSV';
|
||||
import { hasPermissionAsync, hasAllPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
const scopedChannels = ['all_public_channels', 'all_private_groups', 'all_direct_messages'];
|
||||
const validChannelChars = ['@', '#'];
|
||||
|
||||
function _verifyRequiredFields(integration: INewOutgoingIntegration | IUpdateOutgoingIntegration): void {
|
||||
if (
|
||||
!integration.event ||
|
||||
!Match.test(integration.event, String) ||
|
||||
integration.event.trim() === '' ||
|
||||
!outgoingEvents[integration.event]
|
||||
) {
|
||||
throw new Meteor.Error('error-invalid-event-type', 'Invalid event type', {
|
||||
function: 'validateOutgoing._verifyRequiredFields',
|
||||
});
|
||||
}
|
||||
|
||||
if (!integration.username || !Match.test(integration.username, String) || integration.username.trim() === '') {
|
||||
throw new Meteor.Error('error-invalid-username', 'Invalid username', {
|
||||
function: 'validateOutgoing._verifyRequiredFields',
|
||||
});
|
||||
}
|
||||
|
||||
if (outgoingEvents[integration.event].use.targetRoom && !integration.targetRoom) {
|
||||
throw new Meteor.Error('error-invalid-targetRoom', 'Invalid Target Room', {
|
||||
function: 'validateOutgoing._verifyRequiredFields',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Match.test(integration.urls, [String])) {
|
||||
throw new Meteor.Error('error-invalid-urls', 'Invalid URLs', {
|
||||
function: 'validateOutgoing._verifyRequiredFields',
|
||||
});
|
||||
}
|
||||
|
||||
integration.urls = integration.urls.filter((url) => url && url.trim() !== '');
|
||||
|
||||
if (integration.urls.length === 0) {
|
||||
throw new Meteor.Error('error-invalid-urls', 'Invalid URLs', {
|
||||
function: 'validateOutgoing._verifyRequiredFields',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function _verifyUserHasPermissionForChannels(userId: IUser['_id'], channels: string[]): Promise<void> {
|
||||
for (let channel of channels) {
|
||||
if (scopedChannels.includes(channel)) {
|
||||
if (channel === 'all_public_channels') {
|
||||
// No special permissions needed to add integration to public channels
|
||||
} else if (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations'))) {
|
||||
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
|
||||
function: 'validateOutgoing._verifyUserHasPermissionForChannels',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let record;
|
||||
const channelType = channel[0];
|
||||
channel = channel.substr(1);
|
||||
|
||||
switch (channelType) {
|
||||
case '#':
|
||||
record = await Rooms.findOne({
|
||||
$or: [{ _id: channel }, { name: channel }],
|
||||
});
|
||||
break;
|
||||
case '@':
|
||||
record = await Users.findOne({
|
||||
$or: [{ _id: channel }, { username: channel }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
|
||||
function: 'validateOutgoing._verifyUserHasPermissionForChannels',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(await hasAllPermissionAsync(userId, ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'])) &&
|
||||
!(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } }))
|
||||
) {
|
||||
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
|
||||
function: 'validateOutgoing._verifyUserHasPermissionForChannels',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _verifyRetryInformation(integration: IOutgoingIntegration): void {
|
||||
if (!integration.retryFailedCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow negative retry counts
|
||||
integration.retryCount =
|
||||
integration.retryCount && parseInt(String(integration.retryCount)) > 0 ? parseInt(String(integration.retryCount)) : 4;
|
||||
integration.retryDelay = !integration.retryDelay?.trim() ? 'powers-of-ten' : integration.retryDelay.toLowerCase();
|
||||
}
|
||||
|
||||
export const validateOutgoingIntegration = async function (
|
||||
integration: INewOutgoingIntegration | IUpdateOutgoingIntegration,
|
||||
userId: IUser['_id'],
|
||||
): Promise<IOutgoingIntegration> {
|
||||
if (integration.channel && Match.test(integration.channel, String) && integration.channel.trim() === '') {
|
||||
delete integration.channel;
|
||||
}
|
||||
|
||||
// Moved to it's own function to satisfy the complexity rule
|
||||
_verifyRequiredFields(integration);
|
||||
|
||||
let channels: string[] = [];
|
||||
if (outgoingEvents[integration.event].use.channel) {
|
||||
if (!Match.test(integration.channel, String)) {
|
||||
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
|
||||
function: 'validateOutgoing',
|
||||
});
|
||||
} else {
|
||||
channels = parseCSV(integration.channel);
|
||||
|
||||
for (const channel of channels) {
|
||||
if (!validChannelChars.includes(channel[0]) && !scopedChannels.includes(channel.toLowerCase())) {
|
||||
throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', {
|
||||
function: 'validateOutgoing',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations'))) {
|
||||
throw new Meteor.Error('error-invalid-permissions', 'Invalid permission for required Integration creation.', {
|
||||
function: 'validateOutgoing',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await Users.findOne({ username: integration.username });
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user (did you delete the `rocket.cat` user?)', { function: 'validateOutgoing' });
|
||||
}
|
||||
|
||||
const integrationData: IOutgoingIntegration = {
|
||||
...integration,
|
||||
scriptEngine: integration.scriptEngine ?? 'isolated-vm',
|
||||
type: 'webhook-outgoing',
|
||||
channel: channels,
|
||||
userId: user._id,
|
||||
_createdAt: new Date(),
|
||||
_createdBy: await Users.findOne(userId, { projection: { username: 1 } }),
|
||||
};
|
||||
|
||||
if (outgoingEvents[integration.event].use.triggerWords && integration.triggerWords) {
|
||||
if (!Match.test(integration.triggerWords, [String])) {
|
||||
throw new Meteor.Error('error-invalid-triggerWords', 'Invalid triggerWords', {
|
||||
function: 'validateOutgoing',
|
||||
});
|
||||
}
|
||||
|
||||
integrationData.triggerWords = integration.triggerWords.filter((word) => word && word.trim() !== '');
|
||||
} else {
|
||||
delete integrationData.triggerWords;
|
||||
}
|
||||
|
||||
// Default to transpiling with Babel for backwards compatibility; integrations
|
||||
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
|
||||
const skipTranspile = integration.skipTranspile === true;
|
||||
integrationData.skipTranspile = skipTranspile;
|
||||
|
||||
if (
|
||||
!isScriptEngineFrozen(integrationData.scriptEngine) &&
|
||||
integration.scriptEnabled === true &&
|
||||
integration.script &&
|
||||
integration.script.trim() !== ''
|
||||
) {
|
||||
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
|
||||
integrationData.scriptCompiled = script;
|
||||
integrationData.scriptError = error;
|
||||
}
|
||||
|
||||
if (typeof integration.runOnEdits !== 'undefined') {
|
||||
// Verify this value is only true/false
|
||||
integrationData.runOnEdits = integration.runOnEdits === true;
|
||||
}
|
||||
|
||||
await _verifyUserHasPermissionForChannels(userId, channels);
|
||||
_verifyRetryInformation(integrationData);
|
||||
|
||||
return integrationData;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IntegrationScriptEngine } from '@rocket.chat/core-typings';
|
||||
import { wrapExceptions } from '@rocket.chat/tools';
|
||||
|
||||
const FREEZE_INTEGRATION_SCRIPTS_VALUE = String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase();
|
||||
const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(FREEZE_INTEGRATION_SCRIPTS_VALUE);
|
||||
|
||||
export const validateScriptEngine = (engine?: IntegrationScriptEngine) => {
|
||||
if (FREEZE_INTEGRATION_SCRIPTS) {
|
||||
throw new Error('integration-scripts-disabled');
|
||||
}
|
||||
|
||||
if (engine && engine !== 'isolated-vm') {
|
||||
throw new Error('integration-scripts-unknown-engine');
|
||||
}
|
||||
|
||||
const engineCode = 'ivm';
|
||||
|
||||
if (engineCode === FREEZE_INTEGRATION_SCRIPTS_VALUE) {
|
||||
throw new Error('integration-scripts-isolated-vm-disabled');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isScriptEngineFrozen = (engine?: IntegrationScriptEngine) =>
|
||||
wrapExceptions(() => !validateScriptEngine(engine)).catch(() => true);
|
||||
6
apps/meteor/server/lib/integrations/logger.ts
Normal file
6
apps/meteor/server/lib/integrations/logger.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const integrationLogger = new Logger('Integrations');
|
||||
|
||||
export const incomingLogger = integrationLogger.section('Incoming WebHook');
|
||||
export const outgoingLogger = integrationLogger.section('Outgoing WebHook');
|
||||
11
apps/meteor/server/lib/integrations/startup.ts
Normal file
11
apps/meteor/server/lib/integrations/startup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { IOutgoingIntegration } from '@rocket.chat/core-typings';
|
||||
import { Integrations } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { triggerHandler } from './lib/triggerHandler';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Integrations.find<IOutgoingIntegration>({ type: 'webhook-outgoing' }).forEach((data) =>
|
||||
triggerHandler.addIntegration(data as IOutgoingIntegration),
|
||||
);
|
||||
});
|
||||
23
apps/meteor/server/lib/integrations/triggers.ts
Normal file
23
apps/meteor/server/lib/integrations/triggers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { callbacks } from '../callbacks';
|
||||
import { triggerHandler } from './lib/triggerHandler';
|
||||
import { afterLeaveRoomCallback } from '../callbacks/afterLeaveRoomCallback';
|
||||
|
||||
const callbackHandler = function _callbackHandler(eventType: string) {
|
||||
return function _wrapperFunction(...args: any[]) {
|
||||
return triggerHandler.executeTriggers(eventType, ...args);
|
||||
};
|
||||
};
|
||||
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
(message, { room }) => callbackHandler('sendMessage')(message, room),
|
||||
callbacks.priority.LOW,
|
||||
'integrations-sendMessage',
|
||||
);
|
||||
callbacks.add('afterCreateChannel', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated');
|
||||
callbacks.add('afterCreatePrivateGroup', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated');
|
||||
callbacks.add('afterCreateUser', callbackHandler('userCreated'), callbacks.priority.LOW, 'integrations-userCreated');
|
||||
callbacks.add('afterJoinRoom', callbackHandler('roomJoined'), callbacks.priority.LOW, 'integrations-roomJoined');
|
||||
afterLeaveRoomCallback.add(callbackHandler('roomLeft'), callbacks.priority.LOW, 'integrations-roomLeft');
|
||||
callbacks.add('afterRoomArchived', callbackHandler('roomArchived'), callbacks.priority.LOW, 'integrations-roomArchived');
|
||||
callbacks.add('afterFileUpload', callbackHandler('fileUploaded'), callbacks.priority.LOW, 'integrations-fileUploaded');
|
||||
Reference in New Issue
Block a user