From ecbd73d0e251b777632e557f507d01bee713f7c3 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 5 Sep 2023 15:09:51 +0200 Subject: [PATCH 01/10] Add new Facebook Lead Ads Node --- .../FacebookLeadAdsOAuth2Api.credentials.ts | 61 +++++ .../FacebookLeadAdsTrigger.node.json | 18 ++ .../FacebookLeadAdsTrigger.node.ts | 238 ++++++++++++++++++ .../nodes/FacebookLeadAds/GenericFunctions.ts | 232 +++++++++++++++++ .../nodes/FacebookLeadAds/facebook.svg | 1 + .../nodes/FacebookLeadAds/methods/index.ts | 1 + .../FacebookLeadAds/methods/listSearch.ts | 42 ++++ .../nodes-base/nodes/FacebookLeadAds/types.ts | 86 +++++++ packages/nodes-base/package.json | 2 + 9 files changed, 681 insertions(+) create mode 100644 packages/nodes-base/credentials/FacebookLeadAdsOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.json create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/facebook.svg create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/methods/index.ts create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/methods/listSearch.ts create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/types.ts diff --git a/packages/nodes-base/credentials/FacebookLeadAdsOAuth2Api.credentials.ts b/packages/nodes-base/credentials/FacebookLeadAdsOAuth2Api.credentials.ts new file mode 100644 index 0000000000000..bb9c53cb5c05c --- /dev/null +++ b/packages/nodes-base/credentials/FacebookLeadAdsOAuth2Api.credentials.ts @@ -0,0 +1,61 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class FacebookLeadAdsOAuth2Api implements ICredentialType { + name = 'facebookLeadAdsOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Facebook Lead Ads OAuth2 API'; + + documentationUrl = 'facebookleadads'; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://www.facebook.com/v17.0/dialog/oauth', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://graph.facebook.com/v17.0/oauth/access_token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: 'leads_retrieval pages_show_list pages_manage_metadata pages_manage_ads', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + { + displayName: 'App Secret', + name: 'appSecret', + type: 'string', + typeOptions: { password: true }, + default: '', + description: + '(Optional) When the app secret is set the node will verify this signature to validate the integrity and origin of the payload', + }, + ]; +} diff --git a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.json b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.json new file mode 100644 index 0000000000000..ee37bf06d7951 --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.facebookLeadAdsTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Marketing & Content"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/credentials/facebookleadads/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.facebookleadadstrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts new file mode 100644 index 0000000000000..5ad619fc70bc3 --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts @@ -0,0 +1,238 @@ +import { createHmac } from 'crypto'; +import type { + IDataObject, + IHookFunctions, + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, +} from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; +import { + appWebhookSubscriptionCreate, + appWebhookSubscriptionDelete, + appWebhookSubscriptionList, + facebookEntityDetail, + installAppOnPage, +} from './GenericFunctions'; +import { listSearch } from './methods'; +import type { FacebookFormLeadData, FacebookPageEvent } from './types'; + +export class FacebookLeadAdsTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Facebook Lead Ads Trigger', + name: 'facebookLeadAdsTrigger', + icon: 'file:facebook.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["event"]}}', + description: 'Handle Facebook Lead Ads events via webhooks', + defaults: { + name: 'Facebook Lead Ads Trigger', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'facebookLeadAdsOAuth2Api', + required: true, + }, + ], + webhooks: [ + { + name: 'setup', + httpMethod: 'GET', + responseMode: 'onReceived', + path: 'webhook', + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Event', + name: 'event', + type: 'options', + required: true, + default: 'leadAdded', + options: [ + { + name: 'New Lead', + value: 'leadAdded', + }, + ], + }, + { + displayName: 'Page', + name: 'page', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The page linked to the form for retrieving new leads', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'pageList', + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '121637951029080', + }, + ], + }, + { + displayName: 'Form', + name: 'form', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The form to monitor for fetching lead details upon submission', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'formList', + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '121637951029080', + }, + ], + }, + ], + }; + + methods = { + listSearch, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const credential = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const appId = credential.clientId as string; + + const webhooks = await appWebhookSubscriptionList.call(this, appId); + + return webhooks.some( + (webhook) => + webhook.object === 'page' && + webhook.callback_url === webhookUrl && + webhook.fields.find((field) => field.name === 'leadgen') && + webhook.active, + ); + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const credential = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const appId = credential.clientId as string; + const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string; + const verifyToken = uuid(); + const staticData = this.getWorkflowStaticData('node'); + staticData.verifyToken = verifyToken; + + await appWebhookSubscriptionCreate.call(this, appId, { + object: 'page', + callback_url: webhookUrl, + verify_token: verifyToken, + fields: ['leadgen'], + include_values: true, + }); + + await installAppOnPage.call(this, pageId, 'leadgen'); + + return true; + }, + async delete(this: IHookFunctions): Promise { + const credential = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const appId = credential.clientId as string; + + await appWebhookSubscriptionDelete.call(this, appId, 'page'); + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData() as unknown as FacebookPageEvent; + 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('facebookLeadAdsOAuth2Api'); + const formId = this.getNodeParameter('form', '', { extractValue: true }) as string; + + // 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']) { + //compare hub.verify_token with the saved token + const staticData = this.getWorkflowStaticData('node'); + if (staticData.verifyToken !== query['hub.verify_token']) { + return {}; + } + + res.status(200).send(query['hub.challenge']).end(); + + return { noWebhookResponse: true }; + } + } + + // validate signature if app secret is set + if (credentials.appSecret) { + const computedSignature = createHmac('sha1', credentials.appSecret as string) + .update(req.rawBody) + .digest('hex'); + if (headerData['x-hub-signature'] !== `sha1=${computedSignature}`) { + return {}; + } + } + + if (bodyData.object !== 'page') { + return {}; + } + + const events = await Promise.all( + bodyData.entry + .map((entry) => + entry.changes + .filter((change) => change.field === 'leadgen' && change.value.form_id === formId) + .map((change) => ({ ...change.value, entry_id: entry.id })), + ) + .flat() + .map(async (event) => { + const leadFormData = (await facebookEntityDetail.call( + this, + event.leadgen_id, + 'id,field_data,created_time', + )) as FacebookFormLeadData; + + return { ...event, ...leadFormData }; + }), + ); + + if (events.length === 0) { + return {}; + } + + return { + workflowData: [this.helpers.returnJsonArray(events)], + }; + } +} diff --git a/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts b/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts new file mode 100644 index 0000000000000..ffbbe853e89e2 --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts @@ -0,0 +1,232 @@ +import type { OptionsWithUri } from 'request'; + +import type { + IDataObject, + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import type { + CreateFacebookAppWebhookSubscription, + FacebookAppWebhookSubscription, + FacebookAppWebhookSubscriptionsResponse, + FacebookFormListResponse, + FacebookPage, + FacebookPageListResponse, +} from './types'; + +export async function facebookApiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + method: string, + resource: string, + body = {}, + qs: IDataObject = {}, +): Promise { + const options: OptionsWithUri = { + headers: { + accept: 'application/json', + }, + method, + qs, + body, + gzip: true, + uri: `https://graph.facebook.com/v17.0${resource}`, + json: true, + }; + + try { + return await this.helpers.requestOAuth2.call(this, 'facebookLeadAdsOAuth2Api', options); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function appAccessTokenRead( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, +): Promise<{ access_token: string }> { + const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api'); + + const options: OptionsWithUri = { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + form: { + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + grant_type: 'client_credentials', + }, + uri: credentials.accessTokenUrl as string, + json: true, + }; + try { + return await this.helpers.request(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function facebookAppApiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + method: string, + resource: string, + body?: { type: 'json'; payload: IDataObject } | { type: 'form'; payload: IDataObject }, + qs: IDataObject = {}, +): Promise { + const staticData = this.getWorkflowStaticData('node'); + + if (!staticData.appAccessToken) { + const { access_token } = await appAccessTokenRead.call(this); + staticData.appAccessToken = access_token; + } + + const appAccessToken = staticData.appAccessToken as string; + + const options: OptionsWithUri = { + headers: { + accept: 'application/json', + authorization: `Bearer ${appAccessToken}`, + }, + method, + qs, + gzip: true, + uri: `https://graph.facebook.com/v17.0${resource}`, + json: true, + }; + + if (body?.type === 'json') { + options.body = body.payload; + } else if (body?.type === 'form') { + options.form = body.payload; + } + + try { + return await this.helpers.request(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 facebookAppApiRequest.call( + this, + 'GET', + `/${appId}/subscriptions`, + )) as FacebookAppWebhookSubscriptionsResponse; + return response.data; +} + +export async function appWebhookSubscriptionCreate( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + appId: string, + subscription: CreateFacebookAppWebhookSubscription, +) { + return facebookAppApiRequest.call(this, 'POST', `/${appId}/subscriptions`, { + type: 'form', + payload: { ...subscription }, + }); +} + +export async function appWebhookSubscriptionDelete( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + appId: string, + object: string, +) { + return facebookAppApiRequest.call(this, 'DELETE', `/${appId}/subscriptions`, { + type: 'form', + payload: { object }, + }); +} + +export async function facebookPageList( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + cursor?: string, +): Promise { + const response = (await facebookApiRequest.call( + this, + 'GET', + '/me/accounts', + {}, + { cursor, fields: 'id,name' }, + )) as FacebookPageListResponse; + return response; +} + +export async function facebookEntityDetail( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + entityId: string, + fields = 'id,name,access_token', +): Promise { + return facebookApiRequest.call(this, 'GET', `/${entityId}`, {}, { fields }); +} + +export async function facebookPageApiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + method: string, + resource: string, + body = {}, + qs: IDataObject = {}, +): Promise { + const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string; + const staticData = this.getWorkflowStaticData('node'); + + if (!staticData.pageAccessToken) { + const page = (await facebookEntityDetail.call(this, pageId)) as FacebookPage; + staticData.pageAccessToken = page.access_token; + } + + const pageAccessToken = staticData.pageAccessToken as string; + const options: OptionsWithUri = { + headers: { + accept: 'application/json', + authorization: `Bearer ${pageAccessToken}`, + }, + method, + qs, + body, + gzip: true, + uri: `https://graph.facebook.com/v17.0${resource}`, + json: true, + }; + + try { + return await this.helpers.request.call(this, options); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function installAppOnPage( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + pageId: string, + fields: string, +) { + return facebookPageApiRequest.call( + this, + 'POST', + `/${pageId}/subscribed_apps`, + {}, + { subscribed_fields: fields }, + ); +} + +export async function facebookFormList( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + pageId: string, + cursor?: string, +): Promise { + const response = (await facebookPageApiRequest.call( + this, + 'GET', + `/${pageId}/leadgen_forms`, + {}, + { cursor, fields: 'id,name' }, + )) as FacebookPageListResponse; + return response; +} diff --git a/packages/nodes-base/nodes/FacebookLeadAds/facebook.svg b/packages/nodes-base/nodes/FacebookLeadAds/facebook.svg new file mode 100644 index 0000000000000..14021e35e4940 --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/FacebookLeadAds/methods/index.ts b/packages/nodes-base/nodes/FacebookLeadAds/methods/index.ts new file mode 100644 index 0000000000000..c7fb720e474f5 --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/FacebookLeadAds/methods/listSearch.ts b/packages/nodes-base/nodes/FacebookLeadAds/methods/listSearch.ts new file mode 100644 index 0000000000000..66f09950a7b6a --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/methods/listSearch.ts @@ -0,0 +1,42 @@ +import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; +import { facebookFormList, facebookPageList } from '../GenericFunctions'; + +const filterMatches = (name: string, filter?: string): boolean => + !filter || name?.toLowerCase().includes(filter.toLowerCase()); + +export async function pageList( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const { data: pages, paging } = await facebookPageList.call(this, paginationToken); + return { + results: pages + .filter((page) => filterMatches(page.name, filter)) + .map((page) => ({ + name: page.name, + value: page.id, + url: `https://facebook.com/${page.id}`, + })), + paginationToken: paging.cursors.after, + }; +} + +export async function formList( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string; + + const { data: forms, paging } = await facebookFormList.call(this, pageId, paginationToken); + return { + results: forms + .filter((form) => filterMatches(form.name, filter)) + .map((form) => ({ + name: form.name, + value: form.id, + })), + paginationToken: paging.cursors.after, + }; +} diff --git a/packages/nodes-base/nodes/FacebookLeadAds/types.ts b/packages/nodes-base/nodes/FacebookLeadAds/types.ts new file mode 100644 index 0000000000000..7af837c21a501 --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/types.ts @@ -0,0 +1,86 @@ +import type { GenericValue } from 'n8n-workflow'; + +export type BaseFacebookResponse = { data: TData }; +export type BasePaginatedFacebookResponse = BaseFacebookResponse & { + paging: { cursors: { before?: string; after?: string } }; +}; + +export type FacebookAppWebhookSubscriptionsResponse = BaseFacebookResponse< + FacebookAppWebhookSubscription[] +>; + +export interface FacebookAppWebhookSubscription { + object: string; + callback_url: string; + active: boolean; + fields: FacebookAppWebhookSubscriptionField[]; +} + +export interface FacebookAppWebhookSubscriptionField { + 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 FacebookForm { + id: string; + name: string; +} + +export interface FacebookPageEvent { + object: 'page'; + entry: FacebookPageEventEntry[]; +} + +export interface FacebookPageEventEntry { + id: string; + time: number; + changes: [ + { + field: 'leadgen'; + value: { + ad_id: string; + form_id: string; + leadgen_id: string; + created_time: number; + page_id: string; + adgroup_id: string; + }; + }, + ]; +} + +export interface FacebookFormLeadData { + created_time: string; + id: string; + field_data: [ + { + name: string; + values: GenericValue[]; + }, + ]; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1c3dc2ecdffb2..3dede2bd84531 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -106,6 +106,7 @@ "dist/credentials/F5BigIpApi.credentials.js", "dist/credentials/FacebookGraphApi.credentials.js", "dist/credentials/FacebookGraphAppApi.credentials.js", + "dist/credentials/FacebookLeadAdsOAuth2Api.credentials.js", "dist/credentials/FigmaApi.credentials.js", "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", @@ -482,6 +483,7 @@ "dist/nodes/ExecutionData/ExecutionData.node.js", "dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/Facebook/FacebookTrigger.node.js", + "dist/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.js", "dist/nodes/Figma/FigmaTrigger.node.js", "dist/nodes/FileMaker/FileMaker.node.js", "dist/nodes/Filter/Filter.node.js", From 3e5338a31079a723802f554a8686b009ee0a9f28 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 6 Sep 2023 11:43:06 +0200 Subject: [PATCH 02/10] Add simplifyOutput option, add form and page metadata, throw when webhook exists in FB --- .../FacebookLeadAdsTrigger.node.ts | 119 +++++++++++++----- .../nodes/FacebookLeadAds/GenericFunctions.ts | 22 +--- .../nodes-base/nodes/FacebookLeadAds/types.ts | 21 +++- 3 files changed, 112 insertions(+), 50 deletions(-) diff --git a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts index 5ad619fc70bc3..74e54088d89ad 100644 --- a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts +++ b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts @@ -1,13 +1,13 @@ import { createHmac } from 'crypto'; -import type { - IDataObject, - IHookFunctions, - INodeType, - INodeTypeDescription, - IWebhookFunctions, - IWebhookResponseData, +import { + NodeOperationError, + type IDataObject, + type IHookFunctions, + type INodeType, + type INodeTypeDescription, + type IWebhookFunctions, + type IWebhookResponseData, } from 'n8n-workflow'; -import { v4 as uuid } from 'uuid'; import { appWebhookSubscriptionCreate, appWebhookSubscriptionDelete, @@ -16,7 +16,7 @@ import { installAppOnPage, } from './GenericFunctions'; import { listSearch } from './methods'; -import type { FacebookFormLeadData, FacebookPageEvent } from './types'; +import type { FacebookForm, FacebookFormLeadData, FacebookPageEvent } from './types'; export class FacebookLeadAdsTrigger implements INodeType { description: INodeTypeDescription = { @@ -58,11 +58,11 @@ export class FacebookLeadAdsTrigger implements INodeType { name: 'event', type: 'options', required: true, - default: 'leadAdded', + default: 'newLead', options: [ { name: 'New Lead', - value: 'leadAdded', + value: 'newLead', }, ], }, @@ -114,6 +114,23 @@ export class FacebookLeadAdsTrigger implements INodeType { }, ], }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Simplify Output', + name: 'simplifyOutput', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the webhook event instead of all fields', + }, + ], + }, ], }; @@ -125,27 +142,37 @@ export class FacebookLeadAdsTrigger implements INodeType { default: { async checkExists(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default') as string; - const credential = await this.getCredentials('facebookLeadAdsOAuth2Api'); - const appId = credential.clientId as string; + const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const appId = credentials.clientId as string; const webhooks = await appWebhookSubscriptionList.call(this, appId); - return webhooks.some( + const subscription = webhooks.find( (webhook) => webhook.object === 'page' && - webhook.callback_url === webhookUrl && webhook.fields.find((field) => field.name === 'leadgen') && webhook.active, ); + + if (!subscription) { + return false; + } + + if (subscription?.callback_url !== webhookUrl) { + throw new NodeOperationError( + this.getNode(), + `The App ID ${appId} already has a callback url ${subscription?.callback_url}. Delete it or use another App ID before executing the trigger. Due to Facebook API limitations, you can have just one trigger per App.`, + ); + } + + return true; }, async create(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default') as string; - const credential = await this.getCredentials('facebookLeadAdsOAuth2Api'); - const appId = credential.clientId as string; + const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const appId = credentials.clientId as string; const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string; - const verifyToken = uuid(); - const staticData = this.getWorkflowStaticData('node'); - staticData.verifyToken = verifyToken; + const verifyToken = this.getNode().id; await appWebhookSubscriptionCreate.call(this, appId, { object: 'page', @@ -160,8 +187,8 @@ export class FacebookLeadAdsTrigger implements INodeType { return true; }, async delete(this: IHookFunctions): Promise { - const credential = await this.getCredentials('facebookLeadAdsOAuth2Api'); - const appId = credential.clientId as string; + const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const appId = credentials.clientId as string; await appWebhookSubscriptionDelete.call(this, appId, 'page'); @@ -182,9 +209,7 @@ export class FacebookLeadAdsTrigger implements INodeType { // 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']) { - //compare hub.verify_token with the saved token - const staticData = this.getWorkflowStaticData('node'); - if (staticData.verifyToken !== query['hub.verify_token']) { + if (this.getNode().id !== query['hub.verify_token']) { return {}; } @@ -213,17 +238,47 @@ export class FacebookLeadAdsTrigger implements INodeType { .map((entry) => entry.changes .filter((change) => change.field === 'leadgen' && change.value.form_id === formId) - .map((change) => ({ ...change.value, entry_id: entry.id })), + .map((change) => change.value), ) .flat() .map(async (event) => { - const leadFormData = (await facebookEntityDetail.call( - this, - event.leadgen_id, - 'id,field_data,created_time', - )) as FacebookFormLeadData; + const [lead, form] = await Promise.all([ + facebookEntityDetail.call( + this, + event.leadgen_id, + 'field_data,created_time,ad_id,ad_name,adset_id,adset_name,form_id', + ) as Promise, + facebookEntityDetail.call( + this, + event.form_id, + 'id,name,locale,status,page,questions', + ) as Promise, + ]); + + const simplifyOutput = this.getNodeParameter('options.simplifyOutput', true) as boolean; + + if (simplifyOutput) { + return { + id: lead.id, + data: lead.field_data.reduce( + (acc, field) => ({ ...acc, [field.name]: field.values[0] }), + {}, + ), + form: { + id: form.id, + name: form.name, + locale: form.locale, + status: form.status, + questions: form.questions, + }, + ad: { id: lead.ad_id, name: lead.ad_name }, + adset: { id: lead.adset_id, name: lead.adset_name }, + page: form.page, + created_time: lead.created_time, + }; + } - return { ...event, ...leadFormData }; + return { lead, form, event }; }), ); diff --git a/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts b/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts index ffbbe853e89e2..4c966214d016a 100644 --- a/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts +++ b/packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts @@ -76,14 +76,8 @@ export async function facebookAppApiRequest( body?: { type: 'json'; payload: IDataObject } | { type: 'form'; payload: IDataObject }, qs: IDataObject = {}, ): Promise { - const staticData = this.getWorkflowStaticData('node'); - - if (!staticData.appAccessToken) { - const { access_token } = await appAccessTokenRead.call(this); - staticData.appAccessToken = access_token; - } - - const appAccessToken = staticData.appAccessToken as string; + const tokenResponse = await appAccessTokenRead.call(this); + const appAccessToken = tokenResponse.access_token; const options: OptionsWithUri = { headers: { @@ -174,14 +168,8 @@ export async function facebookPageApiRequest( qs: IDataObject = {}, ): Promise { const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string; - const staticData = this.getWorkflowStaticData('node'); - - if (!staticData.pageAccessToken) { - const page = (await facebookEntityDetail.call(this, pageId)) as FacebookPage; - staticData.pageAccessToken = page.access_token; - } - - const pageAccessToken = staticData.pageAccessToken as string; + const page = (await facebookEntityDetail.call(this, pageId)) as FacebookPage; + const pageAccessToken = page.access_token; const options: OptionsWithUri = { headers: { accept: 'application/json', @@ -227,6 +215,6 @@ export async function facebookFormList( `/${pageId}/leadgen_forms`, {}, { cursor, fields: 'id,name' }, - )) as FacebookPageListResponse; + )) as FacebookFormListResponse; return response; } diff --git a/packages/nodes-base/nodes/FacebookLeadAds/types.ts b/packages/nodes-base/nodes/FacebookLeadAds/types.ts index 7af837c21a501..d1fc6050a33a9 100644 --- a/packages/nodes-base/nodes/FacebookLeadAds/types.ts +++ b/packages/nodes-base/nodes/FacebookLeadAds/types.ts @@ -46,9 +46,23 @@ export interface FacebookPageCategory { 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 FacebookPageEvent { @@ -75,8 +89,13 @@ export interface FacebookPageEventEntry { } export interface FacebookFormLeadData { - created_time: string; id: string; + created_time: string; + ad_id: string; + ad_name: string; + adset_id: string; + adset_name: string; + form_id: string; field_data: [ { name: string; From b4e304047742c79af41180a5f5cc99c6898a1baf Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 7 Sep 2023 13:54:38 +0200 Subject: [PATCH 03/10] Add error message indicating Facebook only allows 1 webhook URL per App --- .../nodes/Facebook/FacebookTrigger.node.ts | 56 +++++++++++++------ packages/nodes-base/nodes/Facebook/types.ts | 29 ++++++++++ .../FacebookLeadAdsTrigger.node.ts | 12 +++- 3 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 packages/nodes-base/nodes/Facebook/types.ts diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts index ee2b28919390f..2b5758eb2abb2 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -1,15 +1,16 @@ +import { createHmac } from 'crypto'; import type { - IHookFunctions, - IWebhookFunctions, IDataObject, + IHookFunctions, ILoadOptionsFunctions, INodePropertyOptions, INodeType, INodeTypeDescription, + IWebhookFunctions, IWebhookResponseData, JsonObject, } from 'n8n-workflow'; -import { NodeApiError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; @@ -17,7 +18,7 @@ import { snakeCase } from 'change-case'; import { facebookApiRequest, getAllFields, getFields } from './GenericFunctions'; -import { createHmac } from 'crypto'; +import type { FacebookEvent, FacebookWebhookSubscription } from './types'; export class FacebookTrigger implements INodeType { description: INodeTypeDescription = { @@ -177,18 +178,27 @@ export class FacebookTrigger implements INodeType { const object = this.getNodeParameter('object') as string; const appId = this.getNodeParameter('appId') as string; - const { data } = await facebookApiRequest.call(this, 'GET', `/${appId}/subscriptions`, {}); + const { data } = (await facebookApiRequest.call( + this, + 'GET', + `/${appId}/subscriptions`, + {}, + )) as { data: FacebookWebhookSubscription[] }; - for (const webhook of data) { - if ( - webhook.target === webhookUrl && - webhook.object === object && - webhook.status === true - ) { - return true; - } + const subscription = data.find((webhook) => webhook.object === object && webhook.status); + + if (!subscription) { + return false; } - return false; + + if (subscription.callback_url !== webhookUrl) { + throw new NodeOperationError( + this.getNode(), + `The Facebook App ID ${appId} already has a webhook subscription. Delete it or use another App before executing the trigger. Due to Facebook API limitations, you can have just one trigger per App.`, + ); + } + + return true; }, async create(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); @@ -243,11 +253,13 @@ export class FacebookTrigger implements INodeType { }; async webhook(this: IWebhookFunctions): Promise { - const bodyData = this.getBodyData(); + const bodyData = this.getBodyData() as unknown as FacebookEvent; const query = this.getQueryData() as IDataObject; const res = this.getResponseObject(); const req = this.getRequestObject(); const headerData = this.getHeaderData() as IDataObject; + const object = this.getNodeParameter('object') as string; + const fields = this.getNodeParameter('fields') as string[]; const credentials = await this.getCredentials('facebookGraphAppApi'); // Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) if (this.getWebhookName() === 'setup') { @@ -275,8 +287,20 @@ export class FacebookTrigger implements INodeType { } } + if (bodyData.object !== object) { + return {}; + } + + const events = bodyData.entry.map((entry) => + entry.changes.filter((change) => fields.includes(change.field)), + ); + + if (events.length === 0) { + return {}; + } + return { - workflowData: [this.helpers.returnJsonArray(bodyData.entry as IDataObject[])], + workflowData: [this.helpers.returnJsonArray(events as unknown as IDataObject[])], }; } } diff --git a/packages/nodes-base/nodes/Facebook/types.ts b/packages/nodes-base/nodes/Facebook/types.ts new file mode 100644 index 0000000000000..934ceac179237 --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/types.ts @@ -0,0 +1,29 @@ +export interface FacebookEvent { + object: string; + entry: FacebookPageEventEntry[]; +} + +export interface FacebookPageEventEntry { + id: string; + time: number; + changes: [ + { + field: 'leadgen'; + value: { + ad_id: string; + form_id: string; + leadgen_id: string; + created_time: number; + page_id: string; + adgroup_id: string; + }; + }, + ]; +} + +export interface FacebookWebhookSubscription { + object: string; + callback_url: string; + fields: string[]; + status: boolean; +} diff --git a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts index 74e54088d89ad..9cf47f44e5362 100644 --- a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts +++ b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts @@ -158,10 +158,10 @@ export class FacebookLeadAdsTrigger implements INodeType { return false; } - if (subscription?.callback_url !== webhookUrl) { + if (subscription.callback_url !== webhookUrl) { throw new NodeOperationError( this.getNode(), - `The App ID ${appId} already has a callback url ${subscription?.callback_url}. Delete it or use another App ID before executing the trigger. Due to Facebook API limitations, you can have just one trigger per App.`, + `The Facebook App ID ${appId} already has a webhook subscription. Delete it or use another App before executing the trigger. Due to Facebook API limitations, you can have just one trigger per App.`, ); } @@ -204,6 +204,7 @@ export class FacebookLeadAdsTrigger implements INodeType { const req = this.getRequestObject(); const headerData = this.getHeaderData() as IDataObject; const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api'); + const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string; const formId = this.getNodeParameter('form', '', { extractValue: true }) as string; // Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) @@ -237,7 +238,12 @@ export class FacebookLeadAdsTrigger implements INodeType { bodyData.entry .map((entry) => entry.changes - .filter((change) => change.field === 'leadgen' && change.value.form_id === formId) + .filter( + (change) => + change.field === 'leadgen' && + change.value.page_id === pageId && + change.value.form_id === formId, + ) .map((change) => change.value), ) .flat() From b1ea9699f2a0df78e97f9ff97daf52907fb536ba Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 13 Sep 2023 12:07:03 +0200 Subject: [PATCH 04/10] Remove App Secret field, hide webhook URLs when automatically registered --- packages/core/bin/generate-ui-types | 16 ++++++++++++++++ .../editor-ui/src/components/NodeWebhooks.vue | 6 +++--- .../FacebookLeadAdsOAuth2Api.credentials.ts | 9 --------- .../FacebookLeadAdsTrigger.node.ts | 14 +++++--------- packages/workflow/src/Interfaces.ts | 17 +++++++++-------- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index 76ceae31fc16d..f320dea3904f2 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -29,6 +29,21 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { return refs; } +function addWebhookLifecycle(nodeType) { + if (nodeType.description.webhooks) { + nodeType.description.webhooks = nodeType.description.webhooks.map((webhook) => { + const webhookMethods = + nodeType?.webhookMethods?.[webhook.name] ?? nodeType?.webhookMethods?.default; + webhook.hasLifecycleMethods = Boolean( + webhookMethods?.checkExists && webhookMethods?.create && webhookMethods?.delete, + ); + return webhook; + }); + } + + return nodeType; +} + (async () => { const loader = new PackageDirectoryLoader(packageDir); await loader.loadAll(); @@ -60,6 +75,7 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { .map((data) => { const nodeType = NodeHelpers.getVersionedNodeType(data.type); NodeHelpers.applySpecialNodeParameters(nodeType); + addWebhookLifecycle(nodeType); return data.type; }) .flatMap((nodeData) => { diff --git a/packages/editor-ui/src/components/NodeWebhooks.vue b/packages/editor-ui/src/components/NodeWebhooks.vue index d4b502c2f7ccf..230fc81407340 100644 --- a/packages/editor-ui/src/components/NodeWebhooks.vue +++ b/packages/editor-ui/src/components/NodeWebhooks.vue @@ -58,12 +58,12 @@