first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,139 @@
import type {
IDataObject,
IExecuteFunctions,
IHookFunctions,
IHttpRequestMethods,
IHttpRequestOptions,
ILoadOptionsFunctions,
IWebhookFunctions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import type {
WhatsAppAppWebhookSubscriptionsResponse,
WhatsAppAppWebhookSubscription,
} from './types';
import type { SendAndWaitConfig } from '../../utils/sendAndWait/utils';
import { createUtmCampaignLink } from '../../utils/utilities';
export const WHATSAPP_BASE_URL = 'https://graph.facebook.com/v13.0/';
async function appAccessTokenRead(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
): Promise<{ access_token: string }> {
const credentials = await this.getCredentials('whatsAppTriggerApi');
const options: IHttpRequestOptions = {
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
method: 'POST',
body: {
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
grant_type: 'client_credentials',
},
url: 'https://graph.facebook.com/v19.0/oauth/access_token',
json: true,
};
try {
return await this.helpers.httpRequest.call(this, options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
async function whatsappApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
method: IHttpRequestMethods,
resource: string,
body?: { type: 'json'; payload: IDataObject } | { type: 'form'; payload: IDataObject },
qs: IDataObject = {},
): Promise<any> {
const tokenResponse = await appAccessTokenRead.call(this);
const appAccessToken = tokenResponse.access_token;
const options: IHttpRequestOptions = {
headers: {
accept: 'application/json',
authorization: `Bearer ${appAccessToken}`,
},
method,
qs,
body: body?.payload,
url: `https://graph.facebook.com/v19.0${resource}`,
json: true,
};
try {
return await this.helpers.httpRequest.call(this, options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function appWebhookSubscriptionList(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
appId: string,
): Promise<WhatsAppAppWebhookSubscription[]> {
const response = (await whatsappApiRequest.call(
this,
'GET',
`/${appId}/subscriptions`,
)) as WhatsAppAppWebhookSubscriptionsResponse;
return response.data;
}
export async function appWebhookSubscriptionCreate(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
appId: string,
subscription: IDataObject,
) {
return await whatsappApiRequest.call(this, 'POST', `/${appId}/subscriptions`, {
type: 'form',
payload: { ...subscription },
});
}
export async function appWebhookSubscriptionDelete(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
appId: string,
object: string,
) {
return await whatsappApiRequest.call(this, 'DELETE', `/${appId}/subscriptions`, {
type: 'form',
payload: { object },
});
}
export const createMessage = (
sendAndWaitConfig: SendAndWaitConfig,
phoneNumberId: string,
recipientPhoneNumber: string,
instanceId: string,
): IHttpRequestOptions => {
const buttons = sendAndWaitConfig.options.map((option) => {
return `*${option.label}:*\n_${option.url}_\n\n`;
});
let n8nAttribution: string = '';
if (sendAndWaitConfig.appendAttribution) {
const attributionText = 'This message was sent automatically with ';
const link = createUtmCampaignLink('n8n-nodes-base.whatsapp', instanceId);
n8nAttribution = `\n\n${attributionText}${link}`;
}
return {
baseURL: WHATSAPP_BASE_URL,
method: 'POST',
url: `${phoneNumberId}/messages`,
body: {
messaging_product: 'whatsapp',
text: {
body: `${sendAndWaitConfig.message}\n\n${buttons.join('')}${n8nAttribution}`,
},
type: 'text',
to: recipientPhoneNumber,
},
};
};
@@ -0,0 +1,185 @@
import type { INodeProperties } from 'n8n-workflow';
import { setupUpload } from './MediaFunctions';
export const mediaFields: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
noDataExpression: true,
type: 'options',
placeholder: '',
options: [
{
name: 'Upload',
value: 'mediaUpload',
action: 'Upload media',
},
{
name: 'Download',
value: 'mediaUrlGet',
action: 'Download media',
},
{
name: 'Delete',
value: 'mediaDelete',
action: 'Delete media',
},
],
default: 'mediaUpload',
displayOptions: {
show: {
resource: ['media'],
},
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-weak
description: 'The operation to perform on the media',
},
];
export const mediaTypeFields: INodeProperties[] = [
// ----------------------------------
// operation: mediaUpload
// ----------------------------------
{
displayName: 'Sender Phone Number (or ID)',
name: 'phoneNumberId',
type: 'options',
typeOptions: {
loadOptions: {
routing: {
request: {
url: '={{$credentials.businessAccountId}}/phone_numbers',
method: 'GET',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.display_phone_number}} - {{$responseItem.verified_name}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
default: '',
placeholder: '',
routing: {
request: {
method: 'POST',
url: '={{$value}}/media',
},
},
displayOptions: {
show: {
operation: ['mediaUpload'],
resource: ['media'],
},
},
required: true,
description: "The ID of the business account's phone number to store the media",
},
{
displayName: 'Property Name',
name: 'mediaPropertyName',
type: 'string',
default: 'data',
displayOptions: {
show: {
operation: ['mediaUpload'],
resource: ['media'],
},
},
required: true,
description: 'Name of the binary property which contains the data for the file to be uploaded',
routing: {
send: {
preSend: [setupUpload],
},
},
},
// ----------------------------------
// type: mediaUrlGet
// ----------------------------------
{
displayName: 'Media ID',
name: 'mediaGetId',
type: 'string',
default: '',
displayOptions: {
show: {
operation: ['mediaUrlGet'],
resource: ['media'],
},
},
routing: {
request: {
method: 'GET',
url: '=/{{$value}}',
},
},
required: true,
description: 'The ID of the media',
},
// ----------------------------------
// type: mediaUrlGet
// ----------------------------------
{
displayName: 'Media ID',
name: 'mediaDeleteId',
type: 'string',
default: '',
displayOptions: {
show: {
operation: ['mediaDelete'],
resource: ['media'],
},
},
routing: {
request: {
method: 'DELETE',
url: '=/{{$value}}',
},
},
required: true,
description: 'The ID of the media',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['media'],
operation: ['mediaUpload'],
},
},
options: [
{
displayName: 'Filename',
name: 'mediaFileName',
type: 'string',
default: '',
description: 'The name to use for the file',
},
],
},
];
@@ -0,0 +1,38 @@
import FormData from 'form-data';
import type { IDataObject, IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export async function getUploadFormData(
this: IExecuteSingleFunctions,
): Promise<{ fileName: string; formData: FormData }> {
const mediaPropertyName = ((this.getNodeParameter('mediaPropertyName') as string) || '').trim();
if (!mediaPropertyName)
throw new NodeOperationError(this.getNode(), 'Parameter "mediaPropertyName" is not defined');
const binaryData = this.helpers.assertBinaryData(mediaPropertyName);
const mediaFileName = (this.getNodeParameter('additionalFields') as IDataObject).mediaFileName as
| string
| undefined;
const fileName = mediaFileName || binaryData.fileName;
if (!fileName)
throw new NodeOperationError(this.getNode(), 'No file name given for media upload.');
const buffer = await this.helpers.getBinaryDataBuffer(mediaPropertyName);
const formData = new FormData();
formData.append('file', buffer, { contentType: binaryData.mimeType, filename: fileName });
formData.append('messaging_product', 'whatsapp');
return { fileName, formData };
}
export async function setupUpload(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) {
const uploadData = await getUploadFormData.call(this);
requestOptions.body = uploadData.formData;
return requestOptions;
}
@@ -0,0 +1,239 @@
import set from 'lodash/set';
import type {
IDataObject,
IExecuteSingleFunctions,
IHttpRequestOptions,
IN8nHttpFullResponse,
INodeExecutionData,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { getUploadFormData } from './MediaFunctions';
interface WhatsAppApiError {
error: {
message: string;
type: string;
code: number;
fbtrace_id: string;
};
}
export async function addTemplateComponents(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) {
const params = this.getNodeParameter('templateParameters') as IDataObject;
if (!params?.parameter) {
return requestOptions;
}
const components = [
{
type: 'body',
parameters: params.parameter,
},
];
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as IDataObject, 'template.components', components);
return requestOptions;
}
export async function setType(this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions) {
const operation = this.getNodeParameter('operation') as string;
const messageType = this.getNodeParameter('messageType', null) as string | null;
let actualType = messageType;
if (operation === 'sendTemplate') {
actualType = 'template';
}
if (requestOptions.body) {
Object.assign(requestOptions.body, { type: actualType });
}
return requestOptions;
}
export async function mediaUploadFromItem(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) {
const uploadData = await getUploadFormData.call(this);
const phoneNumberId = this.getNodeParameter('phoneNumberId') as string;
const result = (await this.helpers.httpRequestWithAuthentication.call(this, 'whatsAppApi', {
url: `/${phoneNumberId}/media`,
baseURL: requestOptions.baseURL,
method: 'POST',
body: uploadData.formData,
})) as IDataObject;
const operation = this.getNodeParameter('messageType') as string;
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as IDataObject, [operation, 'id'], result.id);
if (operation === 'document') {
set(requestOptions.body as IDataObject, [operation, 'filename'], uploadData.fileName);
}
return requestOptions;
}
export async function templateInfo(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const template = this.getNodeParameter('template') as string;
const [name, language] = template.split('|');
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as IDataObject, 'template.name', name);
set(requestOptions.body as IDataObject, 'template.language.code', language);
return requestOptions;
}
export async function componentsRequest(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const components = this.getNodeParameter('components') as IDataObject;
const componentsRet: object[] = [];
if (!components?.component) {
return requestOptions;
}
for (const component of components.component as IDataObject[]) {
const comp: IDataObject = {
type: component.type,
};
if (component.type === 'body') {
comp.parameters = (
((component.bodyParameters as IDataObject).parameter as IDataObject[]) || []
).map((i: IDataObject) => {
if (i.type === 'text') {
return i;
} else if (i.type === 'currency') {
return {
type: 'currency',
currency: {
code: i.code,
fallback_value: i.fallback_value,
amount_1000: (i.amount_1000 as number) * 1000,
},
};
} else if (i.type === 'date_time') {
return {
type: 'date_time',
date_time: {
fallback_value: i.date_time,
},
};
}
});
} else if (component.type === 'button') {
comp.index = component.index?.toString();
comp.sub_type = component.sub_type;
comp.parameters = [(component.buttonParameters as IDataObject).parameter];
} else if (component.type === 'header') {
comp.parameters = (
(component.headerParameters as IDataObject).parameter as IDataObject[]
).map((i: IDataObject) => {
if (i.type === 'image') {
return {
type: 'image',
image: {
link: i.imageLink,
},
};
}
return i;
});
}
componentsRet.push(comp);
}
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as IDataObject, 'template.components', componentsRet);
return requestOptions;
}
export const sanitizePhoneNumber = (phoneNumber: string) => phoneNumber.replace(/[\-\(\)\+]/g, '');
export async function cleanPhoneNumber(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const phoneNumber = sanitizePhoneNumber(this.getNodeParameter('recipientPhoneNumber') as string);
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as IDataObject, 'to', phoneNumber);
return requestOptions;
}
export async function sendErrorPostReceive(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode === 500) {
throw new NodeApiError(
this.getNode(),
{},
{
message: 'Sending failed',
description:
'If youre sending to a new test number, try sending a message to it from within the Meta developer portal first.',
httpCode: '500',
},
);
} else if (response.statusCode === 400) {
const error = { ...(response.body as WhatsAppApiError).error };
error.message = error.message.replace(/^\(#\d+\) /, '');
const messageType = this.getNodeParameter('messageType', 'media');
if (error.message.endsWith('is not a valid whatsapp business account media attachment ID')) {
throw new NodeApiError(
this.getNode(),
{ error },
{
message: `Invalid ${messageType} ID`,
description: error.message,
httpCode: '400',
},
);
} else if (error.message.endsWith('is not a valid URI.')) {
throw new NodeApiError(
this.getNode(),
{ error },
{
message: `Invalid ${messageType} URL`,
description: error.message,
httpCode: '400',
},
);
}
throw new NodeApiError(
this.getNode(),
{ ...(response as unknown as JsonObject), body: { error } },
{},
);
} else if (response.statusCode > 399) {
throw new NodeApiError(this.getNode(), response as unknown as JsonObject);
}
return data;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
{
"node": "n8n-nodes-base.whatsApp",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication", "HITL"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/whatsapp/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.whatsapp/"
}
]
}
}
@@ -0,0 +1,107 @@
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes, 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,
SEND_AND_WAIT_WAITING_TOOLTIP,
sendAndWaitWebhook,
} from '../../utils/sendAndWait/utils';
const WHATSAPP_CREDENTIALS_TYPE = 'whatsAppApi';
export class WhatsApp implements INodeType {
description: INodeTypeDescription = {
displayName: 'WhatsApp Business Cloud',
name: 'whatsApp',
icon: 'file:whatsapp.svg',
group: ['output'],
version: [1, 1.1],
defaultVersion: 1.1,
subtitle: '={{ $parameter["resource"] + ": " + $parameter["operation"] }}',
description: 'Access WhatsApp API',
defaults: {
name: 'WhatsApp Business Cloud',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
waitingNodeTooltip: SEND_AND_WAIT_WAITING_TOOLTIP,
webhooks: sendAndWaitWebhooksDescription,
credentials: [
{
name: WHATSAPP_CREDENTIALS_TYPE,
required: true,
},
],
requestDefaults: {
baseURL: WHATSAPP_BASE_URL,
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Message',
value: 'message',
},
{
name: 'Media',
value: 'media',
},
],
default: 'message',
},
...messageFields,
...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);
const instanceId = this.getInstanceId();
await this.helpers.httpRequestWithAuthentication.call(
this,
WHATSAPP_CREDENTIALS_TYPE,
createMessage(config, phoneNumberId, recipientPhoneNumber, instanceId),
);
const waitTill = configureWaitTillDate(this);
await this.putExecutionToWait(waitTill);
return [this.getInputData()];
} catch (error) {
throw new NodeOperationError(this.getNode(), error);
}
},
},
};
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.whatsAppTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/whatsapp/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.whatsapptrigger/"
}
]
}
}
@@ -0,0 +1,305 @@
import { createHmac } from 'crypto';
import {
NodeOperationError,
type IDataObject,
type IHookFunctions,
type INodeType,
type INodeTypeDescription,
type IWebhookFunctions,
type IWebhookResponseData,
NodeConnectionTypes,
} from 'n8n-workflow';
import {
appWebhookSubscriptionCreate,
appWebhookSubscriptionDelete,
appWebhookSubscriptionList,
} from './GenericFunctions';
import type { WhatsAppPageEvent } from './types';
export const filterStatuses = (
events: Array<{ statuses?: Array<{ status: string }> }>,
allowedStatuses: string[] | undefined,
) => {
if (!allowedStatuses) return events;
// If allowedStatuses is empty filter out events with statuses
if (!allowedStatuses.length) {
return events.filter((event) => (event?.statuses ? false : true));
}
// If 'all' is not in allowedStatuses, return only events with allowed status
if (!allowedStatuses.includes('all')) {
return events.filter((event) => {
const statuses = event.statuses;
if (statuses?.length) {
return statuses.some((status) => allowedStatuses.includes(status.status));
}
return true;
});
}
return events;
};
export class WhatsAppTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'WhatsApp Trigger',
name: 'whatsAppTrigger',
icon: 'file:whatsapp.svg',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["event"]}}',
description: 'Handle WhatsApp events via webhooks',
defaults: {
name: 'WhatsApp Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'whatsAppTriggerApi',
required: true,
},
],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
path: 'webhook',
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName:
'Due to Facebook API limitations, you can use just one WhatsApp trigger for each Facebook App',
name: 'whatsAppNotice',
type: 'notice',
default: '',
},
{
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',
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add option',
options: [
{
// https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples#message-status-updates
displayName: 'Receive Message Status Updates',
name: 'messageStatusUpdates',
type: 'multiOptions',
default: ['all'],
description:
'WhatsApp sends notifications to the Trigger when the status of a message changes (for example from Sent to Delivered and from Delivered to Read). To avoid multiple executions for one WhatsApp message, you can set the Trigger to execute only on selected message status updates.',
options: [
{
name: 'All',
value: 'all',
},
{
name: 'Deleted',
value: 'deleted',
},
{
name: 'Delivered',
value: 'delivered',
},
{
name: 'Failed',
value: 'failed',
},
{
name: 'Read',
value: 'read',
},
{
name: 'Sent',
value: 'sent',
},
],
},
],
},
],
};
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const credentials = await this.getCredentials('whatsAppTriggerApi');
const updates = this.getNodeParameter('updates', []) as IDataObject[];
const subscribedEvents = updates.sort().join(',');
const appId = credentials.clientId as string;
const webhooks = await appWebhookSubscriptionList.call(this, appId);
const subscription = webhooks.find(
(webhook) =>
webhook.object === 'whatsapp_business_account' &&
webhook.fields
.map((x) => x.name)
.sort()
.join(',') === subscribedEvents &&
webhook.active,
);
if (!subscription) {
return false;
}
if (subscription.callback_url !== webhookUrl) {
throw new NodeOperationError(
this.getNode(),
`The WhatsApp App ID ${appId} already has a webhook subscription. Delete it or use another App before executing the trigger. Due to WhatsApp API limitations, you can have just one trigger per App.`,
{ level: 'warning' },
);
}
if (
subscription?.fields
.map((x) => x.name)
.sort()
.join(',') !== subscribedEvents
) {
await appWebhookSubscriptionDelete.call(this, appId, 'whatsapp_business_account');
return false;
}
return true;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const credentials = await this.getCredentials('whatsAppTriggerApi');
const appId = credentials.clientId as string;
const updates = this.getNodeParameter('updates', []) as IDataObject[];
const verifyToken = this.getNode().id;
await appWebhookSubscriptionCreate.call(this, appId, {
object: 'whatsapp_business_account',
callback_url: webhookUrl,
verify_token: verifyToken,
fields: JSON.stringify(updates),
include_values: true,
});
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const credentials = await this.getCredentials('whatsAppTriggerApi');
const appId = credentials.clientId as string;
await appWebhookSubscriptionDelete.call(this, appId, 'whatsapp_business_account');
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData() as unknown as WhatsAppPageEvent;
const query = this.getQueryData() as IDataObject;
const res = this.getResponseObject();
const req = this.getRequestObject();
const headerData = this.getHeaderData() as IDataObject;
const credentials = await this.getCredentials('whatsAppTriggerApi');
// Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started)
if (this.getWebhookName() === 'setup') {
if (query['hub.challenge']) {
if (this.getNode().id !== query['hub.verify_token']) {
return {};
}
res.status(200).send(query['hub.challenge']).end();
return { noWebhookResponse: true };
}
}
const computedSignature = createHmac('sha256', credentials.clientSecret as string)
.update(req.rawBody)
.digest('hex');
if (headerData['x-hub-signature-256'] !== `sha256=${computedSignature}`) {
return {};
}
if (bodyData.object !== 'whatsapp_business_account') {
return {};
}
const events = await Promise.all(
bodyData.entry
.map((entry) => entry.changes)
.flat()
.map((change) => ({ ...change.value, field: change.field })),
);
const options = this.getNodeParameter('options', {}) as { messageStatusUpdates?: string[] };
const returnData = filterStatuses(events, options.messageStatusUpdates);
if (returnData.length === 0) return {};
return {
workflowData: [this.helpers.returnJsonArray(returnData)],
};
}
}
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,24 @@
{
"type": "object",
"properties": {
"file_size": {
"type": "integer"
},
"id": {
"type": "string"
},
"messaging_product": {
"type": "string"
},
"mime_type": {
"type": "string"
},
"sha256": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,34 @@
{
"type": "object",
"properties": {
"contacts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"wa_id": {
"type": "string"
}
}
}
},
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
}
}
},
"messaging_product": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,14 @@
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
}
}
},
"version": 1
}
@@ -0,0 +1,37 @@
{
"type": "object",
"properties": {
"contacts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"wa_id": {
"type": "string"
}
}
}
},
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"message_status": {
"type": "string"
}
}
}
},
"messaging_product": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,584 @@
import crypto from 'crypto';
import type * as express from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
import type { IDataObject, IHookFunctions, INode, IWebhookFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import * as GenericFunctions from '../GenericFunctions';
import type { WhatsAppAppWebhookSubscription, WhatsAppPageEvent } from '../types';
import { WhatsAppTrigger, filterStatuses } from '../WhatsAppTrigger.node';
describe('WhatsAppTrigger', () => {
let node: WhatsAppTrigger;
let mockHookFunctions: jest.Mocked<IHookFunctions>;
let mockWebhookFunctions: jest.Mocked<IWebhookFunctions>;
let mockNode: jest.Mocked<INode>;
const appWebhookSubscriptionListSpy = jest.spyOn(GenericFunctions, 'appWebhookSubscriptionList');
const appWebhookSubscriptionCreateSpy = jest.spyOn(
GenericFunctions,
'appWebhookSubscriptionCreate',
);
const appWebhookSubscriptionDeleteSpy = jest.spyOn(
GenericFunctions,
'appWebhookSubscriptionDelete',
);
beforeEach(() => {
node = new WhatsAppTrigger();
mockHookFunctions = mockDeep<IHookFunctions>();
mockWebhookFunctions = mockDeep<IWebhookFunctions>();
mockNode = mock<INode>({
id: 'test-node-id',
name: 'WhatsApp Trigger',
type: 'n8n-nodes-base.whatsAppTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
jest.clearAllMocks();
});
afterEach(() => {
jest.resetAllMocks();
});
describe('filterStatuses', () => {
const mockEvents = [
{ statuses: [{ status: 'sent' }] },
{ statuses: [{ status: 'delivered' }] },
{ statuses: [{ status: 'read' }] },
{ statuses: [{ status: 'failed' }] },
{},
];
it('should return all events when allowedStatuses is undefined', () => {
const result = filterStatuses(mockEvents, undefined);
expect(result).toEqual(mockEvents);
});
it('should return all events when allowedStatuses includes "all"', () => {
const result = filterStatuses(mockEvents, ['all']);
expect(result).toEqual(mockEvents);
});
it('should filter events with no statuses when allowedStatuses is empty', () => {
const result = filterStatuses(mockEvents, []);
expect(result).toEqual([{}]);
});
it('should filter events by specific statuses', () => {
const result = filterStatuses(mockEvents, ['sent', 'delivered']);
expect(result).toEqual([
{ statuses: [{ status: 'sent' }] },
{ statuses: [{ status: 'delivered' }] },
{},
]);
});
it('should handle events with multiple statuses', () => {
const eventsWithMultipleStatuses = [
{ statuses: [{ status: 'sent' }, { status: 'delivered' }] },
{ statuses: [{ status: 'read' }] },
];
const result = filterStatuses(eventsWithMultipleStatuses, ['sent']);
expect(result).toEqual([{ statuses: [{ status: 'sent' }, { status: 'delivered' }] }]);
});
});
describe('Webhook Methods', () => {
describe('checkExists', () => {
beforeEach(() => {
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('https://test.com/webhook');
mockHookFunctions.getCredentials.mockResolvedValue({
clientId: 'test-app-id',
clientSecret: 'test-secret',
});
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'updates') {
return ['messages', 'account_update'] as string[];
}
return undefined;
});
mockHookFunctions.getNode.mockReturnValue(mockNode);
});
it('should return false when no subscription exists', async () => {
appWebhookSubscriptionListSpy.mockResolvedValue([]);
const result = await node.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(result).toBe(false);
expect(appWebhookSubscriptionListSpy).toHaveBeenCalledWith('test-app-id');
});
it('should return true when matching subscription exists', async () => {
const mockSubscription: WhatsAppAppWebhookSubscription = {
object: 'whatsapp_business_account',
callback_url: 'https://test.com/webhook',
active: true,
fields: [
{ name: 'account_update', version: '1.0' },
{ name: 'messages', version: '1.0' },
],
};
appWebhookSubscriptionListSpy.mockResolvedValue([mockSubscription]);
const result = await node.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(result).toBe(true);
});
it('should throw error when subscription exists with different callback URL', async () => {
const mockSubscription: WhatsAppAppWebhookSubscription = {
object: 'whatsapp_business_account',
callback_url: 'https://different.com/webhook',
active: true,
fields: [
{ name: 'account_update', version: '1.0' },
{ name: 'messages', version: '1.0' },
],
};
appWebhookSubscriptionListSpy.mockResolvedValue([mockSubscription]);
await expect(
node.webhookMethods.default.checkExists.call(mockHookFunctions),
).rejects.toThrow(NodeOperationError);
});
it('should return false when subscription fields do not match subscribed events', async () => {
const mockSubscription: WhatsAppAppWebhookSubscription = {
object: 'whatsapp_business_account',
callback_url: 'https://test.com/webhook',
active: true,
fields: [{ name: 'different_field', version: '1.0' }],
};
appWebhookSubscriptionListSpy.mockResolvedValue([mockSubscription]);
const result = await node.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(result).toBe(false);
expect(appWebhookSubscriptionDeleteSpy).not.toHaveBeenCalled();
});
});
describe('create', () => {
beforeEach(() => {
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('https://test.com/webhook');
mockHookFunctions.getCredentials.mockResolvedValue({
clientId: 'test-app-id',
clientSecret: 'test-secret',
});
mockHookFunctions.getNodeParameter.mockReturnValue(['messages', 'account_update']);
mockHookFunctions.getNode.mockReturnValue(mockNode);
});
it('should create webhook subscription successfully', async () => {
appWebhookSubscriptionCreateSpy.mockResolvedValue({ success: true });
const result = await node.webhookMethods.default.create.call(mockHookFunctions);
expect(result).toBe(true);
expect(appWebhookSubscriptionCreateSpy).toHaveBeenCalledWith('test-app-id', {
object: 'whatsapp_business_account',
callback_url: 'https://test.com/webhook',
verify_token: 'test-node-id',
fields: JSON.stringify(['messages', 'account_update']),
include_values: true,
});
});
});
describe('delete', () => {
beforeEach(() => {
mockHookFunctions.getCredentials.mockResolvedValue({
clientId: 'test-app-id',
clientSecret: 'test-secret',
});
});
it('should delete webhook subscription successfully', async () => {
appWebhookSubscriptionDeleteSpy.mockResolvedValue({ success: true });
const result = await node.webhookMethods.default.delete.call(mockHookFunctions);
expect(result).toBe(true);
expect(appWebhookSubscriptionDeleteSpy).toHaveBeenCalledWith(
'test-app-id',
'whatsapp_business_account',
);
});
});
});
describe('webhook', () => {
beforeEach(() => {
mockWebhookFunctions.getCredentials.mockResolvedValue({
clientId: 'test-app-id',
clientSecret: 'test-secret',
});
mockWebhookFunctions.getNode.mockReturnValue(mockNode);
mockWebhookFunctions.getNodeParameter.mockReturnValue({});
});
describe('setup webhook (GET)', () => {
it('should handle challenge verification successfully', async () => {
const mockRequest = {
rawBody: Buffer.from('test'),
};
const mockResponse = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
} as unknown as express.Response;
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
mockWebhookFunctions.getQueryData.mockReturnValue({
'hub.challenge': 'test-challenge',
'hub.verify_token': 'test-node-id',
} as any);
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.send).toHaveBeenCalledWith('test-challenge');
expect(mockResponse.end).toHaveBeenCalled();
});
it('should return empty object when verify token does not match', async () => {
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
mockWebhookFunctions.getQueryData.mockReturnValue({
'hub.challenge': 'test-challenge',
'hub.verify_token': 'wrong-token',
} as any);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result).toEqual({});
});
it('should return empty object when no challenge is provided', async () => {
const mockRequest = {
rawBody: Buffer.from('test'),
};
const mockBodyData = {
object: 'whatsapp_business_account',
entry: [],
};
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
mockWebhookFunctions.getQueryData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=test-signature',
} as any);
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('test-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result).toEqual({});
});
});
describe('default webhook (POST)', () => {
beforeEach(() => {
mockWebhookFunctions.getWebhookName.mockReturnValue('default');
});
it('should process valid webhook data successfully', async () => {
const mockRequest = {
rawBody: Buffer.from('test-body'),
};
const mockBodyData: WhatsAppPageEvent = {
object: 'whatsapp_business_account',
entry: [
{
id: 'entry-1',
time: 1234567890,
changes: [
{
field: 'messages',
value: {
statuses: [{ status: 'sent' }],
},
},
],
},
],
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=test-signature',
} as any);
mockWebhookFunctions.getNodeParameter.mockReturnValue({});
// Mock crypto.createHmac to return predictable signature
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('test-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
// Mock helpers.returnJsonArray
(mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([
{ json: { statuses: [{ status: 'sent' }], field: 'messages' } },
]);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result.workflowData).toBeDefined();
expect(result.workflowData?.[0]).toHaveLength(1);
expect(result.workflowData?.[0]?.[0].json).toEqual({
statuses: [{ status: 'sent' }],
field: 'messages',
});
});
it('should return empty object when signature verification fails', async () => {
const mockRequest = {
rawBody: Buffer.from('test-body'),
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=wrong-signature',
});
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('correct-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result).toEqual({});
});
it('should return empty object when object is not whatsapp_business_account', async () => {
const mockRequest = {
rawBody: Buffer.from('test-body'),
};
const mockBodyData = {
object: 'different_object',
entry: [],
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=test-signature',
} as any);
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('test-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result).toEqual({});
});
it('should filter events based on messageStatusUpdates option', async () => {
const mockRequest = {
rawBody: Buffer.from('test-body'),
};
const mockBodyData: WhatsAppPageEvent = {
object: 'whatsapp_business_account',
entry: [
{
id: 'entry-1',
time: 1234567890,
changes: [
{
field: 'messages',
value: {
statuses: [{ status: 'sent' }],
},
},
{
field: 'messages',
value: {
statuses: [{ status: 'delivered' }],
},
},
{
field: 'messages',
value: {
statuses: [{ status: 'read' }],
},
},
],
},
],
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=test-signature',
} as any);
mockWebhookFunctions.getNodeParameter.mockReturnValue({
messageStatusUpdates: ['sent', 'delivered'],
});
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('test-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
// Mock helpers.returnJsonArray for filtered results
(mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([
{ json: { statuses: [{ status: 'sent' }], field: 'messages' } },
{ json: { statuses: [{ status: 'delivered' }], field: 'messages' } },
]);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result.workflowData?.[0]).toHaveLength(2);
expect((result.workflowData?.[0]?.[0]?.json?.statuses as IDataObject[])?.[0]?.status).toBe(
'sent',
);
expect((result.workflowData?.[0]?.[1]?.json?.statuses as IDataObject[])?.[0]?.status).toBe(
'delivered',
);
});
it('should return empty object when no events match filters', async () => {
const mockRequest = {
rawBody: Buffer.from('test-body'),
};
const mockBodyData: WhatsAppPageEvent = {
object: 'whatsapp_business_account',
entry: [
{
id: 'entry-1',
time: 1234567890,
changes: [
{
field: 'messages',
value: {
statuses: [{ status: 'read' }],
},
},
],
},
],
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=test-signature',
} as any);
mockWebhookFunctions.getNodeParameter.mockReturnValue({
messageStatusUpdates: ['sent', 'delivered'],
});
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('test-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result).toEqual({});
});
it('should handle events without statuses when filtering', async () => {
const mockRequest = {
rawBody: Buffer.from('test-body'),
};
const mockBodyData: WhatsAppPageEvent = {
object: 'whatsapp_business_account',
entry: [
{
id: 'entry-1',
time: 1234567890,
changes: [
{
field: 'messages',
value: {
// No statuses property
},
},
],
},
],
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hub-signature-256': 'sha256=test-signature',
} as any);
mockWebhookFunctions.getNodeParameter.mockReturnValue({
messageStatusUpdates: ['sent'],
});
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
const mockHmac = {
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue('test-signature'),
};
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
// Mock helpers.returnJsonArray for events without statuses
(mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([
{ json: { field: 'messages' } },
]);
const result = await node.webhook.call(mockWebhookFunctions);
expect(result.workflowData?.[0]).toHaveLength(1);
expect(result.workflowData?.[0]?.[0].json).toEqual({
field: 'messages',
});
});
});
});
describe('Error Handling', () => {
it('should handle API errors in webhook methods', async () => {
mockHookFunctions.getCredentials.mockResolvedValue({
clientId: 'test-app-id',
clientSecret: 'test-secret',
});
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'updates') {
return ['messages', 'account_update'];
}
return undefined;
});
appWebhookSubscriptionListSpy.mockRejectedValue(new Error('API Error'));
await expect(node.webhookMethods.default.checkExists.call(mockHookFunctions)).rejects.toThrow(
'API Error',
);
});
});
});
@@ -0,0 +1,69 @@
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.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
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&signature=abc_\n\n',
},
to: '22222',
type: 'text',
},
method: 'POST',
url: '11111/messages',
},
);
});
});
@@ -0,0 +1,108 @@
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:',
options: [
{
label: 'Yes',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
{
label: 'No',
style: 'secondary',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
},
],
};
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_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n' +
'*No:*\n_http://localhost/waiting-webhook/nodeID?approved=false&signature=abc_\n\n',
},
type: 'text',
to: recipientPhone,
},
});
});
it('should handle a single option correctly', () => {
const singleOptionConfig: SendAndWaitConfig = {
title: '',
message: 'Choose an option:',
options: [
{
label: 'Confirm',
style: '',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
};
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_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n',
},
type: 'text',
to: recipientPhone,
},
});
});
});
@@ -0,0 +1,34 @@
import { filterStatuses } from '../WhatsAppTrigger.node';
describe('filterStatuses', () => {
const mockEvents = [
{ statuses: [{ status: 'deleted' }] },
{ statuses: [{ status: 'delivered' }] },
{ statuses: [{ status: 'failed' }] },
{ statuses: [{ status: 'read' }] },
{ statuses: [{ status: 'sent' }] },
];
test('returns events with no statuses when allowedStatuses is empty', () => {
expect(filterStatuses(mockEvents, [])).toEqual([]);
});
test("returns all events when 'all' is in allowedStatuses", () => {
expect(filterStatuses(mockEvents, ['all'])).toEqual(mockEvents);
});
test('filters events correctly when specific statuses are provided', () => {
expect(filterStatuses(mockEvents, ['deleted', 'read'])).toEqual([
{ statuses: [{ status: 'deleted' }] },
{ statuses: [{ status: 'read' }] },
]);
});
test('returns only event with matching status', () => {
expect(filterStatuses(mockEvents, ['failed'])).toEqual([{ statuses: [{ status: 'failed' }] }]);
});
test('returns unchanged event when allowedStatuses is undefined', () => {
expect(filterStatuses(mockEvents, undefined)).toEqual(mockEvents);
});
});
@@ -0,0 +1,98 @@
import type { GenericValue } from 'n8n-workflow';
export type BaseFacebookResponse<TData> = { data: TData };
export type BasePaginatedFacebookResponse<TData> = BaseFacebookResponse<TData> & {
paging: { cursors: { before?: string; after?: string } };
};
export type WhatsAppAppWebhookSubscriptionsResponse = BaseFacebookResponse<
WhatsAppAppWebhookSubscription[]
>;
export interface WhatsAppAppWebhookSubscription {
object: string;
callback_url: string;
active: boolean;
fields: WhatsAppAppWebhookSubscriptionField[];
}
export interface WhatsAppAppWebhookSubscriptionField {
name: string;
version: string;
}
export interface CreateFacebookAppWebhookSubscription {
object: string;
callback_url: string;
fields: string[];
include_values: boolean;
verify_token: string;
}
export type FacebookPageListResponse = BasePaginatedFacebookResponse<FacebookPage[]>;
export type FacebookFormListResponse = BasePaginatedFacebookResponse<FacebookForm[]>;
export interface FacebookPage {
id: string;
name: string;
access_token: string;
category: string;
category_list: FacebookPageCategory[];
tasks: string[];
}
export interface FacebookPageCategory {
id: string;
name: string;
}
export interface FacebookFormQuestion {
id: string;
key: string;
label: string;
type: string;
}
export interface FacebookForm {
id: string;
name: string;
locale: string;
status: string;
page: {
id: string;
name: string;
};
questions: FacebookFormQuestion[];
}
export interface WhatsAppPageEvent {
object: 'whatsapp_business_account';
entry: WhatsAppEventEntry[];
}
export type WhatsAppEventChanges = Array<{
field: string;
value: { statuses?: Array<{ status: string }> };
}>;
export interface WhatsAppEventEntry {
id: string;
time: number;
changes: WhatsAppEventChanges;
}
export interface FacebookFormLeadData {
id: string;
created_time: string;
ad_id: string;
ad_name: string;
adset_id: string;
adset_name: string;
form_id: string;
field_data: [
{
name: string;
values: GenericValue[];
},
];
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 48 48"><path fill="#fff" d="m4.868 43.303 2.694-9.835a18.94 18.94 0 0 1-2.535-9.489C5.032 13.514 13.548 5 24.014 5a18.87 18.87 0 0 1 13.43 5.566A18.87 18.87 0 0 1 43 23.994c-.004 10.465-8.522 18.98-18.986 18.98h-.008a19 19 0 0 1-9.073-2.311z"/><path fill="#fff" d="M4.868 43.803a.5.5 0 0 1-.482-.631l2.639-9.636a19.5 19.5 0 0 1-2.497-9.556C4.532 13.238 13.273 4.5 24.014 4.5a19.37 19.37 0 0 1 13.784 5.713A19.36 19.36 0 0 1 43.5 23.994c-.004 10.741-8.746 19.48-19.486 19.48a19.54 19.54 0 0 1-9.144-2.277l-9.875 2.589a.5.5 0 0 1-.127.017"/><path fill="#cfd8dc" d="M24.014 5a18.87 18.87 0 0 1 13.43 5.566A18.87 18.87 0 0 1 43 23.994c-.004 10.465-8.522 18.98-18.986 18.98h-.008a19 19 0 0 1-9.073-2.311l-10.065 2.64 2.694-9.835a18.94 18.94 0 0 1-2.535-9.489C5.032 13.514 13.548 5 24.014 5m0-1C12.998 4 4.032 12.962 4.027 23.979a20 20 0 0 0 2.461 9.622L3.903 43.04a.998.998 0 0 0 1.219 1.231l9.687-2.54a20 20 0 0 0 9.197 2.244c11.024 0 19.99-8.963 19.995-19.98A19.86 19.86 0 0 0 38.153 9.86 19.87 19.87 0 0 0 24.014 4"/><path fill="#40c351" d="M35.176 12.832a15.67 15.67 0 0 0-11.157-4.626c-8.704 0-15.783 7.076-15.787 15.774a15.74 15.74 0 0 0 2.413 8.396l.376.597-1.595 5.821 5.973-1.566.577.342a15.75 15.75 0 0 0 8.032 2.199h.006c8.698 0 15.777-7.077 15.78-15.776a15.68 15.68 0 0 0-4.618-11.161"/><path fill="#fff" d="M19.268 16.045c-.355-.79-.729-.806-1.068-.82-.277-.012-.593-.011-.909-.011s-.83.119-1.265.594-1.661 1.622-1.661 3.956 1.7 4.59 1.937 4.906 3.282 5.259 8.104 7.161c4.007 1.58 4.823 1.266 5.693 1.187s2.807-1.147 3.202-2.255.395-2.057.277-2.255c-.119-.198-.435-.316-.909-.554s-2.807-1.385-3.242-1.543-.751-.237-1.068.238c-.316.474-1.225 1.543-1.502 1.859s-.554.357-1.028.119-2.002-.738-3.815-2.354c-1.41-1.257-2.362-2.81-2.639-3.285-.277-.474-.03-.731.208-.968.213-.213.474-.554.712-.831.237-.277.316-.475.474-.791s.079-.594-.04-.831c-.117-.238-1.039-2.584-1.461-3.522"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB