Skip to content

Commit

Permalink
feat: Add WhatsApp Business Trigger Node (n8n-io#8840)
Browse files Browse the repository at this point in the history
Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Michael Kret <michael.k@radency.com>
  • Loading branch information
3 people authored Apr 18, 2024
1 parent 5310196 commit 23a2dd0
Show file tree
Hide file tree
Showing 8 changed files with 511 additions and 0 deletions.
41 changes: 41 additions & 0 deletions packages/nodes-base/credentials/WhatsAppTriggerApi.credentials.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
};
}
11 changes: 11 additions & 0 deletions packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
103 changes: 103 additions & 0 deletions packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts
Original file line number Diff line number Diff line change
@@ -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<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 },
});
}
18 changes: 18 additions & 0 deletions packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.json
Original file line number Diff line number Diff line change
@@ -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/"
}
]
}
}
185 changes: 185 additions & 0 deletions packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts
Original file line number Diff line number Diff line change
@@ -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<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 })),
);

if (events.length === 0) {
return {};
}

return {
workflowData: [this.helpers.returnJsonArray(events)],
};
}
}
Loading

0 comments on commit 23a2dd0

Please sign in to comment.