diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 432cc6e621..202cbc854d 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -31,6 +31,7 @@ import type { IWorkflowExecuteAdditionalData, WorkflowTestData, RelatedExecution, + IExecuteFunctions, } from 'n8n-workflow'; import { ApplicationError, @@ -1462,4 +1463,90 @@ describe('WorkflowExecute', () => { expect(runExecutionData.executionData?.nodeExecutionStack).toContain(executionData); }); }); + + describe('customOperations', () => { + const nodeTypes = mock(); + const testNode = mock(); + + const workflow = new Workflow({ + nodeTypes, + nodes: [testNode], + connections: {}, + active: false, + }); + + const executionData = mock({ + node: { parameters: { resource: 'test', operation: 'test' } }, + data: { main: [[{ json: {} }]] }, + }); + const runExecutionData = mock(); + const additionalData = mock(); + const workflowExecute = new WorkflowExecute(additionalData, 'manual'); + + test('should execute customOperations', async () => { + const nodeType = mock({ + description: { + properties: [], + }, + execute: undefined, + customOperations: { + test: { + async test(this: IExecuteFunctions) { + return [[{ json: { customOperationsRun: true } }]]; + }, + }, + }, + }); + + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + const runPromise = workflowExecute.runNode( + workflow, + executionData, + runExecutionData, + 0, + additionalData, + 'manual', + ); + + const result = await runPromise; + + expect(result).toEqual({ data: [[{ json: { customOperationsRun: true } }]], hints: [] }); + }); + + test('should throw error if customOperation and execute both defined', async () => { + const nodeType = mock({ + description: { + properties: [], + }, + async execute(this: IExecuteFunctions) { + return []; + }, + customOperations: { + test: { + async test(this: IExecuteFunctions) { + return []; + }, + }, + }, + }); + + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + try { + await workflowExecute.runNode( + workflow, + executionData, + runExecutionData, + 0, + additionalData, + 'manual', + ); + } catch (error) { + expect(error.message).toBe( + 'Node type cannot have both customOperations and execute defined', + ); + } + }); + }); }); diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index 9f777b68ae..c224530379 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -40,6 +40,7 @@ import type { IRunNodeResponse, IWorkflowIssues, INodeIssues, + INodeType, } from 'n8n-workflow'; import { LoggerProxy as Logger, @@ -49,6 +50,7 @@ import { sleep, ExecutionCancelledError, Node, + UnexpectedError, } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; @@ -971,6 +973,26 @@ export class WorkflowExecute { return workflowIssues; } + private getCustomOperation(node: INode, type: INodeType) { + if (!type.customOperations) return undefined; + + if (type.execute) { + throw new UnexpectedError('Node type cannot have both customOperations and execute defined'); + } + + if (!node.parameters) return undefined; + + const { customOperations } = type; + const { resource, operation } = node.parameters; + + if (typeof resource !== 'string' || typeof operation !== 'string') return undefined; + if (!customOperations[resource] || !customOperations[resource][operation]) return undefined; + + const customOperation = customOperations[resource][operation]; + + return customOperation; + } + /** Executes the given node */ // eslint-disable-next-line complexity async runNode( @@ -1000,8 +1022,16 @@ export class WorkflowExecute { const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + const isDeclarativeNode = nodeType.description.requestDefaults !== undefined; + + const customOperation = this.getCustomOperation(node, nodeType); + let connectionInputData: INodeExecutionData[] = []; - if (nodeType.execute || (!nodeType.poll && !nodeType.trigger && !nodeType.webhook)) { + if ( + nodeType.execute || + customOperation || + (!nodeType.poll && !nodeType.trigger && !nodeType.webhook) + ) { // Only stop if first input is empty for execute runs. For all others run anyways // because then it is a trigger node. As they only pass data through and so the input-data // becomes output-data it has to be possible. @@ -1060,7 +1090,7 @@ export class WorkflowExecute { inputData = newInputData; } - if (nodeType.execute) { + if (nodeType.execute || customOperation) { const closeFunctions: CloseFunction[] = []; const context = new ExecuteContext( workflow, @@ -1076,10 +1106,16 @@ export class WorkflowExecute { abortSignal, ); - const data = - nodeType instanceof Node - ? await nodeType.execute(context) - : await nodeType.execute.call(context); + let data; + + if (customOperation) { + data = await customOperation.call(context); + } else if (nodeType.execute) { + data = + nodeType instanceof Node + ? await nodeType.execute(context) + : await nodeType.execute.call(context); + } const closeFunctionsResults = await Promise.allSettled( closeFunctions.map(async (fn) => await fn()), @@ -1152,8 +1188,10 @@ export class WorkflowExecute { } // For trigger nodes in any mode except "manual" do we simply pass the data through return { data: inputData.main as INodeExecutionData[][] }; - } else if (nodeType.webhook) { - // For webhook nodes always simply pass the data through + } else if (nodeType.webhook && !isDeclarativeNode) { + // Check if the node have requestDefaults(Declarative Node), + // else for webhook nodes always simply pass the data through + // as webhook method would be called by WebhookService return { data: inputData.main as INodeExecutionData[][] }; } else { // NOTE: This block is only called by nodes tests. diff --git a/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts b/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts index 6f027ccd8e..db677d1bf8 100644 --- a/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts +++ b/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts @@ -14,6 +14,8 @@ import type { WhatsAppAppWebhookSubscriptionsResponse, WhatsAppAppWebhookSubscription, } from './types'; +import type { SendAndWaitConfig } from '../../utils/sendAndWait/utils'; +export const WHATSAPP_BASE_URL = 'https://graph.facebook.com/v13.0/'; async function appAccessTokenRead( this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, @@ -102,3 +104,27 @@ export async function appWebhookSubscriptionDelete( payload: { object }, }); } + +export const createMessage = ( + sendAndWaitConfig: SendAndWaitConfig, + phoneNumberId: string, + recipientPhoneNumber: string, +): IHttpRequestOptions => { + const buttons = sendAndWaitConfig.options.map((option) => { + return `*${option.label}:*\n_${sendAndWaitConfig.url}?approved=${option.value}_\n\n`; + }); + + return { + baseURL: WHATSAPP_BASE_URL, + method: 'POST', + url: `${phoneNumberId}/messages`, + body: { + messaging_product: 'whatsapp', + text: { + body: `${sendAndWaitConfig.message}\n\n${buttons.join('')}`, + }, + type: 'text', + to: recipientPhoneNumber, + }, + }; +}; diff --git a/packages/nodes-base/nodes/WhatsApp/MessageFunctions.ts b/packages/nodes-base/nodes/WhatsApp/MessageFunctions.ts index 3cecb0811e..c1602ee620 100644 --- a/packages/nodes-base/nodes/WhatsApp/MessageFunctions.ts +++ b/packages/nodes-base/nodes/WhatsApp/MessageFunctions.ts @@ -169,12 +169,13 @@ export async function componentsRequest( return requestOptions; } +export const sanitizePhoneNumber = (phoneNumber: string) => phoneNumber.replace(/[\-\(\)\+]/g, ''); + export async function cleanPhoneNumber( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, ): Promise { - let phoneNumber = this.getNodeParameter('recipientPhoneNumber') as string; - phoneNumber = phoneNumber.replace(/[\-\(\)\+]/g, ''); + const phoneNumber = sanitizePhoneNumber(this.getNodeParameter('recipientPhoneNumber') as string); if (!requestOptions.body) { requestOptions.body = {}; diff --git a/packages/nodes-base/nodes/WhatsApp/MessagesDescription.ts b/packages/nodes-base/nodes/WhatsApp/MessagesDescription.ts index 10fdc64d3d..787868b41a 100644 --- a/packages/nodes-base/nodes/WhatsApp/MessagesDescription.ts +++ b/packages/nodes-base/nodes/WhatsApp/MessagesDescription.ts @@ -1,5 +1,5 @@ import countryCodes from 'currency-codes'; -import type { INodeProperties } from 'n8n-workflow'; +import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow'; import { cleanPhoneNumber, @@ -32,6 +32,11 @@ export const messageFields: INodeProperties[] = [ value: 'send', action: 'Send message', }, + { + name: 'Send and Wait for Response', + value: SEND_AND_WAIT_OPERATION, + action: 'Send message and wait for response', + }, { name: 'Send Template', value: 'sendTemplate', diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.json b/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.json index d495137617..e073a679a3 100644 --- a/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.json +++ b/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.json @@ -2,7 +2,10 @@ "node": "n8n-nodes-base.whatsApp", "nodeVersion": "1.0", "codexVersion": "1.0", - "categories": ["Communication"], + "categories": ["Communication", "HITL"], + "subcategories": { + "HITL": ["Human in the Loop"] + }, "resources": { "credentialDocumentation": [ { diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts b/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts index a89b22184b..ecfae62334 100644 --- a/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts +++ b/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts @@ -1,8 +1,19 @@ -import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; +import { createMessage, WHATSAPP_BASE_URL } from './GenericFunctions'; import { mediaFields, mediaTypeFields } from './MediaDescription'; +import { sanitizePhoneNumber } from './MessageFunctions'; import { messageFields, messageTypeFields } from './MessagesDescription'; +import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util'; +import { sendAndWaitWebhooksDescription } from '../../utils/sendAndWait/descriptions'; +import { + getSendAndWaitConfig, + getSendAndWaitProperties, + sendAndWaitWebhook, +} from '../../utils/sendAndWait/utils'; + +const WHATSAPP_CREDENTIALS_TYPE = 'whatsAppApi'; export class WhatsApp implements INodeType { description: INodeTypeDescription = { @@ -19,14 +30,15 @@ export class WhatsApp implements INodeType { usableAsTool: true, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + webhooks: sendAndWaitWebhooksDescription, credentials: [ { - name: 'whatsAppApi', + name: WHATSAPP_CREDENTIALS_TYPE, required: true, }, ], requestDefaults: { - baseURL: 'https://graph.facebook.com/v13.0/', + baseURL: WHATSAPP_BASE_URL, }, properties: [ { @@ -50,6 +62,42 @@ export class WhatsApp implements INodeType { ...mediaFields, ...messageTypeFields, ...mediaTypeFields, + ...getSendAndWaitProperties([], 'message', undefined, { + noButtonStyle: true, + defaultApproveLabel: '✓ Approve', + defaultDisapproveLabel: '✗ Decline', + }).filter((p) => p.name !== 'subject'), ], }; + + webhook = sendAndWaitWebhook; + + customOperations = { + message: { + async [SEND_AND_WAIT_OPERATION](this: IExecuteFunctions) { + try { + const phoneNumberId = this.getNodeParameter('phoneNumberId', 0) as string; + + const recipientPhoneNumber = sanitizePhoneNumber( + this.getNodeParameter('recipientPhoneNumber', 0) as string, + ); + + const config = getSendAndWaitConfig(this); + + await this.helpers.httpRequestWithAuthentication.call( + this, + WHATSAPP_CREDENTIALS_TYPE, + createMessage(config, phoneNumberId, recipientPhoneNumber), + ); + + const waitTill = configureWaitTillDate(this); + + await this.putExecutionToWait(waitTill); + return [this.getInputData()]; + } catch (error) { + throw new NodeOperationError(this.getNode(), error); + } + }, + }, + }; } diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts index a9603fb25f..adeb6b4901 100644 --- a/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts +++ b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts @@ -8,6 +8,7 @@ import { type IWebhookFunctions, type IWebhookResponseData, NodeConnectionType, + type INodeProperties, } from 'n8n-workflow'; import { @@ -16,7 +17,58 @@ import { appWebhookSubscriptionList, } from './GenericFunctions'; import type { WhatsAppPageEvent } from './types'; -import { whatsappTriggerDescription } from './WhatsappDescription'; + +const whatsappTriggerDescription: INodeProperties[] = [ + { + displayName: 'Trigger On', + name: 'updates', + type: 'multiOptions', + required: true, + default: [], + options: [ + { + name: 'Account Review Update', + value: 'account_review_update', + }, + { + name: 'Account Update', + value: 'account_update', + }, + { + name: 'Business Capability Update', + value: 'business_capability_update', + }, + { + name: 'Message Template Quality Update', + value: 'message_template_quality_update', + }, + { + name: 'Message Template Status Update', + value: 'message_template_status_update', + }, + { + name: 'Messages', + value: 'messages', + }, + { + name: 'Phone Number Name Update', + value: 'phone_number_name_update', + }, + { + name: 'Phone Number Quality Update', + value: 'phone_number_quality_update', + }, + { + name: 'Security', + value: 'security', + }, + { + name: 'Template Category Update', + value: 'template_category_update', + }, + ], + }, +]; export class WhatsAppTrigger implements INodeType { description: INodeTypeDescription = { diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts b/packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts deleted file mode 100644 index 564cb3bc7f..0000000000 --- a/packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; - -export const whatsappTriggerDescription: INodeProperties[] = [ - { - displayName: 'Trigger On', - name: 'updates', - type: 'multiOptions', - required: true, - default: [], - options: [ - { - name: 'Account Review Update', - value: 'account_review_update', - }, - { - name: 'Account Update', - value: 'account_update', - }, - { - name: 'Business Capability Update', - value: 'business_capability_update', - }, - { - name: 'Message Template Quality Update', - value: 'message_template_quality_update', - }, - { - name: 'Message Template Status Update', - value: 'message_template_status_update', - }, - { - name: 'Messages', - value: 'messages', - }, - { - name: 'Phone Number Name Update', - value: 'phone_number_name_update', - }, - { - name: 'Phone Number Quality Update', - value: 'phone_number_quality_update', - }, - { - name: 'Security', - value: 'security', - }, - { - name: 'Template Category Update', - value: 'template_category_update', - }, - ], - }, -]; diff --git a/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts b/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts new file mode 100644 index 0000000000..f3f46ca021 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts @@ -0,0 +1,68 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import { type IExecuteFunctions } from 'n8n-workflow'; + +import { WhatsApp } from '../../WhatsApp.node'; + +describe('Test WhatsApp Business Cloud, sendAndWait operation', () => { + let whatsApp: WhatsApp; + let mockExecuteFunctions: MockProxy; + + beforeEach(() => { + whatsApp = new WhatsApp(); + mockExecuteFunctions = mock(); + + mockExecuteFunctions.helpers = { + httpRequestWithAuthentication: jest.fn().mockResolvedValue({}), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should send message and put execution to wait', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => { + if (key === 'phoneNumberId') return '11111'; + if (key === 'recipientPhoneNumber') return '22222'; + if (key === 'message') return 'my message'; + if (key === 'subject') return ''; + if (key === 'approvalOptions.values') return {}; + if (key === 'responseType') return 'approval'; + if (key === 'sendTo') return 'channel'; + if (key === 'channelId') return 'channelID'; + if (key === 'options.limitWaitTime.values') return {}; + }); + + mockExecuteFunctions.putExecutionToWait.mockImplementation(); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID'); + + const result = await whatsApp.customOperations.message.sendAndWait.call(mockExecuteFunctions); + + expect(result).toEqual([items]); + + expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1); + + expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'whatsAppApi', + { + baseURL: 'https://graph.facebook.com/v13.0/', + body: { + messaging_product: 'whatsapp', + text: { + body: 'my message\n\n*Approve:*\n_http://localhost/waiting-webhook/nodeID?approved=true_\n\n', + }, + to: '22222', + type: 'text', + }, + method: 'POST', + url: '11111/messages', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts b/packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts new file mode 100644 index 0000000000..e881a18669 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts @@ -0,0 +1,96 @@ +import type { IHttpRequestOptions } from 'n8n-workflow'; + +import type { SendAndWaitConfig } from '../../../utils/sendAndWait/utils'; +import { createMessage, WHATSAPP_BASE_URL } from '../GenericFunctions'; +import { sanitizePhoneNumber } from '../MessageFunctions'; + +describe('sanitizePhoneNumber', () => { + const testNumber = '+99-(000)-111-2222'; + + it('should remove hyphens, parentheses, and plus signs from the phone number', () => { + expect(sanitizePhoneNumber(testNumber)).toBe('990001112222'); + }); + + it('should return an empty string if input is empty', () => { + expect(sanitizePhoneNumber('')).toBe(''); + }); + + it('should return the same number if no special characters are present', () => { + expect(sanitizePhoneNumber('990001112222')).toBe('990001112222'); + }); + + it('should handle numbers with spaces correctly (not removing them)', () => { + expect(sanitizePhoneNumber('+99 000 111 2222')).toBe('99 000 111 2222'); + }); +}); + +describe('createMessage', () => { + const mockSendAndWaitConfig: SendAndWaitConfig = { + title: '', + message: 'Please approve an option:', + url: 'https://example.com/approve', + options: [ + { label: 'Yes', value: 'yes', style: 'primary' }, + { label: 'No', value: 'no', style: 'secondary' }, + ], + }; + + const phoneID = '123456789'; + const recipientPhone = '990001112222'; + + it('should return a valid HTTP request object', () => { + const request: IHttpRequestOptions = createMessage( + mockSendAndWaitConfig, + phoneID, + recipientPhone, + ); + + expect(request).toEqual({ + baseURL: WHATSAPP_BASE_URL, + method: 'POST', + url: `${phoneID}/messages`, + body: { + messaging_product: 'whatsapp', + text: { + body: + 'Please approve an option:\n\n' + + '*Yes:*\n_https://example.com/approve?approved=yes_\n\n' + + '*No:*\n_https://example.com/approve?approved=no_\n\n', + }, + type: 'text', + to: recipientPhone, + }, + }); + }); + + it('should handle a single option correctly', () => { + const singleOptionConfig: SendAndWaitConfig = { + title: '', + message: 'Choose an option:', + url: 'https://example.com/approve', + options: [ + { + label: 'Confirm', + value: 'confirm', + style: '', + }, + ], + }; + + const request: IHttpRequestOptions = createMessage(singleOptionConfig, phoneID, recipientPhone); + + expect(request).toEqual({ + baseURL: WHATSAPP_BASE_URL, + method: 'POST', + url: `${phoneID}/messages`, + body: { + messaging_product: 'whatsapp', + text: { + body: 'Choose an option:\n\n*Confirm:*\n_https://example.com/approve?approved=confirm_\n\n', + }, + type: 'text', + to: recipientPhone, + }, + }); + }); +}); diff --git a/packages/nodes-base/utils/sendAndWait/utils.ts b/packages/nodes-base/utils/sendAndWait/utils.ts index 96a4114d63..6fe569825f 100644 --- a/packages/nodes-base/utils/sendAndWait/utils.ts +++ b/packages/nodes-base/utils/sendAndWait/utils.ts @@ -24,7 +24,7 @@ import { formFieldsProperties } from '../../nodes/Form/Form.node'; import { prepareFormData, prepareFormReturnItem, resolveRawData } from '../../nodes/Form/utils'; import { escapeHtml } from '../utilities'; -type SendAndWaitConfig = { +export type SendAndWaitConfig = { title: string; message: string; url: string; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index d9203af8d1..46a4713491 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1560,12 +1560,12 @@ export interface SupplyData { closeFunction?: CloseFunction; } +type NodeOutput = INodeExecutionData[][] | NodeExecutionWithMetadata[][] | null; + export interface INodeType { description: INodeTypeDescription; supplyData?(this: ISupplyDataFunctions, itemIndex: number): Promise; - execute?( - this: IExecuteFunctions, - ): Promise; + execute?(this: IExecuteFunctions): Promise; poll?(this: IPollFunctions): Promise; trigger?(this: ITriggerFunctions): Promise; webhook?(this: IWebhookFunctions): Promise; @@ -1602,6 +1602,18 @@ export interface INodeType { [method in WebhookSetupMethodNames]: (this: IHookFunctions) => Promise; }; }; + /** + * Defines custom operations for nodes that do not implement an `execute` method, such as declarative nodes. + * This function will be invoked instead of `execute` for a specific resource and operation. + * Should be either `execute` or `customOperations` defined for a node, but not both. + * + * @property customOperations - Maps specific resource and operation to a custom function + */ + customOperations?: { + [resource: string]: { + [operation: string]: (this: IExecuteFunctions) => Promise; + }; + }; } /**