Skip to content

Commit bb2c548

Browse files
committed
feat(syndicator-linkedin): add LinkedIn syndicator
1 parent 69d2d4f commit bb2c548

File tree

9 files changed

+418
-0
lines changed

9 files changed

+418
-0
lines changed

indiekit.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ const config = {
8484
accessKey: process.env.INTERNET_ARCHIVE_ACCESS_KEY,
8585
secretKey: process.env.INTERNET_ARCHIVE_SECRET_KEY,
8686
},
87+
"@indiekit/syndicator-linkedin": {
88+
checked: true,
89+
accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
90+
// authorId: process.env.LINKEDIN_AUTHOR_ID,
91+
authorName: process.env.LINKEDIN_AUTHOR_NAME,
92+
authorProfileUrl: process.env.LINKEDIN_AUTHOR_PROFILE_URL,
93+
clientId: process.env.LINKEDIN_CLIENT_ID,
94+
clientSecret: process.env.LINKEDIN_CLIENT_SECRET,
95+
},
8796
"@indiekit/syndicator-mastodon": {
8897
checked: true,
8998
url: process.env.MASTODON_URL,

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# @indiekit/syndicator-linkedin
2+
3+
[LinkedIn](https://www.linkedin.com/) syndicator for Indiekit.
4+
5+
## Installation
6+
7+
`npm i @indiekit/syndicator-linkedin`
8+
9+
## Requirements
10+
11+
todo
12+
13+
## Usage
14+
15+
Add `@indiekit/syndicator-linkedin` to your list of plug-ins, specifying options as required:
16+
17+
```js
18+
{
19+
"plugins": ["@indiekit/syndicator-linkedin"],
20+
"@indiekit/syndicator-linkedin": {
21+
"accessToken": process.env.LINKEDIN_ACCESS_TOKEN,
22+
"clientId": process.env.LINKEDIN_CLIENT_ID,
23+
"clientSecret": process.env.LINKEDIN_CLIENT_SECRET,
24+
"checked": true
25+
}
26+
}
27+
```
28+
29+
## Options
30+
31+
todo
Lines changed: 6 additions & 0 deletions
Loading
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import process from "node:process";
2+
import makeDebug from "debug";
3+
import { IndiekitError } from "@indiekit/error";
4+
import { createPost, userInfo } from "./lib/linkedin.js";
5+
6+
const debug = makeDebug(`indiekit-syndicator:linkedin`);
7+
8+
const UID = "https://www.linkedin.com/";
9+
10+
const defaults = {
11+
accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
12+
// The character limit for a LinkedIn post is 3000 characters.
13+
// https://www.linkedin.com/help/linkedin/answer/a528176
14+
characterLimit: 3000,
15+
checked: false,
16+
// Client ID and Client Secret of the LinkedIn OAuth app you are using for
17+
// publishing on your LinkedIn account.
18+
// This LinkedIn OAuth app could be the official Indiekit app or a custom one.
19+
// TODO:
20+
// 1. create a LinkedIn page for Indiekit
21+
// 2. create a Linkedin OAuth app
22+
// 3. associate the Linkedin OAuth app to the LinkedIn page
23+
// 4. submit the Linkedin OAuth app for verification
24+
clientId: process.env.LINKEDIN_CLIENT_ID,
25+
clientSecret: process.env.LINKEDIN_CLIENT_SECRET,
26+
27+
// https://learn.microsoft.com/en-us/linkedin/marketing/versioning
28+
// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
29+
postsAPIVersion: "202401",
30+
};
31+
32+
export default class LinkedInSyndicator {
33+
/**
34+
* @param {object} [options] - Plug-in options
35+
* @param {string} [options.accessToken] - Linkedin OAuth app access token
36+
* @param {string} [options.authorId] - LinkedIn ID of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
37+
* @param {string} [options.authorName] - Full name of the author
38+
* @param {string} [options.authorProfileUrl] - LinkedIn profile URL of the author
39+
* @param {number} [options.characterLimit] - LinkedIn post character limit
40+
* @param {boolean} [options.checked] - Check syndicator in UI
41+
* @param {string} [options.clientId] - Linkedin OAuth app Client ID
42+
* @param {string} [options.clientSecret] - Linkedin OAuth app Client Secret
43+
* @param {string} [options.postsAPIVersion] - Version of the Linkedin /posts API to use
44+
*/
45+
constructor(options = {}) {
46+
this.name = "LinkedIn syndicator";
47+
this.options = { ...defaults, ...options };
48+
}
49+
50+
get environment() {
51+
return [
52+
"LINKEDIN_ACCESS_TOKEN",
53+
"LINKEDIN_AUTHOR_ID",
54+
"LINKEDIN_AUTHOR_NAME",
55+
"LINKEDIN_AUTHOR_PROFILE_URL",
56+
"LINKEDIN_CLIENT_ID",
57+
"LINKEDIN_CLIENT_SECRET",
58+
];
59+
}
60+
61+
get info() {
62+
const service = {
63+
name: "LinkedIn",
64+
photo: "/assets/@indiekit-syndicator-linkedin/icon.svg",
65+
url: "https://www.linkedin.com/",
66+
};
67+
68+
const name = this.options.authorName || "unknown LinkedIn author name";
69+
const uid = this.options.authorProfileUrl || UID;
70+
const url =
71+
this.options.authorProfileUrl || "unknown LinkedIn author profile URL";
72+
73+
return {
74+
checked: this.options.checked,
75+
name,
76+
service,
77+
uid,
78+
user: { name, url },
79+
};
80+
}
81+
82+
// TODO: think about which fields to ask for in the prompt
83+
get prompts() {
84+
return [
85+
{
86+
type: "text",
87+
name: "clientId",
88+
message:
89+
"What is the Client ID of your LinkedIn OAuth 2.0 application?",
90+
description: "i.e. 12abcde3fghi4j",
91+
},
92+
{
93+
type: "text",
94+
name: "clientSecret",
95+
message:
96+
"What is the Client Secret of your LinkedIn OAuth 2.0 application?",
97+
description: "i.e. WPL_AP0.foo.bar",
98+
},
99+
];
100+
}
101+
102+
async syndicate(properties, publication) {
103+
debug(`syndicate properties %O`, properties);
104+
debug(`syndicate publication %O: `, {
105+
categories: publication.categories,
106+
me: publication.me,
107+
});
108+
109+
const accessToken = this.options.accessToken;
110+
// const clientId = this.options.clientId;
111+
// const clientSecret = this.options.clientSecret;
112+
113+
let authorName;
114+
let authorUrn;
115+
try {
116+
const userinfo = await userInfo({ accessToken });
117+
authorName = userinfo.name;
118+
authorUrn = userinfo.urn;
119+
} catch (error) {
120+
throw new IndiekitError(error.message, {
121+
cause: error,
122+
plugin: this.name,
123+
status: error.statusCode,
124+
});
125+
}
126+
127+
// TODO: switch on properties['post-type'] // e.g. article, note
128+
const text = properties.content.text;
129+
130+
try {
131+
const { url } = await createPost({
132+
accessToken,
133+
authorName,
134+
authorUrn,
135+
text,
136+
versionString: this.options.postsAPIVersion,
137+
});
138+
debug(`post created, online at ${url}`);
139+
return url;
140+
} catch (error) {
141+
// Axios Error
142+
// https://axios-http.com/docs/handling_errors
143+
const status = error.response.status;
144+
const message = `could not create LinkedIn post: ${error.response.statusText}`;
145+
throw new IndiekitError(message, {
146+
cause: error,
147+
plugin: this.name,
148+
status,
149+
});
150+
}
151+
}
152+
153+
init(Indiekit) {
154+
const required_configs = ["clientId", "clientSecret"];
155+
for (const required of required_configs) {
156+
if (!this.options[required]) {
157+
const message = `could not initialize ${this.name}: ${required} not set.`;
158+
debug(message);
159+
console.error(message);
160+
throw new Error(message);
161+
}
162+
}
163+
Indiekit.addSyndicator(this);
164+
}
165+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import makeDebug from "debug";
2+
import { AuthClient, RestliClient } from "linkedin-api-client";
3+
4+
const debug = makeDebug(`indiekit-syndicator:linkedin`);
5+
6+
export const introspectToken = async ({
7+
accessToken,
8+
clientId,
9+
clientSecret,
10+
}) => {
11+
// https://github.com/linkedin-developers/linkedin-api-js-client?tab=readme-ov-file#authclient
12+
const authClient = new AuthClient({
13+
clientId,
14+
clientSecret,
15+
// redirectUrl: 'https://www.linkedin.com/developers/tools/oauth/redirect'
16+
});
17+
18+
debug(`try introspecting LinkedIn access token`);
19+
return await authClient.introspectAccessToken(accessToken);
20+
};
21+
22+
export const userInfo = async ({ accessToken }) => {
23+
const client = new RestliClient();
24+
25+
// The /v2/userinfo endpoint is unversioned and requires the `openid` OAuth scope
26+
const response = await client.get({
27+
accessToken,
28+
resourcePath: "/userinfo",
29+
});
30+
31+
// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
32+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
33+
34+
const id = response.data.sub;
35+
debug(`user info %O`, response.data);
36+
37+
return { id, name: response.data.name, urn: `urn:li:person:${id}` };
38+
};
39+
40+
export const createPost = async ({
41+
accessToken,
42+
authorName,
43+
authorUrn,
44+
text,
45+
versionString,
46+
}) => {
47+
const client = new RestliClient();
48+
// client.setDebugParams({ enabled: true });
49+
50+
// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
51+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
52+
53+
// Text share or create an article
54+
// https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
55+
// https://github.com/linkedin-developers/linkedin-api-js-client/blob/master/examples/create-posts.ts
56+
debug(
57+
`create post on behalf of author URN ${authorUrn} (${authorName}) using LinkedIn Posts API version ${versionString}`,
58+
);
59+
const response = await client.create({
60+
accessToken,
61+
resourcePath: "/posts",
62+
entity: {
63+
author: authorUrn,
64+
commentary: text,
65+
distribution: {
66+
feedDistribution: "MAIN_FEED",
67+
targetEntities: [],
68+
thirdPartyDistributionChannels: [],
69+
},
70+
lifecycleState: "PUBLISHED",
71+
visibility: "PUBLIC",
72+
},
73+
versionString,
74+
});
75+
76+
// LinkedIn share URNs are different from LinkedIn activity URNs
77+
// https://stackoverflow.com/questions/51857232/what-is-the-distinction-between-share-and-activity-in-linkedin-v2-api
78+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
79+
80+
return {
81+
url: `https://www.linkedin.com/feed/update/${response.createdEntityId}/`,
82+
};
83+
};

0 commit comments

Comments
 (0)