-
-
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
9 changed files
with
420 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
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,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); | ||
} | ||
} |
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,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}/`, | ||
}; | ||
}; |
Oops, something went wrong.