diff --git a/.changeset/shy-bees-serve.md b/.changeset/shy-bees-serve.md new file mode 100644 index 00000000000..5d7756ee77a --- /dev/null +++ b/.changeset/shy-bees-serve.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +--- + +feat: Allow Incoming Webhooks to override destination channel diff --git a/apps/meteor/app/integrations/server/api/api.js b/apps/meteor/app/integrations/server/api/api.js index 7b703f6bc18..e4912ad89d8 100644 --- a/apps/meteor/app/integrations/server/api/api.js +++ b/apps/meteor/app/integrations/server/api/api.js @@ -276,6 +276,10 @@ async function executeIntegrationRest() { return API.v1.success(); } + if ((this.bodyParams.channel || this.bodyParams.roomId) && !this.integration.overrideDestinationChannelEnabled) { + return API.v1.failure('overriding destination channel is disabled for this integration'); + } + this.bodyParams.bot = { i: this.integration._id }; try { diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index f7f5d0ed930..09ad473267d 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -30,6 +30,7 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn alias: Match.Maybe(String), emoji: Match.Maybe(String), scriptEnabled: Boolean, + overrideDestinationChannelEnabled: Boolean, script: Match.Maybe(String), avatar: Match.Maybe(String), }), diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 6e95cd75ec3..b541c776e0f 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -159,6 +159,7 @@ Meteor.methods({ channel: channels, script: integration.script, scriptEnabled: integration.scriptEnabled, + overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, _updatedAt: new Date(), _updatedBy: await Users.findOne({ _id: this.userId }, { projection: { username: 1 } }), }, diff --git a/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js b/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js index d6a52fcb003..94bbd156b86 100644 --- a/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js +++ b/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js @@ -11,7 +11,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat const absoluteUrl = useAbsoluteUrl(); - const { enabled, channel, username, name, alias, avatar, emoji, scriptEnabled, script } = formValues; + const { enabled, channel, username, name, alias, avatar, emoji, scriptEnabled, script, overrideDestinationChannelEnabled } = formValues; const { handleEnabled, @@ -22,6 +22,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat handleAvatar, handleEmoji, handleScriptEnabled, + handleOverrideDestinationChannelEnabled, handleScript, } = formHandlers; @@ -149,6 +150,17 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat ), [emoji, handleEmoji, t], )} + {useMemo( + () => ( + + + {t('Override_Destination_Channel')} + + + + ), + [t, overrideDestinationChannelEnabled, handleOverrideDestinationChannelEnabled], + )} {useMemo( () => ( diff --git a/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js b/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js index 29da599949d..7de5944b48c 100644 --- a/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js +++ b/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js @@ -17,6 +17,7 @@ const getInitialValue = (data) => { avatar: data.avatar ?? '', emoji: data.emoji ?? '', scriptEnabled: data.scriptEnabled, + overrideDestinationChannelEnabled: data.overrideDestinationChannelEnabled, script: data.script, }; return initialValue; diff --git a/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js b/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js index cfbb6725613..019dc6d0d73 100644 --- a/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js +++ b/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js @@ -15,6 +15,7 @@ const initialState = { avatar: '', emoji: '', scriptEnabled: false, + overrideDestinationChannelEnabled: false, script: '', }; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 66af9d7e738..f170ba28d02 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3810,6 +3810,7 @@ "Outgoing_WebHook_Description": "Get data out of Rocket.Chat in real-time.", "Output_format": "Output format", "Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given": "Override URL to which files are uploaded. This url also used for downloads unless a CDN is given", + "Override_Destination_Channel": "Allow to overwrite destination channel in the body parameters", "Owner": "Owner", "Play": "Play", "Page_not_exist_or_not_permission": "The page does not exist or you may not have access permission", diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts index 7ecd58dcd53..60464a003d1 100644 --- a/apps/meteor/server/startup/migrations/index.ts +++ b/apps/meteor/server/startup/migrations/index.ts @@ -31,4 +31,5 @@ import './v294'; import './v295'; import './v296'; import './v297'; +import './v298'; import './xrun'; diff --git a/apps/meteor/server/startup/migrations/v298.ts b/apps/meteor/server/startup/migrations/v298.ts new file mode 100644 index 00000000000..0146dc19a74 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v298.ts @@ -0,0 +1,20 @@ +import { Integrations } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 298, + name: 'Set overrideDestinationChannelEnabled for all incoming webhook integrations', + async up() { + await Integrations.updateMany( + { + type: 'webhook-incoming', + }, + { + $set: { + overrideDestinationChannelEnabled: true, + }, + }, + ); + }, +}); diff --git a/apps/meteor/tests/end-to-end/api/02-channels.js b/apps/meteor/tests/end-to-end/api/02-channels.js index 444bcd9dce6..b3ef2e581a7 100644 --- a/apps/meteor/tests/end-to-end/api/02-channels.js +++ b/apps/meteor/tests/end-to-end/api/02-channels.js @@ -1137,6 +1137,7 @@ describe('[Channels]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: true, channel: `#${createdChannel.name}`, }, userCredentials, diff --git a/apps/meteor/tests/end-to-end/api/03-groups.js b/apps/meteor/tests/end-to-end/api/03-groups.js index 3a40d3f9658..aaa472eba45 100644 --- a/apps/meteor/tests/end-to-end/api/03-groups.js +++ b/apps/meteor/tests/end-to-end/api/03-groups.js @@ -901,6 +901,7 @@ describe('[Groups]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: true, channel: `#${createdGroup.name}`, }, userCredentials, diff --git a/apps/meteor/tests/end-to-end/api/07-incoming-integrations.js b/apps/meteor/tests/end-to-end/api/07-incoming-integrations.js index 656280d042e..419daeb7bfc 100644 --- a/apps/meteor/tests/end-to-end/api/07-incoming-integrations.js +++ b/apps/meteor/tests/end-to-end/api/07-incoming-integrations.js @@ -5,6 +5,7 @@ import { updatePermission } from '../../data/permissions.helper'; import { createIntegration, removeIntegration } from '../../data/integration.helper'; import { createUser, login } from '../../data/users.helper'; import { password } from '../../data/user'; +import { createRoom } from '../../data/rooms.helper.js'; describe('[Incoming Integrations]', function () { this.retries(0); @@ -13,6 +14,8 @@ describe('[Incoming Integrations]', function () { let integrationCreatedByAnUser; let user; let userCredentials; + let channel; + let testChannelName; before((done) => getCredentials(done)); @@ -20,8 +23,15 @@ describe('[Incoming Integrations]', function () { updatePermission('manage-incoming-integrations', []) .then(() => updatePermission('manage-own-incoming-integrations', [])) .then(() => updatePermission('manage-own-outgoing-integrations', [])) - .then(() => updatePermission('manage-outgoing-integrations', [])) - .then(done); + .then(() => updatePermission('manage-outgoing-integrations', [])); + + testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + + createRoom({ type: 'c', name: testChannelName }).end((err, res) => { + channel = res.body.channel; + + return done(); + }); }); after((done) => { @@ -45,6 +55,7 @@ describe('[Incoming Integrations]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: true, channel: '#general', }) .expect('Content-Type', 'application/json') @@ -69,6 +80,7 @@ describe('[Incoming Integrations]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: true, channel: '#general', }) .expect('Content-Type', 'application/json') @@ -92,6 +104,7 @@ describe('[Incoming Integrations]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: true, channel: '#general', }) .expect('Content-Type', 'application/json') @@ -116,6 +129,7 @@ describe('[Incoming Integrations]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: false, channel: '#general', }) .expect('Content-Type', 'application/json') @@ -142,6 +156,7 @@ describe('[Incoming Integrations]', function () { alias: 'test2', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: false, channel: '#general', }) .expect('Content-Type', 'application/json') @@ -165,6 +180,105 @@ describe('[Incoming Integrations]', function () { .expect(200) .end(done); }); + + it("should return an error when sending 'channel' field telling its configuration is disabled", (done) => { + request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: 'Example message', + channel: [testChannelName], + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'overriding destination channel is disabled for this integration'); + }) + .end(done); + }); + + it("should return an error when sending 'roomId' field telling its configuration is disabled", (done) => { + request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: 'Example message', + roomId: channel._id, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'overriding destination channel is disabled for this integration'); + }) + .end(done); + }); + it('should send a message for a channel that is specified in the webhooks configuration', (done) => { + const successfulMesssage = `Message sent successfully at #${Date.now()}`; + request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: successfulMesssage, + }) + .expect(200) + .end(() => { + return request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(!!res.body.messages.find((m) => m.msg === successfulMesssage)).to.be.true; + }) + .end(done); + }); + }); + it('should send a message for a channel that is not specified in the webhooks configuration', async () => { + await request + .put(api('integrations.update')) + .set(credentials) + .send({ + type: 'webhook-incoming', + overrideDestinationChannelEnabled: true, + integrationId: integration._id, + username: 'rocket.cat', + channel: '#general', + scriptEnabled: true, + enabled: true, + name: integration.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration.overrideDestinationChannelEnabled).to.be.equal(true); + }); + const successfulMesssage = `Message sent successfully at #${Date.now()}`; + await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: successfulMesssage, + channel: [testChannelName], + }) + .expect(200); + + return request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(!!res.body.messages.find((m) => m.msg === successfulMesssage)).to.be.true; + }); + }); }); describe('[/integrations.history]', () => { @@ -200,6 +314,7 @@ describe('[Incoming Integrations]', function () { alias: 'test', username: 'rocket.cat', scriptEnabled: false, + overrideDestinationChannelEnabled: true, channel: '#general', }, userCredentials, @@ -381,6 +496,7 @@ describe('[Incoming Integrations]', function () { alias: 'test updated', username: 'rocket.cat', scriptEnabled: true, + overrideDestinationChannelEnabled: true, channel: '#general', integrationId: integration._id, }) diff --git a/packages/core-typings/src/IIntegration.ts b/packages/core-typings/src/IIntegration.ts index 3d596bfb983..5f31c9f7879 100644 --- a/packages/core-typings/src/IIntegration.ts +++ b/packages/core-typings/src/IIntegration.ts @@ -10,6 +10,7 @@ export interface IIncomingIntegration extends IRocketChatRecord { channel: string[]; token: string; + overrideDestinationChannelEnabled: boolean; scriptEnabled: boolean; script: string; scriptCompiled?: string; @@ -84,7 +85,7 @@ export type INewOutgoingIntegration = Omit< }; export type IUpdateIncomingIntegration = Omit< - IOutgoingIntegration, + IIncomingIntegration, 'type' | 'channel' | 'scriptCompiled' | 'scriptError' | '_createdBy' | '_createdAt' | 'userId' | 'token' | 'username' > & { channel?: string; diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts index bf652d66ad7..9d6f2c88362 100644 --- a/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts +++ b/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts @@ -8,6 +8,7 @@ export type IntegrationsCreateProps = type: 'webhook-incoming'; username: string; channel: string; + overrideDestinationChannelEnabled: boolean; scriptEnabled: boolean; script?: string; name: string; @@ -67,6 +68,10 @@ const integrationsCreateSchema = { type: 'boolean', nullable: false, }, + overrideDestinationChannelEnabled: { + type: 'boolean', + nullable: false, + }, script: { type: 'string', nullable: true, diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts index ebbe2968036..0045551fdca 100644 --- a/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts +++ b/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts @@ -9,6 +9,7 @@ export type IntegrationsUpdateProps = integrationId: string; channel: string; scriptEnabled: boolean; + overrideDestinationChannelEnabled: boolean; script?: string; name: string; enabled: boolean; @@ -69,6 +70,10 @@ const integrationsUpdateSchema = { type: 'boolean', nullable: false, }, + overrideDestinationChannelEnabled: { + type: 'boolean', + nullable: false, + }, script: { type: 'string', nullable: true, @@ -94,7 +99,7 @@ const integrationsUpdateSchema = { nullable: true, }, }, - required: ['integrationId', 'type', 'channel', 'scriptEnabled', 'name', 'enabled'], + required: ['integrationId', 'type', 'channel', 'scriptEnabled', 'overrideDestinationChannelEnabled', 'name', 'enabled'], additionalProperties: true, }, { @@ -157,6 +162,10 @@ const integrationsUpdateSchema = { type: 'boolean', nullable: false, }, + overrideDestinationChannelEnabled: { + type: 'boolean', + nullable: false, + }, script: { type: 'string', nullable: true, @@ -202,7 +211,7 @@ const integrationsUpdateSchema = { nullable: true, }, }, - required: ['type', 'username', 'channel', 'event', 'scriptEnabled', 'name', 'enabled'], + required: ['type', 'username', 'channel', 'event', 'scriptEnabled', 'overrideDestinationChannelEnabled', 'name', 'enabled'], additionalProperties: false, }, ],