Skip to content

Commit

Permalink
feat(syndicator-linkedin): add LinkedIn syndicator
Browse files Browse the repository at this point in the history
  • Loading branch information
jackdbd committed Jun 6, 2024
1 parent b4ab29f commit 4a80a05
Show file tree
Hide file tree
Showing 9 changed files with 420 additions and 0 deletions.
9 changes: 9 additions & 0 deletions indiekit.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ const config = {
accessKey: process.env.INTERNET_ARCHIVE_ACCESS_KEY,
secretKey: process.env.INTERNET_ARCHIVE_SECRET_KEY,
},
"@indiekit/syndicator-linkedin": {
checked: true,
accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
// authorId: process.env.LINKEDIN_AUTHOR_ID,
authorName: process.env.LINKEDIN_AUTHOR_NAME,
authorProfileUrl: process.env.LINKEDIN_AUTHOR_PROFILE_URL,
clientId: process.env.LINKEDIN_CLIENT_ID,
clientSecret: process.env.LINKEDIN_CLIENT_SECRET,
},
"@indiekit/syndicator-mastodon": {
checked: true,
url: process.env.MASTODON_URL,
Expand Down
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions packages/syndicator-linkedin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# @indiekit/syndicator-linkedin

[LinkedIn](https://www.linkedin.com/) syndicator for Indiekit.

## Installation

`npm i @indiekit/syndicator-linkedin`

## Requirements

todo

## Usage

Add `@indiekit/syndicator-linkedin` to your list of plug-ins, specifying options as required:

```js
{
"plugins": ["@indiekit/syndicator-linkedin"],
"@indiekit/syndicator-linkedin": {
"accessToken": process.env.LINKEDIN_ACCESS_TOKEN,
"clientId": process.env.LINKEDIN_CLIENT_ID,
"clientSecret": process.env.LINKEDIN_CLIENT_SECRET,
"checked": true
}
}
```

## Options

todo
6 changes: 6 additions & 0 deletions packages/syndicator-linkedin/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
167 changes: 167 additions & 0 deletions packages/syndicator-linkedin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import process from "node:process";
import makeDebug from "debug";
import { IndiekitError } from "@indiekit/error";
import { createPost, userInfo } from "./lib/linkedin.js";

const debug = makeDebug(`indiekit-syndicator:linkedin`);

const UID = "https://www.linkedin.com/";

const defaults = {
accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
// The character limit for a LinkedIn post is 3000 characters.
// https://www.linkedin.com/help/linkedin/answer/a528176
characterLimit: 3000,
checked: false,
// Client ID and Client Secret of the LinkedIn OAuth app you are using for
// publishing on your LinkedIn account.
// This LinkedIn OAuth app could be the official Indiekit app or a custom one.
// TODO:
// 1. create a LinkedIn page for Indiekit
// 2. create a Linkedin OAuth app
// 3. associate the Linkedin OAuth app to the LinkedIn page
// 4. submit the Linkedin OAuth app for verification
clientId: process.env.LINKEDIN_CLIENT_ID,
clientSecret: process.env.LINKEDIN_CLIENT_SECRET,

// https://learn.microsoft.com/en-us/linkedin/marketing/versioning
// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
postsAPIVersion: "202401",
};

export default class LinkedInSyndicator {
/**
* @param {object} [options] - Plug-in options
* @param {string} [options.accessToken] - Linkedin OAuth app access token
* @param {string} [options.authorId] - LinkedIn ID of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
* @param {string} [options.authorName] - Full name of the author
* @param {string} [options.authorProfileUrl] - LinkedIn profile URL of the author
* @param {number} [options.characterLimit] - LinkedIn post character limit
* @param {boolean} [options.checked] - Check syndicator in UI
* @param {string} [options.clientId] - Linkedin OAuth app Client ID
* @param {string} [options.clientSecret] - Linkedin OAuth app Client Secret
* @param {string} [options.postsAPIVersion] - Version of the Linkedin /posts API to use
*/
constructor(options = {}) {
this.name = "LinkedIn syndicator";
this.options = { ...defaults, ...options };
}

get environment() {
return [
"LINKEDIN_ACCESS_TOKEN",
"LINKEDIN_AUTHOR_ID",
"LINKEDIN_AUTHOR_NAME",
"LINKEDIN_AUTHOR_PROFILE_URL",
"LINKEDIN_CLIENT_ID",
"LINKEDIN_CLIENT_SECRET",
];
}

get info() {
const service = {
name: "LinkedIn",
photo: "/assets/@indiekit-syndicator-linkedin/icon.svg",
url: "https://www.linkedin.com/",
};

const name = this.options.authorName || "unknown LinkedIn author name";
const uid = this.options.authorProfileUrl || UID;
const url =
this.options.authorProfileUrl || "unknown LinkedIn author profile URL";

return {
checked: this.options.checked,
name,
service,
uid,
user: { name, url },
};
}

// TODO: think about which fields to ask for in the prompt
get prompts() {
return [
{
type: "text",
name: "clientId",
message:
"What is the Client ID of your LinkedIn OAuth 2.0 application?",
description: "i.e. 12abcde3fghi4j",
},
{
type: "text",
name: "clientSecret",
message:
"What is the Client Secret of your LinkedIn OAuth 2.0 application?",
description: "i.e. WPL_AP0.foo.bar",
},
];
}

async syndicate(properties, publication) {
debug(`syndicate properties %O`, properties);
debug(`syndicate publication %O: `, {
categories: publication.categories,
me: publication.me,
});

const accessToken = this.options.accessToken;
// const clientId = this.options.clientId;
// const clientSecret = this.options.clientSecret;

let authorName;
let authorUrn;
try {
const userinfo = await userInfo({ accessToken });
authorName = userinfo.name;
authorUrn = userinfo.urn;
} catch (error) {
throw new IndiekitError(error.message, {
cause: error,
plugin: this.name,
status: error.statusCode,
});
}

// TODO: switch on properties['post-type'] // e.g. article

const text = properties.content.text;

try {
const { url } = await createPost({
accessToken,
authorName,
authorUrn,
text,
versionString: this.options.postsAPIVersion,
});
// https://www.linkedin.com/feed/update/urn:li:share:7204493027191492608/
debug(`post created, online at ${url}`);
return url;
} catch (error) {
// Axios Error
// https://axios-http.com/docs/handling_errors
const status = error.response.status;
const message = `could not create LinkedIn post: ${error.response.statusText}`;
throw new IndiekitError(message, {
cause: error,
plugin: this.name,
status,
});
}
}

init(Indiekit) {
const required_configs = ["clientId", "clientSecret"];
for (const required of required_configs) {
if (!this.options[required]) {
const message = `could not initialize ${this.name}: ${required} not set.`;
debug(message);
console.error(message);
throw new Error(message);
}
}
Indiekit.addSyndicator(this);
}
}
83 changes: 83 additions & 0 deletions packages/syndicator-linkedin/lib/linkedin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import makeDebug from "debug";
import { AuthClient, RestliClient } from "linkedin-api-client";

const debug = makeDebug(`indiekit-syndicator:linkedin`);

export const introspectToken = async ({
accessToken,
clientId,
clientSecret,
}) => {
// https://github.com/linkedin-developers/linkedin-api-js-client?tab=readme-ov-file#authclient
const authClient = new AuthClient({
clientId,
clientSecret,
// redirectUrl: 'https://www.linkedin.com/developers/tools/oauth/redirect'
});

debug(`try introspecting LinkedIn access token`);
return await authClient.introspectAccessToken(accessToken);
};

export const userInfo = async ({ accessToken }) => {
const client = new RestliClient();

// The /v2/userinfo endpoint is unversioned and requires the `openid` OAuth scope
const response = await client.get({
accessToken,
resourcePath: "/userinfo",
});

// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns

const id = response.data.sub;
debug(`user info %O`, response.data);

return { id, name: response.data.name, urn: `urn:li:person:${id}` };
};

export const createPost = async ({
accessToken,
authorName,
authorUrn,
text,
versionString,
}) => {
const client = new RestliClient();
// client.setDebugParams({ enabled: true });

// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns

// Text share or create an article
// https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
// https://github.com/linkedin-developers/linkedin-api-js-client/blob/master/examples/create-posts.ts
debug(
`create post on behalf of author URN ${authorUrn} (${authorName}) using LinkedIn Posts API version ${versionString}`,
);
const response = await client.create({
accessToken,
resourcePath: "/posts",
entity: {
author: authorUrn,
commentary: text,
distribution: {
feedDistribution: "MAIN_FEED",
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: "PUBLISHED",
visibility: "PUBLIC",
},
versionString,
});

// LinkedIn share URNs are different from LinkedIn activity URNs
// https://stackoverflow.com/questions/51857232/what-is-the-distinction-between-share-and-activity-in-linkedin-v2-api
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns

return {
url: `https://www.linkedin.com/feed/update/${response.createdEntityId}/`,
};
};
Loading

0 comments on commit 4a80a05

Please sign in to comment.