@opencoredev/social-sdk 0.1.1 → 0.2.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.
Files changed (117) hide show
  1. package/README.md +10 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +1 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cloud/common.d.ts.map +1 -1
  6. package/dist/cloud/common.js +48 -20
  7. package/dist/cloud/common.js.map +1 -1
  8. package/dist/cloud/lifecycle.d.ts.map +1 -1
  9. package/dist/cloud/lifecycle.js +5 -0
  10. package/dist/cloud/lifecycle.js.map +1 -1
  11. package/dist/cloud/media.d.ts.map +1 -1
  12. package/dist/cloud/media.js +12 -8
  13. package/dist/cloud/media.js.map +1 -1
  14. package/dist/cloud/outcomes.d.ts.map +1 -1
  15. package/dist/cloud/outcomes.js +17 -9
  16. package/dist/cloud/outcomes.js.map +1 -1
  17. package/dist/cloud/post-for-me.d.ts.map +1 -1
  18. package/dist/cloud/post-for-me.js +99 -28
  19. package/dist/cloud/post-for-me.js.map +1 -1
  20. package/dist/cloud/zernio.d.ts.map +1 -1
  21. package/dist/cloud/zernio.js +100 -9
  22. package/dist/cloud/zernio.js.map +1 -1
  23. package/dist/core/adapter.d.ts +42 -1
  24. package/dist/core/adapter.d.ts.map +1 -1
  25. package/dist/core/adapter.js.map +1 -1
  26. package/dist/core/client.d.ts +35 -1
  27. package/dist/core/client.d.ts.map +1 -1
  28. package/dist/core/client.js +503 -110
  29. package/dist/core/client.js.map +1 -1
  30. package/dist/core/concurrency.d.ts.map +1 -1
  31. package/dist/core/concurrency.js +5 -3
  32. package/dist/core/concurrency.js.map +1 -1
  33. package/dist/core/errors.d.ts.map +1 -1
  34. package/dist/core/errors.js +14 -7
  35. package/dist/core/errors.js.map +1 -1
  36. package/dist/core/idempotency.d.ts.map +1 -1
  37. package/dist/core/idempotency.js.map +1 -1
  38. package/dist/core/index.d.ts.map +1 -1
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/pagination.d.ts.map +1 -1
  41. package/dist/core/pagination.js +1 -0
  42. package/dist/core/pagination.js.map +1 -1
  43. package/dist/core/types.d.ts +70 -2
  44. package/dist/core/types.d.ts.map +1 -1
  45. package/dist/core/types.js +10 -0
  46. package/dist/core/types.js.map +1 -1
  47. package/dist/platforms/bluesky.d.ts +131 -1
  48. package/dist/platforms/bluesky.d.ts.map +1 -1
  49. package/dist/platforms/bluesky.js +772 -44
  50. package/dist/platforms/bluesky.js.map +1 -1
  51. package/dist/platforms/instagram.d.ts +70 -3
  52. package/dist/platforms/instagram.d.ts.map +1 -1
  53. package/dist/platforms/instagram.js +403 -47
  54. package/dist/platforms/instagram.js.map +1 -1
  55. package/dist/platforms/linkedin.d.ts +55 -0
  56. package/dist/platforms/linkedin.d.ts.map +1 -1
  57. package/dist/platforms/linkedin.js +350 -39
  58. package/dist/platforms/linkedin.js.map +1 -1
  59. package/dist/platforms/threads.d.ts +37 -4
  60. package/dist/platforms/threads.d.ts.map +1 -1
  61. package/dist/platforms/threads.js +419 -44
  62. package/dist/platforms/threads.js.map +1 -1
  63. package/dist/platforms/tiktok.d.ts.map +1 -1
  64. package/dist/platforms/tiktok.js +109 -26
  65. package/dist/platforms/tiktok.js.map +1 -1
  66. package/dist/platforms/x-engagement.d.ts.map +1 -1
  67. package/dist/platforms/x-engagement.js +4 -0
  68. package/dist/platforms/x-engagement.js.map +1 -1
  69. package/dist/platforms/x.d.ts +227 -9
  70. package/dist/platforms/x.d.ts.map +1 -1
  71. package/dist/platforms/x.js +826 -49
  72. package/dist/platforms/x.js.map +1 -1
  73. package/dist/platforms/youtube-upload.d.ts.map +1 -1
  74. package/dist/platforms/youtube-upload.js +25 -9
  75. package/dist/platforms/youtube-upload.js.map +1 -1
  76. package/dist/platforms/youtube.d.ts +56 -2
  77. package/dist/platforms/youtube.d.ts.map +1 -1
  78. package/dist/platforms/youtube.js +571 -31
  79. package/dist/platforms/youtube.js.map +1 -1
  80. package/dist/server/connections.d.ts.map +1 -1
  81. package/dist/server/connections.js +4 -2
  82. package/dist/server/connections.js.map +1 -1
  83. package/dist/server/credentials.d.ts.map +1 -1
  84. package/dist/server/credentials.js +10 -16
  85. package/dist/server/credentials.js.map +1 -1
  86. package/dist/server/index.d.ts.map +1 -1
  87. package/dist/server/index.js.map +1 -1
  88. package/dist/server/oauth.d.ts +5 -0
  89. package/dist/server/oauth.d.ts.map +1 -1
  90. package/dist/server/oauth.js +118 -47
  91. package/dist/server/oauth.js.map +1 -1
  92. package/dist/server/webhooks.d.ts.map +1 -1
  93. package/dist/server/webhooks.js +11 -4
  94. package/dist/server/webhooks.js.map +1 -1
  95. package/dist/testing/index.d.ts.map +1 -1
  96. package/dist/testing/index.js +153 -1
  97. package/dist/testing/index.js.map +1 -1
  98. package/dist/transport/binary.d.ts.map +1 -1
  99. package/dist/transport/binary.js.map +1 -1
  100. package/dist/transport/budget.d.ts.map +1 -1
  101. package/dist/transport/budget.js.map +1 -1
  102. package/dist/transport/http.d.ts +2 -1
  103. package/dist/transport/http.d.ts.map +1 -1
  104. package/dist/transport/http.js +42 -12
  105. package/dist/transport/http.js.map +1 -1
  106. package/dist/transport/json.d.ts +7 -1
  107. package/dist/transport/json.d.ts.map +1 -1
  108. package/dist/transport/json.js +7 -1
  109. package/dist/transport/json.js.map +1 -1
  110. package/dist/transport/upload.d.ts +1 -0
  111. package/dist/transport/upload.d.ts.map +1 -1
  112. package/dist/transport/upload.js +38 -17
  113. package/dist/transport/upload.js.map +1 -1
  114. package/dist/transport/validation.d.ts.map +1 -1
  115. package/dist/transport/validation.js +1 -0
  116. package/dist/transport/validation.js.map +1 -1
  117. package/package.json +2 -1
@@ -1,18 +1,26 @@
1
+ /* oxlint-disable anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof, anti-slop/require-safety-comment-for-type-assertion, anti-slop/require-readable-spacing, anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract. */
1
2
  import { remainingBudget } from "../transport/budget.js";
2
3
  import { defineAdapter } from "../core/adapter.js";
3
4
  import { SocialError } from "../core/errors.js";
4
5
  import { managedHttp, publicFields } from "../cloud/common.js";
6
+ import { createHttp, HttpError } from "../transport/http.js";
5
7
  import { array, object, optionalString, string } from "../transport/validation.js";
6
8
  import { beginYouTubeUpload, queryYouTubeUpload, sendYouTubeUpload, } from "./youtube-upload.js";
7
9
  export function youtube(options) {
8
10
  const request = managedHttp("https://www.googleapis.com", {
9
11
  apiKey: options.auth.accessToken,
12
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
10
13
  ...(options.fetch ? { fetch: options.fetch } : {}),
11
14
  });
12
15
  const analyticsRequest = managedHttp("https://youtubeanalytics.googleapis.com", {
13
16
  apiKey: options.auth.accessToken,
17
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
14
18
  ...(options.fetch ? { fetch: options.fetch } : {}),
15
19
  });
20
+ const binaryRequest = createHttp({
21
+ ...(options.fetch ? { fetch: options.fetch } : {}),
22
+ timeoutMs: 30_000,
23
+ });
16
24
  const now = () => (options.clock?.() ?? new Date()).toISOString();
17
25
  const authorize = (ref, context) => {
18
26
  if (ref.backend !== context.backendInstance ||
@@ -48,7 +56,9 @@ export function youtube(options) {
48
56
  });
49
57
  return video;
50
58
  };
51
- const outcome = (video, target) => {
59
+ const outcome = (
60
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
61
+ video, target) => {
52
62
  const id = string(video["id"]);
53
63
  const status = object(video["status"]);
54
64
  const uploaded = optionalString(status["uploadStatus"]) ?? "unknown";
@@ -123,6 +133,56 @@ export function youtube(options) {
123
133
  status: "connected",
124
134
  };
125
135
  };
136
+ const nativeAuthorize = (context) => authorize({ backend: context.backendInstance, platform: "youtube", accountId: options.auth.channelId }, context);
137
+ const mediaBlob = (media) => {
138
+ if (!media || media.source.kind !== "blob")
139
+ throw new SocialError({
140
+ code: "invalid_input",
141
+ operation: "native",
142
+ message: "This YouTube operation requires a Blob media source.",
143
+ });
144
+ return media.source.blob;
145
+ };
146
+ const binaryJson = async (path, context, body, query, method = "POST", contentType) => {
147
+ const url = new URL(`https://www.googleapis.com${path}`);
148
+ for (const [key, value] of Object.entries(query))
149
+ url.searchParams.set(key, value);
150
+ try {
151
+ const result = await binaryRequest({
152
+ url,
153
+ method,
154
+ body,
155
+ headers: {
156
+ Authorization: `Bearer ${options.auth.accessToken}`,
157
+ ...(contentType ? { "Content-Type": contentType } : {}),
158
+ },
159
+ timeoutMs: remainingBudget(context),
160
+ ...(context.signal ? { signal: context.signal } : {}),
161
+ });
162
+ return object(result);
163
+ }
164
+ catch (error) {
165
+ if (!(error instanceof HttpError))
166
+ throw error;
167
+ const ambiguous = error.dispatched && (error.kind !== "http" || (error.status ?? 0) >= 500);
168
+ throw new SocialError({
169
+ code: ambiguous
170
+ ? "ambiguous_outcome"
171
+ : error.status === 401
172
+ ? "reconnect_required"
173
+ : error.status === 403
174
+ ? "missing_permission"
175
+ : error.status === 429
176
+ ? "rate_limited"
177
+ : "upstream_failure",
178
+ operation: path,
179
+ message: error.message,
180
+ upstreamStatus: error.status,
181
+ retryDisposition: ambiguous ? { kind: "reconcile-first" } : { kind: "never" },
182
+ cause: error,
183
+ });
184
+ }
185
+ };
126
186
  return defineAdapter({
127
187
  id: "youtube",
128
188
  capabilities: {
@@ -158,6 +218,25 @@ export function youtube(options) {
158
218
  platform: "youtube",
159
219
  availability: "available",
160
220
  requiredScopes: ["https://www.googleapis.com/auth/youtube.upload"],
221
+ notes: "Scheduled videos are uploaded private with a future ISO publishAt timestamp.",
222
+ },
223
+ {
224
+ operation: "posts.removeFromPlatform",
225
+ platform: "youtube",
226
+ availability: "available",
227
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
228
+ },
229
+ {
230
+ operation: "playlists.read",
231
+ platform: "youtube",
232
+ availability: "available",
233
+ requiredScopes: ["https://www.googleapis.com/auth/youtube.readonly"],
234
+ },
235
+ {
236
+ operation: "playlists.write",
237
+ platform: "youtube",
238
+ availability: "available",
239
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
161
240
  },
162
241
  {
163
242
  operation: "thumbnails.write",
@@ -165,6 +244,36 @@ export function youtube(options) {
165
244
  availability: "available",
166
245
  requiredScopes: ["https://www.googleapis.com/auth/youtube"],
167
246
  },
247
+ {
248
+ operation: "videos.update",
249
+ platform: "youtube",
250
+ availability: "available",
251
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
252
+ },
253
+ {
254
+ operation: "posts.update",
255
+ platform: "youtube",
256
+ availability: "available",
257
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
258
+ },
259
+ {
260
+ operation: "videos.delete",
261
+ platform: "youtube",
262
+ availability: "available",
263
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
264
+ },
265
+ {
266
+ operation: "posts.delete",
267
+ platform: "youtube",
268
+ availability: "available",
269
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
270
+ },
271
+ {
272
+ operation: "videos.rate",
273
+ platform: "youtube",
274
+ availability: "available",
275
+ requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
276
+ },
168
277
  {
169
278
  operation: "captions.read",
170
279
  platform: "youtube",
@@ -177,17 +286,52 @@ export function youtube(options) {
177
286
  availability: "available",
178
287
  requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
179
288
  },
180
- { operation: "playlists.read", platform: "youtube", availability: "available" },
181
- { operation: "playlists.write", platform: "youtube", availability: "available" },
182
- { operation: "posts.update", platform: "youtube", availability: "available" },
183
- { operation: "posts.delete", platform: "youtube", availability: "available" },
289
+ {
290
+ operation: "subscriptions.read",
291
+ platform: "youtube",
292
+ availability: "available",
293
+ requiredScopes: ["https://www.googleapis.com/auth/youtube.readonly"],
294
+ },
295
+ {
296
+ operation: "subscriptions.write",
297
+ platform: "youtube",
298
+ availability: "available",
299
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
300
+ },
301
+ { operation: "search.keyword", platform: "youtube", availability: "available" },
302
+ {
303
+ operation: "search.posts",
304
+ platform: "youtube",
305
+ availability: "available",
306
+ requiredScopes: ["https://www.googleapis.com/auth/youtube.readonly"],
307
+ notes: "Uses search.list with type=video. Each request costs 100 quota units.",
308
+ },
309
+ {
310
+ operation: "comments.moderate",
311
+ platform: "youtube",
312
+ availability: "available",
313
+ requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
314
+ },
184
315
  {
185
316
  operation: "analytics.youtube.read",
186
317
  platform: "youtube",
187
318
  availability: "available",
188
319
  requiredScopes: ["https://www.googleapis.com/auth/yt-analytics.readonly"],
189
320
  },
190
- { operation: "live.broadcasts", platform: "youtube", availability: "approval-dependent" },
321
+ {
322
+ operation: "analytics.report.read",
323
+ platform: "youtube",
324
+ availability: "available",
325
+ requiredScopes: ["https://www.googleapis.com/auth/yt-analytics.readonly"],
326
+ notes: "Reports require an explicit bounded date range and provider-supported metrics.",
327
+ },
328
+ {
329
+ operation: "live.broadcasts",
330
+ platform: "youtube",
331
+ availability: "available",
332
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
333
+ notes: "The channel must have live streaming enabled in YouTube Studio. Use native access: liveBroadcasts.",
334
+ },
191
335
  ],
192
336
  },
193
337
  accounts: {
@@ -227,6 +371,7 @@ export function youtube(options) {
227
371
  playlistId: uploads,
228
372
  part: "snippet,contentDetails",
229
373
  maxResults: String(limit),
374
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
230
375
  ...(input.cursor ? { pageToken: input.cursor } : {}),
231
376
  }));
232
377
  return {
@@ -252,6 +397,14 @@ export function youtube(options) {
252
397
  contentDetails: publicFields(content, ["videoId", "videoPublishedAt"]),
253
398
  };
254
399
  }),
400
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
401
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
402
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- provider payload is validated at this adapter boundary.
403
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
404
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
405
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
406
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
407
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
255
408
  ...(typeof page["nextPageToken"] === "string"
256
409
  ? { nextCursor: page["nextPageToken"] }
257
410
  : {}),
@@ -276,16 +429,27 @@ export function youtube(options) {
276
429
  fail("youtube.size", "Streaming upload requires its exact byte size.");
277
430
  }
278
431
  const config = target.options === undefined ? {} : object(target.options);
279
- if (typeof config["title"] !== "string" ||
432
+ if (
433
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
434
+ typeof config["title"] !== "string" ||
280
435
  !config["title"] ||
281
- [...config["title"]].length > 100)
436
+ [...config["title"]].length > 100 ||
437
+ /[<>]/u.test(config["title"]))
282
438
  fail("youtube.title", "Select a title of 1 to 100 characters.");
283
439
  if (!["public", "unlisted", "private"].includes(String(config["visibility"])))
284
440
  fail("youtube.visibility", "Explicit visibility is required.");
441
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
285
442
  if (typeof config["madeForKids"] !== "boolean")
286
443
  fail("youtube.audience", "Explicit made-for-kids declaration is required.");
287
- if (target.schedule || target.replyTo)
288
- fail("youtube.operation", "Use a job runner for scheduling or comments.reply for replies.");
444
+ if (target.content.text !== undefined &&
445
+ new TextEncoder().encode(target.content.text).byteLength > 5000)
446
+ fail("youtube.description", "Description must be at most 5000 UTF-8 bytes.");
447
+ if (target.schedule &&
448
+ (!Number.isFinite(Date.parse(target.schedule.at)) ||
449
+ Date.parse(target.schedule.at) <= (options.clock?.() ?? new Date()).getTime()))
450
+ fail("youtube.schedule", "Schedule time must be a valid timestamp in the future.");
451
+ if (target.replyTo)
452
+ fail("youtube.operation", "Use comments.reply for replies.");
289
453
  return issues;
290
454
  },
291
455
  async publishTarget(target, context) {
@@ -299,9 +463,11 @@ export function youtube(options) {
299
463
  });
300
464
  const config = object(target.options);
301
465
  const uploadOptions = {
302
- timeoutMs: remainingBudget(context),
466
+ timeoutMs: Math.max(remainingBudget(context), 15 * 60_000),
303
467
  accessToken: options.auth.accessToken,
468
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
304
469
  ...(options.fetch ? { fetch: options.fetch } : {}),
470
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
305
471
  ...(context.signal ? { signal: context.signal } : {}),
306
472
  };
307
473
  const size = media.byteSize ?? (media.source.kind === "blob" ? media.source.blob.size : 0);
@@ -314,6 +480,12 @@ export function youtube(options) {
314
480
  status: {
315
481
  privacyStatus: string(config["visibility"]),
316
482
  selfDeclaredMadeForKids: config["madeForKids"] === true,
483
+ ...(target.schedule
484
+ ? {
485
+ publishAt: new Date(target.schedule.at).toISOString(),
486
+ privacyStatus: "private",
487
+ }
488
+ : {}),
317
489
  },
318
490
  },
319
491
  }, uploadOptions);
@@ -359,6 +531,48 @@ export function youtube(options) {
359
531
  const video = await get({ ...account, kind: "platform-post", postId: ref.deliveryId }, context);
360
532
  return outcome(video, { account, targetIndex: 0 });
361
533
  },
534
+ async removeFromPlatform(ref, context) {
535
+ authorize(ref, context);
536
+ if (!ref.postId)
537
+ throw new SocialError({
538
+ code: "invalid_input",
539
+ operation: "posts.removeFromPlatform",
540
+ message: "postId is required.",
541
+ });
542
+ await request("/youtube/v3/videos", context, undefined, { id: ref.postId }, "DELETE");
543
+ },
544
+ },
545
+ search: {
546
+ async posts(account, input, context) {
547
+ authorize(account, context);
548
+ const limit = input.limit ?? 25;
549
+ if (input.scope !== undefined && input.scope !== "recent")
550
+ throw new SocialError({
551
+ code: "invalid_input",
552
+ operation: "search.posts",
553
+ message: "YouTube search supports only the recent scope.",
554
+ });
555
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
556
+ throw new SocialError({
557
+ code: "invalid_input",
558
+ operation: "search.posts",
559
+ message: "YouTube search limit must be between 1 and 50.",
560
+ });
561
+ const page = object(await request("/youtube/v3/search", context, undefined, {
562
+ part: "snippet",
563
+ q: input.query,
564
+ type: "video",
565
+ maxResults: String(limit),
566
+ ...(input.cursor ? { pageToken: input.cursor } : {}),
567
+ ...(input.startTime ? { publishedAfter: input.startTime } : {}),
568
+ ...(input.endTime ? { publishedBefore: input.endTime } : {}),
569
+ }));
570
+ const nextCursor = optionalString(page["nextPageToken"]);
571
+ return {
572
+ items: array(page["items"]).map((value) => object(value)),
573
+ ...(nextCursor ? { nextCursor } : {}),
574
+ };
575
+ },
362
576
  },
363
577
  analytics: {
364
578
  async getPostMetrics(ref, context) {
@@ -369,6 +583,7 @@ export function youtube(options) {
369
583
  const metrics = [];
370
584
  for (const name of ["viewCount", "likeCount", "commentCount"]) {
371
585
  const raw = values[name];
586
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
372
587
  if (typeof raw !== "string" || !/^\d+$/.test(raw))
373
588
  continue;
374
589
  const value = Number(raw);
@@ -408,6 +623,7 @@ export function youtube(options) {
408
623
  if (name === "subscriberCount" && stats["hiddenSubscriberCount"] === true)
409
624
  return [];
410
625
  const raw = stats[name];
626
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
411
627
  return typeof raw === "string" && /^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))
412
628
  ? [
413
629
  {
@@ -423,6 +639,68 @@ export function youtube(options) {
423
639
  : [];
424
640
  });
425
641
  },
642
+ async getReport(accountRef, query, context) {
643
+ authorize(accountRef, context);
644
+ const date = /^\d{4}-\d{2}-\d{2}$/;
645
+ if (!date.test(query.from) ||
646
+ !date.test(query.to) ||
647
+ query.from > query.to ||
648
+ query.metrics.length === 0 ||
649
+ query.metrics.some((metric) => !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(metric)) ||
650
+ query.dimensions?.some((dimension) => !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(dimension)))
651
+ throw new SocialError({
652
+ code: "invalid_input",
653
+ operation: "analytics.report.read",
654
+ message: "YouTube reports require YYYY-MM-DD dates (from <= to) and provider-safe metric names.",
655
+ });
656
+ const result = object(await analyticsRequest("/v2/reports", context, undefined, {
657
+ ids: `channel==${options.auth.channelId}`,
658
+ startDate: query.from,
659
+ endDate: query.to,
660
+ metrics: query.metrics.join(","),
661
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
662
+ ...(query.dimensions && query.dimensions.length > 0
663
+ ? { dimensions: query.dimensions.join(",") }
664
+ : {}),
665
+ }));
666
+ const headers = array(result["columnHeaders"]).map((value) => {
667
+ const header = object(value);
668
+ return {
669
+ name: string(header["name"]),
670
+ type: optionalString(header["columnType"]) ?? "METRIC",
671
+ };
672
+ });
673
+ const rows = [];
674
+ // Analytics omits `rows` entirely when the requested period has no data.
675
+ for (const value of Array.isArray(result["rows"]) ? result["rows"] : []) {
676
+ const cells = array(value);
677
+ const dimensions = Object.fromEntries(headers.flatMap((header, index) => {
678
+ if (header.type !== "DIMENSION")
679
+ return [];
680
+ const raw = cells[index];
681
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
682
+ return typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean"
683
+ ? [[header.name, raw]]
684
+ : [];
685
+ }));
686
+ const metrics = {};
687
+ headers.forEach((header, index) => {
688
+ if (header.type === "DIMENSION")
689
+ return;
690
+ const raw = cells[index];
691
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
692
+ const parsed = typeof raw === "number"
693
+ ? raw
694
+ : typeof raw === "string" && raw.trim() !== ""
695
+ ? Number(raw)
696
+ : undefined;
697
+ if (parsed !== undefined && Number.isFinite(parsed))
698
+ metrics[header.name] = parsed;
699
+ });
700
+ rows.push({ dimensions, metrics });
701
+ }
702
+ return { query, rows, fetchedAt: now(), source: "youtube-analytics-v2" };
703
+ },
426
704
  },
427
705
  comments: {
428
706
  async list(ref, input, context) {
@@ -439,6 +717,7 @@ export function youtube(options) {
439
717
  videoId: ref.postId,
440
718
  textFormat: "plainText",
441
719
  maxResults: String(limit),
720
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
442
721
  ...(input.cursor === undefined ? {} : { pageToken: input.cursor }),
443
722
  }));
444
723
  const cursor = optionalString(result["nextPageToken"]);
@@ -456,6 +735,7 @@ export function youtube(options) {
456
735
  ]),
457
736
  };
458
737
  }),
738
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
459
739
  ...(cursor ? { nextCursor: cursor } : {}),
460
740
  };
461
741
  },
@@ -488,7 +768,9 @@ export function youtube(options) {
488
768
  const uploadOptions = {
489
769
  timeoutMs: remainingBudget(context),
490
770
  accessToken: options.auth.accessToken,
771
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
491
772
  ...(options.fetch ? { fetch: options.fetch } : {}),
773
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
492
774
  ...(context.signal ? { signal: context.signal } : {}),
493
775
  };
494
776
  const confirmed = await queryYouTubeUpload(session, uploadOptions);
@@ -505,28 +787,131 @@ export function youtube(options) {
505
787
  });
506
788
  return queryYouTubeUpload(session, {
507
789
  accessToken: options.auth.accessToken,
790
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
508
791
  ...(options.fetch ? { fetch: options.fetch } : {}),
792
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
509
793
  ...(context.signal ? { signal: context.signal } : {}),
510
794
  });
511
795
  },
512
796
  async setThumbnail({ videoId, thumbnail, context }) {
513
- void thumbnail;
514
- authorize({
515
- backend: context.backendInstance,
516
- platform: "youtube",
517
- accountId: options.auth.channelId,
518
- }, context);
519
- return object(await request("/youtube/v3/thumbnails/set", context, undefined, { videoId, uploadType: "media" }, "POST"));
797
+ nativeAuthorize(context);
798
+ return binaryJson("/upload/youtube/v3/thumbnails/set", context, mediaBlob(thumbnail), { videoId, uploadType: "media" }, "POST", thumbnail.mimeType ?? "application/octet-stream");
520
799
  },
521
- async captions({ action, videoId, captionId, context }) {
522
- const method = action === "list" ? "GET" : action === "delete" ? "DELETE" : "POST";
523
- return object(await request("/youtube/v3/captions", context, action === "insert" ? { snippet: { videoId } } : undefined, {
524
- ...(action === "list" || action === "insert" ? { videoId } : {}),
525
- ...(captionId ? { id: captionId } : {}),
526
- part: "snippet",
800
+ async captions({ action, videoId, captionId, caption, body, context }) {
801
+ nativeAuthorize(context);
802
+ if (action === "download") {
803
+ if (!captionId)
804
+ throw new SocialError({
805
+ code: "invalid_input",
806
+ operation: "captions.download",
807
+ message: "captionId is required.",
808
+ });
809
+ const url = new URL(`https://www.googleapis.com/youtube/v3/captions/${encodeURIComponent(captionId)}`);
810
+ url.searchParams.set("tfmt", "vtt");
811
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
812
+ headers: { Authorization: `Bearer ${options.auth.accessToken}` },
813
+ redirect: "error",
814
+ ...(context.signal ? { signal: context.signal } : {}),
815
+ });
816
+ if (!response.ok)
817
+ throw new SocialError({
818
+ code: "upstream_failure",
819
+ operation: "captions.download",
820
+ message: `YouTube returned HTTP ${response.status}.`,
821
+ upstreamStatus: response.status,
822
+ retryDisposition: { kind: "never" },
823
+ });
824
+ return response.blob();
825
+ }
826
+ if (action === "insert" || action === "update") {
827
+ if (!body || (action === "insert" && !caption))
828
+ throw new SocialError({
829
+ code: "invalid_input",
830
+ operation: `captions.${action}`,
831
+ message: "Caption metadata and media are required for insert.",
832
+ });
833
+ const snippet = object(body["snippet"] ?? {});
834
+ if (action === "insert" && (!videoId || (snippet["videoId"] ?? videoId) !== videoId))
835
+ throw new SocialError({
836
+ code: "invalid_input",
837
+ operation: "captions.insert",
838
+ message: "videoId is required and must match body.snippet.videoId.",
839
+ });
840
+ const metadata = action === "update"
841
+ ? { ...body, id: captionId ?? body["id"] ?? null }
842
+ : { ...body, snippet: { ...snippet, videoId: videoId ?? null } };
843
+ if (action === "update" && typeof metadata["id"] !== "string")
844
+ throw new SocialError({
845
+ code: "invalid_input",
846
+ operation: "captions.update",
847
+ message: "captionId or body.id is required.",
848
+ });
849
+ if (action === "update" && !caption)
850
+ return binaryJson("/youtube/v3/captions", context, JSON.stringify(metadata), { part: "snippet" }, "PUT", "application/json");
851
+ if (!caption)
852
+ throw new SocialError({
853
+ code: "invalid_input",
854
+ operation: "captions.insert",
855
+ message: "Caption media is required.",
856
+ });
857
+ const boundary = `youtube-caption-${crypto.randomUUID()}`;
858
+ const media = mediaBlob(caption);
859
+ const filename = (caption.filename ?? "captions.vtt").replace(/[\r\n]/gu, "");
860
+ const encoded = new Blob([
861
+ `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n`,
862
+ `--${boundary}\r\nContent-Type: ${caption.mimeType ?? "text/vtt"}\r\nContent-Disposition: attachment; filename="${filename}"\r\n\r\n`,
863
+ media,
864
+ `\r\n--${boundary}--\r\n`,
865
+ ]);
866
+ return binaryJson("/upload/youtube/v3/captions", context, encoded, { uploadType: "multipart", part: "snippet" }, action === "update" ? "PUT" : "POST", `multipart/related; boundary=${boundary}`);
867
+ }
868
+ if (action === "delete") {
869
+ if (!captionId)
870
+ throw new SocialError({
871
+ code: "invalid_input",
872
+ operation: "captions.delete",
873
+ message: "captionId is required.",
874
+ });
875
+ await request("/youtube/v3/captions", context, undefined, { id: captionId ?? "" }, "DELETE");
876
+ return {};
877
+ }
878
+ return object(await request("/youtube/v3/captions", context, undefined, { part: "snippet", videoId }, "GET"));
879
+ },
880
+ async playlists({ action, playlistId, channelId, mine, pageToken, maxResults, body, context, }) {
881
+ nativeAuthorize(context);
882
+ const method = action === "list"
883
+ ? "GET"
884
+ : action === "delete"
885
+ ? "DELETE"
886
+ : action === "update"
887
+ ? "PUT"
888
+ : "POST";
889
+ if ((action === "delete" || action === "update") && !playlistId)
890
+ throw new SocialError({
891
+ code: "invalid_input",
892
+ operation: `playlists.${action}`,
893
+ message: "playlistId is required.",
894
+ });
895
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
896
+ if (action === "delete") {
897
+ await request("/youtube/v3/playlists", context, undefined, { id: playlistId ?? "" }, method);
898
+ return {};
899
+ }
900
+ return object(await request("/youtube/v3/playlists", context, body, {
901
+ part: "snippet,status,contentDetails",
902
+ ...(playlistId ? { id: playlistId } : {}),
903
+ ...(channelId ? { channelId } : {}),
904
+ ...(action === "list" && !playlistId && !channelId
905
+ ? { mine: "true" }
906
+ : mine
907
+ ? { mine: "true" }
908
+ : {}),
909
+ ...(pageToken ? { pageToken } : {}),
910
+ ...(maxResults ? { maxResults: String(maxResults) } : {}),
527
911
  }, method));
528
912
  },
529
- async playlists({ action, playlistId, body, context }) {
913
+ async playlistItems({ action, playlistId, playlistItemId, body, pageToken, context }) {
914
+ nativeAuthorize(context);
530
915
  const method = action === "list"
531
916
  ? "GET"
532
917
  : action === "delete"
@@ -534,25 +919,180 @@ export function youtube(options) {
534
919
  : action === "update"
535
920
  ? "PUT"
536
921
  : "POST";
537
- return object(await request("/youtube/v3/playlists", context, body, { ...(playlistId ? { id: playlistId } : {}), part: "snippet,status,contentDetails" }, method));
922
+ if (action === "delete") {
923
+ if (!playlistItemId)
924
+ throw new SocialError({
925
+ code: "invalid_input",
926
+ operation: "playlistItems.delete",
927
+ message: "playlistItemId is required.",
928
+ });
929
+ await request("/youtube/v3/playlistItems", context, undefined, { id: playlistItemId }, method);
930
+ return {};
931
+ }
932
+ return object(await request("/youtube/v3/playlistItems", context, body, {
933
+ part: "snippet,contentDetails",
934
+ ...(playlistId ? { playlistId } : {}),
935
+ ...(playlistItemId ? { id: playlistItemId } : {}),
936
+ ...(pageToken ? { pageToken } : {}),
937
+ }, method));
538
938
  },
539
939
  async updateVideo({ videoId, body, context }) {
540
- return object(await request("/youtube/v3/videos", context, { id: videoId, ...body }, { part: "snippet,status" }, "PUT"));
940
+ nativeAuthorize(context);
941
+ if (!videoId)
942
+ throw new SocialError({
943
+ code: "invalid_input",
944
+ operation: "videos.update",
945
+ message: "videoId is required.",
946
+ });
947
+ const existing = await get({
948
+ kind: "platform-post",
949
+ version: 1,
950
+ backend: context.backendInstance,
951
+ platform: "youtube",
952
+ accountId: options.auth.channelId,
953
+ postId: videoId,
954
+ }, context);
955
+ const merged = {
956
+ ...existing,
957
+ ...body,
958
+ id: videoId,
959
+ snippet: { ...object(existing["snippet"]), ...object(body["snippet"] ?? {}) },
960
+ status: { ...object(existing["status"] ?? {}), ...object(body["status"] ?? {}) },
961
+ };
962
+ return object(await request("/youtube/v3/videos", context, merged, { part: "snippet,status" }, "PUT"));
541
963
  },
542
964
  async deleteVideo({ videoId, context }) {
965
+ nativeAuthorize(context);
966
+ if (!videoId)
967
+ throw new SocialError({
968
+ code: "invalid_input",
969
+ operation: "videos.delete",
970
+ message: "videoId is required.",
971
+ });
543
972
  await request("/youtube/v3/videos", context, undefined, { id: videoId }, "DELETE");
544
973
  },
974
+ async rateVideo({ videoId, rating, context }) {
975
+ nativeAuthorize(context);
976
+ await request("/youtube/v3/videos/rate", context, undefined, { id: videoId, rating }, "POST");
977
+ },
978
+ async getRating({ videoIds, context }) {
979
+ nativeAuthorize(context);
980
+ return object(await request("/youtube/v3/videos/getRating", context, undefined, {
981
+ id: videoIds.join(","),
982
+ }));
983
+ },
984
+ async subscriptions({ action, subscriptionId, channelId, pageToken, context }) {
985
+ nativeAuthorize(context);
986
+ const method = action === "list" ? "GET" : action === "delete" ? "DELETE" : "POST";
987
+ if (action === "delete" && !subscriptionId)
988
+ throw new SocialError({
989
+ code: "invalid_input",
990
+ operation: "subscriptions.delete",
991
+ message: "subscriptionId is required.",
992
+ });
993
+ const insertBody = channelId
994
+ ? {
995
+ snippet: { resourceId: { kind: "youtube#channel", channelId } },
996
+ }
997
+ : undefined;
998
+ if (action === "delete") {
999
+ await request("/youtube/v3/subscriptions", context, undefined, { id: subscriptionId ?? "" }, method);
1000
+ return {};
1001
+ }
1002
+ return object(await request("/youtube/v3/subscriptions", context, insertBody, {
1003
+ part: "snippet,contentDetails",
1004
+ ...(subscriptionId
1005
+ ? { id: subscriptionId }
1006
+ : channelId
1007
+ ? { channelId }
1008
+ : action === "list"
1009
+ ? { mine: "true" }
1010
+ : {}),
1011
+ ...(pageToken ? { pageToken } : {}),
1012
+ }, method));
1013
+ },
1014
+ async search({ q, type, channelId, order, publishedAfter, publishedBefore, pageToken, maxResults, context, }) {
1015
+ nativeAuthorize(context);
1016
+ return object(await request("/youtube/v3/search", context, undefined, {
1017
+ part: "snippet",
1018
+ q,
1019
+ ...(type ? { type } : {}),
1020
+ ...(channelId ? { channelId } : {}),
1021
+ ...(order ? { order } : {}),
1022
+ ...(publishedAfter ? { publishedAfter } : {}),
1023
+ ...(publishedBefore ? { publishedBefore } : {}),
1024
+ ...(pageToken ? { pageToken } : {}),
1025
+ ...(maxResults ? { maxResults: String(maxResults) } : {}),
1026
+ }));
1027
+ },
1028
+ async commentsModeration({ action, commentId, moderationStatus, banAuthor, body, context }) {
1029
+ nativeAuthorize(context);
1030
+ if (!commentId)
1031
+ throw new SocialError({
1032
+ code: "invalid_input",
1033
+ operation: "comments.moderate",
1034
+ message: "commentId is required.",
1035
+ });
1036
+ if (action === "setModerationStatus") {
1037
+ await request("/youtube/v3/comments/setModerationStatus", context, undefined, {
1038
+ id: commentId,
1039
+ moderationStatus: moderationStatus ??
1040
+ (() => {
1041
+ throw new SocialError({
1042
+ code: "invalid_input",
1043
+ operation: "comments.moderate",
1044
+ message: "moderationStatus is required.",
1045
+ });
1046
+ })(),
1047
+ ...(banAuthor ? { banAuthor: "true" } : {}),
1048
+ }, "POST");
1049
+ return;
1050
+ }
1051
+ if (action === "delete") {
1052
+ await request("/youtube/v3/comments", context, undefined, { id: commentId }, "DELETE");
1053
+ return;
1054
+ }
1055
+ if (!body || (body["id"] ?? commentId) !== commentId)
1056
+ throw new SocialError({
1057
+ code: "invalid_input",
1058
+ operation: "comments.update",
1059
+ message: "Provide a comment body whose id, if present, matches commentId.",
1060
+ });
1061
+ return object(await request("/youtube/v3/comments", context, { ...body, id: commentId }, { part: "snippet" }, "PUT"));
1062
+ },
1063
+ async heldComments({ pageToken, maxResults, context }) {
1064
+ nativeAuthorize(context);
1065
+ return object(await request("/youtube/v3/commentThreads", context, undefined, {
1066
+ part: "snippet",
1067
+ moderationStatus: "heldForReview",
1068
+ allThreadsRelatedToChannelId: options.auth.channelId,
1069
+ ...(pageToken ? { pageToken } : {}),
1070
+ ...(maxResults ? { maxResults: String(maxResults) } : {}),
1071
+ }));
1072
+ },
545
1073
  async analytics({ query, context }) {
1074
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
546
1075
  return object(await analyticsRequest("/v2/reports", context, undefined, query));
547
1076
  },
548
- async liveBroadcasts({ action, body, id, context }) {
1077
+ async liveBroadcasts({ action, body, id, broadcastStatus, context }) {
549
1078
  if (action === "list")
1079
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
550
1080
  return object(await request("/youtube/v3/liveBroadcasts", context, undefined, {
551
1081
  part: "snippet,status",
552
- ...(id ? { id } : {}),
1082
+ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1083
+ ...(id ? { id } : { mine: "true" }),
553
1084
  }));
554
- if (action === "transition")
555
- return object(await request("/youtube/v3/liveBroadcasts/transition", context, body, { part: "snippet,status", id: id ?? "" }, "POST"));
1085
+ if (action === "transition") {
1086
+ if (!id || !broadcastStatus)
1087
+ throw new SocialError({
1088
+ code: "invalid_input",
1089
+ operation: "live.broadcasts",
1090
+ message: "id and broadcastStatus are required.",
1091
+ });
1092
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1093
+ return object(await request("/youtube/v3/liveBroadcasts/transition", context, undefined, { part: "snippet,status", id, broadcastStatus }, "POST"));
1094
+ }
1095
+ // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
556
1096
  return object(await request("/youtube/v3/liveBroadcasts", context, body, { part: "snippet,status" }, "POST"));
557
1097
  },
558
1098
  },