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",