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>
118 lines
6.5 KiB
JavaScript
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 };
|
|
},
|
|
};
|