// LinkedIn: post to a person's profile or to a company Page they administer. // Company Pages need the Community Management API product on the LinkedIn app (approval required). import { request, bytesOf, isVideo, sizeOf } from "./http.js"; const API = "https://api.linkedin.com"; const version = () => process.env.LINKEDIN_API_VERSION || "202606"; const rest = (token) => ({ token, headers: { "LinkedIn-Version": version(), "X-Restli-Protocol-Version": "2.0.0" } }); // LinkedIn's "little text" format: reserved characters must be escaped; hashtags use a template. export function littleText(s) { const text = String(s || ""); const esc = (t) => t.replace(/[\\|{}@[\]()<>#*_~]/g, (c) => `\\${c}`); const re = /(^|\s)#([\p{L}\p{N}_]+)/gu; let out = "", last = 0, m; while ((m = re.exec(text))) { out += esc(text.slice(last, m.index)) + m[1] + `{hashtag|\\#|${m[2]}}`; last = m.index + m[0].length; } return out + esc(text.slice(last)); } async function uploadImage(token, owner, src) { const { data } = await request("LinkedIn", `${API}/rest/images?action=initializeUpload`, { ...rest(token), method: "POST", json: { initializeUploadRequest: { owner } } }); const { uploadUrl, image } = data.value; const put = await fetch(uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: await bytesOf(src) }); if (!put.ok) throw new Error(`LinkedIn: image upload failed (${put.status}).`); return image; } async function uploadVideo(token, owner, src) { const bytes = await bytesOf(src); const { data } = await request("LinkedIn", `${API}/rest/videos?action=initializeUpload`, { ...rest(token), method: "POST", json: { initializeUploadRequest: { owner, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: false } }, }); const { video, uploadInstructions, uploadToken } = data.value; const etags = []; for (const part of uploadInstructions) { const r = await fetch(part.uploadUrl, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: bytes.subarray(part.firstByte, part.lastByte + 1) }); if (!r.ok) throw new Error(`LinkedIn: video upload failed (${r.status}).`); etags.push(r.headers.get("etag")); } await request("LinkedIn", `${API}/rest/videos?action=finalizeUpload`, { ...rest(token), method: "POST", json: { finalizeUploadRequest: { video, uploadToken: uploadToken || "", uploadedPartIds: etags } }, }); return video; } export default { id: "linkedin", name: "LinkedIn", color: "#0a66c2", setup: { console: "https://www.linkedin.com/developers/apps", steps: [ "Create an app at linkedin.com/developers/apps and link it to your company's LinkedIn Page (the Page admin must verify it).", "Products tab: request “Community Management API” for posting to clients' company Pages (LinkedIn reviews it; your company Page must be verified).", "Auth tab: add the Redirect URL shown below, then copy the Client ID and Client Secret here.", ], note: "Without Community Management API (personal profiles only): add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn”, and set Scopes to “openid profile w_member_social”.", }, auth: { authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization", tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken", // The scopes Community Management API grants (it can't be combined with the OpenID Connect product). scopes: ["r_basicprofile", "w_member_social", "rw_organization_admin", "w_organization_social", "r_organization_social"], }, formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true }, limits: { caption: 3000, images: 20 }, targetLabel: "Post as", async identify(tokens) { if (/\bopenid\b/.test(tokens.scope || "")) { const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken }); return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null }; } const { data } = await request("LinkedIn", `${API}/v2/me`, { token: tokens.accessToken }); return { id: data.id, name: [data.localizedFirstName, data.localizedLastName].filter(Boolean).join(" ") || "LinkedIn member", username: null, avatar: null, url: null }; }, /** The person, plus every company Page they administer (needs Community Management API). */ async targets(tokens, account) { const out = [{ id: `urn:li:person:${account.id}`, name: `${account.name} (personal profile)` }]; try { const { data } = await request("LinkedIn", `${API}/rest/organizationAcls`, { ...rest(tokens.accessToken), query: { q: "roleAssignee", role: "ADMINISTRATOR", state: "APPROVED" } }); for (const e of data.elements || []) { const orgId = String(e.organization).split(":").pop(); let name = `Company ${orgId}`; try { name = (await request("LinkedIn", `${API}/rest/organizations/${orgId}`, rest(tokens.accessToken))).data.localizedName || name; } catch { /* keep id */ } out.push({ id: e.organization, name: `${name} (company Page)` }); } } catch { /* no organization scopes: personal profile only */ } return out; }, async publish({ tokens, conn, post, caption }) { const author = conn.target?.id || `urn:li:person:${conn.account.id}`; const body = { author, commentary: littleText(caption), visibility: "PUBLIC", lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: false, distribution: { feedDistribution: "MAIN_FEED", targetEntities: [], thirdPartyDistributionChannels: [] }, }; const media = post.media || []; if (post.type === "REELS" || (media.length === 1 && isVideo(media[0]))) { if ((await sizeOf(media[0])) > 5 * 1024 ** 3) throw new Error("LinkedIn: videos must be under 5 GB."); body.content = { media: { id: await uploadVideo(tokens.accessToken, author, media[0]) } }; } else if (media.length > 1) { const images = []; for (const m of media.slice(0, 20)) images.push({ id: await uploadImage(tokens.accessToken, author, m) }); body.content = { multiImage: { images } }; } else if (media.length === 1) { body.content = { media: { id: await uploadImage(tokens.accessToken, author, media[0]) } }; } const { headers } = await request("LinkedIn", `${API}/rest/posts`, { ...rest(tokens.accessToken), method: "POST", json: body }); const id = headers.get("x-restli-id") || headers.get("x-linkedin-id"); return { id, permalink: id ? `https://www.linkedin.com/feed/update/${id}/` : null }; }, };