@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.
Files changed (63) hide show
  1. package/dist/cli-request.d.ts +15 -0
  2. package/dist/cli-request.js +193 -0
  3. package/dist/cli.d.ts +4 -3
  4. package/dist/cli.js +19 -21
  5. package/dist/cloud/common.d.ts +7 -6
  6. package/dist/cloud/common.js +35 -54
  7. package/dist/cloud/lifecycle.js +31 -35
  8. package/dist/cloud/media.d.ts +2 -2
  9. package/dist/cloud/media.js +13 -3
  10. package/dist/cloud/outcomes.d.ts +4 -3
  11. package/dist/cloud/outcomes.js +8 -15
  12. package/dist/cloud/post-for-me.js +41 -49
  13. package/dist/cloud/zernio.js +58 -98
  14. package/dist/core/client.js +79 -99
  15. package/dist/core/fields.d.ts +14 -0
  16. package/dist/core/fields.js +14 -0
  17. package/dist/core/idempotency.d.ts +7 -2
  18. package/dist/core/idempotency.js +37 -20
  19. package/dist/core/pagination.js +8 -7
  20. package/dist/core/types.d.ts +3 -2
  21. package/dist/platforms/bluesky.d.ts +65 -1
  22. package/dist/platforms/bluesky.js +675 -276
  23. package/dist/platforms/instagram.d.ts +2 -0
  24. package/dist/platforms/instagram.js +130 -105
  25. package/dist/platforms/linkedin.d.ts +58 -1
  26. package/dist/platforms/linkedin.js +877 -107
  27. package/dist/platforms/threads.d.ts +13 -1
  28. package/dist/platforms/threads.js +204 -302
  29. package/dist/platforms/tiktok.d.ts +4 -0
  30. package/dist/platforms/tiktok.js +140 -124
  31. package/dist/platforms/webhook-adapter.d.ts +9 -0
  32. package/dist/platforms/webhook-adapter.js +24 -0
  33. package/dist/platforms/x-engagement.js +7 -12
  34. package/dist/platforms/x-stream.d.ts +83 -0
  35. package/dist/platforms/x-stream.js +350 -0
  36. package/dist/platforms/x.d.ts +72 -0
  37. package/dist/platforms/x.js +328 -119
  38. package/dist/platforms/youtube-upload.d.ts +1 -1
  39. package/dist/platforms/youtube-upload.js +6 -2
  40. package/dist/platforms/youtube.d.ts +28 -4
  41. package/dist/platforms/youtube.js +291 -133
  42. package/dist/server/bluesky-oauth.d.ts +177 -0
  43. package/dist/server/bluesky-oauth.js +1229 -0
  44. package/dist/server/connections.d.ts +14 -0
  45. package/dist/server/connections.js +10 -2
  46. package/dist/server/egress.d.ts +14 -0
  47. package/dist/server/egress.js +115 -0
  48. package/dist/server/oauth-internal.d.ts +6 -0
  49. package/dist/server/oauth-internal.js +66 -0
  50. package/dist/server/oauth.d.ts +1 -1
  51. package/dist/server/oauth.js +46 -99
  52. package/dist/server/webhooks.d.ts +136 -3
  53. package/dist/server/webhooks.js +639 -25
  54. package/dist/testing/index.js +14 -28
  55. package/dist/transport/http.d.ts +1 -1
  56. package/dist/transport/http.js +0 -1
  57. package/dist/transport/json.d.ts +7 -0
  58. package/dist/transport/json.js +32 -4
  59. package/dist/transport/upload.d.ts +1 -1
  60. package/dist/transport/upload.js +46 -38
  61. package/dist/transport/validation.d.ts +16 -5
  62. package/dist/transport/validation.js +29 -7
  63. package/package.json +2 -2
@@ -1,24 +1,28 @@
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. */
2
1
  import { remainingBudget } from "../transport/budget.js";
3
2
  import { defineAdapter } from "../core/adapter.js";
4
3
  import { SocialError } from "../core/errors.js";
5
- import { managedHttp, publicFields } from "../cloud/common.js";
4
+ import { managedHttp, optionsObject, publicFields } from "../cloud/common.js";
5
+ import { definedFields } from "../core/fields.js";
6
+ import { verifyYouTubeWebhook } from "../server/webhooks.js";
7
+ import { directWebhooks, webhookCapability } from "./webhook-adapter.js";
6
8
  import { createHttp, HttpError } from "../transport/http.js";
7
- import { array, object, optionalString, string } from "../transport/validation.js";
9
+ import { array, isBoolean, isFiniteNumber, isJsonObject, isString, object, optionalArray, optionalObject, optionalString, string, } from "../transport/validation.js";
8
10
  import { beginYouTubeUpload, queryYouTubeUpload, sendYouTubeUpload, } from "./youtube-upload.js";
11
+ /** Drop empty strings so optional query parameters are omitted rather than sent blank. */
12
+ function nonEmpty(value) {
13
+ return value || undefined;
14
+ }
9
15
  export function youtube(options) {
10
16
  const request = managedHttp("https://www.googleapis.com", {
11
17
  apiKey: options.auth.accessToken,
12
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
13
- ...(options.fetch ? { fetch: options.fetch } : {}),
18
+ ...definedFields({ fetch: options.fetch }),
14
19
  });
15
20
  const analyticsRequest = managedHttp("https://youtubeanalytics.googleapis.com", {
16
21
  apiKey: options.auth.accessToken,
17
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
18
- ...(options.fetch ? { fetch: options.fetch } : {}),
22
+ ...definedFields({ fetch: options.fetch }),
19
23
  });
20
24
  const binaryRequest = createHttp({
21
- ...(options.fetch ? { fetch: options.fetch } : {}),
25
+ ...definedFields({ fetch: options.fetch }),
22
26
  timeoutMs: 30_000,
23
27
  });
24
28
  const now = () => (options.clock?.() ?? new Date()).toISOString();
@@ -56,9 +60,17 @@ export function youtube(options) {
56
60
  });
57
61
  return video;
58
62
  };
59
- const outcome = (
60
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
61
- video, target) => {
63
+ /** Returns the publishAt time only while the video is private and still waiting to publish. */
64
+ const scheduledAt = (status) => {
65
+ const publishAt = optionalString(status["publishAt"]);
66
+ if (status["privacyStatus"] !== "private" || publishAt === undefined)
67
+ return undefined;
68
+ const time = Date.parse(publishAt);
69
+ return Number.isFinite(time) && time > (options.clock?.() ?? new Date()).getTime()
70
+ ? publishAt
71
+ : undefined;
72
+ };
73
+ const outcome = (video, target) => {
62
74
  const id = string(video["id"]);
63
75
  const status = object(video["status"]);
64
76
  const uploaded = optionalString(status["uploadStatus"]) ?? "unknown";
@@ -75,6 +87,28 @@ export function youtube(options) {
75
87
  deliveryId: id,
76
88
  },
77
89
  };
90
+ if (uploaded === "failed" || uploaded === "rejected")
91
+ return {
92
+ ...base,
93
+ state: "failed",
94
+ code: "media_error",
95
+ message: "YouTube rejected or failed to process the video. Inspect channel eligibility and upload requirements.",
96
+ retryDisposition: { kind: "never" },
97
+ };
98
+ // A private video with a future publishAt is waiting for YouTube to publish it.
99
+ if (scheduledAt(status) !== undefined)
100
+ return {
101
+ ...base,
102
+ state: "scheduled",
103
+ job: {
104
+ kind: "scheduled-job",
105
+ version: 1,
106
+ backend: target.account.backend,
107
+ platform: "youtube",
108
+ accountId: target.account.accountId,
109
+ jobId: id,
110
+ },
111
+ };
78
112
  if (uploaded === "processed")
79
113
  return {
80
114
  ...base,
@@ -91,14 +125,6 @@ export function youtube(options) {
91
125
  };
92
126
  if (uploaded === "uploaded")
93
127
  return { ...base, state: "processing" };
94
- if (uploaded === "failed" || uploaded === "rejected")
95
- return {
96
- ...base,
97
- state: "failed",
98
- code: "media_error",
99
- message: "YouTube rejected or failed to process the video. Inspect channel eligibility and upload requirements.",
100
- retryDisposition: { kind: "never" },
101
- };
102
128
  return {
103
129
  ...base,
104
130
  state: "unknown",
@@ -154,10 +180,10 @@ export function youtube(options) {
154
180
  body,
155
181
  headers: {
156
182
  Authorization: `Bearer ${options.auth.accessToken}`,
157
- ...(contentType ? { "Content-Type": contentType } : {}),
183
+ ...definedFields({ "Content-Type": nonEmpty(contentType) }),
158
184
  },
159
185
  timeoutMs: remainingBudget(context),
160
- ...(context.signal ? { signal: context.signal } : {}),
186
+ ...definedFields({ signal: context.signal }),
161
187
  });
162
188
  return object(result);
163
189
  }
@@ -191,6 +217,7 @@ export function youtube(options) {
191
217
  apiRevision: "YouTube Data API v3",
192
218
  runtime: ["node22", "node24", "bun"],
193
219
  capabilities: [
220
+ webhookCapability("youtube", "Verifies the PubSubHubbub X-Hub-Signature for subscriptions created with hub.secret and decodes the Atom feed. Answer the GET verification with answerYouTubeWebhookChallenge."),
194
221
  ...[
195
222
  "accounts.read",
196
223
  "posts.read",
@@ -220,12 +247,25 @@ export function youtube(options) {
220
247
  requiredScopes: ["https://www.googleapis.com/auth/youtube.upload"],
221
248
  notes: "Scheduled videos are uploaded private with a future ISO publishAt timestamp.",
222
249
  },
250
+ {
251
+ operation: "posts.cancelScheduled",
252
+ platform: "youtube",
253
+ availability: "available",
254
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
255
+ notes: "Clears status.publishAt with videos.update and keeps the video private. The video is not deleted. Costs 51 quota units (videos.list + videos.update).",
256
+ },
223
257
  {
224
258
  operation: "posts.removeFromPlatform",
225
259
  platform: "youtube",
226
260
  availability: "available",
227
261
  requiredScopes: ["https://www.googleapis.com/auth/youtube"],
228
262
  },
263
+ {
264
+ operation: "notifications.read",
265
+ platform: "youtube",
266
+ availability: "unsupported-by-platform",
267
+ notes: "The YouTube Data API has no notifications resource. activities.list reports actions a channel took, not notifications it received.",
268
+ },
229
269
  {
230
270
  operation: "playlists.read",
231
271
  platform: "youtube",
@@ -256,6 +296,13 @@ export function youtube(options) {
256
296
  availability: "available",
257
297
  requiredScopes: ["https://www.googleapis.com/auth/youtube"],
258
298
  },
299
+ {
300
+ operation: "profile.update",
301
+ platform: "youtube",
302
+ availability: "available",
303
+ requiredScopes: ["https://www.googleapis.com/auth/youtube"],
304
+ notes: "Native access: updateProfile. Writes brandingSettings.channel or localizations through channels.update. Each call reads the channel (1 quota unit) and then writes it (50 units).",
305
+ },
259
306
  {
260
307
  operation: "videos.delete",
261
308
  platform: "youtube",
@@ -312,6 +359,13 @@ export function youtube(options) {
312
359
  availability: "available",
313
360
  requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
314
361
  },
362
+ {
363
+ operation: "comments.delete",
364
+ platform: "youtube",
365
+ availability: "available",
366
+ requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
367
+ notes: "comments.delete costs 50 quota units. Delete a thread through its top-level comment ID. Google does not document which comments a channel may delete; insufficient permissions return 403 forbidden.",
368
+ },
315
369
  {
316
370
  operation: "analytics.youtube.read",
317
371
  platform: "youtube",
@@ -334,6 +388,7 @@ export function youtube(options) {
334
388
  },
335
389
  ],
336
390
  },
391
+ webhooks: directWebhooks("youtube", (input) => verifyYouTubeWebhook({ ...input, secret: options.webhookSecret ?? "" }), now),
337
392
  accounts: {
338
393
  async list(_input, context) {
339
394
  return { items: [await accountInfo(context)] };
@@ -371,8 +426,7 @@ export function youtube(options) {
371
426
  playlistId: uploads,
372
427
  part: "snippet,contentDetails",
373
428
  maxResults: String(limit),
374
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
375
- ...(input.cursor ? { pageToken: input.cursor } : {}),
429
+ ...definedFields({ pageToken: nonEmpty(input.cursor) }),
376
430
  }));
377
431
  return {
378
432
  items: array(page["items"]).map((value) => {
@@ -397,17 +451,7 @@ export function youtube(options) {
397
451
  contentDetails: publicFields(content, ["videoId", "videoPublishedAt"]),
398
452
  };
399
453
  }),
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.
408
- ...(typeof page["nextPageToken"] === "string"
409
- ? { nextCursor: page["nextPageToken"] }
410
- : {}),
454
+ ...definedFields({ nextCursor: optionalString(page["nextPageToken"]) }),
411
455
  };
412
456
  },
413
457
  prepareTarget(target) {
@@ -428,18 +472,13 @@ export function youtube(options) {
428
472
  if (item.source.kind !== "blob" && !item.byteSize)
429
473
  fail("youtube.size", "Streaming upload requires its exact byte size.");
430
474
  }
431
- const config = target.options === undefined ? {} : object(target.options);
432
- if (
433
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
434
- typeof config["title"] !== "string" ||
435
- !config["title"] ||
436
- [...config["title"]].length > 100 ||
437
- /[<>]/u.test(config["title"]))
475
+ const config = optionsObject(target);
476
+ const title = config["title"];
477
+ if (!isString(title) || !title || [...title].length > 100 || /[<>]/u.test(title))
438
478
  fail("youtube.title", "Select a title of 1 to 100 characters.");
439
479
  if (!["public", "unlisted", "private"].includes(String(config["visibility"])))
440
480
  fail("youtube.visibility", "Explicit visibility is required.");
441
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
442
- if (typeof config["madeForKids"] !== "boolean")
481
+ if (!isBoolean(config["madeForKids"]))
443
482
  fail("youtube.audience", "Explicit made-for-kids declaration is required.");
444
483
  if (target.content.text !== undefined &&
445
484
  new TextEncoder().encode(target.content.text).byteLength > 5000)
@@ -461,32 +500,32 @@ export function youtube(options) {
461
500
  operation: "posts.publish",
462
501
  message: "Video required.",
463
502
  });
464
- const config = object(target.options);
503
+ const config = optionsObject(target);
465
504
  const uploadOptions = {
466
505
  timeoutMs: Math.max(remainingBudget(context), 15 * 60_000),
467
506
  accessToken: options.auth.accessToken,
468
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
469
- ...(options.fetch ? { fetch: options.fetch } : {}),
470
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
471
- ...(context.signal ? { signal: context.signal } : {}),
507
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
472
508
  };
473
509
  const size = media.byteSize ?? (media.source.kind === "blob" ? media.source.blob.size : 0);
510
+ const mimeType = string(media.mimeType);
511
+ const title = string(config["title"]);
512
+ const visibility = string(config["visibility"]);
513
+ const selfDeclaredMadeForKids = config["madeForKids"] === true;
514
+ // A schedule forces private visibility until YouTube publishes at `publishAt`.
515
+ const status = target.schedule === undefined
516
+ ? { privacyStatus: visibility, selfDeclaredMadeForKids }
517
+ : {
518
+ privacyStatus: "private",
519
+ selfDeclaredMadeForKids,
520
+ publishAt: new Date(target.schedule.at).toISOString(),
521
+ };
474
522
  const session = await beginYouTubeUpload({
475
523
  channelId: options.auth.channelId,
476
524
  size,
477
- mimeType: string(media.mimeType),
525
+ mimeType,
478
526
  metadata: {
479
- snippet: { title: string(config["title"]), description: target.content.text ?? "" },
480
- status: {
481
- privacyStatus: string(config["visibility"]),
482
- selfDeclaredMadeForKids: config["madeForKids"] === true,
483
- ...(target.schedule
484
- ? {
485
- publishAt: new Date(target.schedule.at).toISOString(),
486
- privacyStatus: "private",
487
- }
488
- : {}),
489
- },
527
+ snippet: { title, description: target.content.text ?? "" },
528
+ status,
490
529
  },
491
530
  }, uploadOptions);
492
531
  if (options.saveUploadSession)
@@ -531,6 +570,81 @@ export function youtube(options) {
531
570
  const video = await get({ ...account, kind: "platform-post", postId: ref.deliveryId }, context);
532
571
  return outcome(video, { account, targetIndex: 0 });
533
572
  },
573
+ async cancelScheduled(ref, context) {
574
+ authorize(ref, context);
575
+ if (!ref.jobId)
576
+ throw new SocialError({
577
+ code: "invalid_input",
578
+ operation: "posts.cancelScheduled",
579
+ message: "jobId must be the scheduled video's ID.",
580
+ });
581
+ const video = await get({
582
+ kind: "platform-post",
583
+ version: 1,
584
+ backend: ref.backend,
585
+ platform: "youtube",
586
+ accountId: ref.accountId,
587
+ postId: ref.jobId,
588
+ }, context);
589
+ const status = object(video["status"]);
590
+ if (scheduledAt(status) === undefined)
591
+ throw new SocialError({
592
+ code: "invalid_input",
593
+ operation: "posts.cancelScheduled",
594
+ message: "Only a private video with a future publishAt can be cancelled. Reconcile a due or published video.",
595
+ });
596
+ // videos.update replaces the whole status part: an omitted field is reset. Resend every
597
+ // writable field read above, set privacyStatus to private, and omit publishAt to clear it.
598
+ const flag = (key) => {
599
+ const value = status[key];
600
+ if (value === undefined || value === true || value === false)
601
+ return value;
602
+ throw new SocialError({
603
+ code: "upstream_failure",
604
+ operation: "posts.cancelScheduled",
605
+ message: `YouTube returned an invalid status.${key} value.`,
606
+ });
607
+ };
608
+ const selfDeclaredMadeForKids = flag("selfDeclaredMadeForKids");
609
+ if (selfDeclaredMadeForKids === undefined)
610
+ throw new SocialError({
611
+ code: "upstream_failure",
612
+ operation: "posts.cancelScheduled",
613
+ message: "YouTube did not return the made-for-kids declaration, so the update could not preserve it. Nothing was changed.",
614
+ });
615
+ const license = optionalString(status["license"]);
616
+ const embeddable = flag("embeddable");
617
+ const publicStatsViewable = flag("publicStatsViewable");
618
+ const containsSyntheticMedia = flag("containsSyntheticMedia");
619
+ const next = (() => {
620
+ const result = {};
621
+ result["privacyStatus"] = "private";
622
+ result["selfDeclaredMadeForKids"] = selfDeclaredMadeForKids;
623
+ if (license !== undefined)
624
+ result["license"] = license;
625
+ if (embeddable !== undefined)
626
+ result["embeddable"] = embeddable;
627
+ if (publicStatsViewable !== undefined)
628
+ result["publicStatsViewable"] = publicStatsViewable;
629
+ if (containsSyntheticMedia !== undefined)
630
+ result["containsSyntheticMedia"] = containsSyntheticMedia;
631
+ return result;
632
+ })();
633
+ const result = object(await request("/youtube/v3/videos", context, { id: ref.jobId, status: next }, { part: "status" }, "PUT"));
634
+ const written = result["status"] === undefined ? undefined : object(result["status"]);
635
+ if (result["id"] !== ref.jobId ||
636
+ written?.["privacyStatus"] !== "private" ||
637
+ (written?.["publishAt"] !== undefined && written?.["publishAt"] !== null))
638
+ throw new SocialError({
639
+ code: "ambiguous_outcome",
640
+ operation: "posts.cancelScheduled",
641
+ backend: context.backendInstance,
642
+ correlationId: context.correlationId,
643
+ message: "YouTube did not confirm that the schedule was cleared. Read the video before retrying.",
644
+ retryDisposition: { kind: "reconcile-first" },
645
+ });
646
+ return { state: "cancelled", backendRecord: "retained" };
647
+ },
534
648
  async removeFromPlatform(ref, context) {
535
649
  authorize(ref, context);
536
650
  if (!ref.postId)
@@ -563,14 +677,15 @@ export function youtube(options) {
563
677
  q: input.query,
564
678
  type: "video",
565
679
  maxResults: String(limit),
566
- ...(input.cursor ? { pageToken: input.cursor } : {}),
567
- ...(input.startTime ? { publishedAfter: input.startTime } : {}),
568
- ...(input.endTime ? { publishedBefore: input.endTime } : {}),
680
+ ...definedFields({
681
+ pageToken: nonEmpty(input.cursor),
682
+ publishedAfter: nonEmpty(input.startTime),
683
+ publishedBefore: nonEmpty(input.endTime),
684
+ }),
569
685
  }));
570
- const nextCursor = optionalString(page["nextPageToken"]);
571
686
  return {
572
- items: array(page["items"]).map((value) => object(value)),
573
- ...(nextCursor ? { nextCursor } : {}),
687
+ items: array(page["items"]).map(object),
688
+ ...definedFields({ nextCursor: nonEmpty(optionalString(page["nextPageToken"])) }),
574
689
  };
575
690
  },
576
691
  },
@@ -583,8 +698,7 @@ export function youtube(options) {
583
698
  const metrics = [];
584
699
  for (const name of ["viewCount", "likeCount", "commentCount"]) {
585
700
  const raw = values[name];
586
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
587
- if (typeof raw !== "string" || !/^\d+$/.test(raw))
701
+ if (!isString(raw) || !/^\d+$/.test(raw))
588
702
  continue;
589
703
  const value = Number(raw);
590
704
  if (!Number.isSafeInteger(value))
@@ -623,8 +737,7 @@ export function youtube(options) {
623
737
  if (name === "subscriberCount" && stats["hiddenSubscriberCount"] === true)
624
738
  return [];
625
739
  const raw = stats[name];
626
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
627
- return typeof raw === "string" && /^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))
740
+ return isString(raw) && /^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))
628
741
  ? [
629
742
  {
630
743
  name,
@@ -658,10 +771,11 @@ export function youtube(options) {
658
771
  startDate: query.from,
659
772
  endDate: query.to,
660
773
  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
- : {}),
774
+ ...definedFields({
775
+ dimensions: query.dimensions && query.dimensions.length > 0
776
+ ? query.dimensions.join(",")
777
+ : undefined,
778
+ }),
665
779
  }));
666
780
  const headers = array(result["columnHeaders"]).map((value) => {
667
781
  const header = object(value);
@@ -672,14 +786,14 @@ export function youtube(options) {
672
786
  });
673
787
  const rows = [];
674
788
  // Analytics omits `rows` entirely when the requested period has no data.
675
- for (const value of Array.isArray(result["rows"]) ? result["rows"] : []) {
789
+ for (const value of optionalArray(result["rows"]) ?? []) {
676
790
  const cells = array(value);
677
791
  const dimensions = Object.fromEntries(headers.flatMap((header, index) => {
678
792
  if (header.type !== "DIMENSION")
679
793
  return [];
680
794
  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"
795
+ // JSON numbers are always finite, so isFiniteNumber accepts every numeric cell.
796
+ return isString(raw) || isFiniteNumber(raw) || isBoolean(raw)
683
797
  ? [[header.name, raw]]
684
798
  : [];
685
799
  }));
@@ -688,10 +802,9 @@ export function youtube(options) {
688
802
  if (header.type === "DIMENSION")
689
803
  return;
690
804
  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"
805
+ const parsed = isFiniteNumber(raw)
693
806
  ? raw
694
- : typeof raw === "string" && raw.trim() !== ""
807
+ : isString(raw) && raw.trim() !== ""
695
808
  ? Number(raw)
696
809
  : undefined;
697
810
  if (parsed !== undefined && Number.isFinite(parsed))
@@ -717,8 +830,7 @@ export function youtube(options) {
717
830
  videoId: ref.postId,
718
831
  textFormat: "plainText",
719
832
  maxResults: String(limit),
720
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
721
- ...(input.cursor === undefined ? {} : { pageToken: input.cursor }),
833
+ ...definedFields({ pageToken: input.cursor }),
722
834
  }));
723
835
  const cursor = optionalString(result["nextPageToken"]);
724
836
  return {
@@ -735,8 +847,7 @@ export function youtube(options) {
735
847
  ]),
736
848
  };
737
849
  }),
738
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
739
- ...(cursor ? { nextCursor: cursor } : {}),
850
+ ...definedFields({ nextCursor: nonEmpty(cursor) }),
740
851
  };
741
852
  },
742
853
  async reply(ref, content, context) {
@@ -768,10 +879,7 @@ export function youtube(options) {
768
879
  const uploadOptions = {
769
880
  timeoutMs: remainingBudget(context),
770
881
  accessToken: options.auth.accessToken,
771
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
772
- ...(options.fetch ? { fetch: options.fetch } : {}),
773
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
774
- ...(context.signal ? { signal: context.signal } : {}),
882
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
775
883
  };
776
884
  const confirmed = await queryYouTubeUpload(session, uploadOptions);
777
885
  if (confirmed.state === "complete")
@@ -787,10 +895,7 @@ export function youtube(options) {
787
895
  });
788
896
  return queryYouTubeUpload(session, {
789
897
  accessToken: options.auth.accessToken,
790
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
791
- ...(options.fetch ? { fetch: options.fetch } : {}),
792
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
793
- ...(context.signal ? { signal: context.signal } : {}),
898
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
794
899
  });
795
900
  },
796
901
  async setThumbnail({ videoId, thumbnail, context }) {
@@ -811,7 +916,7 @@ export function youtube(options) {
811
916
  const response = await (options.fetch ?? globalThis.fetch)(url, {
812
917
  headers: { Authorization: `Bearer ${options.auth.accessToken}` },
813
918
  redirect: "error",
814
- ...(context.signal ? { signal: context.signal } : {}),
919
+ ...definedFields({ signal: context.signal }),
815
920
  });
816
921
  if (!response.ok)
817
922
  throw new SocialError({
@@ -840,7 +945,7 @@ export function youtube(options) {
840
945
  const metadata = action === "update"
841
946
  ? { ...body, id: captionId ?? body["id"] ?? null }
842
947
  : { ...body, snippet: { ...snippet, videoId: videoId ?? null } };
843
- if (action === "update" && typeof metadata["id"] !== "string")
948
+ if (action === "update" && !isString(metadata["id"]))
844
949
  throw new SocialError({
845
950
  code: "invalid_input",
846
951
  operation: "captions.update",
@@ -892,22 +997,20 @@ export function youtube(options) {
892
997
  operation: `playlists.${action}`,
893
998
  message: "playlistId is required.",
894
999
  });
895
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
896
1000
  if (action === "delete") {
897
1001
  await request("/youtube/v3/playlists", context, undefined, { id: playlistId ?? "" }, method);
898
1002
  return {};
899
1003
  }
900
1004
  return object(await request("/youtube/v3/playlists", context, body, {
901
1005
  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) } : {}),
1006
+ ...definedFields({
1007
+ id: nonEmpty(playlistId),
1008
+ channelId: nonEmpty(channelId),
1009
+ // Listing with no playlist or channel filter defaults to the caller's playlists.
1010
+ mine: (action === "list" && !playlistId && !channelId) || mine ? "true" : undefined,
1011
+ pageToken: nonEmpty(pageToken),
1012
+ maxResults: maxResults ? String(maxResults) : undefined,
1013
+ }),
911
1014
  }, method));
912
1015
  },
913
1016
  async playlistItems({ action, playlistId, playlistItemId, body, pageToken, context }) {
@@ -931,9 +1034,11 @@ export function youtube(options) {
931
1034
  }
932
1035
  return object(await request("/youtube/v3/playlistItems", context, body, {
933
1036
  part: "snippet,contentDetails",
934
- ...(playlistId ? { playlistId } : {}),
935
- ...(playlistItemId ? { id: playlistItemId } : {}),
936
- ...(pageToken ? { pageToken } : {}),
1037
+ ...definedFields({
1038
+ playlistId: nonEmpty(playlistId),
1039
+ id: nonEmpty(playlistItemId),
1040
+ pageToken: nonEmpty(pageToken),
1041
+ }),
937
1042
  }, method));
938
1043
  },
939
1044
  async updateVideo({ videoId, body, context }) {
@@ -961,6 +1066,52 @@ export function youtube(options) {
961
1066
  };
962
1067
  return object(await request("/youtube/v3/videos", context, merged, { part: "snippet,status" }, "PUT"));
963
1068
  },
1069
+ async updateProfile({ part, value, context }) {
1070
+ nativeAuthorize(context);
1071
+ const operation = "profile.update";
1072
+ const invalid = (message) => new SocialError({ code: "invalid_input", operation, message });
1073
+ if (part !== "brandingSettings" && part !== "localizations")
1074
+ throw invalid("part must be brandingSettings or localizations.");
1075
+ if (!isJsonObject(value) || Object.keys(value).length === 0)
1076
+ throw invalid("value must be a non-empty object.");
1077
+ const channelPatch = value["channel"];
1078
+ if (part === "brandingSettings" &&
1079
+ (Object.keys(value).some((key) => key !== "channel") || !isJsonObject(channelPatch)))
1080
+ throw invalid("brandingSettings updates accept only a channel object.");
1081
+ if (part === "localizations" &&
1082
+ Object.entries(value).some(([key, entry]) => key.trim() === "" || (entry !== null && !isJsonObject(entry))))
1083
+ throw invalid("Each localization must be an object keyed by language, or null to remove it.");
1084
+ // channels.update deletes omitted mutable properties, so merge into the current part.
1085
+ const merge = (current, patch) => Object.fromEntries(Object.entries({ ...optionalObject(current), ...patch }).filter(([, entry]) => entry !== null));
1086
+ const channel = array(object(await request("/youtube/v3/channels", context, undefined, {
1087
+ id: options.auth.channelId,
1088
+ part,
1089
+ }))["items"])
1090
+ .map(object)
1091
+ .find((item) => item["id"] === options.auth.channelId);
1092
+ if (!channel)
1093
+ throw new SocialError({
1094
+ code: "unauthorized",
1095
+ operation,
1096
+ message: "Configured channel is absent or inaccessible to this authorization.",
1097
+ });
1098
+ const branding = optionalObject(channel["brandingSettings"]) ?? {};
1099
+ // Resend only documented writable branding: the merged channel object and the current
1100
+ // banner URL, which channels.update would otherwise delete. Deprecated watch, hints, and
1101
+ // image fields are dropped; YouTube rejects some of them on write.
1102
+ const bannerExternalUrl = optionalString(optionalObject(branding["image"])?.["bannerExternalUrl"]);
1103
+ const brandingNext = (patch) => {
1104
+ const result = {};
1105
+ result["channel"] = merge(branding["channel"], patch);
1106
+ if (bannerExternalUrl !== undefined && bannerExternalUrl !== "")
1107
+ result["image"] = { bannerExternalUrl };
1108
+ return result;
1109
+ };
1110
+ const next = part === "brandingSettings" && isJsonObject(channelPatch)
1111
+ ? brandingNext(channelPatch)
1112
+ : merge(channel["localizations"], value);
1113
+ return object(await request("/youtube/v3/channels", context, { id: options.auth.channelId, [part]: next }, { part }, "PUT"));
1114
+ },
964
1115
  async deleteVideo({ videoId, context }) {
965
1116
  nativeAuthorize(context);
966
1117
  if (!videoId)
@@ -991,9 +1142,7 @@ export function youtube(options) {
991
1142
  message: "subscriptionId is required.",
992
1143
  });
993
1144
  const insertBody = channelId
994
- ? {
995
- snippet: { resourceId: { kind: "youtube#channel", channelId } },
996
- }
1145
+ ? { snippet: { resourceId: { kind: "youtube#channel", channelId } } }
997
1146
  : undefined;
998
1147
  if (action === "delete") {
999
1148
  await request("/youtube/v3/subscriptions", context, undefined, { id: subscriptionId ?? "" }, method);
@@ -1001,14 +1150,13 @@ export function youtube(options) {
1001
1150
  }
1002
1151
  return object(await request("/youtube/v3/subscriptions", context, insertBody, {
1003
1152
  part: "snippet,contentDetails",
1004
- ...(subscriptionId
1005
- ? { id: subscriptionId }
1006
- : channelId
1007
- ? { channelId }
1008
- : action === "list"
1009
- ? { mine: "true" }
1010
- : {}),
1011
- ...(pageToken ? { pageToken } : {}),
1153
+ // Filter by subscription, else by channel, else list the caller's own subscriptions.
1154
+ ...definedFields({
1155
+ id: nonEmpty(subscriptionId),
1156
+ channelId: subscriptionId ? undefined : nonEmpty(channelId),
1157
+ mine: subscriptionId || channelId || action !== "list" ? undefined : "true",
1158
+ pageToken: nonEmpty(pageToken),
1159
+ }),
1012
1160
  }, method));
1013
1161
  },
1014
1162
  async search({ q, type, channelId, order, publishedAfter, publishedBefore, pageToken, maxResults, context, }) {
@@ -1016,13 +1164,15 @@ export function youtube(options) {
1016
1164
  return object(await request("/youtube/v3/search", context, undefined, {
1017
1165
  part: "snippet",
1018
1166
  q,
1019
- ...(type ? { type } : {}),
1020
- ...(channelId ? { channelId } : {}),
1021
- ...(order ? { order } : {}),
1022
- ...(publishedAfter ? { publishedAfter } : {}),
1023
- ...(publishedBefore ? { publishedBefore } : {}),
1024
- ...(pageToken ? { pageToken } : {}),
1025
- ...(maxResults ? { maxResults: String(maxResults) } : {}),
1167
+ ...definedFields({
1168
+ type: nonEmpty(type),
1169
+ channelId: nonEmpty(channelId),
1170
+ order: nonEmpty(order),
1171
+ publishedAfter: nonEmpty(publishedAfter),
1172
+ publishedBefore: nonEmpty(publishedBefore),
1173
+ pageToken: nonEmpty(pageToken),
1174
+ maxResults: maxResults ? String(maxResults) : undefined,
1175
+ }),
1026
1176
  }));
1027
1177
  },
1028
1178
  async commentsModeration({ action, commentId, moderationStatus, banAuthor, body, context }) {
@@ -1044,7 +1194,7 @@ export function youtube(options) {
1044
1194
  message: "moderationStatus is required.",
1045
1195
  });
1046
1196
  })(),
1047
- ...(banAuthor ? { banAuthor: "true" } : {}),
1197
+ ...definedFields({ banAuthor: banAuthor ? "true" : undefined }),
1048
1198
  }, "POST");
1049
1199
  return;
1050
1200
  }
@@ -1060,26 +1210,36 @@ export function youtube(options) {
1060
1210
  });
1061
1211
  return object(await request("/youtube/v3/comments", context, { ...body, id: commentId }, { part: "snippet" }, "PUT"));
1062
1212
  },
1213
+ async deleteComment({ account, commentId, context }) {
1214
+ // Source: https://developers.google.com/youtube/v3/docs/comments/delete (accessed 2026-09-24).
1215
+ authorize(account, context);
1216
+ if (!commentId.trim())
1217
+ throw new SocialError({
1218
+ code: "invalid_input",
1219
+ operation: "comments.delete",
1220
+ message: "commentId is required.",
1221
+ });
1222
+ await request("/youtube/v3/comments", context, undefined, { id: commentId }, "DELETE");
1223
+ },
1063
1224
  async heldComments({ pageToken, maxResults, context }) {
1064
1225
  nativeAuthorize(context);
1065
1226
  return object(await request("/youtube/v3/commentThreads", context, undefined, {
1066
1227
  part: "snippet",
1067
1228
  moderationStatus: "heldForReview",
1068
1229
  allThreadsRelatedToChannelId: options.auth.channelId,
1069
- ...(pageToken ? { pageToken } : {}),
1070
- ...(maxResults ? { maxResults: String(maxResults) } : {}),
1230
+ ...definedFields({
1231
+ pageToken: nonEmpty(pageToken),
1232
+ maxResults: maxResults ? String(maxResults) : undefined,
1233
+ }),
1071
1234
  }));
1072
1235
  },
1073
1236
  async analytics({ query, context }) {
1074
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1075
1237
  return object(await analyticsRequest("/v2/reports", context, undefined, query));
1076
1238
  },
1077
1239
  async liveBroadcasts({ action, body, id, broadcastStatus, context }) {
1078
1240
  if (action === "list")
1079
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1080
1241
  return object(await request("/youtube/v3/liveBroadcasts", context, undefined, {
1081
1242
  part: "snippet,status",
1082
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1083
1243
  ...(id ? { id } : { mine: "true" }),
1084
1244
  }));
1085
1245
  if (action === "transition") {
@@ -1089,10 +1249,8 @@ export function youtube(options) {
1089
1249
  operation: "live.broadcasts",
1090
1250
  message: "id and broadcastStatus are required.",
1091
1251
  });
1092
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1093
1252
  return object(await request("/youtube/v3/liveBroadcasts/transition", context, undefined, { part: "snippet,status", id, broadcastStatus }, "POST"));
1094
1253
  }
1095
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1096
1254
  return object(await request("/youtube/v3/liveBroadcasts", context, body, { part: "snippet,status" }, "POST"));
1097
1255
  },
1098
1256
  },