From c522ba75ca6d551b094943332cfaef385411da43 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Thu, 17 Sep 2020 23:32:12 +0200 Subject: [PATCH] :sparkles: Add LinkedIn Integration (#942) * :construction: node logic, request logic, authentication, logo, descriptions * :construction: Posting image, article and text finished. * :construction: Posting image, article and text in post finished * :zap: Post creation (image, articles, text) * :zap: Added post creation by organization, fixed up descriptions, credentials, indentation * :zap: Fix issues on LinkedIn-Node Co-authored-by: Jan Oberhauser Co-authored-by: Jan --- .../LinkedInOAuth2Api.credentials.ts | 55 ++++ .../nodes/LinkedIn/GenericFunctions.ts | 52 ++++ .../nodes/LinkedIn/LinkedIn.node.ts | 249 +++++++++++++++++ .../nodes/LinkedIn/PostDescription.ts | 250 ++++++++++++++++++ .../nodes-base/nodes/LinkedIn/linkedin.png | Bin 0 -> 1760 bytes packages/nodes-base/package.json | 2 + 6 files changed, 608 insertions(+) create mode 100644 packages/nodes-base/credentials/LinkedInOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/LinkedIn/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/LinkedIn/LinkedIn.node.ts create mode 100644 packages/nodes-base/nodes/LinkedIn/PostDescription.ts create mode 100644 packages/nodes-base/nodes/LinkedIn/linkedin.png diff --git a/packages/nodes-base/credentials/LinkedInOAuth2Api.credentials.ts b/packages/nodes-base/credentials/LinkedInOAuth2Api.credentials.ts new file mode 100644 index 0000000000..f0c9ba525c --- /dev/null +++ b/packages/nodes-base/credentials/LinkedInOAuth2Api.credentials.ts @@ -0,0 +1,55 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class LinkedInOAuth2Api implements ICredentialType { + name = 'linkedInOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'LinkedIn OAuth2 API'; + properties = [ + { + displayName: 'Organization Support', + name: 'organizationSupport', + type: 'boolean' as NodePropertyTypes, + default: true, + description: 'Request permissions to post as an orgaization.', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.linkedin.com/oauth/v2/authorization', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.linkedin.com/oauth/v2/accessToken', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '=r_liteprofile,r_emailaddress,w_member_social{{$parameter["organizationSupport"] === true ? ",w_organization_social":""}}', + description: 'Standard scopes for posting on behalf of a user or organization. See this resource .' + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/LinkedIn/GenericFunctions.ts b/packages/nodes-base/nodes/LinkedIn/GenericFunctions.ts new file mode 100644 index 0000000000..23045edd84 --- /dev/null +++ b/packages/nodes-base/nodes/LinkedIn/GenericFunctions.ts @@ -0,0 +1,52 @@ +import { + OptionsWithUrl, +} from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +export async function linkedInApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, binary?: boolean, headers?: object): Promise { // tslint:disable-line:no-any + const options: OptionsWithUrl = { + headers: { + 'Accept': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0' + }, + method, + body, + url: binary ? endpoint : `https://api.linkedin.com/v2${endpoint}`, + json: true, + }; + + // If uploading binary data + if (binary) { + delete options.json; + options.encoding = null; + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + try { + return await this.helpers.requestOAuth2!.call(this, 'linkedInOAuth2Api', options, { tokenType: 'Bearer' }); + } catch (error) { + if (error.respose && error.response.body && error.response.body.detail) { + throw new Error(`Linkedin Error response [${error.statusCode}]: ${error.response.body.detail}`); + } + throw error; + } +} + + +export function validateJSON(json: string | undefined): any { // tslint:disable-line:no-any + let result; + try { + result = JSON.parse(json!); + } catch (exception) { + result = ''; + } + return result; +} diff --git a/packages/nodes-base/nodes/LinkedIn/LinkedIn.node.ts b/packages/nodes-base/nodes/LinkedIn/LinkedIn.node.ts new file mode 100644 index 0000000000..6220b67712 --- /dev/null +++ b/packages/nodes-base/nodes/LinkedIn/LinkedIn.node.ts @@ -0,0 +1,249 @@ +import { IExecuteFunctions, BINARY_ENCODING } from 'n8n-core'; +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; +import { linkedInApiRequest } from './GenericFunctions'; +import { postOperations, postFields } from './PostDescription'; + +export class LinkedIn implements INodeType { + description: INodeTypeDescription = { + displayName: 'LinkedIn', + name: 'linkedIn', + icon: 'file:linkedin.png', + group: ['input'], + version: 1, + description: 'Consume LinkedIn Api', + defaults: { + name: 'LinkedIn', + color: '#0075b4', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'linkedInOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Post', + value: 'post', + }, + ], + default: 'post', + description: 'The resource to consume', + }, + //POST + ...postOperations, + ...postFields, + ], + + + }; + + methods = { + loadOptions: { + // Get Person URN which has to be used with other LinkedIn API Requests + // https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin + async getPersonUrn(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const person = await linkedInApiRequest.call(this, 'GET', '/me', {}); + returnData.push({ name: `${person.localizedFirstName} ${person.localizedLastName}`, value: person.id }); + return returnData; + }, + } + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + let body = {}; + + for (let i = 0; i < items.length; i++) { + if (resource === 'post') { + if (operation === 'create') { + const text = this.getNodeParameter('text', i) as string; + const shareMediaCategory = this.getNodeParameter('shareMediaCategory', i) as string; + const postAs = this.getNodeParameter('postAs', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + let authorUrn = ''; + let visibility = 'PUBLIC'; + + if (postAs === 'person') { + const personUrn = this.getNodeParameter('person', i) as string; + // Only if posting as a person can user decide if post visible by public or connections + visibility = additionalFields.visibility as string || 'PUBLIC'; + authorUrn = `urn:li:person:${personUrn}`; + } else { + const organizationUrn = this.getNodeParameter('organization', i) as string; + authorUrn = `urn:li:organization:${organizationUrn}`; + } + + let description = ''; + let title = ''; + let originalUrl = ''; + + if (shareMediaCategory === 'IMAGE') { + + if (additionalFields.description) { + description = additionalFields.description as string; + } + if (additionalFields.title) { + title = additionalFields.title as string; + } + // Send a REQUEST to prepare a register of a media image file + const registerRequest = { + registerUploadRequest: { + recipes: [ + 'urn:li:digitalmediaRecipe:feedshare-image', + ], + owner: authorUrn, + serviceRelationships: [ + { + relationshipType: 'OWNER', + identifier: 'urn:li:userGeneratedContent', + }, + ], + }, + }; + + const registerObject = await linkedInApiRequest.call(this, 'POST', '/assets?action=registerUpload', registerRequest); + + // Response provides a specific upload URL that is used to upload the binary image file + const uploadUrl = registerObject.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl as string; + const asset = registerObject.value.asset as string; + + // Prepare binary file upload + const item = items[i]; + + if (item.binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + const propertyNameUpload = this.getNodeParameter('binaryPropertyName', i) as string; + + if (item.binary[propertyNameUpload] === undefined) { + throw new Error(`No binary data property "${propertyNameUpload}" does not exists on item!`); + } + + // Buffer binary data + const buffer = Buffer.from(item.binary[propertyNameUpload].data, BINARY_ENCODING) as Buffer; + // Upload image + await linkedInApiRequest.call(this, 'POST', uploadUrl, buffer, true); + + body = { + author: authorUrn, + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { + text, + }, + shareMediaCategory: 'IMAGE', + media: [ + { + status: 'READY', + description: { + text: description, + }, + media: asset, + title: { + text: title, + }, + }, + ], + }, + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': visibility, + }, + }; + + } else if (shareMediaCategory === 'ARTICLE') { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.description) { + description = additionalFields.description as string; + } + if (additionalFields.title) { + title = additionalFields.title as string; + } + if (additionalFields.originalUrl) { + originalUrl = additionalFields.originalUrl as string; + } + + body = { + author: `${authorUrn}`, + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { + text, + }, + shareMediaCategory, + media: [ + { + status: 'READY', + description: { + text: description + }, + originalUrl, + title: { + text: title + } + } + ] + } + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': visibility + } + }; + } else { + body = { + author: authorUrn, + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { + text, + }, + shareMediaCategory, + }, + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': visibility + } + }; + } + + const endpoint = '/ugcPosts'; + responseData = await linkedInApiRequest.call(this, 'POST', endpoint, body); + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/LinkedIn/PostDescription.ts b/packages/nodes-base/nodes/LinkedIn/PostDescription.ts new file mode 100644 index 0000000000..7bfb579071 --- /dev/null +++ b/packages/nodes-base/nodes/LinkedIn/PostDescription.ts @@ -0,0 +1,250 @@ +import { INodeProperties } from "n8n-workflow"; + +export const postOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'post', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new post', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const postFields = [ +/* -------------------------------------------------------------------------- */ +/* post:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Post As', + name: 'postAs', + type: 'options', + default: '', + description: 'If to post on behalf of a user or an organization.', + options: [ + { + name: 'Person', + value: 'person', + }, + { + name: 'Organization', + value: 'organization', + }, + ], + }, + { + displayName: 'Person', + name: 'person', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getPersonUrn', + }, + default: '', + required: true, + description: 'Person as which the post should be posted as.', + displayOptions: { + show: { + operation: [ + 'create', + ], + postAs: [ + 'person', + ], + resource: [ + 'post', + ], + } + } + }, + { + displayName: 'Organization', + name: 'organization', + type: 'string', + default: '', + description: 'URN of Organization as which the post should be posted as', + displayOptions: { + show: { + operation: [ + 'create', + ], + postAs: [ + 'organization', + ], + resource: [ + 'post', + ], + }, + }, + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + default: '', + description: 'The primary content of the post.', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + }, + { + displayName: 'Media Category', + name: 'shareMediaCategory', + type: 'options', + default: 'NONE', + options: [ + { + name: 'None', + value: 'NONE', + description: 'The post does not contain any media, and will only consist of text', + }, + { + name: 'Article', + value: 'ARTICLE', + description: 'The post contains an article URL', + }, + { + name: 'Image', + value: 'IMAGE', + description: 'The post contains an image', + } + ], + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + }, + { + displayName: 'Binary Property', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + shareMediaCategory: [ + 'IMAGE', + ], + }, + }, + name: 'binaryPropertyName', + type: 'string', + default: 'data', + description: 'Object property name which holds binary data', + required: true, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + options: [ + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'Provide a short description for your image or article.', + displayOptions: { + show: { + '/shareMediaCategory': [ + 'ARTICLE', + 'IMAGE', + ], + }, + }, + }, + { + displayName: 'Original URL', + name: 'originalUrl', + type: 'string', + default: '', + description: 'Provide the URL of the article you would like to share here.', + displayOptions: { + show: { + '/shareMediaCategory': [ + 'ARTICLE', + ], + }, + }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Customize the title of your image or article.', + displayOptions: { + show: { + '/shareMediaCategory': [ + 'ARTICLE', + 'IMAGE', + ], + }, + }, + }, + { + displayName: 'Visibility', + name: 'visibility', + type: 'options', + default: 'PUBLIC', + description: 'Dictate if post will be seen by the public or only connections.', + displayOptions: { + show: { + '/postAs': [ + 'person', + ], + }, + }, + options: [ + { + name: 'Connections', + value: 'CONNECTIONS', + }, + { + name: 'Public', + value: 'PUBLIC', + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/LinkedIn/linkedin.png b/packages/nodes-base/nodes/LinkedIn/linkedin.png new file mode 100644 index 0000000000000000000000000000000000000000..dd53ef11d313ea62b09992a0e78599df78fad0e0 GIT binary patch literal 1760 zcmZ{kc{tnY7RP^@gorBbSSwVki`3FsVu-D_O61zLmJ(}JCPhV1N~NZap-ro#T3dyR zt(Hbf?P4e%4NbY72vSRJQ;gkQ)pllVM?t< zs;pJY(;vkq!r0;4T}9EITPag{M;rE#7bRZF-h}uZhHl!ymVjLesXIp_5ATAe<20u^ z_ePn1(*{A1^@oxxywI_$Fd!Q6K2xr(=)a!-X1t8{wPY7OMUHs`cs#B<8mrj!JPAHAJWmf+V zoUm#utbKW2de7ob*DWLW{>2LO#DB)le03Z(lZceAp&lykb9|3#NEzx4!mIGdI_i{I z%=rPckx+f>0)lEIqhxH-ZTS96C1>;{=-3CZp%?dYNV2+$M1k5MG|@7*$0-eO^6w|D zL*zBC2BK1X-P|vt+alG40ng>OmNcv4(0)q`n8EfxueH-p7>1y$k+z!`QlM zr{#dMg>`npXZV3X>nfYAUsbL^OU9m(W>4mVk%c@yc@FM`!27ghahm~?Y~HJ`ru&xt z`j2@kW^6ebVRAF5l6}04QOd1nS9KQBcf!j*symZso$eM-e8tW|b@_inRelVy?jd7| zb_h4Kr?>>Pz6N-~G6m=H8*zfBQ0^@{x&wjsTHeam3Oc9i#nGu{*di9UqUdsa7G4Vf zLC7XCPEgj`e-X<(}NX`?!U5dREr5%DOTJFL6)yrViP$gS@PHL_b{_5;0PoEjf)T^#OQQ9^5M zmR;&DQ!Vr974Ol8mgjw$iM}F^=@P42`=V#uQS(2%wnQxGntB_CB;co}#V51@pe8=0@ud8TpipfXJ@pMHrNQCyJ(%*Pz#K(Nr@l5U`uR0hfClGLaeu)H%Uv5-D?U;ks)S;246~ z)#&1N8wJ>hASfV|G0j|1lm)W$a8g1@NEJdv=Uc?%ug{*D7OC~qaKh2zW z%EM-O@Xb^fMK6?1lh}gzqQO$YroNveHG^Kyt2=q55f1{->I);0Pi~E8!pShkSn7EWOATPms9tySOxbMqrM1cnhw$&ER_CWpG-jPv#GIQjjyu zSc+Bsl1mK88?x4s#(a@vGvWfBVRv#yCNBpCAZArX@r5O?=$+xEsCV^9C$qta~c<# ziu94oA2GV95}Y4=+he+*{e2l#{PnQDakrC9iTc(wo=l53Y z83LKFaRBC>jBlhUL^)5knZs{EEaAB^m}Yo(Uqcz-AT zr5uL|vPOR4oH1g$2X*x(la`G%|Kd1|R+_FOUb(7iO@aIY4hT1pu^6I`m00B}KdYam z{{WHhAClCf8>fqxv?9g9hhj%21XGC7QJP-FNCIGb!3cT5z%U