@volter/twin-linkedin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +39 -0
- package/dist/client/linkedin-mirror.bundle.js +323 -0
- package/dist/client/linkedin-mirror.d.ts +43 -0
- package/dist/client/linkedin-mirror.js +393 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +74 -0
- package/dist/src/linkedin-budget.d.ts +36 -0
- package/dist/src/linkedin-budget.js +87 -0
- package/dist/src/linkedin-capabilities.d.ts +4 -0
- package/dist/src/linkedin-capabilities.js +1027 -0
- package/dist/src/linkedin-conformance.d.ts +8 -0
- package/dist/src/linkedin-conformance.js +58 -0
- package/dist/src/linkedin-connector.d.ts +66 -0
- package/dist/src/linkedin-connector.js +326 -0
- package/dist/src/linkedin-errors.d.ts +21 -0
- package/dist/src/linkedin-errors.js +38 -0
- package/dist/src/linkedin-media.d.ts +125 -0
- package/dist/src/linkedin-media.js +331 -0
- package/dist/src/linkedin-mirror-ui.d.ts +59 -0
- package/dist/src/linkedin-mirror-ui.js +174 -0
- package/dist/src/linkedin-server.d.ts +10 -0
- package/dist/src/linkedin-server.js +154 -0
- package/dist/src/linkedin-twin.d.ts +23 -0
- package/dist/src/linkedin-twin.js +1220 -0
- package/package.json +58 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +118 -0
- package/src/linkedin-budget.ts +108 -0
- package/src/linkedin-capabilities.ts +1057 -0
- package/src/linkedin-conformance.ts +73 -0
- package/src/linkedin-connector.ts +314 -0
- package/src/linkedin-errors.ts +58 -0
- package/src/linkedin-media.ts +363 -0
- package/src/linkedin-mirror-ui.ts +182 -0
- package/src/linkedin-server.ts +150 -0
- package/src/linkedin-twin.ts +1143 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Conformance for the LinkedIn surface — dev-only, imported LAZILY by cli.ts so it never enters the
|
|
2
|
+
// pack's runtime entrypoint graph.
|
|
3
|
+
//
|
|
4
|
+
// One real request per claimed behaviour, asserting the OUTCOME a live handler produces against the
|
|
5
|
+
// docs' own samples: create is a 201 with the URN in x-restli-id and no body, a read is the Post
|
|
6
|
+
// schema, the finder pages as `{paging, elements}`, a delete is an idempotent 204, the version gate
|
|
7
|
+
// answers the two quoted error bodies, and an unmodelled resource is the vendor's not-found.
|
|
8
|
+
import { handleLinkedinTwinRequest } from './linkedin-twin.ts';
|
|
9
|
+
|
|
10
|
+
export type LinkedinConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
|
|
11
|
+
|
|
12
|
+
export async function checkLinkedinConformance(options: { root?: string } = {}): Promise<LinkedinConformanceReport> {
|
|
13
|
+
const failures: string[] = [];
|
|
14
|
+
let checks = 0;
|
|
15
|
+
const check = (name: string, ok: boolean) => { checks += 1; if (!ok) failures.push(name); };
|
|
16
|
+
const root = options.root;
|
|
17
|
+
const api = { authorization: 'Bearer conformance-token-1', 'linkedin-version': '202609', 'x-restli-protocol-version': '2.0.0' };
|
|
18
|
+
const h = (method: string, path: string, body?: unknown, headers: Record<string, string> = api) =>
|
|
19
|
+
handleLinkedinTwinRequest({ method, path, headers, root, occurredAt: '2026-09-26T12:00:00.000Z', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
20
|
+
|
|
21
|
+
await h('POST', '/_twin/organizations', { id: '5515715', localizedName: 'Conformance Co', vanityName: 'conformance-co', followerCount: 42 }, {});
|
|
22
|
+
await h('POST', '/_twin/members', { id: 'confAdmin', localizedFirstName: 'Con', localizedLastName: 'Form', email: 'con@example.com' }, {});
|
|
23
|
+
await h('POST', '/_twin/organizationAcls', { organization: 'urn:li:organization:5515715', roleAssignee: 'urn:li:person:confAdmin', role: 'ADMINISTRATOR' }, {});
|
|
24
|
+
await h('POST', '/_twin/tokens', { token: 'conformance-token-1', member: 'urn:li:person:confAdmin', scopes: ['w_organization_social', 'r_organization_social', 'rw_organization_admin', 'openid', 'email', 'profile'] }, {});
|
|
25
|
+
|
|
26
|
+
const post = { author: 'urn:li:organization:5515715', commentary: 'Follow best practices #coding', visibility: 'PUBLIC', distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] }, lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false };
|
|
27
|
+
const created = await h('POST', '/rest/posts', post);
|
|
28
|
+
const urn = created.headers?.['x-restli-id'] ?? '';
|
|
29
|
+
check('create answers 201, the URN in x-restli-id, no body', created.status === 201 && /^urn:li:share:\d{19}$/.test(urn) && created.body === null);
|
|
30
|
+
|
|
31
|
+
const got = await h('GET', `/rest/posts/${encodeURIComponent(urn)}`);
|
|
32
|
+
const g = got.body as any;
|
|
33
|
+
check('a read answers the Post schema, the hashtag as the little template', got.status === 200 && g.id === urn && g.author === post.author
|
|
34
|
+
&& g.commentary === 'Follow best practices {hashtag|\\#|coding}' && g.lifecycleState === 'PUBLISHED' && g.lifecycleStateInfo?.isEditedByAuthor === false
|
|
35
|
+
&& typeof g.createdAt === 'number' && g.publishedAt === g.createdAt && g.distribution?.feedDistribution === 'MAIN_FEED' && Object.keys(g).every((k) => !k.startsWith('_')));
|
|
36
|
+
|
|
37
|
+
const found = await h('GET', `/rest/posts?q=author&author=${encodeURIComponent(post.author)}&count=10`);
|
|
38
|
+
const f = found.body as any;
|
|
39
|
+
check('the author finder pages as {paging:{start,count,links}, elements}', found.status === 200 && f.paging?.start === 0 && f.paging?.count === 10
|
|
40
|
+
&& Array.isArray(f.paging?.links) && f.elements?.length === 1 && f.elements[0].id === urn);
|
|
41
|
+
|
|
42
|
+
const deleted = await h('DELETE', `/rest/posts/${encodeURIComponent(urn)}`);
|
|
43
|
+
const again = await h('DELETE', `/rest/posts/${encodeURIComponent(urn)}`);
|
|
44
|
+
const gone = await h('GET', `/rest/posts/${encodeURIComponent(urn)}`);
|
|
45
|
+
check('delete is an idempotent 204 and the post is gone', deleted.status === 204 && again.status === 204 && gone.status === 404 && (gone.body as any).code === 'NOT_FOUND');
|
|
46
|
+
|
|
47
|
+
const noVersion = await h('GET', '/rest/posts?q=author', undefined, { authorization: api.authorization });
|
|
48
|
+
check('a missing Linkedin-Version is the docs\' 400 VERSION_MISSING body', noVersion.status === 400
|
|
49
|
+
&& JSON.stringify(noVersion.body) === JSON.stringify({ status: 400, code: 'VERSION_MISSING', message: 'A version must be present. Please specify a version by adding the Linkedin-Version header.' }));
|
|
50
|
+
const oldVersion = await h('GET', '/rest/posts?q=author', undefined, { ...api, 'linkedin-version': '202401' });
|
|
51
|
+
check('an inactive version is 426 NONEXISTENT_VERSION', oldVersion.status === 426 && (oldVersion.body as any).code === 'NONEXISTENT_VERSION');
|
|
52
|
+
|
|
53
|
+
const noToken = await h('GET', '/rest/posts?q=author', undefined, { 'linkedin-version': '202609' });
|
|
54
|
+
check('no bearer is 401 EMPTY_ACCESS_TOKEN', noToken.status === 401 && (noToken.body as any).code === 'EMPTY_ACCESS_TOKEN');
|
|
55
|
+
|
|
56
|
+
const missing = await h('POST', '/rest/posts', { ...post, visibility: undefined });
|
|
57
|
+
check('a create without visibility is 400 MISSING_FIELD', missing.status === 400 && (missing.body as any).code === 'MISSING_FIELD');
|
|
58
|
+
|
|
59
|
+
const unknown = await h('GET', '/rest/adAccounts');
|
|
60
|
+
check('an unmodelled resource is the vendor\'s not-found, never a success', unknown.status === 404 && (unknown.body as any).code === 'NOT_FOUND');
|
|
61
|
+
|
|
62
|
+
const init = await h('POST', '/rest/videos?action=initializeUpload', { initializeUploadRequest: { owner: post.author, fileSizeBytes: 5_000_000, uploadCaptions: false, uploadThumbnail: false } });
|
|
63
|
+
const v = (init.body as any)?.value;
|
|
64
|
+
check('video initializeUpload answers 4 MB byte-range instructions on a /dms-uploads URL', init.status === 200 && /^urn:li:video:/.test(v?.video)
|
|
65
|
+
&& v.uploadInstructions?.length === 2 && v.uploadInstructions[0].firstByte === 0 && v.uploadInstructions[0].lastByte === 4194303
|
|
66
|
+
&& v.uploadInstructions[1].firstByte === 4194304 && v.uploadInstructions[1].lastByte === 4999999
|
|
67
|
+
&& /\/dms-uploads\/[^/]+\/uploadedVideo\/0\?.*ut=[0-9a-f]{64}/.test(v.uploadInstructions[0].uploadUrl) && v.uploadToken === '');
|
|
68
|
+
|
|
69
|
+
const me = await h('GET', '/v2/userinfo', undefined, { authorization: api.authorization });
|
|
70
|
+
check('userinfo answers the member (sub, name, email) with no version header', me.status === 200 && (me.body as any).sub === 'confAdmin' && (me.body as any).email === 'con@example.com');
|
|
71
|
+
|
|
72
|
+
return { ok: failures.length === 0, checksRun: checks, failures };
|
|
73
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// LINKEDIN CONNECTOR — the pack's half of the REAL state system (protocol 2), over the kernel's
|
|
2
|
+
// executor. The kernel builds `execute` from the root's origin and its sealed credential; this file
|
|
3
|
+
// holds no credential and imports no network client.
|
|
4
|
+
//
|
|
5
|
+
// PERFORM (twin → LinkedIn). A landed `linkedin.post.create` becomes `POST /rest/posts`; a landed
|
|
6
|
+
// `linkedin.post.delete` becomes `DELETE /rest/posts/{urn}`. A post that carries a VIDEO names it on
|
|
7
|
+
// its entry by local asset id and DIGEST (linkedin-twin.ts `_media`). The local video URN means
|
|
8
|
+
// nothing at LinkedIn and an upload is not an entry of its own (linkedin-media.ts), so the adapter
|
|
9
|
+
// reads the file's bytes off the blob seam by digest and uploads them through the Videos API as
|
|
10
|
+
// LinkedIn documents it: `action=initializeUpload`, one PUT per upload instruction with exactly
|
|
11
|
+
// that byte range, each to the signed URL LinkedIn returned (a PRESIGNED request — on
|
|
12
|
+
// www.linkedin.com, not the API host, and sent without the credential; `presigned: true` on the
|
|
13
|
+
// kernel executor), the ETags collected in order, `action=finalizeUpload`, then GET the video until
|
|
14
|
+
// its status is AVAILABLE — bounded, and failing the entry on PROCESSING_FAILED or on the bound —
|
|
15
|
+
// and only then the post, naming the video URN LinkedIn minted.
|
|
16
|
+
//
|
|
17
|
+
// AN IMAGE DOES NOT CROSS. LinkedIn's image upload PUT to www.linkedin.com/dms-uploads needs the
|
|
18
|
+
// member's access token in Authorization; a presigned request carries no credential, and the
|
|
19
|
+
// executor sends the credential only to the root's own origin. So a post with an image (or an
|
|
20
|
+
// article with a thumbnail) fails its perform loudly and names why
|
|
21
|
+
// (`linkedin.connector.perform_image_upload`, todo). The twin still takes the image locally, so the
|
|
22
|
+
// preview and the local write are whole.
|
|
23
|
+
//
|
|
24
|
+
// THE PERFORM AND REFRESH PATHS ARE BUDGETED. The kernel hands the adapters its own executor (the
|
|
25
|
+
// World's sealed credential), which knows nothing of LinkedIn's limits, so each adapter wraps it
|
|
26
|
+
// (`budgetedLinkedinExecute`) in this pack's LinkedinBudget ledger: every API call is charged BEFORE it
|
|
27
|
+
// goes out, a refusal THROWS without calling LinkedIn, and a 429 / Retry-After arms the persisted
|
|
28
|
+
// cooldown. An answer that has already arrived is never dropped: if settling the ledger throws after a
|
|
29
|
+
// 2xx, the answer is returned and the NEXT call meets the cooldown. The presigned part uploads are not
|
|
30
|
+
// API calls and pass through uncharged (linkedin-budget.ts says why); a perform bounds their number.
|
|
31
|
+
//
|
|
32
|
+
// REFRESH (LinkedIn → twin). The organizations the credential's member administers
|
|
33
|
+
// (`organizationAcls?q=roleAssignee`), each organization with its follower count (`networkSizes`),
|
|
34
|
+
// and each one's posts (`q=author`, paged),
|
|
35
|
+
// OBSERVED through the kernel. A refused read throws; it is never an empty page folded over state.
|
|
36
|
+
import { assertBudgetGuardIntact, observeResource } from '@volter/world-core';
|
|
37
|
+
import type { PerformContext, PushOutcome, RemoteExecute, RemoteExecuteRequest, TwinAction } from '@volter/world-core';
|
|
38
|
+
import { clearPerformVideo, readLinkedinBlob, readPerformVideo, writePerformVideo } from './linkedin-media.ts';
|
|
39
|
+
import { LinkedinBudget, linkedinCallWeight } from './linkedin-budget.ts';
|
|
40
|
+
|
|
41
|
+
const SERVICE = 'linkedin';
|
|
42
|
+
|
|
43
|
+
/** The API version every call this adapter makes pins — the latest the twin models. */
|
|
44
|
+
export const LINKEDIN_VERSION = '202609';
|
|
45
|
+
|
|
46
|
+
const API_HEADERS = { accept: 'application/json', 'linkedin-version': LINKEDIN_VERSION, 'x-restli-protocol-version': '2.0.0' };
|
|
47
|
+
|
|
48
|
+
/** The feed's video ceiling (Videos API: "File size: Between 75kb and 500MB"): at most 125 parts. */
|
|
49
|
+
const MAX_PERFORM_VIDEO_BYTES = 500 * 1024 * 1024;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The kernel executor, charged to this pack's LinkedinBudget: the check RESERVES before the call and
|
|
53
|
+
* throws (LinkedinBudgetError) instead of calling when the ceiling, the burst bound or a cooldown says
|
|
54
|
+
* stop; the answer settles the reservation and arms a cooldown on 429 / Retry-After. A presigned upload
|
|
55
|
+
* (not an API call) passes through uncharged.
|
|
56
|
+
*/
|
|
57
|
+
export function budgetedLinkedinExecute(execute: RemoteExecute, budget: LinkedinBudget = new LinkedinBudget()): RemoteExecute {
|
|
58
|
+
const guard = assertBudgetGuardIntact(budget, LinkedinBudget, 'budgetedLinkedinExecute');
|
|
59
|
+
return async (request) => {
|
|
60
|
+
if (request.presigned) return execute(request);
|
|
61
|
+
const weight = linkedinCallWeight(request.method, request.path);
|
|
62
|
+
const reservation = guard.checkBudget(weight);
|
|
63
|
+
const res = await execute(request);
|
|
64
|
+
try {
|
|
65
|
+
guard.recordCall(weight, Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])), { status: res.status, reservation });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// the vendor has already answered: a 2xx is kept (the cooldown is persisted; the next call meets it)
|
|
68
|
+
if (res.status < 200 || res.status >= 300) throw error;
|
|
69
|
+
}
|
|
70
|
+
return res;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How long a perform waits on LinkedIn's video processing before it fails the entry. */
|
|
75
|
+
export const MAX_PROCESSING_WAIT_MS = 10 * 60_000;
|
|
76
|
+
/** Between two status reads while a video processes: 2 s, doubling to 15 s. Each read costs one unit
|
|
77
|
+
* of the 30-unit-a-minute burst, so the wait backs off instead of spending the burst (and the post
|
|
78
|
+
* that must follow it) on status reads: at most 4 reads a minute once it has settled. */
|
|
79
|
+
const PROCESSING_POLL_FIRST_MS = 2_000;
|
|
80
|
+
const PROCESSING_POLL_MAX_MS = 15_000;
|
|
81
|
+
|
|
82
|
+
/** The wait before the nth status re-read (n from 0) — the schedule the perform follows. */
|
|
83
|
+
export function processingPollWait(n: number): number {
|
|
84
|
+
return Math.min(PROCESSING_POLL_MAX_MS, PROCESSING_POLL_FIRST_MS * 2 ** Math.max(0, n));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Reply = { status: number; headers: Record<string, string>; body: string; json: Record<string, any> };
|
|
88
|
+
|
|
89
|
+
function headerOf(headers: Record<string, string>, name: string): string | undefined {
|
|
90
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return v;
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function call(execute: RemoteExecute, label: string, request: RemoteExecuteRequest, expect: number[]): Promise<Reply> {
|
|
95
|
+
const res = await execute(request.presigned ? request : { ...request, headers: { ...API_HEADERS, ...(request.headers ?? {}) } });
|
|
96
|
+
if (!expect.includes(res.status)) throw new Error(`linkedin refused ${label}: HTTP ${res.status} ${res.body.slice(0, 300)}`);
|
|
97
|
+
let json: Record<string, any> = {};
|
|
98
|
+
if (res.body.trim() !== '') {
|
|
99
|
+
try { json = JSON.parse(res.body) as Record<string, any>; } catch { if (!request.presigned) throw new Error(`linkedin answered ${label} with a body that is not JSON: ${res.body.slice(0, 200)}`); }
|
|
100
|
+
}
|
|
101
|
+
return { status: res.status, headers: res.headers, body: res.body, json };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
105
|
+
|
|
106
|
+
/** The little-format hashtag template back to the `#tag` a create takes (linkedin-twin.ts stores
|
|
107
|
+
* the template, as LinkedIn answers it). */
|
|
108
|
+
export function untemplateHashtags(commentary: string): string {
|
|
109
|
+
return commentary.replace(/\{hashtag\|\\#\|([^}]+)\}/g, '#$1');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A video's bytes to LinkedIn: initialize, the presigned part PUTs, finalize, and the bounded wait
|
|
113
|
+
* for AVAILABLE. Returns the video URN LinkedIn minted. */
|
|
114
|
+
export async function uploadVideoToVendor(execute: RemoteExecute, owner: string, bytes: Uint8Array, thumbnail?: { bytes: Uint8Array; mediaType: string }, wait: (ms: number) => Promise<void> = sleep, onFinalized?: (video: string) => Promise<void>): Promise<string> {
|
|
115
|
+
if (bytes.length > MAX_PERFORM_VIDEO_BYTES) throw new Error(`linkedin: a ${bytes.length}-byte video is over the feed's 500 MB; not uploaded`);
|
|
116
|
+
const init = await call(execute, 'videos initializeUpload', {
|
|
117
|
+
method: 'POST', path: '/rest/videos?action=initializeUpload', headers: { 'content-type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ initializeUploadRequest: { owner, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: thumbnail !== undefined } }),
|
|
119
|
+
}, [200]);
|
|
120
|
+
const value = init.json.value ?? {};
|
|
121
|
+
const video = value.video;
|
|
122
|
+
const instructions = value.uploadInstructions;
|
|
123
|
+
if (typeof video !== 'string' || !/^urn:li:video:/.test(video) || !Array.isArray(instructions) || instructions.length === 0) {
|
|
124
|
+
throw new Error(`linkedin answered videos initializeUpload without a video and its upload instructions: ${init.body.slice(0, 300)}`);
|
|
125
|
+
}
|
|
126
|
+
const etags: string[] = [];
|
|
127
|
+
for (const [i, part] of (instructions as Array<Record<string, any>>).entries()) {
|
|
128
|
+
const first = Number(part.firstByte);
|
|
129
|
+
const last = Number(part.lastByte);
|
|
130
|
+
if (typeof part.uploadUrl !== 'string' || !Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first || last >= bytes.length) {
|
|
131
|
+
throw new Error(`linkedin answered an unusable upload instruction ${i}: ${JSON.stringify(part).slice(0, 200)}`);
|
|
132
|
+
}
|
|
133
|
+
const put = await call(execute, `video part ${i} upload`, {
|
|
134
|
+
method: 'PUT', path: part.uploadUrl, presigned: true, headers: { 'content-type': 'application/octet-stream' }, body: bytes.subarray(first, last + 1),
|
|
135
|
+
}, [200, 201]);
|
|
136
|
+
const etag = headerOf(put.headers, 'etag');
|
|
137
|
+
if (!etag) throw new Error(`linkedin answered video part ${i} upload with no ETag`);
|
|
138
|
+
etags.push(etag);
|
|
139
|
+
}
|
|
140
|
+
if (thumbnail) {
|
|
141
|
+
if (typeof value.thumbnailUploadUrl !== 'string') throw new Error('linkedin answered initializeUpload with no thumbnailUploadUrl though a thumbnail was asked for');
|
|
142
|
+
await call(execute, 'video thumbnail upload', {
|
|
143
|
+
method: 'PUT', path: value.thumbnailUploadUrl, presigned: true, headers: { 'media-type-family': 'STILLIMAGE', 'content-type': 'application/octet-stream' }, body: thumbnail.bytes,
|
|
144
|
+
}, [200, 201]);
|
|
145
|
+
}
|
|
146
|
+
await call(execute, 'videos finalizeUpload', {
|
|
147
|
+
method: 'POST', path: '/rest/videos?action=finalizeUpload', headers: { 'content-type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({ finalizeUploadRequest: { video, uploadToken: typeof value.uploadToken === 'string' ? value.uploadToken : '', uploadedPartIds: etags } }),
|
|
149
|
+
}, [200, 201, 204]);
|
|
150
|
+
if (onFinalized) await onFinalized(video);
|
|
151
|
+
return waitForVideo(execute, video, wait);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The bounded wait for a finalized video to be AVAILABLE, backing off between status reads. */
|
|
155
|
+
export async function waitForVideo(execute: RemoteExecute, video: string, wait: (ms: number) => Promise<void> = sleep): Promise<string> {
|
|
156
|
+
const deadline = Date.now() + MAX_PROCESSING_WAIT_MS;
|
|
157
|
+
for (let n = 0; ; n += 1) {
|
|
158
|
+
const got = await call(execute, 'videos GET', { method: 'GET', path: `/rest/videos/${encodeURIComponent(video)}` }, [200]);
|
|
159
|
+
const status = got.json.status;
|
|
160
|
+
if (status === 'AVAILABLE') return video;
|
|
161
|
+
if (status === 'PROCESSING_FAILED') throw new Error(`linkedin failed to process ${video}: ${String(got.json.processingFailureReason ?? 'no reason given')}`);
|
|
162
|
+
if (status !== 'PROCESSING' && status !== 'WAITING_UPLOAD') throw new Error(`linkedin answered ${video} with an unknown status ${JSON.stringify(status)}`);
|
|
163
|
+
if (Date.now() > deadline) throw new Error(`linkedin was still processing ${video} after ${MAX_PROCESSING_WAIT_MS / 1000}s`);
|
|
164
|
+
await wait(processingPollWait(n));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The create body a stored post crosses as: its vendor fields, the video named by the vendor's URN. */
|
|
169
|
+
export function postBodyForVendor(fields: Record<string, any>, videoUrn?: string): Record<string, unknown> {
|
|
170
|
+
const content = fields.content as Record<string, any> | undefined;
|
|
171
|
+
let crossing: Record<string, unknown> | undefined;
|
|
172
|
+
if (content?.media) crossing = { media: { ...content.media, id: videoUrn ?? content.media.id } };
|
|
173
|
+
else if (content?.article) crossing = { article: content.article };
|
|
174
|
+
return {
|
|
175
|
+
author: fields.author,
|
|
176
|
+
commentary: untemplateHashtags(String(fields.commentary ?? '')),
|
|
177
|
+
visibility: fields.visibility,
|
|
178
|
+
distribution: { feedDistribution: fields.distribution?.feedDistribution ?? 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
|
|
179
|
+
...(crossing ? { content: crossing } : {}),
|
|
180
|
+
lifecycleState: 'PUBLISHED',
|
|
181
|
+
isReshareDisabledByAuthor: fields.isReshareDisabledByAuthor === true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The perform adapter (runtime contract R18): what a World's deploy calls for each landed LinkedIn
|
|
187
|
+
* entry on a twin whose root is real. A seeded page, member, role or token is the twin's own record
|
|
188
|
+
* and crosses nothing.
|
|
189
|
+
*/
|
|
190
|
+
export async function performLinkedinAction(kernelExecute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
|
|
191
|
+
// one ledger per real credential (the sealed credential's keyed fingerprint), as X and YouTube key theirs:
|
|
192
|
+
// two World roots sealed with one credential spend one allowance
|
|
193
|
+
return performLinkedinActionWithin(new LinkedinBudget(ctx.credential !== undefined ? { token: ctx.credential } : ctx.root !== undefined ? { root: ctx.root } : {}), kernelExecute, action, ctx);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The perform, charged to a given budget: the box's ledger in production (`performLinkedinAction`),
|
|
197
|
+
* a throwaway ledger where a verify must not spend the operator's allowance. */
|
|
198
|
+
export async function performLinkedinActionWithin(budget: LinkedinBudget, kernelExecute: RemoteExecute, action: TwinAction, ctx: PerformContext, wait: (ms: number) => Promise<void> = sleep): Promise<PushOutcome> {
|
|
199
|
+
const execute = budgetedLinkedinExecute(kernelExecute, budget);
|
|
200
|
+
const fields = { ...(action.fields ?? {}) } as Record<string, any>;
|
|
201
|
+
const subjectId = action.subject?.id ?? '';
|
|
202
|
+
if ((action.operation ?? '').startsWith('linkedin.twin.')) {
|
|
203
|
+
return { externalId: ctx.resolve(action.subject?.type ?? 'organization', subjectId), data: { performed: false, reason: `${action.operation} is the twin's own record — nothing at LinkedIn to write` } };
|
|
204
|
+
}
|
|
205
|
+
if (action.operation === 'linkedin.post.delete') {
|
|
206
|
+
const target = ctx.resolve('post', subjectId);
|
|
207
|
+
// "Post deletions are idempotent": a 204 either way.
|
|
208
|
+
await call(execute, 'posts DELETE', { method: 'DELETE', path: `/rest/posts/${encodeURIComponent(target)}`, headers: { 'x-restli-method': 'DELETE' } }, [204]);
|
|
209
|
+
return { externalId: target, data: { deleted: true } };
|
|
210
|
+
}
|
|
211
|
+
if (action.operation !== 'linkedin.post.create') throw new Error(`linkedin cannot perform ${action.operation ?? 'this entry'} on ${subjectId}: no LinkedIn request expresses it`);
|
|
212
|
+
|
|
213
|
+
let videoUrn: string | undefined;
|
|
214
|
+
const media = fields._media as Record<string, any> | undefined;
|
|
215
|
+
if (media) {
|
|
216
|
+
if (media.kind !== 'video') {
|
|
217
|
+
throw new Error(`linkedin cannot perform ${subjectId}: its image must be uploaded to www.linkedin.com/dms-uploads WITH the API token in Authorization, and a presigned upload carries no credential (linkedin.connector.perform_image_upload)`);
|
|
218
|
+
}
|
|
219
|
+
const bytes = typeof media.sha256 === 'string' ? await readLinkedinBlob(media.sha256, ctx.root) : null;
|
|
220
|
+
if (!bytes) throw new Error(`linkedin cannot perform ${subjectId}: the bytes of urn:li:video:${String(media.id)} are not on this twin's blob seam`);
|
|
221
|
+
const thumbBytes = typeof media.thumbnail_sha256 === 'string' ? await readLinkedinBlob(media.thumbnail_sha256, ctx.root) : null;
|
|
222
|
+
if (typeof media.thumbnail_sha256 === 'string' && !thumbBytes) throw new Error(`linkedin cannot perform ${subjectId}: its video thumbnail's bytes are not on this twin's blob seam`);
|
|
223
|
+
// a retry of an entry whose video was finalized at the vendor posts THAT video: no second upload
|
|
224
|
+
const kept = await readPerformVideo(action.id, ctx.root);
|
|
225
|
+
if (kept && kept.sha256 === media.sha256) {
|
|
226
|
+
const got = await execute({ method: 'GET', path: `/rest/videos/${encodeURIComponent(kept.video)}`, headers: { ...API_HEADERS } });
|
|
227
|
+
let status: unknown;
|
|
228
|
+
if (got.status === 200) {
|
|
229
|
+
try { status = (JSON.parse(got.body || '{}') as Record<string, any>).status; } catch {
|
|
230
|
+
throw new Error(`linkedin answered the kept video ${kept.video} with a body that is not JSON: ${got.body.slice(0, 200)}; not uploading it again — perform again`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (status === 'AVAILABLE' || status === 'PROCESSING') videoUrn = await waitForVideo(execute, kept.video, wait);
|
|
234
|
+
else if (got.status !== 404 && status !== 'PROCESSING_FAILED') throw new Error(`linkedin answered the kept video ${kept.video} with HTTP ${got.status} ${got.body.slice(0, 200)}; not uploading it again — perform again`);
|
|
235
|
+
}
|
|
236
|
+
videoUrn ??= await uploadVideoToVendor(execute, String(fields.author), bytes, thumbBytes ? { bytes: thumbBytes, mediaType: String(media.thumbnail_media_type ?? 'image/jpeg') } : undefined, wait,
|
|
237
|
+
(video) => writePerformVideo(action.id, { video, sha256: String(media.sha256) }, ctx.root));
|
|
238
|
+
}
|
|
239
|
+
const created = await call(execute, 'posts CREATE', {
|
|
240
|
+
method: 'POST', path: '/rest/posts', headers: { 'content-type': 'application/json' }, body: JSON.stringify(postBodyForVendor(fields, videoUrn)),
|
|
241
|
+
}, [201]);
|
|
242
|
+
const urn = headerOf(created.headers, 'x-restli-id');
|
|
243
|
+
if (!urn || !/^urn:li:(share|ugcPost):\d+$/.test(urn)) throw new Error(`linkedin answered posts CREATE with no post URN in x-restli-id: ${JSON.stringify(created.headers).slice(0, 200)}`);
|
|
244
|
+
if (videoUrn) await clearPerformVideo(action.id, ctx.root);
|
|
245
|
+
return { externalId: urn, url: `https://www.linkedin.com/feed/update/${urn}/`, data: { ...(videoUrn ? { video: videoUrn } : {}) } };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── refresh ──────────────────────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/** How many pages of one organization's posts a refresh reads before it refuses to go on. */
|
|
251
|
+
const MAX_PAGES = 50;
|
|
252
|
+
|
|
253
|
+
let lastPollMs = 0;
|
|
254
|
+
/** A MOVING observation instant, strictly increasing in the process — never a pinned constant. */
|
|
255
|
+
function pollTimestamp(): string {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
|
|
258
|
+
return new Date(lastPollMs).toISOString();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The refresh adapter: the organizations the credential's member administers, and every post each
|
|
263
|
+
* one authored, observed into the parent log. Read-side only. A refusal throws.
|
|
264
|
+
*/
|
|
265
|
+
export async function syncLinkedinFromRemote(kernelExecute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string; budget?: LinkedinBudget; credential?: string } = {}): Promise<{ observed: number; organizations: number; posts: number }> {
|
|
266
|
+
// keyed like perform: by the credential's fingerprint when the caller has one, else by the root
|
|
267
|
+
const execute = budgetedLinkedinExecute(kernelExecute, opts.budget ?? new LinkedinBudget(opts.credential !== undefined ? { token: opts.credential } : opts.root !== undefined ? { root: opts.root } : {}));
|
|
268
|
+
const at = opts.occurredAt ?? pollTimestamp();
|
|
269
|
+
const observe = (type: string, id: string, fields: Record<string, unknown>) =>
|
|
270
|
+
observeResource(SERVICE, { type, id, fields }, { ...(opts.root !== undefined ? { root: opts.root } : {}), at });
|
|
271
|
+
const acls = await call(execute, 'organizationAcls FINDER roleAssignee', { method: 'GET', path: '/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=100' }, [200]);
|
|
272
|
+
if (!Array.isArray(acls.json.elements)) throw new Error(`linkedin answered organizationAcls with no elements: ${acls.body.slice(0, 200)}`);
|
|
273
|
+
const orgs = [...new Set((acls.json.elements as Array<Record<string, any>>).map((e) => String(e.organization ?? e.organizationTarget ?? '')).filter((o) => /^urn:li:organization:\d+$/.test(o)))];
|
|
274
|
+
let posts = 0;
|
|
275
|
+
for (const org of orgs) {
|
|
276
|
+
const id = org.split(':').pop()!;
|
|
277
|
+
const got = await call(execute, `organizations GET ${id}`, { method: 'GET', path: `/rest/organizations/${id}` }, [200]);
|
|
278
|
+
const o = got.json;
|
|
279
|
+
if (typeof o.localizedName !== 'string' || typeof o.vanityName !== 'string') throw new Error(`linkedin answered organization ${id} without its name: ${got.body.slice(0, 200)}`);
|
|
280
|
+
const size = await call(execute, `networkSizes GET ${id}`, { method: 'GET', path: `/rest/networkSizes/${encodeURIComponent(org)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` }, [200]);
|
|
281
|
+
if (typeof size.json.firstDegreeSize !== 'number') throw new Error(`linkedin answered networkSizes for ${org} without firstDegreeSize: ${size.body.slice(0, 200)}`);
|
|
282
|
+
observe('organization', id, {
|
|
283
|
+
localizedName: o.localizedName, vanityName: o.vanityName, followerCount: size.json.firstDegreeSize,
|
|
284
|
+
...(Array.isArray(o.industries) ? { industries: o.industries } : {}),
|
|
285
|
+
...(typeof o.localizedWebsite === 'string' ? { localizedWebsite: o.localizedWebsite } : {}),
|
|
286
|
+
...(typeof o.localizedDescription === 'string' ? { localizedDescription: o.localizedDescription } : {}),
|
|
287
|
+
...(typeof o.logoV2?.original === 'string' && /^urn:li:image:/.test(o.logoV2.original) ? { logo: o.logoV2.original } : {}),
|
|
288
|
+
});
|
|
289
|
+
for (let page = 0, start = 0; ; page += 1) {
|
|
290
|
+
if (page >= MAX_PAGES) throw new Error(`linkedin posts of ${org} exceeded ${MAX_PAGES} pages; refusing to page further`);
|
|
291
|
+
const res = await call(execute, `posts FINDER author ${org}`, { method: 'GET', path: `/rest/posts?q=author&author=${encodeURIComponent(org)}&count=100&start=${start}&sortBy=CREATED` }, [200]);
|
|
292
|
+
const elements = res.json.elements;
|
|
293
|
+
if (!Array.isArray(elements)) throw new Error(`linkedin answered the posts finder with no elements: ${res.body.slice(0, 200)}`);
|
|
294
|
+
for (const post of elements as Array<Record<string, any>>) {
|
|
295
|
+
if (typeof post.id !== 'string' || !/^urn:li:(share|ugcPost):\d+$/.test(post.id)) throw new Error(`linkedin answered a post with no LinkedIn-shaped id: ${JSON.stringify(post).slice(0, 200)}`);
|
|
296
|
+
const { id: postId, ...rest } = post;
|
|
297
|
+
observe('post', postId, rest);
|
|
298
|
+
posts += 1;
|
|
299
|
+
}
|
|
300
|
+
const next = Array.isArray(res.json.paging?.links) && (res.json.paging.links as Array<Record<string, any>>).some((l) => l.rel === 'next');
|
|
301
|
+
if (!next || elements.length === 0) break;
|
|
302
|
+
start += elements.length;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { observed: orgs.length + posts, organizations: orgs.length, posts };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* D7's consumer-facing entry point, the same read as the refresh adapter: everything readable from
|
|
310
|
+
* the organizations the credential's member administers, observed into the twin's log.
|
|
311
|
+
*/
|
|
312
|
+
export async function syncLinkedinFromReal(execute: RemoteExecute, opts: { root?: string; occurredAt?: string; budget?: LinkedinBudget; credential?: string } = {}): Promise<{ observed: number; organizations: number; posts: number }> {
|
|
313
|
+
return syncLinkedinFromRemote(execute, opts);
|
|
314
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// LinkedIn's error envelope for the versioned `/rest` surface.
|
|
2
|
+
//
|
|
3
|
+
// SHAPE. `{ status, code, message }` — the Marketing API's error body (learn.microsoft.com
|
|
4
|
+
// /linkedin/marketing/error-responses, li-lms-2026-09): `status` the HTTP status, `code` the constant
|
|
5
|
+
// category, `message` the description. Two samples on that page are quoted exactly here: the missing
|
|
6
|
+
// version header (400 VERSION_MISSING) and an inactive version (426 NONEXISTENT_VERSION). The
|
|
7
|
+
// per-resource codes (MISSING_FIELD, INVALID_VALUE_FOR_FIELD, INVALID_URN_TYPE, INVALID_URN_ID,
|
|
8
|
+
// ACCESS_DENIED, NOT_FOUND, EMPTY_ACCESS_TOKEN, UNPROCESSABLE_ENTITY, MEDIA_ASSET_WAITING_UPLOAD,
|
|
9
|
+
// MEDIA_ASSET_PROCESSING_FAILED, EXPIRED_UPLOAD_URL) are the Posts and Videos API pages' own
|
|
10
|
+
// "API Error Details" tables.
|
|
11
|
+
//
|
|
12
|
+
// EVIDENCE BOUNDARY. `serviceErrorCode` is part of the envelope, but no page this build read gives
|
|
13
|
+
// the number for these refusals, so it is omitted rather than invented
|
|
14
|
+
// (`linkedin.errors.service_error_codes`). Message wording beyond the quoted samples states the
|
|
15
|
+
// refusal plainly and is not claimed to be LinkedIn's (`linkedin.errors.message_wording`).
|
|
16
|
+
|
|
17
|
+
export type LinkedinResponse = {
|
|
18
|
+
status: number;
|
|
19
|
+
body: unknown;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const JSON_HEADERS = { 'content-type': 'application/json', 'x-restli-protocol-version': '2.0.0' };
|
|
24
|
+
|
|
25
|
+
export function problem(status: number, code: string, message: string): LinkedinResponse {
|
|
26
|
+
return { status, body: { status, code, message }, headers: { ...JSON_HEADERS } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const versionMissing = (): LinkedinResponse =>
|
|
30
|
+
problem(400, 'VERSION_MISSING', 'A version must be present. Please specify a version by adding the Linkedin-Version header.');
|
|
31
|
+
|
|
32
|
+
export const versionInactive = (version: string): LinkedinResponse =>
|
|
33
|
+
problem(426, 'NONEXISTENT_VERSION', `Requested version ${version} is not active`);
|
|
34
|
+
|
|
35
|
+
export const emptyAccessToken = (): LinkedinResponse => problem(401, 'EMPTY_ACCESS_TOKEN', 'Empty oauth2_access_token');
|
|
36
|
+
export const invalidAccessToken = (): LinkedinResponse => problem(401, 'INVALID_ACCESS_TOKEN', 'Invalid access token');
|
|
37
|
+
export const accessDenied = (message: string): LinkedinResponse => problem(403, 'ACCESS_DENIED', message);
|
|
38
|
+
export const notFound = (message = 'Not Found'): LinkedinResponse => problem(404, 'NOT_FOUND', message);
|
|
39
|
+
export const missingField = (field: string): LinkedinResponse => problem(400, 'MISSING_FIELD', `${field} is required but missing`);
|
|
40
|
+
export const invalidValue = (field: string, value: unknown): LinkedinResponse =>
|
|
41
|
+
problem(400, 'INVALID_VALUE_FOR_FIELD', `${field} cannot be set to ${typeof value === 'string' ? value : JSON.stringify(value)}`);
|
|
42
|
+
export const invalidUrnType = (field: string, value: unknown, urnType: string): LinkedinResponse =>
|
|
43
|
+
problem(400, 'INVALID_URN_TYPE', `${field} value ${String(value)} must be a ${urnType} URN`);
|
|
44
|
+
export const invalidUrnId = (value: unknown): LinkedinResponse => problem(400, 'INVALID_URN_ID', `This URN ID is invalid: ${String(value)}`);
|
|
45
|
+
|
|
46
|
+
/** Real LinkedIn surface this twin does not model: refused by name, never answered as a success. */
|
|
47
|
+
export const unmodeled = (what: string): LinkedinResponse =>
|
|
48
|
+
problem(422, 'UNPROCESSABLE_ENTITY', `[twin gap] ${what} is real LinkedIn surface this twin does not model`);
|
|
49
|
+
|
|
50
|
+
export const readOnlyRefusal = (): LinkedinResponse => problem(405, 'METHOD_NOT_ALLOWED', 'This twin is read-only');
|
|
51
|
+
|
|
52
|
+
export function ok(body: unknown, status = 200, headers: Record<string, string> = {}): LinkedinResponse {
|
|
53
|
+
return { status, body, headers: { ...JSON_HEADERS, ...headers } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function noContent(headers: Record<string, string> = {}): LinkedinResponse {
|
|
57
|
+
return { status: 204, body: '', headers: { 'x-restli-protocol-version': '2.0.0', ...headers } };
|
|
58
|
+
}
|