forked from n8n-io/n8n
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add WhatsApp Business Trigger Node (n8n-io#8840)
Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Michael Kret <michael.k@radency.com>
- Loading branch information
1 parent
5310196
commit 23a2dd0
Showing
8 changed files
with
511 additions
and
0 deletions.
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
packages/nodes-base/credentials/WhatsAppTriggerApi.credentials.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}, | ||
}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
18
packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
185
packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)], | ||
}; | ||
} | ||
} |
Oops, something went wrong.