-
-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(syndicator-linkedin): add LinkedIn syndicator
- Loading branch information
Showing
10 changed files
with
402 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}/`, | ||
}; | ||
}; |
Oops, something went wrong.