← back to Linkedin Agent
src/lib/linkedin.ts
210 lines
/**
* LinkedIn v2 API wrapper. Handles OAuth tokens + REST calls.
*
* Endpoints used:
* GET /v2/userinfo ← profile (OpenID)
* POST /v2/ugcPosts ← create text+image post
* POST /v2/socialActions/{urn}/comments ← comment on a post
* POST /v2/reactions ← like/love/celebrate/etc.
* POST /v2/organizations/{orgId}/ugcPosts ← post on Company Page
*/
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(process.cwd(), '.env.local');
const API = 'https://api.linkedin.com';
const AUTH = 'https://www.linkedin.com/oauth/v2';
export type LinkedInToken = {
access_token: string;
refresh_token?: string;
expires_at: number;
};
// ---------------------------------------------------------------------------
// Token store — reads/writes .env.local in place. No remote secrets store.
// ---------------------------------------------------------------------------
function readEnv(): Record<string, string> {
try {
const raw = readFileSync(ROOT, 'utf8');
const out: Record<string, string> = {};
for (const line of raw.split('\n')) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) out[m[1]] = m[2];
}
return out;
} catch { return {}; }
}
function writeEnv(updates: Record<string, string>): void {
const env = readEnv();
for (const [k, v] of Object.entries(updates)) env[k] = v;
const out = Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n';
writeFileSync(ROOT, out, { mode: 0o600 });
}
export function getToken(): LinkedInToken | null {
const env = readEnv();
if (!env.LINKEDIN_ACCESS_TOKEN) return null;
return {
access_token: env.LINKEDIN_ACCESS_TOKEN,
refresh_token: env.LINKEDIN_REFRESH_TOKEN,
expires_at: parseInt(env.LINKEDIN_TOKEN_EXPIRES_AT || '0', 10),
};
}
export function saveToken(t: LinkedInToken): void {
writeEnv({
LINKEDIN_ACCESS_TOKEN: t.access_token,
LINKEDIN_REFRESH_TOKEN: t.refresh_token || '',
LINKEDIN_TOKEN_EXPIRES_AT: String(t.expires_at),
});
}
// ---------------------------------------------------------------------------
// OAuth 2.0 — authorization-code flow
// ---------------------------------------------------------------------------
// 2026-05-06 (post-OAuth-blank-page debug): the OLD SCOPES (r_liteprofile +
// r_emailaddress + w_organization_social + rw_organization_admin) were
// rejected silently because the Marketing Developer Platform isn't approved
// on this app. Modern LinkedIn uses OpenID Connect scopes for the basic
// "sign in + post on member feed" flow — these come with the two instant-
// approval products: "Sign In with LinkedIn using OpenID Connect" + "Share
// on LinkedIn". Add Marketing Developer Platform later for org/page posts.
const SCOPES = [
'openid',
'profile',
'email',
// w_member_social dropped 2026-07-16 — needs the "Share on LinkedIn" product the
// app lacks (invalid_scope_error). Connect with sign-in scopes now (token + author
// URN = connected); re-add w_member_social once the Share product is approved.
].join(' ');
export function authorizeUrl(state: string): string {
const env = readEnv();
const params = new URLSearchParams({
response_type: 'code',
client_id: env.LINKEDIN_CLIENT_ID || '',
redirect_uri: env.LINKEDIN_REDIRECT_URI || '',
state,
scope: SCOPES,
});
return `${AUTH}/authorization?${params}`;
}
export async function exchangeCode(code: string): Promise<LinkedInToken> {
const env = readEnv();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: env.LINKEDIN_REDIRECT_URI || '',
client_id: env.LINKEDIN_CLIENT_ID || '',
client_secret: env.LINKEDIN_CLIENT_SECRET || '',
});
const res = await fetch(`${AUTH}/accessToken`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!res.ok) throw new Error(`OAuth exchange failed: ${res.status} ${await res.text()}`);
const json = await res.json() as {
access_token: string;
refresh_token?: string;
expires_in: number;
};
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
expires_at: Date.now() + json.expires_in * 1000,
};
}
// ---------------------------------------------------------------------------
// API calls
// ---------------------------------------------------------------------------
async function api<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const t = getToken();
if (!t) throw new Error('No LinkedIn access token — run OAuth flow first');
const res = await fetch(`${API}${path}`, {
...init,
headers: {
'Authorization': `Bearer ${t.access_token}`,
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
...(init.headers || {}),
},
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(`LinkedIn API ${res.status}: ${txt.slice(0, 300)}`);
}
return res.json() as Promise<T>;
}
export async function getProfile(): Promise<{ sub: string; name: string; email?: string }> {
return api('/v2/userinfo');
}
export async function postText(text: string, authorUrn?: string): Promise<{ id: string }> {
const env = readEnv();
const author = authorUrn || env.LINKEDIN_AUTHOR_URN;
if (!author) {
// Auto-derive from /v2/userinfo
const me = await getProfile();
const memberUrn = `urn:li:person:${me.sub}`;
return postText(text, memberUrn);
}
return api('/v2/ugcPosts', {
method: 'POST',
body: JSON.stringify({
author,
lifecycleState: 'PUBLISHED',
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: { text },
shareMediaCategory: 'NONE',
},
},
visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' },
}),
});
}
export async function postOnPage(text: string): Promise<{ id: string }> {
const env = readEnv();
const orgUrn = env.LINKEDIN_ORG_URN;
if (!orgUrn) throw new Error('LINKEDIN_ORG_URN not set in .env.local');
return postText(text, orgUrn);
}
export async function commentOnPost(postUrn: string, text: string): Promise<{ id: string }> {
// Encode the URN for path safety
const encoded = encodeURIComponent(postUrn);
return api(`/v2/socialActions/${encoded}/comments`, {
method: 'POST',
body: JSON.stringify({
actor: postUrn.startsWith('urn:li:organization:') ? postUrn : undefined, // omit to default to authed user
object: postUrn,
message: { text },
}),
});
}
export async function react(
postUrn: string,
reaction: 'LIKE' | 'PRAISE' | 'EMPATHY' | 'INTEREST' | 'APPRECIATION' | 'MAYBE' = 'LIKE',
): Promise<void> {
await api('/v2/reactions', {
method: 'POST',
body: JSON.stringify({ object: postUrn, reactionType: reaction }),
});
}