@opencoredev/social-sdk 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +19 -21
- package/dist/cloud/common.d.ts +7 -6
- package/dist/cloud/common.js +35 -54
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +2 -2
- package/dist/cloud/media.js +13 -3
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +72 -0
- package/dist/platforms/x.js +328 -119
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +2 -2
|
@@ -1,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
|
-
|
|
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
|
-
|
|
18
|
-
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
22
|
+
...definedFields({ fetch: options.fetch }),
|
|
19
23
|
});
|
|
20
24
|
const binaryRequest = createHttp({
|
|
21
|
-
...(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
...(
|
|
183
|
+
...definedFields({ "Content-Type": nonEmpty(contentType) }),
|
|
158
184
|
},
|
|
159
185
|
timeoutMs: remainingBudget(context),
|
|
160
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
525
|
+
mimeType,
|
|
478
526
|
metadata: {
|
|
479
|
-
snippet: { title
|
|
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
|
-
...(
|
|
567
|
-
|
|
568
|
-
|
|
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(
|
|
573
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
|
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
|
-
//
|
|
682
|
-
return
|
|
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
|
-
|
|
692
|
-
const parsed = typeof raw === "number"
|
|
805
|
+
const parsed = isFiniteNumber(raw)
|
|
693
806
|
? raw
|
|
694
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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" &&
|
|
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
|
-
...(
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
: mine
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
...(
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
:
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
...(
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
1070
|
-
|
|
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
|
},
|