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 0000000000..dd53ef11d3 Binary files /dev/null and b/packages/nodes-base/nodes/LinkedIn/linkedin.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index ba13e8ff07..090087c580 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -98,6 +98,7 @@ "dist/credentials/JiraSoftwareServerApi.credentials.js", "dist/credentials/JotFormApi.credentials.js", "dist/credentials/KeapOAuth2Api.credentials.js", + "dist/credentials/LinkedInOAuth2Api.credentials.js", "dist/credentials/LinkFishApi.credentials.js", "dist/credentials/MailchimpApi.credentials.js", "dist/credentials/MailchimpOAuth2Api.credentials.js", @@ -279,6 +280,7 @@ "dist/nodes/JotForm/JotFormTrigger.node.js", "dist/nodes/Keap/Keap.node.js", "dist/nodes/Keap/KeapTrigger.node.js", + "dist/nodes/LinkedIn/LinkedIn.node.js", "dist/nodes/LinkFish/LinkFish.node.js", "dist/nodes/Mailchimp/Mailchimp.node.js", "dist/nodes/Mailchimp/MailchimpTrigger.node.js",