@opencoredev/social-sdk 0.1.2 → 0.2.1

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.
Files changed (82) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.d.ts +13 -0
  3. package/dist/cli.js +234 -0
  4. package/dist/cloud/common.d.ts +25 -0
  5. package/dist/cloud/common.js +334 -0
  6. package/dist/cloud/lifecycle.d.ts +10 -0
  7. package/dist/cloud/lifecycle.js +112 -0
  8. package/dist/cloud/media.d.ts +23 -0
  9. package/dist/cloud/media.js +100 -0
  10. package/dist/cloud/outcomes.d.ts +9 -0
  11. package/dist/cloud/outcomes.js +195 -0
  12. package/dist/cloud/post-for-me.d.ts +69 -0
  13. package/dist/cloud/post-for-me.js +396 -0
  14. package/dist/cloud/zernio.d.ts +111 -0
  15. package/dist/cloud/zernio.js +632 -0
  16. package/dist/core/adapter.d.ts +158 -0
  17. package/dist/core/adapter.js +3 -0
  18. package/dist/core/client.d.ts +141 -0
  19. package/dist/core/client.js +1285 -0
  20. package/dist/core/concurrency.d.ts +9 -0
  21. package/dist/core/concurrency.js +79 -0
  22. package/dist/core/errors.d.ts +44 -0
  23. package/dist/core/errors.js +50 -0
  24. package/dist/core/idempotency.d.ts +33 -0
  25. package/dist/core/idempotency.js +58 -0
  26. package/dist/core/index.d.ts +6 -0
  27. package/dist/core/index.js +6 -0
  28. package/dist/core/pagination.d.ts +11 -0
  29. package/dist/core/pagination.js +81 -0
  30. package/dist/core/types.d.ts +396 -0
  31. package/dist/core/types.js +16 -0
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.js +1 -0
  34. package/dist/platforms/bluesky.d.ts +261 -0
  35. package/dist/platforms/bluesky.js +1776 -0
  36. package/dist/platforms/instagram.d.ts +129 -0
  37. package/dist/platforms/instagram.js +1031 -0
  38. package/dist/platforms/linkedin.d.ts +108 -0
  39. package/dist/platforms/linkedin.js +890 -0
  40. package/dist/platforms/threads.d.ts +134 -0
  41. package/dist/platforms/threads.js +945 -0
  42. package/dist/platforms/tiktok.d.ts +31 -0
  43. package/dist/platforms/tiktok.js +593 -0
  44. package/dist/platforms/x-engagement.d.ts +16 -0
  45. package/dist/platforms/x-engagement.js +50 -0
  46. package/dist/platforms/x-text.d.ts +2 -0
  47. package/dist/platforms/x-text.js +123 -0
  48. package/dist/platforms/x-tlds.d.ts +1 -0
  49. package/dist/platforms/x-tlds.js +2 -0
  50. package/dist/platforms/x.d.ts +299 -0
  51. package/dist/platforms/x.js +1287 -0
  52. package/dist/platforms/youtube-upload.d.ts +32 -0
  53. package/dist/platforms/youtube-upload.js +296 -0
  54. package/dist/platforms/youtube.d.ts +108 -0
  55. package/dist/platforms/youtube.js +1100 -0
  56. package/dist/server/connections.d.ts +123 -0
  57. package/dist/server/connections.js +335 -0
  58. package/dist/server/credentials.d.ts +51 -0
  59. package/dist/server/credentials.js +108 -0
  60. package/dist/server/index.d.ts +4 -0
  61. package/dist/server/index.js +4 -0
  62. package/dist/server/oauth.d.ts +52 -0
  63. package/dist/server/oauth.js +553 -0
  64. package/dist/server/webhooks.d.ts +57 -0
  65. package/dist/server/webhooks.js +204 -0
  66. package/dist/testing/index.d.ts +46 -0
  67. package/dist/testing/index.js +564 -0
  68. package/dist/testing.d.ts +1 -0
  69. package/dist/testing.js +1 -0
  70. package/dist/transport/binary.d.ts +2 -0
  71. package/dist/transport/binary.js +29 -0
  72. package/dist/transport/budget.d.ts +3 -0
  73. package/dist/transport/budget.js +26 -0
  74. package/dist/transport/http.d.ts +44 -0
  75. package/dist/transport/http.js +259 -0
  76. package/dist/transport/json.d.ts +8 -0
  77. package/dist/transport/json.js +16 -0
  78. package/dist/transport/upload.d.ts +25 -0
  79. package/dist/transport/upload.js +153 -0
  80. package/dist/transport/validation.d.ts +5 -0
  81. package/dist/transport/validation.js +24 -0
  82. package/package.json +2 -7
@@ -0,0 +1,890 @@
1
+ import { remainingBudget } from "../transport/budget.js";
2
+ import { defineAdapter } from "../core/adapter.js";
3
+ import { SocialError } from "../core/errors.js";
4
+ import { createHttp, HttpError } from "../transport/http.js";
5
+ import { array, object, string, optionalString, optionalNumber } from "../transport/validation.js";
6
+ import { upload } from "../transport/upload.js";
7
+ import { publicFields } from "../cloud/common.js";
8
+ export function linkedin(options) {
9
+ if (!options.auth.accessToken.trim() ||
10
+ !/^urn:li:(person|organization):[a-zA-Z0-9_-]+$/.test(options.auth.author) ||
11
+ !/^20\d{2}(0[1-9]|1[0-2])$/.test(options.apiVersion)) {
12
+ throw new SocialError({
13
+ code: "invalid_config",
14
+ operation: "createAdapter",
15
+ message: "Provide a LinkedIn access token, member/organization author URN and explicit YYYYMM API version.",
16
+ });
17
+ }
18
+ const http = createHttp(options.fetch ? { fetch: options.fetch } : {});
19
+ const now = () => (options.clock?.() ?? new Date()).toISOString();
20
+ const escapeCommentary = (text) => text.replace(/[|{}@()[\]<>#\\*_~]/g, "\\$&");
21
+ const authorize = (ref, context) => {
22
+ if (ref.backend !== context.backendInstance ||
23
+ ref.platform !== "linkedin" ||
24
+ ref.accountId !== options.auth.author)
25
+ throw new SocialError({
26
+ code: "unauthorized",
27
+ operation: "linkedin",
28
+ message: "Reference does not belong to this LinkedIn author authorization.",
29
+ });
30
+ };
31
+ async function request(path, context, body, selectedHeaders, method = body === undefined ? "GET" : "POST", extraHeaders) {
32
+ try {
33
+ return await http({
34
+ timeoutMs: remainingBudget(context),
35
+ url: new URL(`https://api.linkedin.com${path}`),
36
+ headers: {
37
+ Authorization: `Bearer ${options.auth.accessToken}`,
38
+ "Content-Type": "application/json",
39
+ "Linkedin-Version": options.apiVersion,
40
+ "X-Restli-Protocol-Version": "2.0.0",
41
+ ...extraHeaders,
42
+ },
43
+ method,
44
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
45
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
46
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
47
+ ...(context.signal ? { signal: context.signal } : {}),
48
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
49
+ ...(selectedHeaders ? { responseHeaders: selectedHeaders } : {}),
50
+ maxAttempts: method === "GET" ? Math.min(5, context.retryBudget.maxAttempts) : 1,
51
+ });
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof HttpError))
55
+ throw error;
56
+ const ambiguous = body !== undefined &&
57
+ error.dispatched &&
58
+ (error.kind !== "http" || (error.status ?? 0) >= 500);
59
+ throw new SocialError({
60
+ code: ambiguous
61
+ ? "ambiguous_outcome"
62
+ : error.kind === "timeout"
63
+ ? "timeout"
64
+ : error.kind === "cancelled"
65
+ ? "cancelled"
66
+ : error.status === 401
67
+ ? "reconnect_required"
68
+ : error.status === 403
69
+ ? "missing_permission"
70
+ : error.status === 404
71
+ ? "not_found"
72
+ : error.status === 429
73
+ ? "rate_limited"
74
+ : "upstream_failure",
75
+ operation: path,
76
+ message: error.message,
77
+ upstreamStatus: error.status,
78
+ retryDisposition: ambiguous
79
+ ? { kind: "reconcile-first" }
80
+ : error.status === 429 && error.retryAfterMs !== undefined
81
+ ? { kind: "after-delay", delayMs: error.retryAfterMs }
82
+ : { kind: "never" },
83
+ });
84
+ }
85
+ }
86
+ async function readPost(ref, context) {
87
+ authorize(ref, context);
88
+ const result = object(await request(`/rest/posts/${encodeURIComponent(ref.postId)}`, context));
89
+ if (result["author"] !== ref.accountId)
90
+ throw new SocialError({
91
+ code: "unauthorized",
92
+ operation: "posts.read",
93
+ message: "LinkedIn post belongs to a different author.",
94
+ });
95
+ return result;
96
+ }
97
+ async function readCommentablePost(ref, context) {
98
+ authorize(ref, context);
99
+ const result = object(await request(`/rest/posts/${encodeURIComponent(ref.postId)}`, context));
100
+ if (result["id"] !== undefined && result["id"] !== ref.postId)
101
+ throw new SocialError({
102
+ code: "unauthorized",
103
+ operation: "comments.write",
104
+ message: "LinkedIn returned a different post than the declared parent.",
105
+ });
106
+ return result;
107
+ }
108
+ async function uploadImage(media, account, context) {
109
+ authorize(account, context);
110
+ const source = media.source;
111
+ if (media.kind !== "image" ||
112
+ !["image/jpeg", "image/png", "image/gif"].includes(media.mimeType ?? "") ||
113
+ (source.kind !== "blob" && source.kind !== "stream"))
114
+ throw new SocialError({
115
+ code: "invalid_input",
116
+ operation: "media.upload",
117
+ message: "Provide JPEG, PNG or GIF bytes as a Blob or replayable stream.",
118
+ });
119
+ const initialized = object(object(await request("/rest/images?action=initializeUpload", context, {
120
+ initializeUploadRequest: { owner: account.accountId },
121
+ }))["value"]);
122
+ const mediaId = string(initialized["image"]);
123
+ if (!/^urn:li:image:[a-zA-Z0-9_-]+$/.test(mediaId))
124
+ throw new SocialError({
125
+ code: "media_error",
126
+ operation: "media.upload",
127
+ message: "LinkedIn returned an invalid image identifier.",
128
+ });
129
+ const size = media.byteSize ?? (source.kind === "blob" ? source.blob.size : undefined);
130
+ await upload({
131
+ url: string(initialized["uploadUrl"]),
132
+ source: {
133
+ mimeType: media.mimeType,
134
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
135
+ ...(size === undefined ? {} : { size }),
136
+ open: source.kind === "blob" ? () => source.blob.stream() : source.open,
137
+ },
138
+ allowHost: (host) => host === "www.linkedin.com",
139
+ maxBytes: 20 * 1024 * 1024,
140
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
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 } : {}),
144
+ });
145
+ return {
146
+ kind: "media",
147
+ version: 1,
148
+ backend: account.backend,
149
+ platform: "linkedin",
150
+ accountId: account.accountId,
151
+ mediaId,
152
+ };
153
+ }
154
+ /* oxlint-disable 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, anti-slop/require-readable-spacing -- These helpers normalize documented LinkedIn response facets at the transport boundary. */
155
+ const organizationOnly = (account, context, operation) => {
156
+ authorize(account, context);
157
+ if (!account.accountId.startsWith("urn:li:organization:"))
158
+ throw new SocialError({
159
+ code: "unsupported_capability",
160
+ operation,
161
+ message: "LinkedIn organization analytics require an organization account, not a member account.",
162
+ });
163
+ };
164
+ const statisticsPath = (path, finder, account, interval) => {
165
+ const params = [`q=${finder}`, `${finder}=${encodeURIComponent(account.accountId)}`];
166
+ if (interval) {
167
+ if (interval.start !== undefined &&
168
+ (!Number.isSafeInteger(interval.start) || interval.start < 0))
169
+ throw new SocialError({
170
+ code: "invalid_input",
171
+ operation: "analytics.organization.read",
172
+ message: "Interval start must be a nonnegative epoch-millisecond integer.",
173
+ });
174
+ if (interval.end !== undefined && (!Number.isSafeInteger(interval.end) || interval.end < 0))
175
+ throw new SocialError({
176
+ code: "invalid_input",
177
+ operation: "analytics.organization.read",
178
+ message: "Interval end must be a nonnegative epoch-millisecond integer.",
179
+ });
180
+ if (interval.start !== undefined &&
181
+ interval.end !== undefined &&
182
+ interval.end <= interval.start)
183
+ throw new SocialError({
184
+ code: "invalid_input",
185
+ operation: "analytics.organization.read",
186
+ message: "Interval end must be after interval start.",
187
+ });
188
+ if (interval.start === undefined)
189
+ throw new SocialError({
190
+ code: "invalid_input",
191
+ operation: "analytics.organization.read",
192
+ message: "Interval start is required for time-bound organization statistics.",
193
+ });
194
+ const rangeParts = [
195
+ `start:${interval.start}`,
196
+ ...(interval.end === undefined ? [] : [`end:${interval.end}`]),
197
+ ];
198
+ const range = `(timeRange:(${rangeParts.join(",")}),timeGranularityType:${interval.granularity})`;
199
+ if (!["DAY", "WEEK", "MONTH"].includes(interval.granularity))
200
+ throw new SocialError({
201
+ code: "invalid_input",
202
+ operation: "analytics.organization.read",
203
+ message: "Interval granularity must be DAY, WEEK or MONTH.",
204
+ });
205
+ params.push(`timeIntervals=${range}`);
206
+ }
207
+ return `${path}?${params.join("&")}`;
208
+ };
209
+ const numberMap = (value) => {
210
+ const row = value === undefined ? {} : object(value);
211
+ const output = {};
212
+ for (const [key, item] of Object.entries(row)) {
213
+ const number = optionalNumber(item);
214
+ if (number !== undefined)
215
+ output[key] = number;
216
+ else if (item !== null && typeof item === "object" && !Array.isArray(item)) {
217
+ const nestedObject = object(item);
218
+ const nested = ["pageViews", "uniquePageViews", "clicks", "count"]
219
+ .map((name) => optionalNumber(nestedObject[name]))
220
+ .find((candidate) => candidate !== undefined);
221
+ if (nested !== undefined)
222
+ output[key] = nested;
223
+ else {
224
+ for (const [nestedKey, nestedValue] of Object.entries(nestedObject)) {
225
+ const nestedNumber = optionalNumber(nestedValue);
226
+ if (nestedNumber !== undefined)
227
+ output[`${key}.${nestedKey}`] = nestedNumber;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return output;
233
+ };
234
+ const followerBreakdowns = (row) => {
235
+ const dimensions = [
236
+ ["function", "followerCountsByFunction", "function"],
237
+ ["seniority", "followerCountsBySeniority", "seniority"],
238
+ ["industry", "followerCountsByIndustry", "industry"],
239
+ ["geo", "followerCountsByGeo", "geo"],
240
+ ["geoCountry", "followerCountsByGeoCountry", "geo"],
241
+ ["staffCountRange", "followerCountsByStaffCountRange", "staffCountRange"],
242
+ ["associationType", "followerCountsByAssociationType", "associationType"],
243
+ ];
244
+ const output = [];
245
+ for (const [dimension, field, key] of dimensions) {
246
+ if (row[field] === undefined)
247
+ continue;
248
+ for (const value of array(row[field])) {
249
+ const item = object(value);
250
+ const label = optionalString(item[key]);
251
+ if (!label)
252
+ continue;
253
+ const counts = item["followerCounts"] === undefined ? {} : object(item["followerCounts"]);
254
+ output.push({
255
+ dimension,
256
+ value: label,
257
+ ...(optionalNumber(counts["organicFollowerCount"]) === undefined
258
+ ? {}
259
+ : { organicFollowerCount: optionalNumber(counts["organicFollowerCount"]) }),
260
+ ...(optionalNumber(counts["paidFollowerCount"]) === undefined
261
+ ? {}
262
+ : { paidFollowerCount: optionalNumber(counts["paidFollowerCount"]) }),
263
+ });
264
+ }
265
+ }
266
+ return output;
267
+ };
268
+ const parseInterval = (row, requestedGranularity) => {
269
+ if (row["timeRange"] === undefined)
270
+ return undefined;
271
+ const range = object(row["timeRange"]);
272
+ const start = optionalNumber(range["start"]);
273
+ const end = optionalNumber(range["end"]);
274
+ const granularity = requestedGranularity ?? optionalString(row["timeGranularityType"]);
275
+ if ((granularity !== "DAY" && granularity !== "WEEK" && granularity !== "MONTH") ||
276
+ (start === undefined && end === undefined))
277
+ return undefined;
278
+ return {
279
+ granularity,
280
+ ...(start === undefined ? {} : { start }),
281
+ ...(end === undefined ? {} : { end }),
282
+ };
283
+ };
284
+ const parseFollowerStatistics = (result, requestedGranularity) => array(result["elements"]).map((value) => {
285
+ const row = object(value);
286
+ const gains = row["followerGains"] === undefined ? {} : object(row["followerGains"]);
287
+ return {
288
+ organization: string(row["organizationalEntity"]),
289
+ ...(parseInterval(row, requestedGranularity) === undefined
290
+ ? {}
291
+ : { interval: parseInterval(row, requestedGranularity) }),
292
+ ...(optionalNumber(gains["organicFollowerGain"]) === undefined
293
+ ? {}
294
+ : { organicFollowerGain: optionalNumber(gains["organicFollowerGain"]) }),
295
+ ...(optionalNumber(gains["paidFollowerGain"]) === undefined
296
+ ? {}
297
+ : { paidFollowerGain: optionalNumber(gains["paidFollowerGain"]) }),
298
+ breakdowns: followerBreakdowns(row),
299
+ };
300
+ });
301
+ const pageBreakdowns = (row) => {
302
+ const output = [];
303
+ for (const [field, dimension, key] of [
304
+ ["pageStatisticsByFunction", "function", "function"],
305
+ ["pageStatisticsBySeniority", "seniority", "seniority"],
306
+ ["pageStatisticsByIndustryV2", "industryV2", "industryV2"],
307
+ ["pageStatisticsByIndustry", "industry", "industry"],
308
+ ["pageStatisticsByGeo", "geo", "geo"],
309
+ ["pageStatisticsByGeoCountry", "geoCountry", "geo"],
310
+ ["pageStatisticsByStaffCountRange", "staffCountRange", "staffCountRange"],
311
+ ]) {
312
+ if (row[field] === undefined)
313
+ continue;
314
+ for (const value of array(row[field])) {
315
+ const item = object(value);
316
+ const label = optionalString(item[key]);
317
+ if (!label)
318
+ continue;
319
+ const stats = item["pageStatistics"] === undefined ? {} : object(item["pageStatistics"]);
320
+ const views = stats["views"] === undefined ? {} : object(stats["views"]);
321
+ const clicks = stats["clicks"] === undefined ? {} : object(stats["clicks"]);
322
+ output.push({
323
+ dimension,
324
+ value: label,
325
+ views: numberMap(views),
326
+ clicks: numberMap(clicks),
327
+ });
328
+ }
329
+ }
330
+ return output;
331
+ };
332
+ const parsePageStatistics = (result, requestedGranularity) => array(result["elements"]).map((value) => {
333
+ const row = object(value);
334
+ const total = row["totalPageStatistics"] === undefined ? {} : object(row["totalPageStatistics"]);
335
+ return {
336
+ organization: string(row["organization"]),
337
+ ...(parseInterval(row, requestedGranularity) === undefined
338
+ ? {}
339
+ : { interval: parseInterval(row, requestedGranularity) }),
340
+ views: numberMap(total["views"]),
341
+ clicks: numberMap(total["clicks"]),
342
+ breakdowns: pageBreakdowns(row),
343
+ };
344
+ });
345
+ const parseShareStatistics = (result, requestedGranularity) => array(result["elements"]).map((value) => {
346
+ const row = object(value);
347
+ return {
348
+ organization: string(row["organizationalEntity"]),
349
+ ...(parseInterval(row, requestedGranularity) === undefined
350
+ ? {}
351
+ : { interval: parseInterval(row, requestedGranularity) }),
352
+ metrics: numberMap(row["totalShareStatistics"]),
353
+ };
354
+ });
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
+ return defineAdapter({
357
+ id: "linkedin",
358
+ capabilities: {
359
+ schemaVersion: 1,
360
+ backend: "linkedin",
361
+ apiRevision: options.apiVersion,
362
+ runtime: ["node22", "node24", "bun"],
363
+ capabilities: [
364
+ {
365
+ platform: "linkedin",
366
+ operation: "posts.publish",
367
+ availability: "available",
368
+ formats: ["text", "image"],
369
+ requiredScopes: [
370
+ options.auth.author.startsWith("urn:li:organization:")
371
+ ? "w_organization_social"
372
+ : "w_member_social",
373
+ ],
374
+ notes: "Explicit author URN and public visibility. Organization role and app product approval required. Registered images must be AVAILABLE before creating a post.",
375
+ },
376
+ {
377
+ platform: "linkedin",
378
+ operation: "posts.read",
379
+ availability: "available",
380
+ requiredScopes: [
381
+ options.auth.author.startsWith("urn:li:organization:")
382
+ ? "r_organization_social"
383
+ : "r_member_social",
384
+ ],
385
+ notes: "Member read access is restricted. Publication permission does not grant read permission.",
386
+ },
387
+ {
388
+ platform: "linkedin",
389
+ operation: "posts.multi-image",
390
+ availability: "not-implemented-by-adapter",
391
+ formats: ["carousel"],
392
+ },
393
+ {
394
+ platform: "linkedin",
395
+ operation: "posts.video",
396
+ availability: "not-implemented-by-adapter",
397
+ formats: ["video"],
398
+ },
399
+ {
400
+ platform: "linkedin",
401
+ operation: "posts.document",
402
+ availability: "not-implemented-by-adapter",
403
+ },
404
+ { platform: "linkedin", operation: "polls.create", availability: "available" },
405
+ { platform: "linkedin", operation: "reactions.write", availability: "available" },
406
+ { platform: "linkedin", operation: "reshares.write", availability: "available" },
407
+ { platform: "linkedin", operation: "posts.update", availability: "available" },
408
+ { platform: "linkedin", operation: "posts.delete", availability: "available" },
409
+ {
410
+ platform: "linkedin",
411
+ operation: "posts.removeFromPlatform",
412
+ availability: "available",
413
+ },
414
+ {
415
+ platform: "linkedin",
416
+ operation: "analytics.organization.read",
417
+ availability: options.auth.author.startsWith("urn:li:organization:")
418
+ ? "available"
419
+ : "account-ineligible",
420
+ requiredScopes: ["rw_organization_admin"],
421
+ notes: "Requires the Community Management API product and organization administrator access. Member accounts are not eligible.",
422
+ },
423
+ {
424
+ platform: "linkedin",
425
+ operation: "articles.create",
426
+ availability: "approval-dependent",
427
+ },
428
+ {
429
+ platform: "linkedin",
430
+ operation: "messages.write",
431
+ availability: "unsupported-by-platform",
432
+ },
433
+ {
434
+ platform: "linkedin",
435
+ operation: "analytics.account.read",
436
+ availability: options.auth.author.startsWith("urn:li:organization:")
437
+ ? "available"
438
+ : "account-ineligible",
439
+ requiredScopes: ["rw_organization_admin"],
440
+ notes: "Organization total followers only; authenticated member must administer the organization. No member-profile analytics claimed.",
441
+ },
442
+ ...["analytics.followers.read", "analytics.page.read", "analytics.shares.read"].map((operation) => ({
443
+ platform: "linkedin",
444
+ operation,
445
+ availability: options.auth.author.startsWith("urn:li:organization:")
446
+ ? "available"
447
+ : "account-ineligible",
448
+ requiredScopes: ["rw_organization_admin"],
449
+ notes: "Community Management API product and organization administrator access are required. Member accounts are not eligible.",
450
+ })),
451
+ {
452
+ platform: "linkedin",
453
+ operation: "posts.list",
454
+ availability: "available",
455
+ requiredScopes: [
456
+ options.auth.author.startsWith("urn:li:organization:")
457
+ ? "r_organization_social"
458
+ : "r_member_social",
459
+ ],
460
+ notes: "Author-feed pagination uses the returned offset cursor.",
461
+ },
462
+ {
463
+ platform: "linkedin",
464
+ operation: "media.upload",
465
+ availability: "available",
466
+ formats: ["image"],
467
+ },
468
+ ...["comments.read", "comments.write", "analytics.read"].map((operation) => ({
469
+ platform: "linkedin",
470
+ operation,
471
+ availability: "available",
472
+ ...(operation === "analytics.read"
473
+ ? { requiredScopes: ["r_member_social"] }
474
+ : { requiredScopes: ["w_member_social", "r_member_social"] }),
475
+ notes: "Community Management product permissions and author post read access required. Analytics contains returned social-action counts only.",
476
+ })),
477
+ ],
478
+ },
479
+ media: { upload: uploadImage },
480
+ posts: {
481
+ prepareTarget(target) {
482
+ const issues = [];
483
+ const fail = (code, message) => issues.push({ code, message, severity: "error", targetIndex: target.targetIndex });
484
+ if (target.account.platform !== "linkedin" ||
485
+ target.account.accountId !== options.auth.author)
486
+ fail("linkedin.author", "Select the configured member or organization author URN.");
487
+ if ((target.content.text?.length ?? 0) > 3000)
488
+ fail("linkedin.text", "LinkedIn commentary exceeds 3,000 characters.");
489
+ if (target.schedule || target.replyTo || target.content.link)
490
+ fail("linkedin.operation", "Scheduling, reply posts and structured links are not supported by this publishing slice.");
491
+ if (target.options !== undefined) {
492
+ const settings = object(target.options);
493
+ if (Object.keys(settings).some((key) => key !== "visibility") ||
494
+ (settings["visibility"] !== undefined && settings["visibility"] !== "public"))
495
+ fail("linkedin.options", "This slice supports public visibility only; other native options require explicit implementation.");
496
+ }
497
+ const media = target.content.media ?? [];
498
+ if (media.length > 1)
499
+ fail("linkedin.media_count", "This slice accepts one registered image per post.");
500
+ for (const item of media) {
501
+ if (item.kind !== "image" || item.source.kind !== "media-ref")
502
+ fail("linkedin.media", "Upload an image first with media.upload, then publish its account-bound reference.");
503
+ else if (item.source.ref.backend !== target.account.backend ||
504
+ item.source.ref.accountId !== target.account.accountId ||
505
+ item.source.ref.platform !== "linkedin" ||
506
+ !/^urn:li:image:[a-zA-Z0-9_-]+$/.test(item.source.ref.mediaId))
507
+ fail("linkedin.media_owner", "Image reference belongs to another author/backend or has an invalid URN.");
508
+ }
509
+ return issues;
510
+ },
511
+ async publishTarget(target, context) {
512
+ authorize(target.account, context);
513
+ const media = target.content.media?.[0];
514
+ let content;
515
+ if (media?.source.kind === "media-ref") {
516
+ authorize(media.source.ref, context);
517
+ let image;
518
+ try {
519
+ // SAFETY: object() validates the upstream response as a JSON object.
520
+ image = object(await request(`/rest/images/${encodeURIComponent(media.source.ref.mediaId)}`, context));
521
+ }
522
+ catch (error) {
523
+ if (!(error instanceof SocialError) ||
524
+ error.code !== "missing_permission" ||
525
+ !options.auth.author.startsWith("urn:li:person:"))
526
+ throw error;
527
+ }
528
+ if (image?.["owner"] !== undefined && image["owner"] !== target.account.accountId)
529
+ throw new SocialError({
530
+ code: "unauthorized",
531
+ operation: "posts.publish",
532
+ message: "LinkedIn image belongs to a different author.",
533
+ });
534
+ if (image?.["status"] !== undefined && image["status"] !== "AVAILABLE")
535
+ throw new SocialError({
536
+ code: "media_error",
537
+ operation: "posts.publish",
538
+ message: "Image is not AVAILABLE. Check its status explicitly before publishing.",
539
+ });
540
+ content = {
541
+ media: {
542
+ id: media.source.ref.mediaId,
543
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
544
+ ...(media.altText ? { altText: media.altText } : {}),
545
+ },
546
+ };
547
+ }
548
+ const result = object(await request("/rest/posts", context, {
549
+ author: target.account.accountId,
550
+ commentary: escapeCommentary(target.content.text ?? ""),
551
+ visibility: "PUBLIC",
552
+ distribution: {
553
+ feedDistribution: "MAIN_FEED",
554
+ targetEntities: [],
555
+ thirdPartyDistributionChannels: [],
556
+ },
557
+ lifecycleState: "PUBLISHED",
558
+ isReshareDisabledByAuthor: false,
559
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
560
+ ...(content ? { content } : {}),
561
+ }, ["x-restli-id"]));
562
+ const id = optionalString(object(result["headers"])["x-restli-id"]);
563
+ const base = {
564
+ account: target.account,
565
+ targetIndex: target.targetIndex,
566
+ observedAt: now(),
567
+ };
568
+ if (!id || !/^urn:li:(share|ugcPost):[0-9]+$/.test(id))
569
+ return {
570
+ ...base,
571
+ state: "unknown",
572
+ reason: "unmapped-state",
573
+ diagnostic: "LinkedIn accepted the create request without a valid native post identifier. Do not repeat it automatically.",
574
+ };
575
+ return {
576
+ ...base,
577
+ state: "published",
578
+ post: {
579
+ kind: "platform-post",
580
+ version: 1,
581
+ backend: target.account.backend,
582
+ platform: "linkedin",
583
+ accountId: target.account.accountId,
584
+ postId: id,
585
+ },
586
+ };
587
+ },
588
+ async get(ref, context) {
589
+ return publicFields(await readPost(ref, context), [
590
+ "id",
591
+ "author",
592
+ "commentary",
593
+ "visibility",
594
+ "lifecycleState",
595
+ "createdAt",
596
+ "lastModifiedAt",
597
+ ]);
598
+ },
599
+ async list(account, input, context) {
600
+ authorize(account, context);
601
+ const start = input.cursor === undefined ? 0 : Number(input.cursor);
602
+ const count = input.limit ?? 25;
603
+ if (!Number.isSafeInteger(start) ||
604
+ start < 0 ||
605
+ input.cursor === "" ||
606
+ !Number.isSafeInteger(count) ||
607
+ count < 1 ||
608
+ count > 100)
609
+ throw new SocialError({
610
+ code: "invalid_input",
611
+ operation: "posts.read",
612
+ message: "LinkedIn requires a nonnegative offset and page size from 1 to 100.",
613
+ });
614
+ const result = object(await request(`/rest/posts?q=author&author=${encodeURIComponent(options.auth.author)}&start=${start}&count=${count}&sortBy=LAST_MODIFIED`, context));
615
+ const rows = array(result["elements"]).map(object);
616
+ if (rows.some((row) => row["author"] !== options.auth.author))
617
+ throw new SocialError({
618
+ code: "unauthorized",
619
+ operation: "posts.read",
620
+ message: "LinkedIn returned a post from another author.",
621
+ });
622
+ const items = rows.map((row) => publicFields(row, [
623
+ "id",
624
+ "author",
625
+ "commentary",
626
+ "visibility",
627
+ "lifecycleState",
628
+ "createdAt",
629
+ "lastModifiedAt",
630
+ ]));
631
+ const paging = result["paging"] === undefined ? {} : object(result["paging"]);
632
+ const total = optionalNumber(paging["total"]);
633
+ const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
634
+ return {
635
+ items,
636
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
637
+ ...(items.length > 0 && (hasNext || (total !== undefined && start + items.length < total))
638
+ ? { nextCursor: String(start + items.length) }
639
+ : {}),
640
+ };
641
+ },
642
+ async removeFromPlatform(ref, context) {
643
+ authorize(ref, context);
644
+ await request(`/rest/posts/${encodeURIComponent(ref.postId)}`, context, undefined, undefined, "DELETE");
645
+ },
646
+ },
647
+ comments: {
648
+ async list(ref, input, context) {
649
+ await readPost(ref, context);
650
+ const start = input.cursor === undefined ? 0 : Number(input.cursor);
651
+ const count = input.limit ?? 25;
652
+ if (!Number.isSafeInteger(start) ||
653
+ start < 0 ||
654
+ !Number.isSafeInteger(count) ||
655
+ count < 1 ||
656
+ count > 100)
657
+ throw new SocialError({
658
+ code: "invalid_input",
659
+ operation: "comments.read",
660
+ message: "LinkedIn comment pagination requires a returned offset and a page size from 1 to 100.",
661
+ });
662
+ const result = object(await request(`/rest/socialActions/${encodeURIComponent(ref.postId)}/comments?start=${start}&count=${count}`, context));
663
+ const items = array(result["elements"]).map((value) => {
664
+ const row = object(value);
665
+ return {
666
+ ...publicFields(row, ["id", "actor", "commentUrn", "object"]),
667
+ text: string(object(row["message"])["text"]),
668
+ };
669
+ });
670
+ const paging = result["paging"] === undefined ? {} : object(result["paging"]);
671
+ const total = optionalNumber(paging["total"]);
672
+ const next = total !== undefined && start + items.length < total
673
+ ? String(start + items.length)
674
+ : undefined;
675
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
676
+ return { items, ...(next === undefined ? {} : { nextCursor: next }) };
677
+ },
678
+ async reply(ref, content, context) {
679
+ if (!content.text.trim() || content.text.length > 1250)
680
+ throw new SocialError({
681
+ code: "invalid_input",
682
+ operation: "comments.write",
683
+ message: "Provide a comment of 1 to 1,250 characters.",
684
+ });
685
+ await readCommentablePost({ ...ref, kind: "platform-post" }, context);
686
+ const match = /^urn:li:comment:\(urn:li:activity:(\d+),(\d+)\)$/.exec(ref.commentId);
687
+ if (!match)
688
+ throw new SocialError({
689
+ code: "invalid_input",
690
+ operation: "comments.write",
691
+ message: "Use the complete commentUrn returned by LinkedIn comment reads.",
692
+ });
693
+ const parent = object(await request(`/rest/socialActions/${encodeURIComponent(ref.postId)}/comments/${match[2]}`, context));
694
+ if (parent["commentUrn"] !== ref.commentId)
695
+ throw new SocialError({
696
+ code: "unauthorized",
697
+ operation: "comments.write",
698
+ message: "Comment does not belong to the supplied post.",
699
+ });
700
+ const parentObject = optionalString(parent["object"]);
701
+ if (parentObject !== undefined &&
702
+ parentObject !== ref.postId &&
703
+ parentObject !== `urn:li:activity:${match[1]}`)
704
+ throw new SocialError({
705
+ code: "unauthorized",
706
+ operation: "comments.write",
707
+ message: "Comment parent object does not match the supplied post.",
708
+ });
709
+ const response = object(await request(`/rest/socialActions/${encodeURIComponent(ref.commentId)}/comments`, context, {
710
+ actor: ref.accountId,
711
+ message: { text: content.text },
712
+ object: ref.postId,
713
+ parentComment: ref.commentId,
714
+ }));
715
+ const commentId = optionalString(response["commentUrn"]);
716
+ if (!commentId)
717
+ throw new SocialError({
718
+ code: "ambiguous_outcome",
719
+ operation: "comments.write",
720
+ message: "LinkedIn accepted the comment without returning its composite URN. Reconcile before retrying.",
721
+ retryDisposition: { kind: "reconcile-first" },
722
+ });
723
+ return { ...ref, commentId };
724
+ },
725
+ },
726
+ analytics: {
727
+ async getAccountMetrics(account, context) {
728
+ authorize(account, context);
729
+ if (!options.auth.author.startsWith("urn:li:organization:"))
730
+ throw new SocialError({
731
+ code: "unsupported_capability",
732
+ operation: "analytics.account.read",
733
+ message: "LinkedIn organization follower counts require an organization author and administrator access.",
734
+ });
735
+ const response = object(await request(`/rest/networkSizes/${encodeURIComponent(options.auth.author)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER`, context));
736
+ const value = optionalNumber(response["firstDegreeSize"]);
737
+ return value === undefined
738
+ ? []
739
+ : [
740
+ {
741
+ name: "followers",
742
+ value,
743
+ unit: "count",
744
+ period: "lifetime",
745
+ fetchedAt: now(),
746
+ freshness: "unknown",
747
+ source: `linkedin:${options.apiVersion}:networkSizes`,
748
+ },
749
+ ];
750
+ },
751
+ async getPostMetrics(ref, context) {
752
+ await readPost(ref, context);
753
+ const result = object(await request(`/rest/socialActions/${encodeURIComponent(ref.postId)}`, context));
754
+ if (result["target"] !== undefined && result["target"] !== ref.postId)
755
+ throw new SocialError({
756
+ code: "unauthorized",
757
+ operation: "analytics.read",
758
+ message: "LinkedIn returned social actions for a different post.",
759
+ });
760
+ const metrics = [];
761
+ for (const [summary, field, name] of [
762
+ ["likesSummary", "totalLikes", "likes"],
763
+ ["commentsSummary", "totalFirstLevelComments", "comments"],
764
+ ]) {
765
+ if (result[summary] === undefined)
766
+ continue;
767
+ const value = optionalNumber(object(result[summary])[field]);
768
+ if (value !== undefined)
769
+ metrics.push({
770
+ name,
771
+ value,
772
+ unit: "count",
773
+ period: "lifetime",
774
+ fetchedAt: now(),
775
+ freshness: "unknown",
776
+ source: `linkedin:${options.apiVersion}:socialActions`,
777
+ });
778
+ }
779
+ return metrics;
780
+ },
781
+ },
782
+ native: {
783
+ async imageStatus(ref, context) {
784
+ authorize(ref, context);
785
+ const image = object(await request(`/rest/images/${encodeURIComponent(ref.mediaId)}`, context));
786
+ if (image["owner"] !== ref.accountId)
787
+ throw new SocialError({
788
+ code: "unauthorized",
789
+ operation: "media.read",
790
+ message: "LinkedIn image belongs to another author.",
791
+ });
792
+ return publicFields(image, ["id", "owner", "status"]);
793
+ },
794
+ async registerVideo({ account, context }) {
795
+ authorize(account, context);
796
+ throw new SocialError({
797
+ code: "unsupported_capability",
798
+ operation: "posts.video",
799
+ message: "LinkedIn video publishing is not implemented by this adapter.",
800
+ });
801
+ },
802
+ async createPoll({ account, text, options: pollOptions, duration = "THREE_DAYS", context }) {
803
+ authorize(account, context);
804
+ const result = object(await request("/rest/posts", context, {
805
+ author: account.accountId,
806
+ commentary: escapeCommentary(text),
807
+ visibility: "PUBLIC",
808
+ distribution: {
809
+ feedDistribution: "MAIN_FEED",
810
+ targetEntities: [],
811
+ thirdPartyDistributionChannels: [],
812
+ },
813
+ lifecycleState: "PUBLISHED",
814
+ content: {
815
+ poll: {
816
+ question: text,
817
+ options: pollOptions.map((option) => ({ text: option })),
818
+ settings: { duration },
819
+ },
820
+ },
821
+ }, ["x-restli-id"]));
822
+ const id = optionalString(object(result["headers"])["x-restli-id"]);
823
+ // SAFETY: result is validated as a JSON object and id is a JSON string.
824
+ return (id === undefined ? result : { ...result, id });
825
+ },
826
+ async react({ account, postId, reaction, context }) {
827
+ authorize(account, context);
828
+ await request(`/rest/reactions?actor=${encodeURIComponent(account.accountId)}`, context, {
829
+ root: postId,
830
+ reactionType: reaction,
831
+ });
832
+ },
833
+ async reshare({ account, postId, context }) {
834
+ authorize(account, context);
835
+ const result = object(await request("/rest/posts", context, {
836
+ author: account.accountId,
837
+ commentary: "",
838
+ visibility: "PUBLIC",
839
+ distribution: {
840
+ feedDistribution: "MAIN_FEED",
841
+ targetEntities: [],
842
+ thirdPartyDistributionChannels: [],
843
+ },
844
+ lifecycleState: "PUBLISHED",
845
+ reshareContext: { parent: postId },
846
+ }, ["x-restli-id"]));
847
+ const id = optionalString(object(result["headers"])["x-restli-id"]);
848
+ // SAFETY: result is validated as a JSON object and id is a JSON string.
849
+ return (id === undefined ? result : { ...result, id });
850
+ },
851
+ async updatePost({ account, postId, body, context }) {
852
+ authorize(account, context);
853
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
854
+ return (await request(`/rest/posts/${encodeURIComponent(postId)}`, context, { patch: { $set: body } }, ["x-restli-id"], "POST", { "X-RestLi-Method": "PARTIAL_UPDATE" }));
855
+ },
856
+ async deletePost({ account, postId, context }) {
857
+ authorize(account, context);
858
+ await request(`/rest/posts/${encodeURIComponent(postId)}`, context, undefined, undefined, "DELETE");
859
+ },
860
+ async organizationAnalytics({ account, context }) {
861
+ authorize(account, context);
862
+ if (!account.accountId.startsWith("urn:li:organization:"))
863
+ throw new SocialError({
864
+ code: "unsupported_capability",
865
+ operation: "analytics.organization.read",
866
+ message: "Organization analytics requires an organization author.",
867
+ });
868
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
869
+ return (await request(`/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(account.accountId)}`, context));
870
+ },
871
+ async getOrganizationFollowerStatistics({ account, interval, context }) {
872
+ organizationOnly(account, context, "analytics.followers.read");
873
+ return parseFollowerStatistics(object(await request(statisticsPath("/rest/organizationalEntityFollowerStatistics", "organizationalEntity", account, interval), context)), interval?.granularity);
874
+ },
875
+ async getOrganizationPageStatistics({ account, interval, context }) {
876
+ organizationOnly(account, context, "analytics.page.read");
877
+ return parsePageStatistics(object(await request(statisticsPath("/rest/organizationPageStatistics", "organization", account, interval), context)), interval?.granularity);
878
+ },
879
+ async getOrganizationShareStatistics({ account, interval, context }) {
880
+ organizationOnly(account, context, "analytics.shares.read");
881
+ return parseShareStatistics(object(await request(statisticsPath("/rest/organizationalEntityShareStatistics", "organizationalEntity", account, interval), context)), interval?.granularity);
882
+ },
883
+ async getOrganizationFollowerCount({ account, context }) {
884
+ organizationOnly(account, context, "analytics.account.read");
885
+ const response = object(await request(`/rest/networkSizes/${encodeURIComponent(account.accountId)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER`, context));
886
+ return optionalNumber(response["firstDegreeSize"]);
887
+ },
888
+ },
889
+ });
890
+ }