metatroncubeswdev 1307146921 LinkedIn: default to the scopes Community Management API grants
The approved app has no OpenID Connect product, so openid/profile/r_organization_admin
would fail. Use r_basicprofile + rw_organization_admin and read the member from /v2/me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:02:47 -04:00

118 lines
6.5 KiB
JavaScript

// 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 };
},
};