diff --git a/indiekit.config.js b/indiekit.config.js index 9c8a878ca..89f049c9f 100644 --- a/indiekit.config.js +++ b/indiekit.config.js @@ -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, diff --git a/package-lock.json b/package-lock.json index a37e9f9ae..435ca0964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2968,6 +2968,10 @@ "resolved": "packages/syndicator-internet-archive", "link": true }, + "node_modules/@indiekit/syndicator-linkedin": { + "resolved": "packages/syndicator-linkedin", + "link": true + }, "node_modules/@indiekit/syndicator-mastodon": { "resolved": "packages/syndicator-mastodon", "link": true @@ -13356,6 +13360,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/linkedin-api-client": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/linkedin-api-client/-/linkedin-api-client-0.3.0.tgz", + "integrity": "sha512-vxfjg0cpWtiMVYmAnjwppPLb5CI26bTPowiY6YZTAi/OjdQH1BWGD3i0gA8I9KcTgYdDHrY083zkHcmfK3jPCw==", + "dependencies": { + "axios": "^1.1.3", + "lodash": "^4.17.21", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -21156,6 +21173,21 @@ "node": ">=20" } }, + "packages/syndicator-linkedin": { + "name": "@indiekit/syndicator-linkedin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@indiekit/error": "^1.0.0-beta.15", + "@indiekit/util": "^1.0.0-beta.16", + "brevity": "^0.2.9", + "html-to-text": "^9.0.0", + "linkedin-api-client": "^0.3.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/syndicator-mastodon": { "name": "@indiekit/syndicator-mastodon", "version": "1.0.0-beta.16", diff --git a/packages/syndicator-linkedin/README.md b/packages/syndicator-linkedin/README.md new file mode 100644 index 000000000..099d123b3 --- /dev/null +++ b/packages/syndicator-linkedin/README.md @@ -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 diff --git a/packages/syndicator-linkedin/assets/icon.svg b/packages/syndicator-linkedin/assets/icon.svg new file mode 100644 index 000000000..3d428c234 --- /dev/null +++ b/packages/syndicator-linkedin/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/syndicator-linkedin/index.js b/packages/syndicator-linkedin/index.js new file mode 100644 index 000000000..8c301cd7f --- /dev/null +++ b/packages/syndicator-linkedin/index.js @@ -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); + } +} diff --git a/packages/syndicator-linkedin/lib/linkedin.js b/packages/syndicator-linkedin/lib/linkedin.js new file mode 100644 index 000000000..c6631fde0 --- /dev/null +++ b/packages/syndicator-linkedin/lib/linkedin.js @@ -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}/`, + }; +}; diff --git a/packages/syndicator-linkedin/package.json b/packages/syndicator-linkedin/package.json new file mode 100644 index 000000000..8e106e9b5 --- /dev/null +++ b/packages/syndicator-linkedin/package.json @@ -0,0 +1,46 @@ +{ + "name": "@indiekit/syndicator-linkedin", + "version": "0.1.0", + "description": "LinkedIn syndicator for Indiekit", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "linkedin", + "syndication" + ], + "homepage": "https://getindiekit.com", + "author": { + "name": "Giacomo Debidda", + "url": "https://giacomodebidda.com" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "index.js", + "files": [ + "assets", + "lib", + "index.js" + ], + "bugs": { + "url": "https://github.com/getindiekit/indiekit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/getindiekit/indiekit.git", + "directory": "packages/syndicator-linkedin" + }, + "dependencies": { + "@indiekit/error": "^1.0.0-beta.15", + "@indiekit/util": "^1.0.0-beta.16", + "brevity": "^0.2.9", + "html-to-text": "^9.0.0", + "linkedin-api-client": "^0.3.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/syndicator-linkedin/test/index.js b/packages/syndicator-linkedin/test/index.js new file mode 100644 index 000000000..c4d4fa6d5 --- /dev/null +++ b/packages/syndicator-linkedin/test/index.js @@ -0,0 +1,27 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +// import { Indiekit } from "@indiekit/indiekit"; +// import { getFixture } from "@indiekit-test/fixtures"; +import { mockAgent } from "@indiekit-test/mock-agent"; +import LinkedInSyndicator from "../index.js"; + +await mockAgent("syndicator-linkedin"); + +describe("syndicator-linkedin", () => { + const linkedin = new LinkedInSyndicator({ + // accessToken: "token", + // user: "username", + }); + + // const properties = JSON.parse( + // getFixture("jf2/article-content-provided-html-text.jf2"), + // ); + + // const publication = { + // me: "https://website.example", + // }; + + it("Gets plug-in environment", () => { + assert.deepEqual(linkedin.environment, ["LINKEDIN_ACCESS_TOKEN"]); + }); +}); diff --git a/packages/syndicator-linkedin/test/unit/linkedin.js b/packages/syndicator-linkedin/test/unit/linkedin.js new file mode 100644 index 000000000..89df513cc --- /dev/null +++ b/packages/syndicator-linkedin/test/unit/linkedin.js @@ -0,0 +1,19 @@ +// import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { mockAgent } from "@indiekit-test/mock-agent"; +// import { linkedin } from "../../lib/linkedin.js"; + +await mockAgent("syndicator-linkedin"); + +describe("syndicator-linkedin/lib/linkedin", () => { + // let context; + + // beforeEach(() => { + // context = { + // me: "https://website.example", + // options: {}, + // }; + // }); + + it.todo("do something"); +});