From 23a2dd08b6e5391b61b73bdd4496cdb2f6fa9205 Mon Sep 17 00:00:00 2001 From: Bram Kn Date: Thu, 18 Apr 2024 14:45:07 +0200 Subject: [PATCH] feat: Add WhatsApp Business Trigger Node (#8840) Co-authored-by: Giulio Andreini Co-authored-by: Michael Kret --- .../WhatsAppTriggerApi.credentials.ts | 41 ++++ .../nodes/Facebook/FacebookTrigger.node.ts | 11 ++ .../nodes/WhatsApp/GenericFunctions.ts | 103 ++++++++++ .../nodes/WhatsApp/WhatsAppTrigger.node.json | 18 ++ .../nodes/WhatsApp/WhatsAppTrigger.node.ts | 185 ++++++++++++++++++ .../nodes/WhatsApp/WhatsappDescription.ts | 53 +++++ packages/nodes-base/nodes/WhatsApp/types.ts | 98 ++++++++++ packages/nodes-base/package.json | 2 + 8 files changed, 511 insertions(+) create mode 100644 packages/nodes-base/credentials/WhatsAppTriggerApi.credentials.ts create mode 100644 packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.json create mode 100644 packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts create mode 100644 packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts create mode 100644 packages/nodes-base/nodes/WhatsApp/types.ts diff --git a/packages/nodes-base/credentials/WhatsAppTriggerApi.credentials.ts b/packages/nodes-base/credentials/WhatsAppTriggerApi.credentials.ts new file mode 100644 index 0000000000000..d339b36a2be48 --- /dev/null +++ b/packages/nodes-base/credentials/WhatsAppTriggerApi.credentials.ts @@ -0,0 +1,41 @@ +import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class WhatsAppTriggerApi implements ICredentialType { + name = 'whatsAppTriggerApi'; + + displayName = 'WhatsApp API'; + + documentationUrl = 'whatsApp'; + + properties: INodeProperties[] = [ + { + displayName: 'Client ID', + name: 'clientId', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + required: true, + }, + ]; + + test: ICredentialTestRequest = { + request: { + method: 'POST', + baseURL: 'https://graph.facebook.com/v19.0/oauth/access_token', + body: { + client_id: '={{$credentials.clientId}}', + client_secret: '={{$credentials.clientSecret}}', + grant_type: 'client_credentials', + }, + }, + }; +} diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts index 0a1cea0c4e4cc..b337ec1fd83e1 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -63,6 +63,17 @@ export class FacebookTrigger implements INodeType { default: '', description: 'Facebook APP ID', }, + { + displayName: 'To watch Whatsapp business account events use the Whatsapp trigger node', + name: 'whatsappBusinessAccountNotice', + type: 'notice', + default: '', + displayOptions: { + show: { + object: ['whatsappBusinessAccount'], + }, + }, + }, { displayName: 'Object', name: 'object', diff --git a/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts b/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts new file mode 100644 index 0000000000000..b50c9adad9e20 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts @@ -0,0 +1,103 @@ +import type { + IDataObject, + IExecuteFunctions, + IHookFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + IWebhookFunctions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import type { + WhatsAppAppWebhookSubscriptionsResponse, + WhatsAppAppWebhookSubscription, +} from './types'; + +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 { + 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 { + 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 }, + }); +} diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.json b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.json new file mode 100644 index 0000000000000..7cbbea6b53ef4 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.json @@ -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/credentials/whatsapp/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.whatsapptrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts new file mode 100644 index 0000000000000..58444a6c62538 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts @@ -0,0 +1,185 @@ +import { createHmac } from 'crypto'; +import { + NodeOperationError, + type IDataObject, + type IHookFunctions, + type INodeType, + type INodeTypeDescription, + type IWebhookFunctions, + type IWebhookResponseData, +} from 'n8n-workflow'; +import { + appWebhookSubscriptionCreate, + appWebhookSubscriptionDelete, + appWebhookSubscriptionList, +} from './GenericFunctions'; +import type { WhatsAppPageEvent } from './types'; +import { whatsappTriggerDescription } from './WhatsappDescription'; + +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: ['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: '', + }, + ...whatsappTriggerDescription, + ], + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + 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 { + 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 { + 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 { + 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 })), + ); + + if (events.length === 0) { + return {}; + } + + return { + workflowData: [this.helpers.returnJsonArray(events)], + }; + } +} diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts b/packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts new file mode 100644 index 0000000000000..564cb3bc7fba6 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/WhatsappDescription.ts @@ -0,0 +1,53 @@ +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/types.ts b/packages/nodes-base/nodes/WhatsApp/types.ts new file mode 100644 index 0000000000000..6cf3dc8fe17a1 --- /dev/null +++ b/packages/nodes-base/nodes/WhatsApp/types.ts @@ -0,0 +1,98 @@ +import type { GenericValue, IDataObject } from 'n8n-workflow'; + +export type BaseFacebookResponse = { data: TData }; +export type BasePaginatedFacebookResponse = BaseFacebookResponse & { + 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; +export type FacebookFormListResponse = BasePaginatedFacebookResponse; + +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 interface WhatsAppEventEntry { + id: string; + time: number; + changes: [ + { + field: string; + value: IDataObject; + }, + ]; +} + +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[]; + }, + ]; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 488ad8bd2d160..e3eb68f7a39d0 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -368,6 +368,7 @@ "dist/credentials/WebflowOAuth2Api.credentials.js", "dist/credentials/WekanApi.credentials.js", "dist/credentials/WhatsAppApi.credentials.js", + "dist/credentials/WhatsAppTriggerApi.credentials.js", "dist/credentials/WiseApi.credentials.js", "dist/credentials/WooCommerceApi.credentials.js", "dist/credentials/WordpressApi.credentials.js", @@ -776,6 +777,7 @@ "dist/nodes/Webflow/WebflowTrigger.node.js", "dist/nodes/Webhook/Webhook.node.js", "dist/nodes/Wekan/Wekan.node.js", + "dist/nodes/WhatsApp/WhatsAppTrigger.node.js", "dist/nodes/WhatsApp/WhatsApp.node.js", "dist/nodes/Wise/Wise.node.js", "dist/nodes/Wise/WiseTrigger.node.js",