@opencoredev/social-sdk 0.3.0 → 0.4.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/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +19 -21
- package/dist/cloud/common.d.ts +7 -6
- package/dist/cloud/common.js +35 -54
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +2 -2
- package/dist/cloud/media.js +13 -3
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +72 -0
- package/dist/platforms/x.js +328 -119
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +2 -2
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
import { remainingBudget } from "../transport/budget.js";
|
|
2
|
+
import { definedFields } from "../core/fields.js";
|
|
2
3
|
import { defineAdapter } from "../core/adapter.js";
|
|
3
4
|
import { SocialError } from "../core/errors.js";
|
|
4
5
|
import { createHttp, HttpError } from "../transport/http.js";
|
|
5
|
-
import { array, object, string, optionalString, optionalNumber } from "../transport/validation.js";
|
|
6
|
+
import { array, isJsonObject, object, string, optionalString, optionalNumber, } from "../transport/validation.js";
|
|
6
7
|
import { upload } from "../transport/upload.js";
|
|
7
|
-
import { publicFields } from "../cloud/common.js";
|
|
8
|
+
import { optionsObject, publicFields } from "../cloud/common.js";
|
|
9
|
+
import { verifyLinkedInWebhook } from "../server/webhooks.js";
|
|
10
|
+
import { directWebhooks } from "./webhook-adapter.js";
|
|
11
|
+
/** Documents API file types (PDF, PPT, PPTX, DOC, DOCX), checked 2026-09-24 against version 202609. */
|
|
12
|
+
const linkedInDocumentMimeTypes = [
|
|
13
|
+
"application/pdf",
|
|
14
|
+
"application/vnd.ms-powerpoint",
|
|
15
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
16
|
+
"application/msword",
|
|
17
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
18
|
+
];
|
|
19
|
+
/** LinkedIn documents "100MB"; the decimal reading is the conservative local bound. */
|
|
20
|
+
const linkedInDocumentMaxBytes = 100_000_000;
|
|
21
|
+
const linkedInDocumentUrn = /^urn:li:document:[a-zA-Z0-9_-]+$/;
|
|
22
|
+
/*
|
|
23
|
+
* Videos API, accessed 2026-09-24:
|
|
24
|
+
* https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api?view=li-lms-2026-09
|
|
25
|
+
* Feed video is MP4, 75 KB to 500 MB and 3 seconds to 30 minutes. initializeUpload returns
|
|
26
|
+
* contiguous part instructions of up to 4,194,304 bytes; each part's ETag is passed to finalizeUpload.
|
|
27
|
+
* Posts API video content, accessed 2026-09-24:
|
|
28
|
+
* https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-09
|
|
29
|
+
*/
|
|
30
|
+
const linkedInVideoPartBytes = 4 * 1024 * 1024;
|
|
31
|
+
const linkedInVideoMinBytes = 75_000;
|
|
32
|
+
const linkedInVideoMaxBytes = 500 * 1024 * 1024;
|
|
33
|
+
const linkedInVideoUrn = /^urn:li:video:[a-zA-Z0-9_-]+$/;
|
|
34
|
+
const linkedInImageUrn = /^urn:li:image:[a-zA-Z0-9_-]+$/;
|
|
35
|
+
/* LinkedIn documents no processing time or polling interval; these bounds are the SDK's own. */
|
|
36
|
+
const linkedInVideoRetryDelayMs = 5_000;
|
|
37
|
+
const linkedInVideoWaitDefaults = { intervalMs: 5_000, maxChecks: 12 };
|
|
38
|
+
const linkedInVideoWaitLimits = { minIntervalMs: 1_000, maxIntervalMs: 60_000, maxChecks: 60 };
|
|
8
39
|
export function linkedin(options) {
|
|
9
40
|
if (!options.auth.accessToken.trim() ||
|
|
10
41
|
!/^urn:li:(person|organization):[a-zA-Z0-9_-]+$/.test(options.auth.author) ||
|
|
@@ -33,20 +64,21 @@ export function linkedin(options) {
|
|
|
33
64
|
return await http({
|
|
34
65
|
timeoutMs: remainingBudget(context),
|
|
35
66
|
url: new URL(`https://api.linkedin.com${path}`),
|
|
36
|
-
headers:
|
|
37
|
-
Authorization: `Bearer ${options.auth.accessToken}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
headers: path.startsWith("/v2/")
|
|
68
|
+
? { Authorization: `Bearer ${options.auth.accessToken}` }
|
|
69
|
+
: {
|
|
70
|
+
Authorization: `Bearer ${options.auth.accessToken}`,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"Linkedin-Version": options.apiVersion,
|
|
73
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
74
|
+
...extraHeaders,
|
|
75
|
+
},
|
|
43
76
|
method,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
...(selectedHeaders ? { responseHeaders: selectedHeaders } : {}),
|
|
77
|
+
...definedFields({
|
|
78
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
79
|
+
signal: context.signal,
|
|
80
|
+
responseHeaders: selectedHeaders,
|
|
81
|
+
}),
|
|
50
82
|
maxAttempts: method === "GET" ? Math.min(5, context.retryBudget.maxAttempts) : 1,
|
|
51
83
|
});
|
|
52
84
|
}
|
|
@@ -105,6 +137,119 @@ export function linkedin(options) {
|
|
|
105
137
|
});
|
|
106
138
|
return result;
|
|
107
139
|
}
|
|
140
|
+
/*
|
|
141
|
+
* Account identity sources (accessed 2026-09-24):
|
|
142
|
+
* - Member: OpenID Connect userinfo, GET https://api.linkedin.com/v2/userinfo, scopes `openid` and
|
|
143
|
+
* `profile`. `sub` is the member ID used in `urn:li:person:{sub}`.
|
|
144
|
+
* https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2
|
|
145
|
+
* https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
|
|
146
|
+
* - Organization: GET /rest/organizations/{id} ("Retrieve an Administered Organization"), scope
|
|
147
|
+
* `rw_organization_admin`, 403 unless the member has the ADMINISTRATOR role.
|
|
148
|
+
* https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-lookup-api?view=li-lms-2026-09
|
|
149
|
+
* - Administered organizations: GET /rest/organizationAcls?q=roleAssignee, scope
|
|
150
|
+
* `rw_organization_admin` or `r_organization_admin`.
|
|
151
|
+
* https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-access-control-by-role?view=li-lms-2026-09
|
|
152
|
+
*/
|
|
153
|
+
async function accountRequest(path, context, scopes) {
|
|
154
|
+
try {
|
|
155
|
+
return await request(path, context);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error instanceof SocialError && error.code === "missing_permission")
|
|
159
|
+
throw new SocialError({
|
|
160
|
+
code: "missing_permission",
|
|
161
|
+
operation: "accounts.read",
|
|
162
|
+
message: `LinkedIn denied the account read. The token needs ${scopes}.`,
|
|
163
|
+
upstreamStatus: error.upstreamStatus,
|
|
164
|
+
retryDisposition: { kind: "never" },
|
|
165
|
+
});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function readAccount(context) {
|
|
170
|
+
const ref = {
|
|
171
|
+
kind: "connected-account",
|
|
172
|
+
version: 1,
|
|
173
|
+
backend: context.backendInstance,
|
|
174
|
+
platform: "linkedin",
|
|
175
|
+
accountId: options.auth.author,
|
|
176
|
+
};
|
|
177
|
+
if (options.auth.author.startsWith("urn:li:person:")) {
|
|
178
|
+
const user = object(await accountRequest("/v2/userinfo", context, "the openid and profile scopes (Sign In with LinkedIn using OpenID Connect)"));
|
|
179
|
+
const sub = optionalString(user["sub"]);
|
|
180
|
+
if (sub === undefined || `urn:li:person:${sub}` !== options.auth.author)
|
|
181
|
+
throw new SocialError({
|
|
182
|
+
code: "unauthorized",
|
|
183
|
+
operation: "accounts.read",
|
|
184
|
+
message: "The authenticated LinkedIn member differs from the configured author URN.",
|
|
185
|
+
});
|
|
186
|
+
const fullName = optionalString(user["name"]) ??
|
|
187
|
+
[optionalString(user["given_name"]), optionalString(user["family_name"])]
|
|
188
|
+
.filter((part) => part !== undefined && part !== "")
|
|
189
|
+
.join(" ");
|
|
190
|
+
return { ref, displayName: fullName || options.auth.author, status: "connected" };
|
|
191
|
+
}
|
|
192
|
+
const organizationId = options.auth.author.slice("urn:li:organization:".length);
|
|
193
|
+
const organization = object(await accountRequest(`/rest/organizations/${encodeURIComponent(organizationId)}`, context, "rw_organization_admin and an approved ADMINISTRATOR role for the organization"));
|
|
194
|
+
const returnedId = optionalNumber(organization["id"]);
|
|
195
|
+
if (returnedId === undefined || String(returnedId) !== organizationId)
|
|
196
|
+
throw new SocialError({
|
|
197
|
+
code: "unauthorized",
|
|
198
|
+
operation: "accounts.read",
|
|
199
|
+
message: "LinkedIn returned a different organization than the configured author URN.",
|
|
200
|
+
});
|
|
201
|
+
const vanityName = optionalString(organization["vanityName"]);
|
|
202
|
+
return Object.assign({
|
|
203
|
+
ref,
|
|
204
|
+
displayName: optionalString(organization["localizedName"]) || options.auth.author,
|
|
205
|
+
status: "connected",
|
|
206
|
+
}, vanityName === undefined ? {} : { handle: vanityName });
|
|
207
|
+
}
|
|
208
|
+
async function listAdministeredOrganizations(input, context) {
|
|
209
|
+
const start = input.cursor === undefined ? 0 : Number(input.cursor);
|
|
210
|
+
const count = input.limit ?? 25;
|
|
211
|
+
if (input.cursor === "" ||
|
|
212
|
+
!Number.isSafeInteger(start) ||
|
|
213
|
+
start < 0 ||
|
|
214
|
+
!Number.isSafeInteger(count) ||
|
|
215
|
+
count < 1 ||
|
|
216
|
+
count > 100)
|
|
217
|
+
throw new SocialError({
|
|
218
|
+
code: "invalid_input",
|
|
219
|
+
operation: "accounts.read",
|
|
220
|
+
message: "LinkedIn requires a nonnegative offset and page size from 1 to 100.",
|
|
221
|
+
});
|
|
222
|
+
const memberUrn = options.auth.author;
|
|
223
|
+
if (!memberUrn.startsWith("urn:li:person:"))
|
|
224
|
+
throw new SocialError({
|
|
225
|
+
code: "unauthorized",
|
|
226
|
+
operation: "accounts.read",
|
|
227
|
+
message: "LinkedIn administered organization lookup requires a member account authorization.",
|
|
228
|
+
});
|
|
229
|
+
const result = object(await accountRequest(`/rest/organizationAcls?q=roleAssignee&roleAssignee=${encodeURIComponent(memberUrn)}&role=ADMINISTRATOR&state=APPROVED&start=${start}&count=${count}`, context, "rw_organization_admin or r_organization_admin"));
|
|
230
|
+
const rows = array(result["elements"]).map(object);
|
|
231
|
+
const items = [];
|
|
232
|
+
for (const row of rows) {
|
|
233
|
+
// LinkedIn documents both `organization` and `organizationTarget` for this field.
|
|
234
|
+
const organization = optionalString(row["organization"]) ?? optionalString(row["organizationTarget"]);
|
|
235
|
+
if (organization === undefined ||
|
|
236
|
+
!/^urn:li:organization:[0-9]+$/.test(organization) ||
|
|
237
|
+
row["role"] !== "ADMINISTRATOR" ||
|
|
238
|
+
row["state"] !== "APPROVED")
|
|
239
|
+
continue;
|
|
240
|
+
items.push({
|
|
241
|
+
organization: `urn:li:organization:${organization.slice("urn:li:organization:".length)}`,
|
|
242
|
+
role: "ADMINISTRATOR",
|
|
243
|
+
state: "APPROVED",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const paging = result["paging"] === undefined ? {} : object(result["paging"]);
|
|
247
|
+
const total = optionalNumber(paging["total"]);
|
|
248
|
+
const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
|
|
249
|
+
return Object.assign({ items }, rows.length > 0 && (hasNext || (total !== undefined && start + rows.length < total))
|
|
250
|
+
? { nextCursor: String(start + rows.length) }
|
|
251
|
+
: {});
|
|
252
|
+
}
|
|
108
253
|
async function uploadImage(media, account, context) {
|
|
109
254
|
authorize(account, context);
|
|
110
255
|
const source = media.source;
|
|
@@ -131,16 +276,12 @@ export function linkedin(options) {
|
|
|
131
276
|
url: string(initialized["uploadUrl"]),
|
|
132
277
|
source: {
|
|
133
278
|
mimeType: media.mimeType,
|
|
134
|
-
|
|
135
|
-
...(size === undefined ? {} : { size }),
|
|
279
|
+
...definedFields({ size }),
|
|
136
280
|
open: source.kind === "blob" ? () => source.blob.stream() : source.open,
|
|
137
281
|
},
|
|
138
282
|
allowHost: (host) => host === "www.linkedin.com",
|
|
139
283
|
maxBytes: 20 * 1024 * 1024,
|
|
140
|
-
|
|
141
|
-
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
142
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
143
|
-
...(context.signal ? { signal: context.signal } : {}),
|
|
284
|
+
...definedFields({ fetch: options.fetch, signal: context.signal }),
|
|
144
285
|
});
|
|
145
286
|
return {
|
|
146
287
|
kind: "media",
|
|
@@ -151,7 +292,389 @@ export function linkedin(options) {
|
|
|
151
292
|
mediaId,
|
|
152
293
|
};
|
|
153
294
|
}
|
|
154
|
-
|
|
295
|
+
function videoPartError(error, part) {
|
|
296
|
+
if (error.kind === "cancelled")
|
|
297
|
+
return new SocialError({
|
|
298
|
+
code: "cancelled",
|
|
299
|
+
operation: "media.upload",
|
|
300
|
+
message: `LinkedIn video part ${part} upload was cancelled. No post was created.`,
|
|
301
|
+
});
|
|
302
|
+
if (error.kind === "timeout")
|
|
303
|
+
return new SocialError({
|
|
304
|
+
code: "timeout",
|
|
305
|
+
operation: "media.upload",
|
|
306
|
+
message: `LinkedIn video part ${part} upload exceeded its elapsed budget. No post was created.`,
|
|
307
|
+
retryDisposition: { kind: "never" },
|
|
308
|
+
});
|
|
309
|
+
if (error.status === 429)
|
|
310
|
+
return new SocialError({
|
|
311
|
+
code: "rate_limited",
|
|
312
|
+
operation: "media.upload",
|
|
313
|
+
message: `LinkedIn rate limited video part ${part}. No post was created.`,
|
|
314
|
+
upstreamStatus: error.status,
|
|
315
|
+
retryDisposition: error.retryAfterMs === undefined
|
|
316
|
+
? { kind: "never" }
|
|
317
|
+
: { kind: "after-delay", delayMs: error.retryAfterMs },
|
|
318
|
+
});
|
|
319
|
+
return new SocialError({
|
|
320
|
+
code: "media_error",
|
|
321
|
+
operation: "media.upload",
|
|
322
|
+
message: error.status === 401
|
|
323
|
+
? `LinkedIn rejected video part ${part} because its upload URL expired. Start a new upload.`
|
|
324
|
+
: `LinkedIn did not accept video part ${part}. Start a new upload; no post was created.`,
|
|
325
|
+
upstreamStatus: error.status,
|
|
326
|
+
retryDisposition: { kind: "never" },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/** Reads one video's public status fields with a single GET after checking the grant and owner. */
|
|
330
|
+
async function readVideoStatus(ref, context) {
|
|
331
|
+
authorize(ref, context);
|
|
332
|
+
if (!linkedInVideoUrn.test(ref.mediaId))
|
|
333
|
+
throw new SocialError({
|
|
334
|
+
code: "invalid_input",
|
|
335
|
+
operation: "media.read",
|
|
336
|
+
message: "Use the urn:li:video reference returned by media.upload.",
|
|
337
|
+
});
|
|
338
|
+
const video = object(await request(`/rest/videos/${encodeURIComponent(ref.mediaId)}`, context));
|
|
339
|
+
if (video["owner"] !== ref.accountId)
|
|
340
|
+
throw new SocialError({
|
|
341
|
+
code: "unauthorized",
|
|
342
|
+
operation: "media.read",
|
|
343
|
+
message: "LinkedIn video belongs to another author.",
|
|
344
|
+
});
|
|
345
|
+
if (video["id"] !== undefined && video["id"] !== ref.mediaId)
|
|
346
|
+
throw new SocialError({
|
|
347
|
+
code: "media_error",
|
|
348
|
+
operation: "media.read",
|
|
349
|
+
message: "LinkedIn returned the status of a different video.",
|
|
350
|
+
});
|
|
351
|
+
return publicFields(video, ["id", "owner", "status", "processingFailureReason", "duration"]);
|
|
352
|
+
}
|
|
353
|
+
function videoWaitCancelled() {
|
|
354
|
+
return new SocialError({
|
|
355
|
+
code: "cancelled",
|
|
356
|
+
operation: "media.read",
|
|
357
|
+
message: "Waiting for the LinkedIn video was cancelled. No post was created.",
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/** Remaining elapsed budget in milliseconds, or 0 once it is exhausted. */
|
|
361
|
+
function budgetLeft(context) {
|
|
362
|
+
try {
|
|
363
|
+
return remainingBudget(context);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Sleeps between explicit waitForVideo reads and rejects as soon as the context aborts. */
|
|
370
|
+
function videoWaitDelay(milliseconds, context) {
|
|
371
|
+
if (context.signal?.aborted)
|
|
372
|
+
return Promise.reject(videoWaitCancelled());
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const onAbort = () => {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
reject(videoWaitCancelled());
|
|
377
|
+
};
|
|
378
|
+
const timer = setTimeout(() => {
|
|
379
|
+
context.signal?.removeEventListener("abort", onAbort);
|
|
380
|
+
resolve();
|
|
381
|
+
}, milliseconds);
|
|
382
|
+
context.signal?.addEventListener("abort", onAbort, { once: true });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/** Initializes, uploads every part and finalizes one video. It never waits for processing. */
|
|
386
|
+
async function uploadVideo(media, account, context) {
|
|
387
|
+
authorize(account, context);
|
|
388
|
+
const source = media.source;
|
|
389
|
+
if (media.mimeType !== "video/mp4" || source.kind !== "blob")
|
|
390
|
+
throw new SocialError({
|
|
391
|
+
code: "invalid_input",
|
|
392
|
+
operation: "media.upload",
|
|
393
|
+
message: "Provide one MP4 video as a Blob with mimeType video/mp4.",
|
|
394
|
+
});
|
|
395
|
+
if (media.thumbnail !== undefined)
|
|
396
|
+
throw new SocialError({
|
|
397
|
+
code: "invalid_input",
|
|
398
|
+
operation: "media.upload",
|
|
399
|
+
message: "LinkedIn video thumbnails are not implemented by this adapter.",
|
|
400
|
+
});
|
|
401
|
+
const blob = source.blob;
|
|
402
|
+
if ((media.byteSize !== undefined && media.byteSize !== blob.size) ||
|
|
403
|
+
blob.size < linkedInVideoMinBytes ||
|
|
404
|
+
blob.size > linkedInVideoMaxBytes)
|
|
405
|
+
throw new SocialError({
|
|
406
|
+
code: "invalid_input",
|
|
407
|
+
operation: "media.upload",
|
|
408
|
+
message: "LinkedIn feed video must be 75 KB to 500 MB, and byteSize must match the Blob size.",
|
|
409
|
+
});
|
|
410
|
+
if (media.durationSeconds !== undefined &&
|
|
411
|
+
(!Number.isFinite(media.durationSeconds) ||
|
|
412
|
+
media.durationSeconds < 3 ||
|
|
413
|
+
media.durationSeconds > 1800))
|
|
414
|
+
throw new SocialError({
|
|
415
|
+
code: "invalid_input",
|
|
416
|
+
operation: "media.upload",
|
|
417
|
+
message: "LinkedIn feed video must be 3 seconds to 30 minutes long.",
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
const initialized = object(object(await request("/rest/videos?action=initializeUpload", context, {
|
|
421
|
+
initializeUploadRequest: {
|
|
422
|
+
owner: account.accountId,
|
|
423
|
+
fileSizeBytes: blob.size,
|
|
424
|
+
uploadCaptions: false,
|
|
425
|
+
uploadThumbnail: false,
|
|
426
|
+
},
|
|
427
|
+
}))["value"]);
|
|
428
|
+
const mediaId = string(initialized["video"]);
|
|
429
|
+
// LinkedIn documents an empty upload token for some sessions, so only its type is checked.
|
|
430
|
+
const uploadToken = optionalString(initialized["uploadToken"]);
|
|
431
|
+
const parts = array(initialized["uploadInstructions"]).map((value) => {
|
|
432
|
+
const part = object(value);
|
|
433
|
+
return {
|
|
434
|
+
url: string(part["uploadUrl"]),
|
|
435
|
+
firstByte: optionalNumber(part["firstByte"]),
|
|
436
|
+
lastByte: optionalNumber(part["lastByte"]),
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
let nextByte = 0;
|
|
440
|
+
const plan = [];
|
|
441
|
+
for (const part of parts) {
|
|
442
|
+
const { firstByte, lastByte } = part;
|
|
443
|
+
if (firstByte !== nextByte ||
|
|
444
|
+
lastByte === undefined ||
|
|
445
|
+
!Number.isSafeInteger(lastByte) ||
|
|
446
|
+
lastByte < firstByte ||
|
|
447
|
+
lastByte - firstByte + 1 > linkedInVideoPartBytes)
|
|
448
|
+
break;
|
|
449
|
+
plan.push({ url: part.url, firstByte, lastByte });
|
|
450
|
+
nextByte = lastByte + 1;
|
|
451
|
+
}
|
|
452
|
+
if (!linkedInVideoUrn.test(mediaId) ||
|
|
453
|
+
uploadToken === undefined ||
|
|
454
|
+
plan.length === 0 ||
|
|
455
|
+
plan.length !== parts.length ||
|
|
456
|
+
nextByte !== blob.size)
|
|
457
|
+
throw new SocialError({
|
|
458
|
+
code: "media_error",
|
|
459
|
+
operation: "media.upload",
|
|
460
|
+
message: "LinkedIn returned an invalid video identifier or an upload plan that does not cover the file. No bytes were sent.",
|
|
461
|
+
retryDisposition: { kind: "never" },
|
|
462
|
+
});
|
|
463
|
+
const uploadedPartIds = [];
|
|
464
|
+
for (const [index, part] of plan.entries()) {
|
|
465
|
+
const body = blob.slice(part.firstByte, part.lastByte + 1);
|
|
466
|
+
let result;
|
|
467
|
+
try {
|
|
468
|
+
// fetch sends the Blob slice directly; the declared size lets upload() check every byte.
|
|
469
|
+
result = await upload({
|
|
470
|
+
url: part.url,
|
|
471
|
+
source: {
|
|
472
|
+
mimeType: "application/octet-stream",
|
|
473
|
+
size: body.size,
|
|
474
|
+
body,
|
|
475
|
+
open: () => body.stream(),
|
|
476
|
+
},
|
|
477
|
+
allowHost: (host) => host === "www.linkedin.com",
|
|
478
|
+
maxBytes: linkedInVideoPartBytes,
|
|
479
|
+
timeoutMs: remainingBudget(context),
|
|
480
|
+
...definedFields({ fetch: options.fetch, signal: context.signal }),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error instanceof HttpError)
|
|
485
|
+
throw videoPartError(error, index + 1);
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
// finalizeUpload takes the ETag value without the HTTP quoting.
|
|
489
|
+
const etag = result.etag?.trim().replace(/^"(.*)"$/, "$1");
|
|
490
|
+
if (!etag)
|
|
491
|
+
throw new SocialError({
|
|
492
|
+
code: "media_error",
|
|
493
|
+
operation: "media.upload",
|
|
494
|
+
message: `LinkedIn accepted video part ${index + 1} without an ETag. Start a new upload.`,
|
|
495
|
+
retryDisposition: { kind: "never" },
|
|
496
|
+
});
|
|
497
|
+
uploadedPartIds.push(etag);
|
|
498
|
+
}
|
|
499
|
+
await request("/rest/videos?action=finalizeUpload", context, {
|
|
500
|
+
finalizeUploadRequest: { video: mediaId, uploadToken, uploadedPartIds },
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
kind: "media",
|
|
504
|
+
version: 1,
|
|
505
|
+
backend: account.backend,
|
|
506
|
+
platform: "linkedin",
|
|
507
|
+
accountId: account.accountId,
|
|
508
|
+
mediaId,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
// Malformed upload responses fail before any post request, so no post exists.
|
|
513
|
+
if (error instanceof HttpError)
|
|
514
|
+
throw new SocialError({
|
|
515
|
+
code: "media_error",
|
|
516
|
+
operation: "media.upload",
|
|
517
|
+
message: "LinkedIn returned a malformed video upload response. No post was created.",
|
|
518
|
+
retryDisposition: { kind: "never" },
|
|
519
|
+
});
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async function uploadDocument(media, account, context) {
|
|
524
|
+
authorize(account, context);
|
|
525
|
+
const source = media.source;
|
|
526
|
+
const size = source.kind === "blob" ? source.blob.size : media.byteSize;
|
|
527
|
+
const mimeType = media.mimeType ?? "";
|
|
528
|
+
if (media.kind !== "document" ||
|
|
529
|
+
!linkedInDocumentMimeTypes.includes(mimeType) ||
|
|
530
|
+
(source.kind !== "blob" && source.kind !== "stream"))
|
|
531
|
+
throw new SocialError({
|
|
532
|
+
code: "invalid_input",
|
|
533
|
+
operation: "media.upload",
|
|
534
|
+
message: "Provide PDF, PPT, PPTX, DOC or DOCX bytes as a Blob or replayable stream.",
|
|
535
|
+
});
|
|
536
|
+
if ((source.kind === "blob" && media.byteSize !== undefined && media.byteSize !== size) ||
|
|
537
|
+
(size !== undefined &&
|
|
538
|
+
(!Number.isSafeInteger(size) || size <= 0 || size > linkedInDocumentMaxBytes)))
|
|
539
|
+
throw new SocialError({
|
|
540
|
+
code: "invalid_input",
|
|
541
|
+
operation: "media.upload",
|
|
542
|
+
message: "LinkedIn documents must be non-empty and at most 100 MB, and byteSize must match the Blob size.",
|
|
543
|
+
});
|
|
544
|
+
const initialized = object(object(await request("/rest/documents?action=initializeUpload", context, {
|
|
545
|
+
initializeUploadRequest: { owner: account.accountId },
|
|
546
|
+
}))["value"]);
|
|
547
|
+
const mediaId = string(initialized["document"]);
|
|
548
|
+
if (!linkedInDocumentUrn.test(mediaId))
|
|
549
|
+
throw new SocialError({
|
|
550
|
+
code: "media_error",
|
|
551
|
+
operation: "media.upload",
|
|
552
|
+
message: "LinkedIn returned an invalid document identifier.",
|
|
553
|
+
});
|
|
554
|
+
let transferred;
|
|
555
|
+
try {
|
|
556
|
+
({ bytes: transferred } = await upload({
|
|
557
|
+
url: string(initialized["uploadUrl"]),
|
|
558
|
+
source: {
|
|
559
|
+
mimeType,
|
|
560
|
+
...definedFields({ size }),
|
|
561
|
+
open: source.kind === "blob" ? () => source.blob.stream() : source.open,
|
|
562
|
+
},
|
|
563
|
+
allowHost: (host) => host === "www.linkedin.com",
|
|
564
|
+
maxBytes: linkedInDocumentMaxBytes,
|
|
565
|
+
timeoutMs: remainingBudget(context),
|
|
566
|
+
...definedFields({ fetch: options.fetch, signal: context.signal }),
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
if (!(error instanceof HttpError))
|
|
571
|
+
throw error;
|
|
572
|
+
throw new SocialError({
|
|
573
|
+
code: error.kind === "timeout"
|
|
574
|
+
? "timeout"
|
|
575
|
+
: error.kind === "cancelled"
|
|
576
|
+
? "cancelled"
|
|
577
|
+
: error.kind === "invalid-input"
|
|
578
|
+
? "invalid_input"
|
|
579
|
+
: "media_error",
|
|
580
|
+
operation: "media.upload",
|
|
581
|
+
message: `LinkedIn document upload for ${mediaId} did not complete: ${error.message}`,
|
|
582
|
+
upstreamStatus: error.status,
|
|
583
|
+
retryDisposition: { kind: "never" },
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
// A stream without byteSize can end empty; LinkedIn cannot publish an empty document.
|
|
587
|
+
if (transferred === 0)
|
|
588
|
+
throw new SocialError({
|
|
589
|
+
code: "invalid_input",
|
|
590
|
+
operation: "media.upload",
|
|
591
|
+
message: `LinkedIn document upload for ${mediaId} sent no bytes. Provide a non-empty document.`,
|
|
592
|
+
retryDisposition: { kind: "never" },
|
|
593
|
+
});
|
|
594
|
+
return {
|
|
595
|
+
kind: "media",
|
|
596
|
+
version: 1,
|
|
597
|
+
backend: account.backend,
|
|
598
|
+
platform: "linkedin",
|
|
599
|
+
accountId: account.accountId,
|
|
600
|
+
mediaId,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async function readDocument(ref, context, operation) {
|
|
604
|
+
authorize(ref, context);
|
|
605
|
+
if (!linkedInDocumentUrn.test(ref.mediaId))
|
|
606
|
+
throw new SocialError({
|
|
607
|
+
code: "invalid_input",
|
|
608
|
+
operation,
|
|
609
|
+
message: "Use the urn:li:document reference returned by media.upload.",
|
|
610
|
+
});
|
|
611
|
+
const document = object(await request(`/rest/documents/${encodeURIComponent(ref.mediaId)}`, context));
|
|
612
|
+
if (document["owner"] !== ref.accountId ||
|
|
613
|
+
(document["id"] !== undefined && document["id"] !== ref.mediaId))
|
|
614
|
+
throw new SocialError({
|
|
615
|
+
code: "unauthorized",
|
|
616
|
+
operation,
|
|
617
|
+
message: "LinkedIn document belongs to another author.",
|
|
618
|
+
});
|
|
619
|
+
return document;
|
|
620
|
+
}
|
|
621
|
+
/** The Posts API requires a title for documents; take it from caption, then filename. */
|
|
622
|
+
const documentTitle = (media) => media.caption?.trim() || media.filename?.trim() || "";
|
|
623
|
+
const documentIssues = (target) => {
|
|
624
|
+
const media = target.content.media ?? [];
|
|
625
|
+
if (!media.some((item) => item.kind === "document"))
|
|
626
|
+
return [];
|
|
627
|
+
const issues = [];
|
|
628
|
+
if (media.length !== 1)
|
|
629
|
+
issues.push([
|
|
630
|
+
"linkedin.document_count",
|
|
631
|
+
"A LinkedIn document post carries exactly one document and no other media.",
|
|
632
|
+
]);
|
|
633
|
+
for (const item of media) {
|
|
634
|
+
if (item.kind !== "document")
|
|
635
|
+
continue;
|
|
636
|
+
if (item.source.kind !== "media-ref")
|
|
637
|
+
issues.push([
|
|
638
|
+
"linkedin.document",
|
|
639
|
+
"Upload the document first with media.upload, then publish its account-bound reference.",
|
|
640
|
+
]);
|
|
641
|
+
else if (item.source.ref.backend !== target.account.backend ||
|
|
642
|
+
item.source.ref.accountId !== target.account.accountId ||
|
|
643
|
+
item.source.ref.platform !== "linkedin" ||
|
|
644
|
+
!linkedInDocumentUrn.test(item.source.ref.mediaId))
|
|
645
|
+
issues.push([
|
|
646
|
+
"linkedin.media_owner",
|
|
647
|
+
"Document reference belongs to another author/backend or has an invalid URN.",
|
|
648
|
+
]);
|
|
649
|
+
if (!documentTitle(item))
|
|
650
|
+
issues.push([
|
|
651
|
+
"linkedin.document_title",
|
|
652
|
+
"LinkedIn requires a document title. Set caption or filename on the attachment.",
|
|
653
|
+
]);
|
|
654
|
+
if (item.altText !== undefined)
|
|
655
|
+
issues.push([
|
|
656
|
+
"linkedin.document_alt_text",
|
|
657
|
+
"LinkedIn documents have no verified alt text mapping. Remove altText.",
|
|
658
|
+
]);
|
|
659
|
+
}
|
|
660
|
+
return issues;
|
|
661
|
+
};
|
|
662
|
+
async function documentContent(media, context) {
|
|
663
|
+
if (media.source.kind !== "media-ref")
|
|
664
|
+
throw new SocialError({
|
|
665
|
+
code: "invalid_input",
|
|
666
|
+
operation: "posts.publish",
|
|
667
|
+
message: "Upload the document first with media.upload.",
|
|
668
|
+
});
|
|
669
|
+
const document = await readDocument(media.source.ref, context, "posts.publish");
|
|
670
|
+
if (document["status"] !== "AVAILABLE")
|
|
671
|
+
throw new SocialError({
|
|
672
|
+
code: "media_error",
|
|
673
|
+
operation: "posts.publish",
|
|
674
|
+
message: "Document is not AVAILABLE. Check its status with the native documentStatus helper, then publish again with a new idempotency key.",
|
|
675
|
+
});
|
|
676
|
+
return { media: { id: media.source.ref.mediaId, title: documentTitle(media) } };
|
|
677
|
+
}
|
|
155
678
|
const organizationOnly = (account, context, operation) => {
|
|
156
679
|
authorize(account, context);
|
|
157
680
|
if (!account.accountId.startsWith("urn:li:organization:"))
|
|
@@ -213,8 +736,8 @@ export function linkedin(options) {
|
|
|
213
736
|
const number = optionalNumber(item);
|
|
214
737
|
if (number !== undefined)
|
|
215
738
|
output[key] = number;
|
|
216
|
-
else if (
|
|
217
|
-
const nestedObject =
|
|
739
|
+
else if (isJsonObject(item)) {
|
|
740
|
+
const nestedObject = item;
|
|
218
741
|
const nested = ["pageViews", "uniquePageViews", "clicks", "count"]
|
|
219
742
|
.map((name) => optionalNumber(nestedObject[name]))
|
|
220
743
|
.find((candidate) => candidate !== undefined);
|
|
@@ -254,12 +777,10 @@ export function linkedin(options) {
|
|
|
254
777
|
output.push({
|
|
255
778
|
dimension,
|
|
256
779
|
value: label,
|
|
257
|
-
...(
|
|
258
|
-
|
|
259
|
-
:
|
|
260
|
-
|
|
261
|
-
? {}
|
|
262
|
-
: { paidFollowerCount: optionalNumber(counts["paidFollowerCount"]) }),
|
|
780
|
+
...definedFields({
|
|
781
|
+
organicFollowerCount: optionalNumber(counts["organicFollowerCount"]),
|
|
782
|
+
paidFollowerCount: optionalNumber(counts["paidFollowerCount"]),
|
|
783
|
+
}),
|
|
263
784
|
});
|
|
264
785
|
}
|
|
265
786
|
}
|
|
@@ -275,26 +796,18 @@ export function linkedin(options) {
|
|
|
275
796
|
if ((granularity !== "DAY" && granularity !== "WEEK" && granularity !== "MONTH") ||
|
|
276
797
|
(start === undefined && end === undefined))
|
|
277
798
|
return undefined;
|
|
278
|
-
return {
|
|
279
|
-
granularity,
|
|
280
|
-
...(start === undefined ? {} : { start }),
|
|
281
|
-
...(end === undefined ? {} : { end }),
|
|
282
|
-
};
|
|
799
|
+
return { granularity, ...definedFields({ start, end }) };
|
|
283
800
|
};
|
|
284
801
|
const parseFollowerStatistics = (result, requestedGranularity) => array(result["elements"]).map((value) => {
|
|
285
802
|
const row = object(value);
|
|
286
803
|
const gains = row["followerGains"] === undefined ? {} : object(row["followerGains"]);
|
|
287
804
|
return {
|
|
288
805
|
organization: string(row["organizationalEntity"]),
|
|
289
|
-
...(
|
|
290
|
-
|
|
291
|
-
:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
: { organicFollowerGain: optionalNumber(gains["organicFollowerGain"]) }),
|
|
295
|
-
...(optionalNumber(gains["paidFollowerGain"]) === undefined
|
|
296
|
-
? {}
|
|
297
|
-
: { paidFollowerGain: optionalNumber(gains["paidFollowerGain"]) }),
|
|
806
|
+
...definedFields({
|
|
807
|
+
interval: parseInterval(row, requestedGranularity),
|
|
808
|
+
organicFollowerGain: optionalNumber(gains["organicFollowerGain"]),
|
|
809
|
+
paidFollowerGain: optionalNumber(gains["paidFollowerGain"]),
|
|
810
|
+
}),
|
|
298
811
|
breakdowns: followerBreakdowns(row),
|
|
299
812
|
};
|
|
300
813
|
});
|
|
@@ -334,9 +847,7 @@ export function linkedin(options) {
|
|
|
334
847
|
const total = row["totalPageStatistics"] === undefined ? {} : object(row["totalPageStatistics"]);
|
|
335
848
|
return {
|
|
336
849
|
organization: string(row["organization"]),
|
|
337
|
-
...(parseInterval(row, requestedGranularity)
|
|
338
|
-
? {}
|
|
339
|
-
: { interval: parseInterval(row, requestedGranularity) }),
|
|
850
|
+
...definedFields({ interval: parseInterval(row, requestedGranularity) }),
|
|
340
851
|
views: numberMap(total["views"]),
|
|
341
852
|
clicks: numberMap(total["clicks"]),
|
|
342
853
|
breakdowns: pageBreakdowns(row),
|
|
@@ -346,13 +857,10 @@ export function linkedin(options) {
|
|
|
346
857
|
const row = object(value);
|
|
347
858
|
return {
|
|
348
859
|
organization: string(row["organizationalEntity"]),
|
|
349
|
-
...(parseInterval(row, requestedGranularity)
|
|
350
|
-
? {}
|
|
351
|
-
: { interval: parseInterval(row, requestedGranularity) }),
|
|
860
|
+
...definedFields({ interval: parseInterval(row, requestedGranularity) }),
|
|
352
861
|
metrics: numberMap(row["totalShareStatistics"]),
|
|
353
862
|
};
|
|
354
863
|
});
|
|
355
|
-
/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/no-known-value-widening, anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof */
|
|
356
864
|
return defineAdapter({
|
|
357
865
|
id: "linkedin",
|
|
358
866
|
capabilities: {
|
|
@@ -365,13 +873,25 @@ export function linkedin(options) {
|
|
|
365
873
|
platform: "linkedin",
|
|
366
874
|
operation: "posts.publish",
|
|
367
875
|
availability: "available",
|
|
368
|
-
formats: [
|
|
876
|
+
formats: [
|
|
877
|
+
"text",
|
|
878
|
+
"image",
|
|
879
|
+
"carousel",
|
|
880
|
+
"video",
|
|
881
|
+
"document",
|
|
882
|
+
],
|
|
369
883
|
requiredScopes: [
|
|
370
884
|
options.auth.author.startsWith("urn:li:organization:")
|
|
371
885
|
? "w_organization_social"
|
|
372
886
|
: "w_member_social",
|
|
373
887
|
],
|
|
374
|
-
notes: "Explicit author URN and public visibility. Organization role and app product approval required.
|
|
888
|
+
notes: "Explicit author URN and public visibility. Organization role and app product approval required. One registered image, 2 to 20 images, one MP4 video, or one document per post; every asset must be AVAILABLE before creating a post.",
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
platform: "linkedin",
|
|
892
|
+
operation: "posts.schedule",
|
|
893
|
+
availability: "unsupported-by-platform",
|
|
894
|
+
notes: "The Posts API accepts only lifecycleState PUBLISHED on creation and has no publish-time field.",
|
|
375
895
|
},
|
|
376
896
|
{
|
|
377
897
|
platform: "linkedin",
|
|
@@ -384,22 +904,52 @@ export function linkedin(options) {
|
|
|
384
904
|
],
|
|
385
905
|
notes: "Member read access is restricted. Publication permission does not grant read permission.",
|
|
386
906
|
},
|
|
907
|
+
{
|
|
908
|
+
platform: "linkedin",
|
|
909
|
+
operation: "accounts.read",
|
|
910
|
+
availability: "available",
|
|
911
|
+
requiredScopes: options.auth.author.startsWith("urn:li:organization:")
|
|
912
|
+
? ["rw_organization_admin"]
|
|
913
|
+
: ["openid", "profile"],
|
|
914
|
+
notes: options.auth.author.startsWith("urn:li:organization:")
|
|
915
|
+
? "Returns the configured organization only. Reads /rest/organizations/{id}, which LinkedIn restricts to members with an approved ADMINISTRATOR role."
|
|
916
|
+
: "Returns the configured member only. Reads the OpenID Connect userinfo endpoint and checks that sub matches the author URN. Requires the Sign In with LinkedIn using OpenID Connect product.",
|
|
917
|
+
},
|
|
387
918
|
{
|
|
388
919
|
platform: "linkedin",
|
|
389
920
|
operation: "posts.multi-image",
|
|
390
|
-
availability: "
|
|
921
|
+
availability: "available",
|
|
391
922
|
formats: ["carousel"],
|
|
923
|
+
requiredScopes: [
|
|
924
|
+
options.auth.author.startsWith("urn:li:organization:")
|
|
925
|
+
? "w_organization_social"
|
|
926
|
+
: "w_member_social",
|
|
927
|
+
],
|
|
928
|
+
notes: "Publishes 2 to 20 registered images through posts.publish as organic MultiImage content. Every image must be owned by the author and AVAILABLE. Sponsored multi-image posts are not supported.",
|
|
392
929
|
},
|
|
393
930
|
{
|
|
394
931
|
platform: "linkedin",
|
|
395
932
|
operation: "posts.video",
|
|
396
|
-
availability: "
|
|
933
|
+
availability: "available",
|
|
397
934
|
formats: ["video"],
|
|
935
|
+
requiredScopes: [
|
|
936
|
+
options.auth.author.startsWith("urn:li:organization:")
|
|
937
|
+
? "w_organization_social"
|
|
938
|
+
: "w_member_social",
|
|
939
|
+
],
|
|
940
|
+
notes: "Upload one MP4 Blob (75 KB to 500 MB, 3 seconds to 30 minutes) with media.upload, then publish the video URN once it is AVAILABLE. Check processing with native videoStatus (one read) or the opt-in native waitForVideo (bounded reads within the operation budget). Publishing reads the status once and never waits; a processing video fails with no post created. The attachment caption becomes the optional video title.",
|
|
398
941
|
},
|
|
399
942
|
{
|
|
400
943
|
platform: "linkedin",
|
|
401
944
|
operation: "posts.document",
|
|
402
|
-
availability: "
|
|
945
|
+
availability: "available",
|
|
946
|
+
formats: ["document"],
|
|
947
|
+
requiredScopes: [
|
|
948
|
+
options.auth.author.startsWith("urn:li:organization:")
|
|
949
|
+
? "w_organization_social"
|
|
950
|
+
: "w_member_social",
|
|
951
|
+
],
|
|
952
|
+
notes: "One PDF, PPT, PPTX, DOC or DOCX file up to 100 MB and 300 pages, uploaded through the Documents API. LinkedIn enforces the page limit during processing. Requires a title and AVAILABLE status.",
|
|
403
953
|
},
|
|
404
954
|
{ platform: "linkedin", operation: "polls.create", availability: "available" },
|
|
405
955
|
{ platform: "linkedin", operation: "reactions.write", availability: "available" },
|
|
@@ -411,6 +961,17 @@ export function linkedin(options) {
|
|
|
411
961
|
operation: "posts.removeFromPlatform",
|
|
412
962
|
availability: "available",
|
|
413
963
|
},
|
|
964
|
+
{
|
|
965
|
+
platform: "linkedin",
|
|
966
|
+
operation: "comments.delete",
|
|
967
|
+
availability: "available",
|
|
968
|
+
requiredScopes: [
|
|
969
|
+
options.auth.author.startsWith("urn:li:organization:")
|
|
970
|
+
? "w_organization_social"
|
|
971
|
+
: "w_member_social",
|
|
972
|
+
],
|
|
973
|
+
notes: "Native deleteComment needs the post URN and the complete commentUrn. LinkedIn does not document which comments an actor may delete; expect only the configured author's own comments to succeed.",
|
|
974
|
+
},
|
|
414
975
|
{
|
|
415
976
|
platform: "linkedin",
|
|
416
977
|
operation: "analytics.organization.read",
|
|
@@ -425,11 +986,39 @@ export function linkedin(options) {
|
|
|
425
986
|
operation: "articles.create",
|
|
426
987
|
availability: "approval-dependent",
|
|
427
988
|
},
|
|
989
|
+
{
|
|
990
|
+
platform: "linkedin",
|
|
991
|
+
operation: "profile.update",
|
|
992
|
+
availability: "approval-dependent",
|
|
993
|
+
notes: "Member profile writes use the Profile Edit API, which LinkedIn restricts to approved developers. This adapter does not implement it.",
|
|
994
|
+
},
|
|
428
995
|
{
|
|
429
996
|
platform: "linkedin",
|
|
430
997
|
operation: "messages.write",
|
|
431
998
|
availability: "unsupported-by-platform",
|
|
432
999
|
},
|
|
1000
|
+
{
|
|
1001
|
+
platform: "linkedin",
|
|
1002
|
+
operation: "webhooks.verify",
|
|
1003
|
+
availability: "approval-dependent",
|
|
1004
|
+
requiredScopes: ["rw_organization_admin"],
|
|
1005
|
+
notes: "LinkedIn enables webhooks only for apps with an approved webhook use case. Organization social action notifications also need the Community Management API and an organization administrator. Verifies X-LI-Signature (HMAC-SHA256 over hmacsha256= plus the raw body) with the app client secret; answer the GET validation with answerLinkedInWebhookChallenge.",
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
platform: "linkedin",
|
|
1009
|
+
operation: "notifications.read",
|
|
1010
|
+
availability: options.auth.author.startsWith("urn:li:organization:")
|
|
1011
|
+
? "available"
|
|
1012
|
+
: "account-ineligible",
|
|
1013
|
+
requiredScopes: ["rw_organization_admin"],
|
|
1014
|
+
notes: "Organization social-action notifications from the last 60 days, pulled with offset paging. Requires the Community Management API product and organization administrator access. LinkedIn has no member notification API.",
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
platform: "linkedin",
|
|
1018
|
+
operation: "notifications.seen",
|
|
1019
|
+
availability: "unsupported-by-platform",
|
|
1020
|
+
notes: "LinkedIn does not expose a seen or read state for notifications.",
|
|
1021
|
+
},
|
|
433
1022
|
{
|
|
434
1023
|
platform: "linkedin",
|
|
435
1024
|
operation: "analytics.account.read",
|
|
@@ -463,7 +1052,7 @@ export function linkedin(options) {
|
|
|
463
1052
|
platform: "linkedin",
|
|
464
1053
|
operation: "media.upload",
|
|
465
1054
|
availability: "available",
|
|
466
|
-
formats: ["image"],
|
|
1055
|
+
formats: ["image", "video", "document"],
|
|
467
1056
|
},
|
|
468
1057
|
...["comments.read", "comments.write", "analytics.read"].map((operation) => ({
|
|
469
1058
|
platform: "linkedin",
|
|
@@ -476,7 +1065,22 @@ export function linkedin(options) {
|
|
|
476
1065
|
})),
|
|
477
1066
|
],
|
|
478
1067
|
},
|
|
479
|
-
|
|
1068
|
+
accounts: {
|
|
1069
|
+
async list(_input, context) {
|
|
1070
|
+
return { items: [await readAccount(context)] };
|
|
1071
|
+
},
|
|
1072
|
+
async get(ref, context) {
|
|
1073
|
+
authorize(ref, context);
|
|
1074
|
+
return readAccount(context);
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
media: {
|
|
1078
|
+
upload: (media, account, context) => media.kind === "video"
|
|
1079
|
+
? uploadVideo(media, account, context)
|
|
1080
|
+
: media.kind === "document"
|
|
1081
|
+
? uploadDocument(media, account, context)
|
|
1082
|
+
: uploadImage(media, account, context),
|
|
1083
|
+
},
|
|
480
1084
|
posts: {
|
|
481
1085
|
prepareTarget(target) {
|
|
482
1086
|
const issues = [];
|
|
@@ -488,39 +1092,54 @@ export function linkedin(options) {
|
|
|
488
1092
|
fail("linkedin.text", "LinkedIn commentary exceeds 3,000 characters.");
|
|
489
1093
|
if (target.schedule || target.replyTo || target.content.link)
|
|
490
1094
|
fail("linkedin.operation", "Scheduling, reply posts and structured links are not supported by this publishing slice.");
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
fail("linkedin.options", "This slice supports public visibility only; other native options require explicit implementation.");
|
|
496
|
-
}
|
|
1095
|
+
const settings = optionsObject(target);
|
|
1096
|
+
if (Object.keys(settings).some((key) => key !== "visibility") ||
|
|
1097
|
+
(settings["visibility"] !== undefined && settings["visibility"] !== "public"))
|
|
1098
|
+
fail("linkedin.options", "This slice supports public visibility only; other native options require explicit implementation.");
|
|
497
1099
|
const media = target.content.media ?? [];
|
|
498
|
-
|
|
499
|
-
fail(
|
|
1100
|
+
for (const [code, message] of documentIssues(target))
|
|
1101
|
+
fail(code, message);
|
|
1102
|
+
// A MultiImage post takes 2 to 20 images; alt text is at most 4,086 characters.
|
|
1103
|
+
// A video post carries exactly one video and cannot be mixed with images.
|
|
1104
|
+
if (media.length > 20)
|
|
1105
|
+
fail("linkedin.media_count", "LinkedIn accepts one image, one video, or a multi-image post of 2 to 20 images.");
|
|
1106
|
+
if (media.length > 1 && media.some((item) => item.kind === "video"))
|
|
1107
|
+
fail("linkedin.media_count", "A LinkedIn video post carries exactly one video and cannot be combined with other media.");
|
|
500
1108
|
for (const item of media) {
|
|
501
|
-
if (
|
|
502
|
-
fail("linkedin.
|
|
1109
|
+
if (media.length > 1 && (item.altText?.length ?? 0) > 4086)
|
|
1110
|
+
fail("linkedin.alt_text", "LinkedIn multi-image alt text exceeds 4,086 characters.");
|
|
1111
|
+
if (item.kind === "document")
|
|
1112
|
+
continue;
|
|
1113
|
+
if ((item.kind !== "image" && item.kind !== "video") || item.source.kind !== "media-ref")
|
|
1114
|
+
fail("linkedin.media", "Upload an image or video first with media.upload, then publish its account-bound reference.");
|
|
503
1115
|
else if (item.source.ref.backend !== target.account.backend ||
|
|
504
1116
|
item.source.ref.accountId !== target.account.accountId ||
|
|
505
1117
|
item.source.ref.platform !== "linkedin" ||
|
|
506
|
-
|
|
507
|
-
fail("linkedin.media_owner", "
|
|
1118
|
+
!(item.kind === "video" ? linkedInVideoUrn : linkedInImageUrn).test(item.source.ref.mediaId))
|
|
1119
|
+
fail("linkedin.media_owner", "Media reference belongs to another author/backend or has an invalid URN for its kind.");
|
|
1120
|
+
else if (item.kind === "video" && item.altText !== undefined)
|
|
1121
|
+
fail("linkedin.video_alt_text", "LinkedIn video posts do not accept alt text.");
|
|
508
1122
|
}
|
|
509
1123
|
return issues;
|
|
510
1124
|
},
|
|
511
1125
|
async publishTarget(target, context) {
|
|
512
1126
|
authorize(target.account, context);
|
|
513
|
-
const media = target.content.media
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1127
|
+
const media = target.content.media ?? [];
|
|
1128
|
+
const entries = [];
|
|
1129
|
+
for (const item of media) {
|
|
1130
|
+
// Documents are read and validated by documentContent below.
|
|
1131
|
+
if (item.kind === "document" || item.source.kind !== "media-ref")
|
|
1132
|
+
continue;
|
|
1133
|
+
authorize(item.source.ref, context);
|
|
1134
|
+
const video = item.kind === "video";
|
|
517
1135
|
let image;
|
|
518
1136
|
try {
|
|
519
|
-
|
|
520
|
-
image = object(await request(`/rest/images/${encodeURIComponent(media.source.ref.mediaId)}`, context));
|
|
1137
|
+
image = object(await request(`/rest/${video ? "videos" : "images"}/${encodeURIComponent(item.source.ref.mediaId)}`, context));
|
|
521
1138
|
}
|
|
522
1139
|
catch (error) {
|
|
523
|
-
|
|
1140
|
+
// Only image reads keep the member-token 403 exception. A video must prove AVAILABLE.
|
|
1141
|
+
if (video ||
|
|
1142
|
+
!(error instanceof SocialError) ||
|
|
524
1143
|
error.code !== "missing_permission" ||
|
|
525
1144
|
!options.auth.author.startsWith("urn:li:person:"))
|
|
526
1145
|
throw error;
|
|
@@ -529,22 +1148,61 @@ export function linkedin(options) {
|
|
|
529
1148
|
throw new SocialError({
|
|
530
1149
|
code: "unauthorized",
|
|
531
1150
|
operation: "posts.publish",
|
|
532
|
-
message:
|
|
1151
|
+
message: `LinkedIn ${video ? "video" : "image"} belongs to a different author.`,
|
|
533
1152
|
});
|
|
534
|
-
if (image?.["
|
|
1153
|
+
if (image?.["id"] !== undefined && image["id"] !== item.source.ref.mediaId)
|
|
535
1154
|
throw new SocialError({
|
|
536
1155
|
code: "media_error",
|
|
537
1156
|
operation: "posts.publish",
|
|
538
|
-
message: "
|
|
1157
|
+
message: `LinkedIn returned a different ${video ? "video" : "image"} than the one attached. No post was created.`,
|
|
539
1158
|
});
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
1159
|
+
if (video && image?.["status"] === "PROCESSING_FAILED")
|
|
1160
|
+
throw new SocialError({
|
|
1161
|
+
code: "media_error",
|
|
1162
|
+
operation: "posts.publish",
|
|
1163
|
+
message: "LinkedIn could not process this video. Upload it again; no post was created.",
|
|
1164
|
+
retryDisposition: { kind: "never" },
|
|
1165
|
+
});
|
|
1166
|
+
if (video &&
|
|
1167
|
+
(image?.["status"] === "PROCESSING" || image?.["status"] === "WAITING_UPLOAD"))
|
|
1168
|
+
// No post exists yet, so publishing again later is safe. The SDK does not retry.
|
|
1169
|
+
throw new SocialError({
|
|
1170
|
+
code: "media_error",
|
|
1171
|
+
operation: "posts.publish",
|
|
1172
|
+
message: "Video is still processing; no post was created. Wait with native waitForVideo or check videoStatus, then publish again with a new idempotency key.",
|
|
1173
|
+
retryDisposition: { kind: "after-delay", delayMs: linkedInVideoRetryDelayMs },
|
|
1174
|
+
});
|
|
1175
|
+
if (video
|
|
1176
|
+
? image?.["status"] !== "AVAILABLE"
|
|
1177
|
+
: image?.["status"] !== undefined && image["status"] !== "AVAILABLE")
|
|
1178
|
+
throw new SocialError({
|
|
1179
|
+
code: "media_error",
|
|
1180
|
+
operation: "posts.publish",
|
|
1181
|
+
message: video
|
|
1182
|
+
? "Video is not AVAILABLE. Check it with native videoStatus before publishing."
|
|
1183
|
+
: "Image is not AVAILABLE. Check its status explicitly before publishing.",
|
|
1184
|
+
});
|
|
1185
|
+
// Posts API media.title is optional for video and required only for documents.
|
|
1186
|
+
const title = video ? item.caption?.trim() : undefined;
|
|
1187
|
+
entries.push({
|
|
1188
|
+
id: item.source.ref.mediaId,
|
|
1189
|
+
...definedFields({
|
|
1190
|
+
title: title || undefined,
|
|
1191
|
+
altText: video ? undefined : item.altText || undefined,
|
|
1192
|
+
}),
|
|
1193
|
+
});
|
|
547
1194
|
}
|
|
1195
|
+
// MultiImage content takes 2 to 20 image URNs. Source (accessed 2026-09-24):
|
|
1196
|
+
// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/multiimage-post-api?view=li-lms-2026-09
|
|
1197
|
+
// A single image or video uses content.media; a document post carries only its document.
|
|
1198
|
+
const document = media.find((item) => item.kind === "document");
|
|
1199
|
+
let content;
|
|
1200
|
+
if (document)
|
|
1201
|
+
content = await documentContent(document, context);
|
|
1202
|
+
else if (entries.length > 1)
|
|
1203
|
+
content = { multiImage: { images: entries } };
|
|
1204
|
+
else if (entries[0] !== undefined)
|
|
1205
|
+
content = { media: entries[0] };
|
|
548
1206
|
const result = object(await request("/rest/posts", context, {
|
|
549
1207
|
author: target.account.accountId,
|
|
550
1208
|
commentary: escapeCommentary(target.content.text ?? ""),
|
|
@@ -556,8 +1214,7 @@ export function linkedin(options) {
|
|
|
556
1214
|
},
|
|
557
1215
|
lifecycleState: "PUBLISHED",
|
|
558
1216
|
isReshareDisabledByAuthor: false,
|
|
559
|
-
|
|
560
|
-
...(content ? { content } : {}),
|
|
1217
|
+
...definedFields({ content }),
|
|
561
1218
|
}, ["x-restli-id"]));
|
|
562
1219
|
const id = optionalString(object(result["headers"])["x-restli-id"]);
|
|
563
1220
|
const base = {
|
|
@@ -631,13 +1288,10 @@ export function linkedin(options) {
|
|
|
631
1288
|
const paging = result["paging"] === undefined ? {} : object(result["paging"]);
|
|
632
1289
|
const total = optionalNumber(paging["total"]);
|
|
633
1290
|
const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
|
|
634
|
-
|
|
635
|
-
items
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
? { nextCursor: String(start + items.length) }
|
|
639
|
-
: {}),
|
|
640
|
-
};
|
|
1291
|
+
const nextCursor = items.length > 0 && (hasNext || (total !== undefined && start + items.length < total))
|
|
1292
|
+
? String(start + items.length)
|
|
1293
|
+
: undefined;
|
|
1294
|
+
return { items, ...definedFields({ nextCursor }) };
|
|
641
1295
|
},
|
|
642
1296
|
async removeFromPlatform(ref, context) {
|
|
643
1297
|
authorize(ref, context);
|
|
@@ -672,8 +1326,7 @@ export function linkedin(options) {
|
|
|
672
1326
|
const next = total !== undefined && start + items.length < total
|
|
673
1327
|
? String(start + items.length)
|
|
674
1328
|
: undefined;
|
|
675
|
-
|
|
676
|
-
return { items, ...(next === undefined ? {} : { nextCursor: next }) };
|
|
1329
|
+
return { items, ...definedFields({ nextCursor: next }) };
|
|
677
1330
|
},
|
|
678
1331
|
async reply(ref, content, context) {
|
|
679
1332
|
if (!content.text.trim() || content.text.length > 1250)
|
|
@@ -723,6 +1376,61 @@ export function linkedin(options) {
|
|
|
723
1376
|
return { ...ref, commentId };
|
|
724
1377
|
},
|
|
725
1378
|
},
|
|
1379
|
+
notifications: {
|
|
1380
|
+
async list(account, input, context) {
|
|
1381
|
+
authorize(account, context);
|
|
1382
|
+
if (!account.accountId.startsWith("urn:li:organization:"))
|
|
1383
|
+
throw new SocialError({
|
|
1384
|
+
code: "unsupported_capability",
|
|
1385
|
+
operation: "notifications.read",
|
|
1386
|
+
message: "LinkedIn notifications are available for organization accounts only, not member accounts.",
|
|
1387
|
+
});
|
|
1388
|
+
const start = input.cursor === undefined ? 0 : Number(input.cursor);
|
|
1389
|
+
const count = input.limit ?? 25;
|
|
1390
|
+
if (!Number.isSafeInteger(start) ||
|
|
1391
|
+
start < 0 ||
|
|
1392
|
+
input.cursor === "" ||
|
|
1393
|
+
!Number.isSafeInteger(count) ||
|
|
1394
|
+
count < 1 ||
|
|
1395
|
+
count > 100)
|
|
1396
|
+
throw new SocialError({
|
|
1397
|
+
code: "invalid_input",
|
|
1398
|
+
operation: "notifications.read",
|
|
1399
|
+
message: "LinkedIn requires a nonnegative offset and page size from 1 to 100.",
|
|
1400
|
+
});
|
|
1401
|
+
const actions = "LIKE,COMMENT,SHARE,SHARE_MENTION,ADMIN_COMMENT,COMMENT_EDIT,COMMENT_DELETE";
|
|
1402
|
+
const result = object(await request(`/rest/organizationalEntityNotifications?q=criteria&actions=List(${actions})&organizationalEntity=${encodeURIComponent(account.accountId)}&start=${start}&count=${count}`, context));
|
|
1403
|
+
const rows = array(result["elements"]).map(object);
|
|
1404
|
+
if (rows.some((row) => row["organizationalEntity"] !== account.accountId))
|
|
1405
|
+
throw new SocialError({
|
|
1406
|
+
code: "unauthorized",
|
|
1407
|
+
operation: "notifications.read",
|
|
1408
|
+
message: "LinkedIn returned a notification for another organization.",
|
|
1409
|
+
});
|
|
1410
|
+
const items = rows.map((row) => publicFields(row, [
|
|
1411
|
+
"notificationId",
|
|
1412
|
+
"organizationalEntity",
|
|
1413
|
+
"action",
|
|
1414
|
+
"sourcePost",
|
|
1415
|
+
"generatedActivity",
|
|
1416
|
+
"lastModifiedAt",
|
|
1417
|
+
]));
|
|
1418
|
+
const paging = result["paging"] === undefined ? {} : object(result["paging"]);
|
|
1419
|
+
const total = optionalNumber(paging["total"]);
|
|
1420
|
+
const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
|
|
1421
|
+
return Object.assign({ items }, items.length > 0 && (hasNext || (total !== undefined && start + items.length < total))
|
|
1422
|
+
? { nextCursor: String(start + items.length) }
|
|
1423
|
+
: {});
|
|
1424
|
+
},
|
|
1425
|
+
async markSeen(account, _input, context) {
|
|
1426
|
+
authorize(account, context);
|
|
1427
|
+
throw new SocialError({
|
|
1428
|
+
code: "unsupported_capability",
|
|
1429
|
+
operation: "notifications.seen",
|
|
1430
|
+
message: "LinkedIn does not expose a seen state for organization notifications.",
|
|
1431
|
+
});
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
726
1434
|
analytics: {
|
|
727
1435
|
async getAccountMetrics(account, context) {
|
|
728
1436
|
authorize(account, context);
|
|
@@ -779,7 +1487,18 @@ export function linkedin(options) {
|
|
|
779
1487
|
return metrics;
|
|
780
1488
|
},
|
|
781
1489
|
},
|
|
1490
|
+
webhooks: directWebhooks("linkedin", (input) => verifyLinkedInWebhook({ ...input, secret: options.webhookSecret ?? "" }), now),
|
|
782
1491
|
native: {
|
|
1492
|
+
async listAdministeredOrganizations({ account, cursor, limit, context }) {
|
|
1493
|
+
authorize(account, context);
|
|
1494
|
+
if (!account.accountId.startsWith("urn:li:person:"))
|
|
1495
|
+
throw new SocialError({
|
|
1496
|
+
code: "unauthorized",
|
|
1497
|
+
operation: "accounts.read",
|
|
1498
|
+
message: "LinkedIn administered organization lookup requires a member account reference.",
|
|
1499
|
+
});
|
|
1500
|
+
return listAdministeredOrganizations(Object.assign({}, cursor === undefined ? {} : { cursor }, limit === undefined ? {} : { limit }), context);
|
|
1501
|
+
},
|
|
783
1502
|
async imageStatus(ref, context) {
|
|
784
1503
|
authorize(ref, context);
|
|
785
1504
|
const image = object(await request(`/rest/images/${encodeURIComponent(ref.mediaId)}`, context));
|
|
@@ -791,12 +1510,51 @@ export function linkedin(options) {
|
|
|
791
1510
|
});
|
|
792
1511
|
return publicFields(image, ["id", "owner", "status"]);
|
|
793
1512
|
},
|
|
1513
|
+
videoStatus: readVideoStatus,
|
|
1514
|
+
async waitForVideo(ref, context, waitOptions = {}) {
|
|
1515
|
+
const intervalMs = waitOptions.intervalMs ?? linkedInVideoWaitDefaults.intervalMs;
|
|
1516
|
+
const maxChecks = waitOptions.maxChecks ?? linkedInVideoWaitDefaults.maxChecks;
|
|
1517
|
+
if (!Number.isInteger(intervalMs) ||
|
|
1518
|
+
intervalMs < linkedInVideoWaitLimits.minIntervalMs ||
|
|
1519
|
+
intervalMs > linkedInVideoWaitLimits.maxIntervalMs ||
|
|
1520
|
+
!Number.isInteger(maxChecks) ||
|
|
1521
|
+
maxChecks < 1 ||
|
|
1522
|
+
maxChecks > linkedInVideoWaitLimits.maxChecks)
|
|
1523
|
+
throw new SocialError({
|
|
1524
|
+
code: "invalid_input",
|
|
1525
|
+
operation: "media.read",
|
|
1526
|
+
message: "waitForVideo takes an integer intervalMs from 1,000 to 60,000 and maxChecks from 1 to 60.",
|
|
1527
|
+
});
|
|
1528
|
+
let status = await readVideoStatus(ref, context);
|
|
1529
|
+
for (let check = 1; check < maxChecks; check++) {
|
|
1530
|
+
if (status["status"] !== "PROCESSING" && status["status"] !== "WAITING_UPLOAD")
|
|
1531
|
+
break;
|
|
1532
|
+
// Leave room for the next read; return the pending status instead of timing out.
|
|
1533
|
+
if (context.signal?.aborted)
|
|
1534
|
+
throw videoWaitCancelled();
|
|
1535
|
+
if (intervalMs >= budgetLeft(context))
|
|
1536
|
+
break;
|
|
1537
|
+
await videoWaitDelay(intervalMs, context);
|
|
1538
|
+
// A late timer can wake after the deadline; keep the last status instead of timing out.
|
|
1539
|
+
if (budgetLeft(context) <= 0)
|
|
1540
|
+
break;
|
|
1541
|
+
status = await readVideoStatus(ref, context);
|
|
1542
|
+
}
|
|
1543
|
+
return status;
|
|
1544
|
+
},
|
|
1545
|
+
async documentStatus(ref, context) {
|
|
1546
|
+
return publicFields(await readDocument(ref, context, "media.read"), [
|
|
1547
|
+
"id",
|
|
1548
|
+
"owner",
|
|
1549
|
+
"status",
|
|
1550
|
+
]);
|
|
1551
|
+
},
|
|
794
1552
|
async registerVideo({ account, context }) {
|
|
795
1553
|
authorize(account, context);
|
|
796
1554
|
throw new SocialError({
|
|
797
1555
|
code: "unsupported_capability",
|
|
798
1556
|
operation: "posts.video",
|
|
799
|
-
message: "
|
|
1557
|
+
message: "registerVideo is deprecated. Upload the video with media.upload and check it with videoStatus.",
|
|
800
1558
|
});
|
|
801
1559
|
},
|
|
802
1560
|
async createPoll({ account, text, options: pollOptions, duration = "THREE_DAYS", context }) {
|
|
@@ -820,8 +1578,7 @@ export function linkedin(options) {
|
|
|
820
1578
|
},
|
|
821
1579
|
}, ["x-restli-id"]));
|
|
822
1580
|
const id = optionalString(object(result["headers"])["x-restli-id"]);
|
|
823
|
-
|
|
824
|
-
return (id === undefined ? result : { ...result, id });
|
|
1581
|
+
return id === undefined ? result : { ...result, id };
|
|
825
1582
|
},
|
|
826
1583
|
async react({ account, postId, reaction, context }) {
|
|
827
1584
|
authorize(account, context);
|
|
@@ -845,18 +1602,32 @@ export function linkedin(options) {
|
|
|
845
1602
|
reshareContext: { parent: postId },
|
|
846
1603
|
}, ["x-restli-id"]));
|
|
847
1604
|
const id = optionalString(object(result["headers"])["x-restli-id"]);
|
|
848
|
-
|
|
849
|
-
return (id === undefined ? result : { ...result, id });
|
|
1605
|
+
return id === undefined ? result : { ...result, id };
|
|
850
1606
|
},
|
|
851
1607
|
async updatePost({ account, postId, body, context }) {
|
|
852
1608
|
authorize(account, context);
|
|
853
|
-
|
|
854
|
-
return (await request(`/rest/posts/${encodeURIComponent(postId)}`, context, { patch: { $set: body } }, ["x-restli-id"], "POST", { "X-RestLi-Method": "PARTIAL_UPDATE" }));
|
|
1609
|
+
return object(await request(`/rest/posts/${encodeURIComponent(postId)}`, context, { patch: { $set: body } }, ["x-restli-id"], "POST", { "X-RestLi-Method": "PARTIAL_UPDATE" }));
|
|
855
1610
|
},
|
|
856
1611
|
async deletePost({ account, postId, context }) {
|
|
857
1612
|
authorize(account, context);
|
|
858
1613
|
await request(`/rest/posts/${encodeURIComponent(postId)}`, context, undefined, undefined, "DELETE");
|
|
859
1614
|
},
|
|
1615
|
+
async deleteComment({ account, postId, commentId, context }) {
|
|
1616
|
+
// Source: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/comments-api#delete-a-comment
|
|
1617
|
+
// (li-lms-2026-09, page updated 2026-04-28, accessed 2026-09-24).
|
|
1618
|
+
authorize(account, context);
|
|
1619
|
+
const match = /^urn:li:comment:\(urn:li:(?:activity|share|ugcPost):\d+,(\d+)\)$/.exec(commentId);
|
|
1620
|
+
if (!/^urn:li:(share|ugcPost):\d+$/.test(postId) || !match)
|
|
1621
|
+
throw new SocialError({
|
|
1622
|
+
code: "invalid_input",
|
|
1623
|
+
operation: "comments.delete",
|
|
1624
|
+
message: "Provide the share or ugcPost URN and the complete commentUrn returned by comment reads.",
|
|
1625
|
+
});
|
|
1626
|
+
const actor = options.auth.author.startsWith("urn:li:organization:")
|
|
1627
|
+
? `?actor=${encodeURIComponent(options.auth.author)}`
|
|
1628
|
+
: "";
|
|
1629
|
+
await request(`/rest/socialActions/${encodeURIComponent(postId)}/comments/${match[1]}${actor}`, context, undefined, undefined, "DELETE");
|
|
1630
|
+
},
|
|
860
1631
|
async organizationAnalytics({ account, context }) {
|
|
861
1632
|
authorize(account, context);
|
|
862
1633
|
if (!account.accountId.startsWith("urn:li:organization:"))
|
|
@@ -865,8 +1636,7 @@ export function linkedin(options) {
|
|
|
865
1636
|
operation: "analytics.organization.read",
|
|
866
1637
|
message: "Organization analytics requires an organization author.",
|
|
867
1638
|
});
|
|
868
|
-
|
|
869
|
-
return (await request(`/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(account.accountId)}`, context));
|
|
1639
|
+
return object(await request(`/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(account.accountId)}`, context));
|
|
870
1640
|
},
|
|
871
1641
|
async getOrganizationFollowerStatistics({ account, interval, context }) {
|
|
872
1642
|
organizationOnly(account, context, "analytics.followers.read");
|