mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat: WhatsApp Business Cloud Node - new operation sendAndWait (#12941)
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<IHttpRequestOptions> {
|
||||
let phoneNumber = this.getNodeParameter('recipientPhoneNumber') as string;
|
||||
phoneNumber = phoneNumber.replace(/[\-\(\)\+]/g, '');
|
||||
const phoneNumber = sanitizePhoneNumber(this.getNodeParameter('recipientPhoneNumber') as string);
|
||||
|
||||
if (!requestOptions.body) {
|
||||
requestOptions.body = {};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
whatsApp = new WhatsApp();
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
|
||||
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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
96
packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts
Normal file
96
packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user