From 0e59df65fe1a5178458b5c0a7c174d39ddee1433 Mon Sep 17 00:00:00 2001 From: Jonathan Bennetts Date: Fri, 17 Apr 2026 16:35:18 +0100 Subject: [PATCH] feat(Slack Node): Add emoji reaction filter to Slack Trigger node Adds an optional 'Emoji Names to Filter' field to the Slack Trigger node options. When configured with a comma-separated list of emoji names, only reaction_added events matching the allowlist will start a workflow execution. Reactions not in the list are dropped before execution begins, preventing unnecessary execution budget consumption. The field is only shown when 'Reaction Added' is selected as a trigger event and defaults to empty (no filtering), preserving existing behavior. Co-Authored-By: Claude Sonnet 4.6 --- .../nodes/Slack/SlackTrigger.node.ts | 28 ++++ .../nodes/Slack/test/SlackTrigger.test.ts | 145 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts b/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts index eb54a2fc87e58..f2c8e93081262 100644 --- a/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts +++ b/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts @@ -230,6 +230,20 @@ export class SlackTrigger implements INodeType { description: 'A comma-separated string of encoded user IDs. Choose from the list, or specify IDs using an expression.', }, + { + displayName: 'Emoji Names to Filter', + name: 'reactionEmojis', + type: 'string', + default: '', + placeholder: 'thumbsup, eyes, white_check_mark', + description: + 'Comma-separated list of emoji names to allow (without colons). Leave empty to trigger on any reaction.', + displayOptions: { + show: { + '/trigger': ['reaction_added'], + }, + }, + }, ], }, ], @@ -375,6 +389,20 @@ export class SlackTrigger implements INodeType { } } + // Filter by reaction emoji for reaction_added events + if (eventType === 'reaction_added' && options.reactionEmojis) { + const allowedEmojis = (options.reactionEmojis as string) + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); + if (allowedEmojis.length > 0) { + const reaction = ((req.body.event.reaction as string | undefined) ?? '').toLowerCase(); + if (!allowedEmojis.includes(reaction)) { + return {}; + } + } + } + if (options.resolveIds) { if (req.body.event.user) { if (req.body.event.type === 'reaction_added') { diff --git a/packages/nodes-base/nodes/Slack/test/SlackTrigger.test.ts b/packages/nodes-base/nodes/Slack/test/SlackTrigger.test.ts index 38fe6fe4e1e63..9efcda747feee 100644 --- a/packages/nodes-base/nodes/Slack/test/SlackTrigger.test.ts +++ b/packages/nodes-base/nodes/Slack/test/SlackTrigger.test.ts @@ -464,6 +464,151 @@ describe('SlackTrigger Node', () => { }); }); + describe('webhook method - reaction emoji filter', () => { + const reactionRequest = (reaction: string) => ({ + body: { + type: 'event_callback', + event: { + type: 'reaction_added', + user: 'U456', + item: { channel: 'C123', ts: '1234567890.123456' }, + reaction, + }, + }, + }); + + beforeEach(() => { + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + switch (paramName) { + case 'trigger': + return ['reaction_added']; + case 'watchWorkspace': + return false; + case 'channelId': + return 'C123'; + case 'downloadFiles': + return false; + case 'options': + return {}; + default: + return defaultValue; + } + }, + ); + }); + + it('should trigger when no emoji filter is set', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('thumbsup') as any); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + }); + + it('should trigger when reaction matches the filter', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('thumbsup') as any); + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + if (paramName === 'options') return { reactionEmojis: 'thumbsup' }; + if (paramName === 'trigger') return ['reaction_added']; + if (paramName === 'watchWorkspace') return false; + if (paramName === 'channelId') return 'C123'; + return defaultValue; + }, + ); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + }); + + it('should not trigger when reaction does not match the filter', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('eyes') as any); + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + if (paramName === 'options') return { reactionEmojis: 'thumbsup' }; + if (paramName === 'trigger') return ['reaction_added']; + if (paramName === 'watchWorkspace') return false; + if (paramName === 'channelId') return 'C123'; + return defaultValue; + }, + ); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result).toEqual({}); + }); + + it('should support multiple comma-separated emoji names', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('eyes') as any); + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + if (paramName === 'options') return { reactionEmojis: 'thumbsup, eyes' }; + if (paramName === 'trigger') return ['reaction_added']; + if (paramName === 'watchWorkspace') return false; + if (paramName === 'channelId') return 'C123'; + return defaultValue; + }, + ); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + }); + + it('should match emoji names case-insensitively', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('thumbsup') as any); + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + if (paramName === 'options') return { reactionEmojis: 'ThumbsUp' }; + if (paramName === 'trigger') return ['reaction_added']; + if (paramName === 'watchWorkspace') return false; + if (paramName === 'channelId') return 'C123'; + return defaultValue; + }, + ); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + }); + + it('should trim whitespace around emoji names', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('thumbsup') as any); + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + if (paramName === 'options') return { reactionEmojis: ' thumbsup , eyes ' }; + if (paramName === 'trigger') return ['reaction_added']; + if (paramName === 'watchWorkspace') return false; + if (paramName === 'channelId') return 'C123'; + return defaultValue; + }, + ); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + }); + + it('should not trigger when filter has entries but reaction is an empty string', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue(reactionRequest('') as any); + mockWebhookFunctions.getNodeParameter.mockImplementation( + (paramName: string, defaultValue?: any) => { + if (paramName === 'options') return { reactionEmojis: 'thumbsup' }; + if (paramName === 'trigger') return ['reaction_added']; + if (paramName === 'watchWorkspace') return false; + if (paramName === 'channelId') return 'C123'; + return defaultValue; + }, + ); + + const result = await slackTrigger.webhook!.call(mockWebhookFunctions); + + expect(result).toEqual({}); + }); + }); + describe('webhook method - other event scenarios', () => { it('should handle team_join event (no channel extraction needed)', async () => { const mockRequest = {