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 7, 2024
1 parent 15725fb commit c5e9db9
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 0 deletions.
4 changes: 4 additions & 0 deletions indiekit.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ const config = {
accessKey: process.env.INTERNET_ARCHIVE_ACCESS_KEY,
secretKey: process.env.INTERNET_ARCHIVE_SECRET_KEY,
},
"@indiekit/syndicator-linkedin": {
checked: true,
authorProfileUrl: process.env.LINKEDIN_AUTHOR_PROFILE_URL,
},
"@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.

12 changes: 12 additions & 0 deletions packages/endpoint-linkedin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,28 @@ export default class LinkedInEndpoint {
response.redirect(this.authorizationUri);
});

// If the member chooses to cancel, or the request fails for any reason,
// the client is redirected to your redirect_uri with the following
// additional query parameters appended: error, error_description, state.
// https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?tabs=HTTPS1#member-approves-request
router.get("/callback", async (request, response) => {
debug(`GET ${this.mountPath}${request.path}`);

// https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow#failed-requests
const { code, error, error_description, state } = request.query;
if (error) {
// TODO: display error page
return response.status(400).json({ error, error_description });
}

// Before you use the authorization code, your application should ensure
// that the value returned in the state parameter matches the state value
// from your original authorization code request. This ensures that you
// are dealing with the real member and not a malicious script.
// If the state values do not match, you are likely the victim of a CSRF
// attack and your application should return a 401 Unauthorized error code
// in response.
// https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow#member-approves-request
debug(`state received from LinkedIn: ${state}`);
// TODO: should we check that `state` is the same as the one we sent in
// the initial request?
Expand Down
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.
144 changes: 144 additions & 0 deletions packages/syndicator-linkedin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import makeDebug from "debug";
import { IndiekitError } from "@indiekit/error";
import { createPost, userInfo } from "./lib/linkedin.js";

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

const DEFAULTS = {
// The character limit for a LinkedIn post is 3000 characters.
// https://www.linkedin.com/help/linkedin/answer/a528176
characterLimit: 3000,

checked: false,

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

const retrieveAccessToken = async () => {
// the access token could be stored in an environment variable, in a database, etc
debug(
`retrieve LinkedIn access token from environment variable LINKEDIN_ACCESS_TOKEN`,
);

return process.env.LINKEDIN_ACCESS_TOKEN === undefined
? {
error: new Error(`environment variable LINKEDIN_ACCESS_TOKEN not set`),
}
: { value: process.env.LINKEDIN_ACCESS_TOKEN };
};

export default class LinkedInSyndicator {
/**
* @param {object} [options] - Plug-in options
* @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.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_PROFILE_URL"];
}

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 || "https://www.linkedin.com/";
const url =
this.options.authorProfileUrl || "unknown LinkedIn author profile URL";

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

get prompts() {
return [
{
type: "text",
name: "postsAPIVersion",
message: "What is the LinkedIn Posts API version you want to use?",
description: "e.g. 202401",
},
];
}

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

const { error: tokenError, value: accessToken } =
await retrieveAccessToken();

if (tokenError) {
throw new IndiekitError(tokenError.message, {
cause: tokenError,
plugin: this.name,
status: 500,
});
}

let authorName;
// LinkedIn URN of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
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 || 500,
});
}

// TODO: switch on properties['post-type'] // e.g. article, note
const text = properties.content.text;

try {
const { url } = await createPost({
accessToken,
authorName,
authorUrn,
text,
versionString: this.options.postsAPIVersion,
});
debug(`post created, now 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) {
Indiekit.addSyndicator(this);
}
}
81 changes: 81 additions & 0 deletions packages/syndicator-linkedin/lib/linkedin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import makeDebug from "debug";
import { AuthClient, RestliClient } from "linkedin-api-client";

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

// TODO: introspecting the token could be useful to show the token expiration
// date somewhere in the Indiekit UI (maybe in the syndicator detail page).
export const introspectToken = async ({
accessToken,
clientId,
clientSecret,
}) => {
// https://github.com/linkedin-developers/linkedin-api-js-client?tab=readme-ov-file#authclient
const client = new AuthClient({ clientId, clientSecret });

debug(`try introspecting LinkedIn access token`);
return await client.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 c5e9db9

Please sign in to comment.