From ff9964e10bfb9cde98da221f9691d26b83910325 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 4 Nov 2020 06:25:18 -0500 Subject: [PATCH] :sparkles: Add Facebook Trigger Node (#1106) * :zap: Facebook trigger from webhooks Uses tokens just like the GraphQL API but this one requires an app token instead of an user access token. * :zap: Improvements :zap Improvements * :bug: Fix credential file name Co-authored-by: Omar Ajoue --- ...acebookGraphSubscriptionApi.credentials.ts | 21 ++ .../nodes/Facebook/FacebookGraphApi.node.ts | 6 +- .../nodes/Facebook/FacebookTrigger.node.ts | 251 ++++++++++++++++++ .../nodes/Facebook/GenericFunctions.ts | 53 ++++ .../nodes-base/nodes/Facebook/facebook.png | Bin 1371 -> 2413 bytes packages/nodes-base/package.json | 2 + 6 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Facebook/GenericFunctions.ts diff --git a/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts b/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts new file mode 100644 index 000000000..b96f84134 --- /dev/null +++ b/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts @@ -0,0 +1,21 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class FacebookGraphSubscriptionApi implements ICredentialType { + name = 'facebookGraphSubscriptionApi'; + displayName = 'Facebook Graph API'; + extends = [ + 'facebookGraphApi', + ]; + properties = [ + { + displayName: 'APP Secret', + name: 'appSecret', + type: 'string' as NodePropertyTypes, + 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/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts index 42d13cc87..d8a9aa2d3 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -10,7 +10,9 @@ import { INodeTypeDescription, } from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; export class FacebookGraphApi implements INodeType { description: INodeTypeDescription = { @@ -22,7 +24,7 @@ export class FacebookGraphApi implements INodeType { description: 'Interacts with Facebook using the Graph API', defaults: { name: 'Facebook Graph API', - color: '#772244', + color: '#3B5998', }, inputs: ['main'], outputs: ['main'], diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts new file mode 100644 index 000000000..cb0f16b7a --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -0,0 +1,251 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import * as uuid from 'uuid/v4'; + +import { + snakeCase, +} from 'change-case'; + +import { + facebookApiRequest, +} from './GenericFunctions'; + +import { + createHmac, +} from 'crypto'; + +export class FacebookTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Facebook Trigger', + name: 'facebookTrigger', + icon: 'file:facebook.png', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["appId"] +"/"+ $parameter["object"]}}', + description: 'Starts the workflow when a Facebook events occurs.', + defaults: { + name: 'Facebook Trigger', + color: '#3B5998', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'facebookGraphSubscriptionApi', + required: true, + }, + ], + webhooks: [ + { + name: 'setup', + httpMethod: 'GET', + responseMode: 'onReceived', + path: 'webhook', + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Object', + name: 'object', + type: 'options', + options: [ + { + name: 'Ad Account', + value: 'adAccount', + description: 'Get updates about Ad Account', + }, + { + name: 'Application', + value: 'application', + description: 'Get updates about the app', + }, + { + name: 'Certificate Transparency', + value: 'certificateTransparency', + description: 'Get updates about Certificate Transparency', + }, + { + name: 'Group', + value: 'group', + description: 'Get updates about activity in groups and events in groups for Workplace', + }, + { + name: 'Instagram', + value: 'instagram', + description: 'Get updates about comments on your media', + }, + { + name: 'Link', + value: 'link', + description: 'Get updates about links for rich previews by an external provider', + }, + { + name: 'Page', + value: 'page', + description: 'Page updates', + }, + { + name: 'Permissions', + value: 'permissions', + description: 'Updates regarding granting or revoking permissions', + }, + { + name: 'User', + value: 'user', + description: 'User profile updates', + }, + { + name: 'Whatsapp Business Account', + value: 'whatsappBusinessAccount', + description: 'Get updates about Whatsapp business account', + }, + { + name: 'Workplace Security', + value: 'workplaceSecurity', + description: 'Get updates about Workplace Security', + }, + ], + required: true, + default: 'user', + description: 'The object to subscribe to', + }, + { + displayName: 'App ID', + name: 'appId', + type: 'string', + required: true, + default: '', + description: 'Facebook APP ID', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add option', + options: [ + { + displayName: 'Include values', + name: 'includeValues', + type: 'boolean', + default: true, + description: 'Indicates if change notifications should include the new values.', + }, + ], + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const object = this.getNodeParameter('object') as string; + const appId = this.getNodeParameter('appId') as string; + + const { data } = await facebookApiRequest.call(this, 'GET', `/${appId}/subscriptions`, {}); + + for (const webhook of data) { + if (webhook.target === webhookUrl && webhook.object === object && webhook.status === true) { + return true; + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const object = this.getNodeParameter('object') as string; + const appId = this.getNodeParameter('appId') as string; + const options = this.getNodeParameter('options') as IDataObject; + + const body = { + object: snakeCase(object), + callback_url: webhookUrl, + verify_token: uuid(), + } as IDataObject; + + if (options.includeValues !== undefined) { + body.include_values = options.includeValues; + } + + const responseData = await facebookApiRequest.call(this, 'POST', `/${appId}/subscriptions`, body); + + webhookData.verifyToken = body.verify_token; + + if (responseData.success !== true) { + // Facebook did not return success, so something went wrong + throw new Error('Facebook webhook creation response did not contain the expected data.'); + } + return true; + }, + async delete(this: IHookFunctions): Promise { + const appId = this.getNodeParameter('appId') as string; + const object = this.getNodeParameter('object') as string; + + try { + await facebookApiRequest.call(this, 'DELETE', `/${appId}/subscriptions`, { object: snakeCase(object) }); + } catch (e) { + return false; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData() as IDataObject; + const query = this.getQueryData() as IDataObject; + const res = this.getResponseObject(); + const req = this.getRequestObject(); + const headerData = this.getHeaderData() as IDataObject; + const credentials = this.getCredentials('facebookGraphSubscriptionApi') as IDataObject; + // 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']) { + //TODO + //compare hub.verify_token with the saved token + //const webhookData = this.getWorkflowStaticData('node'); + // if (webhookData.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 !== '') { + //@ts-ignore + const computedSignature = createHmac('sha1', credentials.appSecret as string).update(req.rawBody).digest('hex'); + if (headerData['x-hub-signature'] !== `sha1=${computedSignature}`) { + return {}; + } + } + + return { + workflowData: [ + this.helpers.returnJsonArray(bodyData.entry as IDataObject[]), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts new file mode 100644 index 000000000..45412ff8f --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts @@ -0,0 +1,53 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function facebookApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + let credentials; + + if (this.getNode().name.includes('Trigger')) { + credentials = this.getCredentials('facebookGraphSubscriptionApi') as IDataObject; + } else { + credentials = this.getCredentials('facebookGraphApi') as IDataObject; + } + + qs.access_token = credentials!.accessToken; + + const options: OptionsWithUri = { + headers: { + accept: 'application/json,text/*;q=0.99', + }, + method, + qs, + body, + gzip: true, + uri: uri ||`https://graph.facebook.com/v8.0${resource}`, + json: true, + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.response.body && error.response.body.error) { + const message = error.response.body.error.message; + throw new Error( + `Facebook Trigger error response [${error.statusCode}]: ${message}`, + ); + } + throw new Error(error); + } +} diff --git a/packages/nodes-base/nodes/Facebook/facebook.png b/packages/nodes-base/nodes/Facebook/facebook.png index e0cc04460903fb05de99b1c4189d5170f44e6a02..85e6670eeba4930e5b4a9ab96fdf727f4ccb038e 100644 GIT binary patch literal 2413 zcmb7_c|4SB8^`b2%vdrrl%;GF*@{Hj8M}Csr7UfjlBG^BV^^3g89JQ?LzL_~)Nv#! zidUf{ojS?B^~NMeMqb8PW*9T?GgAHW{`FqZ=kwgx=lNXs^|`;l=en+2FeDfUIA;eZ z2LOWsa1eTcfB~-A$A$y~;Nk+b0RR|)f=K}c8@90x z00bNfLje&qMpR533TVdx7y|l+K#CyIB1nu#1Pl%kc%&2owNH8{PQ=<>e=CWsicV6= zve{PZVemlhw4O|Bc(-l#{$vc}h}Et$vIpkr5oK<6btx?MqlN)Fk!}7xspWEEA2sZa z&OY>vs>p4>ve4@geR;ysE3jTvOn$qjmbS5p=|LxFZ=YkwgMvfO#l*&4Orxe}T+O_Z zmw)qC$(>&-tM1;bee}4Y@yXNYFaGFw+4<_vcYXZ>gG0k3G9jA59%{n2cVvK~l0xeX2@2*kk=LhO&PE~z7eM2(~D=RB? zJ%@kTTRNK%tt&{Ea1_`gjs0wfR{^5LQ4nz=`CuQP~JF&t`o z6kO3xpJ^?)o>zQAVvNpgTiK$c-$RYBTKTK3Xp~l7G^XvszBOz7)Z}oreB;a$!&*mC z1*-3s{HXXQw>i3o7&WfFzjKM?SH@&=qk~^}Ee2M_+_pGL^V+tmWj{iW3}!3(cn23Y z$hzwE|Lo{no)^Sz`Q39>Rl@@jLeG)E51kAH{eKt=9*~-0v~?FhE4MQINZQ{fYj>?h z8yk-@qX#rGE3v0uy7U(gnmwM_8_-VBoqsRie=@u53V{^dbf78BMK0-HaL+$6M!L@j zdRWH)elTk`$7&f+-Qm)qeTNn@M){?2LcbF@AFO&(ua`9&Q5I97J+0}{fBr(JYuui) zEdI5SQMZWTr{xkaAi1R76Njk1&Zf?Db-s~EuN%^z zrm+&u%dT&0dAim!!$eb0R(1JtpP;FO9ZeF5*_IL1`HgH)TSM7MmiOzVsS9TjidK(t z_?|e|@~3_UN~aYvYaH^=%#>#KDveBv%C^~uP4HBm?1px%Jzt+FNn-6mH9Tnj0iLW&UKQR+$S+r!sghizxBDbuG- zSCuB6-=S+V78H~3$ZK+yHP$K;B@acG)t9#>4y`B9aonO;=8uj{4yAMRX~RL ziwIXF(cT)25xoExvQk3ToeJfG%wOy;X)xRdS%bCQ>#x_$DkpxEqUm5sY{k6gXpGWN zu;-?-*-22AwcKAd#uw>JmUpW7m3C^90sytY0Zuqw;X;GP z003}tXao}WS-X?SNm->2TIaiQ43>(Xm79Oq<;;@W=kLb9RSJ<%W2f+&Pio>x&)ZnH z`nTx%lh~KVTt3-1-J>Cw%JZYlyE8xE=Q~_3KIGuz!JyF~kqT6aTmfiHt_X~GIJ9Uudt|@)XdLmIl*ZK zX`g?}PN)zHh8NCB1c1R&(B%N#g`zNkC+x#Pim8z2vyw_5bhk2C|09^Nm~lBK{-zg} z3GsO+m`T-LMa>EyT$XG0T0UdWnz1ukk4cNHr6>!wn5m6AeUL%5B(%LBT^ghvB6Amc zU2{{Rm8ez*XTwX&Pmy576`q=6tQQsUH3M zKefD?p@}{pE7n*#nUOI0K4EWz`4>uFQg6HhXH+i84He&+%aT|)AC!CxXBBTeRr#`V zfznWD`Fpe&KmH;`V=bM|&04Qny6p=S% z<#{VSE^D`j?#ur5W@6-*fim&A!!4Y(M!9$er)oumaRE3Z0Lo4>q9DB}h#yXxD4)L9 zcdh~lrAU@~kv{J3;fQ6Z@V$8gpv8cS>rE`B-{xlV9=mn>aA(Wc#g4)fn=~g*acNkt zP8^;OD83=dz{4M-=`SUsamKeJz-94bEU&q`&yL+^8_IQD@wVKlMjnpMwT_yczjSUz zVj4TXGB#cpU7VF@L76;l^s|MOXYz8hEc4Tzi-U_f3mVt3W5gz-4w)Tanj7wkXRP3! H35Nd*gH!z` literal 1371 zcmV-h1*H0kP)pYF^Q@H$9xBGO*{C2JLA)WRju>TH=@f42oE~WT1x&Jeh@-mI^LA?JxmGND-{7tv~ zW54}q!~Jx@{HV_O6rcGLmh}Z^;}@Lw3ykv~ru!wc{~NFWF{$|+d+I5x{2`e07IWtu zgzXrM@gI8YA9v~@cIGgx{40LyER*v#l<_5t?=`3RJDK!Ex&1w|{6oC{M5*{euKQcA z`8|{HJ&x^5!2egA@L{z14~g#|t^F65^$L9J2z%-afbAl${u6HGA+P=uZsZ}C^&*z^ z9;W*rs{AIO_78ODBa!hKhVCM({0(yFADs3fknk9W?j4El9-#OnrurtU`zD|F8GPz3 zwf+=v<~OGJEvow_qxmG7_B6EpE0OdcjPWn0`6hYiFQNB6vHCBf`98AyEr97XqWC|n z_(O{AHiGCbedsug>_oErNsa47it0?C@(GLa3z72!eCq>w>H~P`43hH{r27-0`W2@9 z0d(gMmh}yk^aX$I0(R&Da_0eX<`ba#5SsT0iSPx1?H8&27N`6WocIcj@&|?Q0dD0I zp7{rd?*@bJ0c_+CnDzi_<^gr0(R*Zr1}DK z=V6=rlK=n!gLG0(Qvd?)K@(C21+RyP`R%W-r<{jKI5aL83HSEx?9su8hi^3~A|MnF z1pfW|@$A{x*V4zu#lW_)udkz=kd1_aetmjzVq#WOQ9U_0CH(vN_V)Aa?CR<0=;h(z z;ojce+}qpR*45O`$-TR}xV5OJrJcZ|^(zJOJeRuknmJ^!D1ix>~#5G3xo7QswTq+kO#jXZqbaw`Tp;?=rUQ z&AFZJ-4Lc=bEvYnu1n#MRKjg#+1llMUMq6Yl%+lSG6M}6Yt!ymX(W?LGS_7I zGBh>1TYbGZbPXExu2cTi`X;|fIq|3*Z5FLTqGC|-3s-!S(gsr;^GY*LZ&epQNIC8S zBwm5#RTcekbu^lYra;305XHzP64fs1;qD138nZaYHhPzKy6o^)cymst+cvXl~+OcSOC7D6n@mvgg8v@M~;r4hn( zp?v9FFKlU-_snmh*dC$8Eg`~8THF~DXraU%0YXR~d#?lpIw)~rz~_s@C+>>} zdI}(J