From bf549301df541c43931fe4493b4bad7905fb0c8a Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 15 May 2024 13:54:32 +0100 Subject: [PATCH] feat: Add Slack trigger node (#9190) Co-authored-by: Giulio Andreini --- cypress/e2e/2-credentials.cy.ts | 5 +- .../nodes/Slack/SlackTrigger.node.ts | 414 ++++++++++++++++++ .../nodes/Slack/SlackTriggerHelpers.ts | 79 ++++ .../nodes/Slack/SlackTriggger.node.json | 18 + .../nodes/Slack/V2/GenericFunctions.ts | 4 +- packages/nodes-base/package.json | 1 + 6 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 packages/nodes-base/nodes/Slack/SlackTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Slack/SlackTriggger.node.json diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index 008758aef2..c4cdcb280b 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -254,8 +254,9 @@ describe('Credentials', () => { }); workflowPage.actions.visit(true); - workflowPage.actions.addNodeToCanvas('Slack'); - workflowPage.actions.openNode('Slack'); + workflowPage.actions.addNodeToCanvas('Manual'); + workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); + workflowPage.getters.nodeCredentialsSelect().should('exist'); workflowPage.getters.nodeCredentialsSelect().click(); getVisibleSelect().find('li').last().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); diff --git a/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts b/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts new file mode 100644 index 0000000000..6f46b2b827 --- /dev/null +++ b/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts @@ -0,0 +1,414 @@ +import type { + INodeListSearchItems, + ILoadOptionsFunctions, + INodeListSearchResult, + INodePropertyOptions, + IHookFunctions, + IWebhookFunctions, + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, + IBinaryKeyData, +} from 'n8n-workflow'; + +import { slackApiRequestAllItems } from './V2/GenericFunctions'; +import { downloadFile, getChannelInfo, getUserInfo } from './SlackTriggerHelpers'; + +export class SlackTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Slack Trigger', + name: 'slackTrigger', + icon: 'file:slack.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["eventFilter"].join(", ")}}', + description: 'Handle Slack events via webhooks', + defaults: { + name: 'Slack Trigger', + }, + inputs: [], + outputs: ['main'], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + credentials: [ + { + name: 'slackApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'accessToken', + }, + { + displayName: + 'Set up a webhook in your Slack app to enable this node. More info', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Trigger On', + name: 'trigger', + type: 'multiOptions', + options: [ + { + name: 'Any Event', + value: 'any_event', + description: 'Triggers on any event', + }, + { + name: 'Bot / App Mention', + value: 'app_mention', + description: 'When your bot or app is mentioned in a channel the app is added to', + }, + { + name: 'File Made Public', + value: 'file_public', + description: 'When a file is made public', + }, + { + name: 'File Shared', + value: 'file_share', + description: 'When a file is shared in a channel the app is added to', + }, + { + name: 'New Message Posted to Channel', + value: 'message', + description: 'When a message is posted to a channel the app is added to', + }, + { + name: 'New Public Channel Created', + value: 'channel_created', + description: 'When a new public channel is created', + }, + { + name: 'New User', + value: 'team_join', + description: 'When a new user is added to Slack', + }, + { + name: 'Reaction Added', + value: 'reaction_added', + description: 'When a reaction is added to a message the app is added to', + }, + ], + default: [], + }, + { + displayName: 'Watch Whole Workspace', + name: 'watchWorkspace', + type: 'boolean', + default: false, + description: + 'Whether to watch for the event in the whole workspace, rather than a specific channel', + displayOptions: { + show: { + trigger: ['any_event', 'message', 'reaction_added', 'file_share', 'app_mention'], + }, + }, + }, + { + displayName: + 'This will use one execution for every event in any channel your bot is in, use with caution', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + trigger: ['any_event', 'message', 'reaction_added', 'file_share', 'app_mention'], + watchWorkspace: [true], + }, + }, + }, + { + displayName: 'Channel to Watch', + name: 'channelId', + type: 'resourceLocator', + required: true, + default: { mode: 'list', value: '' }, + placeholder: 'Select a channel...', + description: + 'The Slack channel to listen to events from. Applies to events: Bot/App mention, File Shared, New Message Posted on Channel, Reaction Added.', + displayOptions: { + show: { + watchWorkspace: [false], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a channel...', + typeOptions: { + searchListMethod: 'getChannels', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Slack Channel ID', + }, + }, + ], + placeholder: 'C0122KQ70S7E', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://app.slack.com/client/TS9594PZK/B0556F47Z3A', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', + errorMessage: 'Not a valid Slack Channel URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', + }, + }, + ], + }, + { + displayName: 'Download Files', + name: 'downloadFiles', + type: 'boolean', + default: false, + description: 'Whether to download the files and add it to the output', + displayOptions: { + show: { + trigger: ['any_event', 'file_share'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Resolve IDs', + name: 'resolveIds', + type: 'boolean', + default: false, + description: 'Whether to resolve the IDs to their respective names and return them', + }, + { + displayName: 'Usernames or IDs to Ignore', + name: 'userIds', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: [], + description: + 'A comma-separated string of encoded user IDs. Choose from the list, or specify IDs using an expression.', + }, + ], + }, + ], + }; + + methods = { + listSearch: { + async getChannels( + this: ILoadOptionsFunctions, + filter?: string, + ): Promise { + const qs = { types: 'public_channel,private_channel' }; + const channels = (await slackApiRequestAllItems.call( + this, + 'channels', + 'GET', + '/conversations.list', + {}, + qs, + )) as Array<{ id: string; name: string }>; + const results: INodeListSearchItems[] = channels + .map((c) => ({ + name: c.name, + value: c.id, + })) + .filter( + (c) => + !filter || + c.name.toLowerCase().includes(filter.toLowerCase()) || + c.value?.toString() === filter, + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + return { results }; + }, + }, + loadOptions: { + async getUsers(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const users = (await slackApiRequestAllItems.call( + this, + 'members', + 'GET', + '/users.list', + )) as Array<{ id: string; name: string }>; + for (const user of users) { + returnData.push({ + name: user.name, + value: user.id, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; + }); + + return returnData; + }, + }, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + return true; + }, + async create(this: IHookFunctions): Promise { + return true; + }, + async delete(this: IHookFunctions): Promise { + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const filters = this.getNodeParameter('trigger', []) as string[]; + const req = this.getRequestObject(); + const options = this.getNodeParameter('options', {}) as IDataObject; + const binaryData: IBinaryKeyData = {}; + const watchWorkspace = this.getNodeParameter('watchWorkspace', false) as boolean; + + // Check if the request is a challenge request + if (req.body.type === 'url_verification') { + const res = this.getResponseObject(); + res.status(200).json({ challenge: req.body.challenge }).end(); + + return { + noWebhookResponse: true, + }; + } + + // Check if the event type is in the filters + const eventType = req.body.event.type as string; + + if ( + !filters.includes('file_share') && + !filters.includes('any_event') && + !filters.includes(eventType) + ) { + return {}; + } + + const eventChannel = req.body.event.channel ?? req.body.event.item.channel; + + // Check for single channel + if (!watchWorkspace) { + if ( + eventChannel !== (this.getNodeParameter('channelId', {}, { extractValue: true }) as string) + ) { + return {}; + } + } + + // Check if user should be ignored + if (options.userIds) { + const userIds = options.userIds as string[]; + if (userIds.includes(req.body.event.user)) { + return {}; + } + } + + if (options.resolveIds) { + if (req.body.event.user) { + if (req.body.event.type === 'reaction_added') { + req.body.event.user_resolved = await getUserInfo.call(this, req.body.event.user); + req.body.event.item_user_resolved = await getUserInfo.call( + this, + req.body.event.item_user, + ); + } else { + req.body.event.user_resolved = await getUserInfo.call(this, req.body.event.user); + } + } + + if (eventChannel) { + const channel = await getChannelInfo.call(this, eventChannel); + const channelResolved = channel; + req.body.event.channel_resolved = channelResolved; + } + } + + if ( + req.body.event.subtype === 'file_share' && + (filters.includes('file_share') || filters.includes('any_event')) + ) { + if (this.getNodeParameter('downloadFiles', false) as boolean) { + for (let i = 0; i < req.body.event.files.length; i++) { + const file = (await downloadFile.call( + this, + req.body.event.files[i].url_private_download, + )) as Buffer; + + binaryData[`file_${i}`] = await this.helpers.prepareBinaryData( + file, + req.body.event.files[i].name, + req.body.event.files[i].mimetype, + ); + } + } + } + + return { + workflowData: [ + [ + { + json: req.body.event, + binary: Object.keys(binaryData).length ? binaryData : undefined, + }, + ], + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts b/packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts new file mode 100644 index 0000000000..a7f565c3f3 --- /dev/null +++ b/packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts @@ -0,0 +1,79 @@ +import type { IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { slackApiRequest } from './V2/GenericFunctions'; + +export async function getUserInfo(this: IWebhookFunctions, userId: string): Promise { + const user = await slackApiRequest.call( + this, + 'GET', + '/users.info', + {}, + { + user: userId, + }, + ); + + return user.user.name; +} + +export async function getChannelInfo(this: IWebhookFunctions, channelId: string): Promise { + const channel = await slackApiRequest.call( + this, + 'GET', + '/conversations.info', + {}, + { + channel: channelId, + }, + ); + + return channel.channel.name; +} + +export async function downloadFile(this: IWebhookFunctions, url: string): Promise { + let options: IHttpRequestOptions = { + method: 'GET', + url, + }; + + const requestOptions = { + encoding: 'arraybuffer', + returnFullResponse: true, + json: false, + useStream: true, + }; + + options = Object.assign({}, options, requestOptions); + + const response = await this.helpers.requestWithAuthentication.call(this, 'slackApi', options); + + if (response.ok === false) { + if (response.error === 'paid_teams_only') { + throw new NodeOperationError( + this.getNode(), + `Your current Slack plan does not include the resource '${ + this.getNodeParameter('resource', 0) as string + }'`, + { + description: + 'Hint: Upgrade to a Slack plan that includes the functionality you want to use.', + level: 'warning', + }, + ); + } else if (response.error === 'missing_scope') { + throw new NodeOperationError( + this.getNode(), + 'Your Slack credential is missing required Oauth Scopes', + { + description: `Add the following scope(s) to your Slack App: ${response.needed}`, + level: 'warning', + }, + ); + } + throw new NodeOperationError( + this.getNode(), + 'Slack error response: ' + JSON.stringify(response.error), + ); + } + return response; +} diff --git a/packages/nodes-base/nodes/Slack/SlackTriggger.node.json b/packages/nodes-base/nodes/Slack/SlackTriggger.node.json new file mode 100644 index 0000000000..2d7fbb859e --- /dev/null +++ b/packages/nodes-base/nodes/Slack/SlackTriggger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.slackTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Communication"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/slack" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts b/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts index 2554565239..f67d8beeb0 100644 --- a/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts @@ -5,6 +5,7 @@ import type { IOAuth2Options, IHttpRequestMethods, IRequestOptions, + IWebhookFunctions, } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; @@ -12,7 +13,7 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow'; import get from 'lodash/get'; export async function slackApiRequest( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: IHttpRequestMethods, resource: string, body: object = {}, @@ -88,6 +89,7 @@ export async function slackApiRequest( Object.assign(response, { message_timestamp: response.ts }); delete response.ts; } + return response; } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index d58703f055..5e481b1358 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -720,6 +720,7 @@ "dist/nodes/Simulate/Simulate.node.js", "dist/nodes/Simulate/SimulateTrigger.node.js", "dist/nodes/Slack/Slack.node.js", + "dist/nodes/Slack/SlackTrigger.node.js", "dist/nodes/Sms77/Sms77.node.js", "dist/nodes/Snowflake/Snowflake.node.js", "dist/nodes/SplitInBatches/SplitInBatches.node.js",