From ad2f2e4f5b5cb220d7393cb75291fee9982468df Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 9 Jul 2025 19:01:42 +0530 Subject: [PATCH 1/4] feat: separate API response for incoming webhooks on multiple channels Signed-off-by: Abhinav Kumar --- apps/meteor/app/api/server/v1/chat.ts | 2 +- .../meteor/app/integrations/server/api/api.ts | 23 +- .../integrations/server/lib/triggerHandler.ts | 15 +- .../server/functions/processWebhookMessage.ts | 249 +++++++++++------- .../modules/core-apps/mention.module.ts | 1 + .../end-to-end/api/incoming-integrations.ts | 241 ++++++++++++++++- 6 files changed, 413 insertions(+), 118 deletions(-) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 39b1109a948f6..d22c9de0279e8 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -216,7 +216,7 @@ API.v1.addRoute( const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; - if (!messageReturn) { + if (!messageReturn?.message) { return API.v1.failure('unknown-error'); } diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index e31666b297e55..cccb38ef00d76 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -13,6 +13,7 @@ import { APIClass } from '../../../api/server/ApiClass'; import type { RateLimiterOptions } from '../../../api/server/api'; import { API, defaultRateLimiterOptions } from '../../../api/server/api'; import type { FailureResult, PartialThis, SuccessResult, UnavailableResult } from '../../../api/server/definition'; +import type { WebhookResponseItem } from '../../../lib/server/functions/processWebhookMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; import { IsolatedVMScriptEngine } from '../lib/isolated-vm/isolated-vm'; @@ -115,7 +116,12 @@ async function removeIntegration(options: { target_url: string }, user: IUser): async function executeIntegrationRest( this: IntegrationThis, -): Promise | undefined | void> | FailureResult | UnavailableResult> { +): Promise< + | SuccessResult | { responses: WebhookResponseItem[] } | undefined | void> + | FailureResult + | FailureResult<{ responses: WebhookResponseItem[] }> + | UnavailableResult +> { incomingLogger.info({ msg: 'Post integration:', integration: this.request.integration.name }); incomingLogger.debug({ urlParams: this.urlParams, bodyParams: this.bodyParams }); @@ -217,16 +223,23 @@ async function executeIntegrationRest( bodyParams.bot = { i: this.request.integration._id }; try { - const message = await processWebhookMessage(bodyParams, this.user, defaultValues); - if (_.isEmpty(message)) { + const messageResponse = await processWebhookMessage(bodyParams, this.user, defaultValues); + if (_.isEmpty(messageResponse)) { return API.v1.failure('unknown-error'); } if (scriptResponse) { incomingLogger.debug({ msg: 'response', response: scriptResponse }); + return API.v1.success(scriptResponse); } - - return API.v1.success(scriptResponse); + if (bodyParams.separateResponse) { + const allFailed = messageResponse.every((response) => 'error' in response && response.error); + if (allFailed) { + return API.v1.failure({ responses: messageResponse }); + } + return API.v1.success({ responses: messageResponse }); + } + return API.v1.success(); } catch ({ error, message }: any) { return API.v1.failure(error || message); } diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 32811a656eb37..9f4519e5b01e5 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -126,7 +126,7 @@ class RocketChatIntegrationHandler { room?: IRoom; message: { channel: string; bot?: Record; message: Partial }; data: IntegrationData; - }): Promise<{ channel: string; message: Partial } | undefined> { + }): Promise<{ channel: string; message: Partial }[] | undefined> { let user: IUser | null = null; // Try to find the user who we are impersonating if (trigger.impersonateUser) { @@ -180,12 +180,7 @@ class RocketChatIntegrationHandler { channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`, }; - message = (await processWebhookMessage( - message as any, - user as IUser & { username: RequiredField }, - defaultValues, - )) as unknown as { channel: string; message: Partial }; - return message; + return processWebhookMessage(message, user as IUser & { username: RequiredField }, defaultValues); } eventNameArgumentsToObject(...args: unknown[]) { @@ -575,7 +570,7 @@ class RocketChatIntegrationHandler { await updateHistory({ historyId, step: 'after-prepare-send-message', - prepareSentMessage: [prepareMessage], + prepareSentMessage: prepareMessage, }); } @@ -669,7 +664,7 @@ class RocketChatIntegrationHandler { await updateHistory({ historyId, step: 'after-process-send-message', - processSentMessage: [resultMessage], + processSentMessage: resultMessage, finished: true, }); return; @@ -765,7 +760,7 @@ class RocketChatIntegrationHandler { await updateHistory({ historyId, step: 'url-response-sent-message', - resultMessage: [resultMsg], + resultMessage: resultMsg, finished: true, }); } diff --git a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts index 1fe93e0b663a8..374cc091e0634 100644 --- a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts +++ b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts @@ -1,10 +1,10 @@ -import type { IMessage, IUser, RequiredField, MessageAttachment } from '@rocket.chat/core-typings'; +import type { IMessage, IUser, RequiredField, MessageAttachment, IRoom } from '@rocket.chat/core-typings'; import { removeEmpty } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { getRoomByNameOrIdWithOptionToJoin } from './getRoomByNameOrIdWithOptionToJoin'; -import { sendMessage } from './sendMessage'; +import { sendMessage, validateMessage } from './sendMessage'; import { ensureArray } from '../../../../lib/utils/arrayUtils'; import { trim } from '../../../../lib/utils/stringUtils'; import { SystemLogger } from '../../../../server/lib/logger/system'; @@ -37,115 +37,168 @@ type DefaultValues = { emoji: string; }; -export const processWebhookMessage = async function ( - messageObj: Payload, +export type WebhookSuccessItem = { channel: string; error?: undefined; message: IMessage }; +export type WebhookFailureItem = { channel: string; error: string; message?: undefined }; +export type WebhookResponseItem = WebhookFailureItem | WebhookSuccessItem; + +export const validateWebhookMessage = async (message: Partial, room: IRoom | null, user: IUser) => { + if (message.msg) { + if (message.msg.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { + throw Error('error-message-size-exceeded'); + } + } + await validateMessage(message, room, user); +}; + +const getRoomWithOptionToJoin = async (channelType: string, channelValue: string, user: IUser) => { + switch (channelType) { + case '#': + return getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: channelValue, + joinChannel: true, + }); + case '@': + return getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: channelValue, + type: 'd', + }); + default: + const _channelValue = channelType + channelValue; + + // Try to find the room by id or name if they didn't include the prefix. + let room = await getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: _channelValue, + joinChannel: true, + errorOnEmpty: false, + }); + if (room) { + return room; + } + + // We didn't get a room, let's try finding direct messages + room = await getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: _channelValue, + tryDirectByUserIdOnly: true, + type: 'd', + }); + if (room) { + return room; + } + + // No room, so throw an error + throw new Meteor.Error('invalid-channel'); + } +}; + +const buildMessage = (messageObj: Payload, defaultValues: DefaultValues) => { + const message: Partial & { parseUrls?: boolean } = { + alias: messageObj.username || messageObj.alias || defaultValues.alias, + msg: trim(messageObj.text || messageObj.msg || ''), + attachments: messageObj.attachments || [], + parseUrls: messageObj.parseUrls !== undefined ? messageObj.parseUrls : !messageObj.attachments, + bot: messageObj.bot, + groupable: messageObj.groupable !== undefined ? messageObj.groupable : false, + tmid: messageObj.tmid, + customFields: messageObj.customFields, + }; + + if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) { + message.avatar = messageObj.icon_url || messageObj.avatar; + } else if (!_.isEmpty(messageObj.icon_emoji) || !_.isEmpty(messageObj.emoji)) { + message.emoji = messageObj.icon_emoji || messageObj.emoji; + } else if (!_.isEmpty(defaultValues.avatar)) { + message.avatar = defaultValues.avatar; + } else if (!_.isEmpty(defaultValues.emoji)) { + message.emoji = defaultValues.emoji; + } + + if (Array.isArray(message.attachments)) { + for (let i = 0; i < message.attachments.length; i++) { + const attachment = message.attachments[i] as MessageAttachment & { msg?: string }; + if (attachment.msg) { + attachment.text = trim(attachment.msg); + delete attachment.msg; + } + } + } + + return message; +}; + +export function processWebhookMessage( + messageObj: Payload & { separateResponse: true }, + user: IUser & { username: RequiredField }, + defaultValues?: DefaultValues, +): Promise; + +export function processWebhookMessage( + messageObj: Payload & { separateResponse?: false | undefined }, + user: IUser & { username: RequiredField }, + defaultValues?: DefaultValues, +): Promise; + +export async function processWebhookMessage( + messageObj: Payload & { + /** + * If true, the response will be sent separately for each channel. Messages will be sent to other channels even if one or more fails. If false or not provided, messages would not be sent to any channel if one or more fails. + */ + separateResponse?: boolean; + }, user: IUser & { username: RequiredField }, defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' }, ) { - const sentData = []; + const rooms: ({ channel: string } & ({ room: IRoom } | { room: IRoom | null; error?: any }))[] = []; + const sentData: WebhookResponseItem[] = []; const channels: Array = [...new Set(ensureArray(messageObj.channel || messageObj.roomId || defaultValues.channel))]; - for await (const channel of channels) { - const channelType = channel[0]; + if (messageObj.attachments && !Array.isArray(messageObj.attachments)) { + SystemLogger.warn({ + msg: 'Attachments should be Array, ignoring value', + attachments: messageObj.attachments, + }); + messageObj.attachments = undefined; + } - let channelValue = channel.slice(1); - let room; - - switch (channelType) { - case '#': - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - joinChannel: true, - }); - break; - case '@': - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - type: 'd', - }); - break; - default: - channelValue = channelType + channelValue; - - // Try to find the room by id or name if they didn't include the prefix. - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - joinChannel: true, - errorOnEmpty: false, - }); - if (room) { - break; - } - - // We didn't get a room, let's try finding direct messages - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - tryDirectByUserIdOnly: true, - type: 'd', - }); - if (room) { - break; - } - - // No room, so throw an error - throw new Meteor.Error('invalid-channel'); - } + const message = buildMessage(messageObj, defaultValues); - if (messageObj.attachments && !Array.isArray(messageObj.attachments)) { - SystemLogger.warn({ - msg: 'Attachments should be Array, ignoring value', - attachments: messageObj.attachments, - }); - messageObj.attachments = undefined; - } - - const message: Partial & { parseUrls?: boolean } = { - alias: messageObj.username || messageObj.alias || defaultValues.alias, - msg: trim(messageObj.text || messageObj.msg || ''), - attachments: messageObj.attachments || [], - parseUrls: messageObj.parseUrls !== undefined ? messageObj.parseUrls : !messageObj.attachments, - bot: messageObj.bot, - groupable: messageObj.groupable !== undefined ? messageObj.groupable : false, - tmid: messageObj.tmid, - customFields: messageObj.customFields, - }; - - if (message.msg) { - if (message.msg.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { - throw Error('error-message-size-exceeded'); + for await (const channel of channels) { + const channelType = channel[0]; + const channelValue = channel.slice(1); + let room: IRoom | null = null; + try { + room = await getRoomWithOptionToJoin(channelType, channelValue, user); + if (!room) { + throw new Error('error-invalid-room'); } + await validateRoomMessagePermissionsAsync(room, { uid: user._id, ...user }); + await validateWebhookMessage(message, room, user); + rooms.push({ room, channel }); + } catch (_error: any) { + if (messageObj.separateResponse) { + const { error, message } = _error || {}; + const errorMessage = error || message || 'unknown-error'; + rooms.push({ error: errorMessage, room, channel }); + continue; + } + throw _error; } + } - if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) { - message.avatar = messageObj.icon_url || messageObj.avatar; - } else if (!_.isEmpty(messageObj.icon_emoji) || !_.isEmpty(messageObj.emoji)) { - message.emoji = messageObj.icon_emoji || messageObj.emoji; - } else if (!_.isEmpty(defaultValues.avatar)) { - message.avatar = defaultValues.avatar; - } else if (!_.isEmpty(defaultValues.emoji)) { - message.emoji = defaultValues.emoji; - } - - if (Array.isArray(message.attachments)) { - for (let i = 0; i < message.attachments.length; i++) { - const attachment = message.attachments[i] as MessageAttachment & { msg?: string }; - if (attachment.msg) { - attachment.text = trim(attachment.msg); - delete attachment.msg; - } + for await (const roomData of rooms) { + if ('error' in roomData && roomData.error) { + if (messageObj.separateResponse) { + sentData.push({ channel: roomData.channel, error: roomData.error }); + continue; } } - - await validateRoomMessagePermissionsAsync(room, { uid: user._id, ...user }); - - const messageReturn = await sendMessage(user, removeEmpty(message), room); - sentData.push({ channel, message: messageReturn }); + const messageReturn = await sendMessage(user, removeEmpty(message), roomData.room); + sentData.push({ channel: roomData.channel, message: messageReturn }); } return sentData; -}; +} diff --git a/apps/meteor/server/modules/core-apps/mention.module.ts b/apps/meteor/server/modules/core-apps/mention.module.ts index 76d3a22ea53b7..8bd5bec4ce090 100644 --- a/apps/meteor/server/modules/core-apps/mention.module.ts +++ b/apps/meteor/server/modules/core-apps/mention.module.ts @@ -95,6 +95,7 @@ export class MentionModule implements IUiKitCoreApp { { roomId: mentions.map(({ _id }) => _id), text, + separateResponse: true, // so that messages are sent to other DMs even if one or more fails }, payload.user, ); diff --git a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts index 4e689d5e4efb0..f70bbc5b98d00 100644 --- a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts +++ b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts @@ -10,7 +10,7 @@ import { createIntegration, removeIntegration } from '../../data/integration.hel import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; -import { password } from '../../data/user'; +import { adminUsername, password } from '../../data/user'; import type { TestUser } from '../../data/users.helper'; import { createUser, deleteUser, login } from '../../data/users.helper'; @@ -44,7 +44,7 @@ describe('[Incoming Integrations]', () => { updatePermission('manage-own-outgoing-integrations', ['admin']), updatePermission('manage-outgoing-integrations', ['admin']), deleteRoom({ type: 'c', roomId: channel._id }), - deleteUser(user), + // deleteUser(user), ]); }); @@ -889,7 +889,7 @@ describe('[Incoming Integrations]', () => { }); }); - describe('Additional Tests for Message Delivery Permissions And Authentication', () => { + describe('Additional Tests for Message Delivery, Permissions, and Authentication', () => { let nonMemberUser: IUser; let privateTeam: ITeam; let publicChannelInPrivateTeam: IRoom; @@ -898,6 +898,8 @@ describe('[Incoming Integrations]', () => { let integration2: IIntegration; let integration3: IIntegration; let integration4: IIntegration; + let integrationMixed1: IIntegration; + let integrationMixed2: IIntegration; before(async () => { nonMemberUser = await createUser({ username: `g_${Random.id()}` }); @@ -990,6 +992,48 @@ describe('[Incoming Integrations]', () => { expect(res.body).to.have.property('integration').and.to.be.an('object'); integration4 = res.body.integration; }); + + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test Mixed - Sending Messages', + enabled: true, + alias: 'Incoming test Mixed - Sending Messages', + username: nonMemberUser.username as string, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${publicRoom.fname}, #${privateRoom.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integrationMixed1 = res.body.integration; + }); + + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test Mixed - Sending Messages', + enabled: true, + alias: 'Incoming test Mixed - Sending Messages', + username: adminUsername, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${publicRoom.fname}, #${privateRoom.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integrationMixed2 = res.body.integration; + }); }); after(async () => { @@ -999,7 +1043,7 @@ describe('[Incoming Integrations]', () => { await deleteTeam(credentials, privateTeam.name); await deleteUser(nonMemberUser); await Promise.all([ - ...[integration2, integration3, integration4].map((integration) => + ...[integration2, integration3, integration4, integrationMixed1, integrationMixed2].map((integration) => request.post(api('integrations.remove')).set(credentials).send({ integrationId: integration._id, type: integration.type, @@ -1264,5 +1308,194 @@ describe('[Incoming Integrations]', () => { }); }); }); + + describe('Multiple channels delivery', () => { + it('should not return separate responses when separateResponse is not provided', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.not.have.property('responses'); + }); + }); + + it('should not return separate responses when separateResponse = false', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: false, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.not.have.property('responses'); + }); + }); + + it('should return separate responses when separateResponse = true', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.responses).to.be.an('array').and.to.have.lengthOf(2); + const publicResponse = res.body.responses.find((r: any) => r.channel === `#${publicRoom.fname}`); + const privateResponse = res.body.responses.find((r: any) => r.channel === `#${privateRoom.fname}`); + expect(publicResponse).not.to.have.property('error'); + expect(privateResponse).to.have.property('error'); + }); + }); + + it('should not deliver to any channel if one fails when separateResponse is not provided', async () => { + const testMsg = `msg-${Random.id()}`; + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error'); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + }); + + it('should not deliver to any channel if one fails when separateResponse = false', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: false, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error'); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + }); + + it('should send message to only public channel when separateResponse = true', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + const found = (res.body.messages as IMessage[]).filter((m) => m.msg === testMsg); + expect(found).to.have.lengthOf(1); + }); + }); + + it('should send messages to all channels when message could be delivered to all channels', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed2._id}/${integrationMixed2.token}`) + .send({ + text: testMsg, + separateResponse: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.responses).to.be.an('array').and.to.have.lengthOf(2); + const publicResponse = res.body.responses.find((r: any) => r.channel === `#${publicRoom.fname}`); + const privateResponse = res.body.responses.find((r: any) => r.channel === `#${privateRoom.fname}`); + expect(publicResponse).not.to.have.property('error'); + expect(privateResponse).not.to.have.property('error'); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + const found = (res.body.messages as IMessage[]).filter((m) => m.msg === testMsg); + expect(found).to.have.lengthOf(1); + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + const found = (res.body.messages as IMessage[]).filter((m) => m.msg === testMsg); + expect(found).to.have.lengthOf(1); + }); + }); + }); }); }); From f5e7af8c25f3c1edd15596e90fad6c2e06177e0c Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 9 Jul 2025 19:07:51 +0530 Subject: [PATCH 2/4] added changeset Signed-off-by: Abhinav Kumar --- .changeset/nice-mice-pump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nice-mice-pump.md diff --git a/.changeset/nice-mice-pump.md b/.changeset/nice-mice-pump.md new file mode 100644 index 0000000000000..3eef62c768279 --- /dev/null +++ b/.changeset/nice-mice-pump.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Adds `separateResponse` param to incoming webhooks to return per-channel statuses; when false or omitted, validates all targets and aborts sending to any channel if one fails. From 911a747ded3030245ada4b9e336608dac9c615a6 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 16 Jul 2025 18:20:10 +0530 Subject: [PATCH 3/4] fix tests Signed-off-by: Abhinav Kumar --- apps/meteor/tests/data/users.helper.ts | 12 ++++++++++++ .../tests/end-to-end/api/incoming-integrations.ts | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 1022edef686ed..51d61cab20fc5 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -126,3 +126,15 @@ export const setUserOnline = (overrideCredentials = credentials) => msg: 'method', }), }); + +export const removeRoleFromUser = (username: string, roleId: string, overrideCredentials = credentials) => + getUserByUsername(username).then((user) => + request + .post(api('users.update')) + .set(overrideCredentials) + .send({ + userId: user._id, + data: { roles: user.roles.filter((role) => role !== roleId) }, + }) + .expect(200), + ); diff --git a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts index f70bbc5b98d00..bdc1691f79869 100644 --- a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts +++ b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts @@ -12,7 +12,7 @@ import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; import { adminUsername, password } from '../../data/user'; import type { TestUser } from '../../data/users.helper'; -import { createUser, deleteUser, login } from '../../data/users.helper'; +import { createUser, deleteUser, login, removeRoleFromUser } from '../../data/users.helper'; describe('[Incoming Integrations]', () => { let integration: IIntegration; @@ -44,7 +44,7 @@ describe('[Incoming Integrations]', () => { updatePermission('manage-own-outgoing-integrations', ['admin']), updatePermission('manage-outgoing-integrations', ['admin']), deleteRoom({ type: 'c', roomId: channel._id }), - // deleteUser(user), + deleteUser(user), ]); }); @@ -1051,6 +1051,7 @@ describe('[Incoming Integrations]', () => { ), updatePermission('manage-incoming-integrations', ['admin']), ]); + await removeRoleFromUser(adminUsername, 'bot'); }); it('should not send a message in public room if token is invalid', async () => { From 51839585efb5dbae9f1e4778d804c59c146c7311 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 17 Jul 2025 00:29:23 +0530 Subject: [PATCH 4/4] separateResponse support for script Signed-off-by: Abhinav Kumar --- apps/meteor/app/integrations/server/api/api.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index cccb38ef00d76..59770587170a4 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -139,6 +139,7 @@ async function executeIntegrationRest( const scriptEngine = getEngine(this.request.integration); let { bodyParams } = this; + const separateResponse = this.bodyParams?.separateResponse === true; let scriptResponse: Record | undefined; if (scriptEngine.integrationHasValidScript(this.request.integration) && this.request.body) { @@ -191,6 +192,11 @@ async function executeIntegrationRest( } bodyParams = result && result.content; + + if (!('separateResponse' in bodyParams)) { + bodyParams.separateResponse = separateResponse; + } + scriptResponse = result.response; if (result.user) { this.user = result.user;