@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
@@ -8,6 +8,10 @@ export interface TikTokOptions {
8
8
  readonly verifiedMediaOrigins: readonly string[];
9
9
  readonly fetch?: typeof globalThis.fetch;
10
10
  readonly clock?: () => Date;
11
+ /** App client secret that TikTok uses to sign webhook deliveries (`TikTok-Signature`). */
12
+ readonly webhookSecret?: string;
13
+ /** Accepted age of a signed webhook timestamp, in seconds. Defaults to 300. */
14
+ readonly webhookToleranceSeconds?: number;
11
15
  }
12
16
  export interface TikTokNative {
13
17
  readonly creatorInfo: (account: ConnectedAccountRef, context: AdapterOperationContext) => Promise<JsonObject>;
@@ -1,9 +1,12 @@
1
- /* oxlint-disable anti-slop/no-unknown-parameters -- validated external boundary or fixture contract. */
2
1
  import { defineAdapter } from "../core/adapter.js";
2
+ import { definedFields } from "../core/fields.js";
3
3
  import { SocialError } from "../core/errors.js";
4
- import { managedHttp, publicFields } from "../cloud/common.js";
5
- import { array, object, optionalNumber, optionalString, string } from "../transport/validation.js";
4
+ import { managedHttp, optionsObject, publicFields } from "../cloud/common.js";
5
+ import { parseJson } from "../transport/json.js";
6
+ import { array, isBoolean, isFiniteNumber, isJsonArray, isJsonObject, isString, object, optionalNumber, optionalString, string, } from "../transport/validation.js";
6
7
  import { httpsUrl } from "../transport/upload.js";
8
+ import { verifyTikTokWebhook } from "../server/webhooks.js";
9
+ import { directWebhooks, webhookCapability } from "./webhook-adapter.js";
7
10
  const videoFields = [
8
11
  "id",
9
12
  "create_time",
@@ -19,34 +22,37 @@ const videoFields = [
19
22
  "share_count",
20
23
  "view_count",
21
24
  ];
25
+ /**
26
+ * TikTok reports some rejections as 4xx responses with a structured `error.code`.
27
+ * Pass those through as 200 so `data()` can map the provider code.
28
+ */
29
+ function withTikTokErrorBodies(fetch) {
30
+ return async (input, init) => {
31
+ const response = await fetch(input, init);
32
+ if (response.status < 400 || response.status >= 500)
33
+ return response;
34
+ const body = await response.clone().text();
35
+ try {
36
+ const errorObject = object(object(parseJson(body))["error"]);
37
+ if (errorObject["code"] !== undefined)
38
+ return new Response(body, {
39
+ status: 200,
40
+ headers: response.headers,
41
+ });
42
+ }
43
+ catch {
44
+ // Preserve ordinary HTTP error handling for non-JSON responses.
45
+ }
46
+ return response;
47
+ };
48
+ }
22
49
  export function tiktok(options) {
50
+ const userFetch = options.fetch;
23
51
  const request = managedHttp("https://open.tiktokapis.com", {
24
52
  apiKey: options.auth.accessToken,
25
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
26
- ...(options.fetch
27
- ? {
28
- fetch: async (input, init) => {
29
- const response = await options.fetch(input, init);
30
- if (response.status < 400 || response.status >= 500)
31
- return response;
32
- const body = await response.clone().text();
33
- try {
34
- const parsed = JSON.parse(body);
35
- const parsedObject = object(parsed);
36
- const errorObject = object(parsedObject["error"]);
37
- if (errorObject["code"] !== undefined)
38
- return new Response(body, {
39
- status: 200,
40
- headers: response.headers,
41
- });
42
- }
43
- catch {
44
- // Preserve ordinary HTTP error handling for non-JSON responses.
45
- }
46
- return response;
47
- },
48
- }
49
- : {}),
53
+ ...definedFields({
54
+ fetch: userFetch === undefined ? undefined : withTikTokErrorBodies(userFetch),
55
+ }),
50
56
  });
51
57
  const origins = new Set(options.verifiedMediaOrigins.map((value) => httpsUrl(value).origin));
52
58
  const now = () => (options.clock?.() ?? new Date()).toISOString();
@@ -60,14 +66,6 @@ export function tiktok(options) {
60
66
  message: "Account reference does not belong to this TikTok authorization.",
61
67
  });
62
68
  };
63
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated boundary or fixture contract.
64
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
65
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- provider payload is validated at this adapter boundary.
66
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated external boundary or fixture contract.
67
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated external boundary or fixture contract.
68
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated external boundary or fixture contract.
69
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated external boundary or fixture contract.
70
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated external boundary or fixture contract.
71
69
  const data = (value) => {
72
70
  const response = object(value);
73
71
  const error = object(response["error"]);
@@ -106,7 +104,7 @@ export function tiktok(options) {
106
104
  const fail = (code, message) => issues.push({ code, message, severity: "error", targetIndex: target.targetIndex });
107
105
  if (target.account.platform !== "tiktok" || target.account.accountId !== options.auth.openId)
108
106
  fail("tiktok.account", "Select the configured TikTok creator.");
109
- const config = target.options === undefined ? {} : object(target.options);
107
+ const config = optionsObject(target);
110
108
  const draft = config["draft"] === true;
111
109
  if (config["consentGiven"] !== true)
112
110
  fail("tiktok.consent", "The creator must preview the content and explicitly consent before transfer.");
@@ -119,18 +117,19 @@ export function tiktok(options) {
119
117
  "aiGenerated",
120
118
  "draft",
121
119
  ])
122
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
123
- if (typeof config[key] !== "boolean")
120
+ if (!isBoolean(config[key]))
124
121
  fail(`tiktok.${key}`, `Explicit ${key} choice is required.`);
125
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
126
- if (!draft && (!config["creatorInfo"] || typeof config["creatorInfo"] !== "object"))
122
+ const creatorInfoValue = config["creatorInfo"];
123
+ if (!draft && !isJsonObject(creatorInfoValue) && !isJsonArray(creatorInfoValue))
127
124
  fail("tiktok.creator_info", "Query creator information explicitly and render its choices before preparation.");
128
125
  else if (!draft) {
129
- const creator = object(config["creatorInfo"]);
126
+ const creator = object(creatorInfoValue);
130
127
  if (creator["accountId"] !== target.account.accountId ||
131
128
  creator["backend"] !== target.account.backend)
132
129
  fail("tiktok.creator_mismatch", "Creator information belongs to a different account or backend.");
133
- if (!array(creator["privacyLevels"]).includes(config["privacy"]))
130
+ const privacyLevels = array(creator["privacyLevels"]);
131
+ const privacy = config["privacy"];
132
+ if (privacy === undefined || !privacyLevels.includes(privacy))
134
133
  fail("tiktok.privacy", "Choose a privacy level returned by this creator's information.");
135
134
  }
136
135
  const media = target.content.media ?? [];
@@ -140,19 +139,17 @@ export function tiktok(options) {
140
139
  fail("tiktok.media", "Choose one video or 1 to 35 photos; formats cannot be mixed.");
141
140
  if ((target.content.text?.length ?? 0) > (video ? 2200 : 4000))
142
141
  fail("tiktok.caption", "Caption exceeds TikTok's UTF-16 limit for this format.");
143
- if (!video &&
144
- config["title"] !== undefined &&
145
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
146
- (typeof config["title"] !== "string" || config["title"].length > 90))
142
+ const title = config["title"];
143
+ if (!video && title !== undefined && (!isString(title) || title.length > 90))
147
144
  fail("tiktok.title", "Photo titles are limited to 90 UTF-16 code units.");
148
- if (video && config["title"] !== undefined)
145
+ if (video && title !== undefined)
149
146
  fail("tiktok.title", "Video captions use content.text; title is a photo-only option.");
147
+ const photoCoverIndex = config["photoCoverIndex"];
150
148
  if (!video &&
151
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
152
- (typeof config["photoCoverIndex"] !== "number" ||
153
- !Number.isInteger(config["photoCoverIndex"]) ||
154
- config["photoCoverIndex"] < 0 ||
155
- config["photoCoverIndex"] >= media.length))
149
+ (!isFiniteNumber(photoCoverIndex) ||
150
+ !Number.isInteger(photoCoverIndex) ||
151
+ photoCoverIndex < 0 ||
152
+ photoCoverIndex >= media.length))
156
153
  fail("tiktok.cover", "Select a photo cover index within the attached images.");
157
154
  for (const item of media) {
158
155
  if (item.source.kind !== "https-url") {
@@ -188,6 +185,7 @@ export function tiktok(options) {
188
185
  apiRevision: "Content Posting API v2",
189
186
  runtime: ["node22", "node24", "bun"],
190
187
  capabilities: [
188
+ webhookCapability("tiktok", "Verifies TikTok-Signature (HMAC-SHA256 over timestamp and raw body) with the client secret and a 300 second default timestamp window, then decodes the event."),
191
189
  {
192
190
  platform: "tiktok",
193
191
  operation: "posts.publish",
@@ -196,6 +194,12 @@ export function tiktok(options) {
196
194
  requiredScopes: ["video.publish"],
197
195
  notes: "Verified URL source; explicit creator choices/consent. Public posting requires app audit. Draft mode uses video.upload.",
198
196
  },
197
+ {
198
+ platform: "tiktok",
199
+ operation: "posts.schedule",
200
+ availability: "unsupported-by-platform",
201
+ notes: "Content Posting API post_info has no publish-time field.",
202
+ },
199
203
  { platform: "tiktok", operation: "posts.status", availability: "available" },
200
204
  {
201
205
  platform: "tiktok",
@@ -216,6 +220,13 @@ export function tiktok(options) {
216
220
  requiredScopes: ["video.upload"],
217
221
  },
218
222
  { platform: "tiktok", operation: "posts.status.poll", availability: "available" },
223
+ // Source, accessed 2026-09-24: https://developers.tiktok.com/doc/content-posting-api-get-started
224
+ {
225
+ platform: "tiktok",
226
+ operation: "posts.update",
227
+ availability: "unsupported-by-platform",
228
+ notes: "The Content Posting API sets caption and privacy only when a post is initialized. It has no endpoint to edit a published post.",
229
+ },
219
230
  {
220
231
  platform: "tiktok",
221
232
  operation: "comments.read",
@@ -227,6 +238,12 @@ export function tiktok(options) {
227
238
  operation: "messages.read",
228
239
  availability: "unsupported-by-platform",
229
240
  },
241
+ {
242
+ platform: "tiktok",
243
+ operation: "notifications.read",
244
+ availability: "unsupported-by-platform",
245
+ notes: "TikTok for Developers APIs do not expose a user notification inbox.",
246
+ },
230
247
  {
231
248
  platform: "tiktok",
232
249
  operation: "accounts.read",
@@ -245,8 +262,20 @@ export function tiktok(options) {
245
262
  availability: "available",
246
263
  requiredScopes: ["video.list"],
247
264
  },
265
+ {
266
+ platform: "tiktok",
267
+ operation: "profile.update",
268
+ availability: "unsupported-by-platform",
269
+ notes: "The Display API reads user info only. TikTok has no profile write endpoint.",
270
+ },
248
271
  ],
249
272
  },
273
+ webhooks: directWebhooks("tiktok", (input) => verifyTikTokWebhook({
274
+ ...input,
275
+ secret: options.webhookSecret ?? "",
276
+ now: () => options.clock?.() ?? new Date(),
277
+ ...definedFields({ toleranceSeconds: options.webhookToleranceSeconds }),
278
+ }), now),
250
279
  accounts: {
251
280
  async list(_input, context) {
252
281
  const response = data(await request("/v2/user/info/", context, undefined, { fields: "open_id,display_name" }));
@@ -325,27 +354,28 @@ export function tiktok(options) {
325
354
  const result = data(await request("/v2/video/list/", context, { cursor, max_count: limit }, {
326
355
  fields: "id,create_time,cover_image_url,share_url,title,video_description,duration,height,width,like_count,comment_count,share_count,view_count",
327
356
  }));
357
+ const nextOffset = result["cursor"];
358
+ const nextCursor = result["has_more"] === true &&
359
+ isFiniteNumber(nextOffset) &&
360
+ Number.isSafeInteger(nextOffset) &&
361
+ nextOffset >= 0
362
+ ? String(nextOffset)
363
+ : undefined;
328
364
  return {
329
365
  items: array(result["videos"]).map((row) => publicFields(row, videoFields)),
330
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
331
- ...(result["has_more"] === true &&
332
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
333
- typeof result["cursor"] === "number" &&
334
- Number.isSafeInteger(result["cursor"]) &&
335
- result["cursor"] >= 0
336
- ? { nextCursor: String(result["cursor"]) }
337
- : {}),
366
+ ...definedFields({ nextCursor }),
338
367
  };
339
368
  },
340
369
  prepareTarget: prepare,
341
370
  async publishTarget(target, context) {
342
371
  authorize(target.account, context);
343
- const config = object(target.options);
372
+ const config = optionsObject(target);
344
373
  const draft = config["draft"] === true;
345
374
  const latest = draft ? undefined : await creatorInfo(target.account, context);
346
375
  const media = target.content.media ?? [];
347
376
  const first = media[0];
348
- if (latest && !array(latest["privacyLevels"]).includes(config["privacy"]))
377
+ const privacy = config["privacy"];
378
+ if (latest && (privacy === undefined || !array(latest["privacyLevels"]).includes(privacy)))
349
379
  throw new SocialError({
350
380
  code: "invalid_input",
351
381
  operation: "posts.publish",
@@ -373,12 +403,10 @@ export function tiktok(options) {
373
403
  operation: "posts.publish",
374
404
  message: "Verified media URL required.",
375
405
  });
406
+ const maxDuration = latest?.["maxVideoDurationSeconds"];
376
407
  if (first.kind === "video" &&
377
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
378
- latest &&
379
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
380
- typeof latest["maxVideoDurationSeconds"] === "number" &&
381
- (first.durationSeconds ?? Infinity) > latest["maxVideoDurationSeconds"])
408
+ isFiniteNumber(maxDuration) &&
409
+ (first.durationSeconds ?? Infinity) > maxDuration)
382
410
  throw new SocialError({
383
411
  code: "invalid_input",
384
412
  operation: "posts.publish",
@@ -391,37 +419,32 @@ export function tiktok(options) {
391
419
  brand_content_toggle: config["brandedContent"] === true,
392
420
  brand_organic_toggle: config["ownBrand"] === true,
393
421
  };
394
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
395
422
  let result;
396
423
  if (first.kind === "video")
397
424
  result = data(await request(draft ? "/v2/post/publish/inbox/video/init/" : "/v2/post/publish/video/init/", context, {
398
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
399
- ...(draft
400
- ? {}
401
- : {
402
- post_info: {
425
+ ...definedFields({
426
+ post_info: draft
427
+ ? undefined
428
+ : {
403
429
  ...postInfo,
404
430
  disable_duet: config["disableDuet"] === true,
405
431
  disable_stitch: config["disableStitch"] === true,
406
432
  is_aigc: config["aiGenerated"] === true,
407
433
  },
408
- }),
434
+ }),
409
435
  source_info: { source: "PULL_FROM_URL", video_url: first.source.url },
410
436
  }));
411
437
  else
412
438
  result = data(await request("/v2/post/publish/content/init/", context, {
413
439
  post_info: {
414
440
  ...postInfo,
415
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
416
- title: typeof config["title"] === "string" ? config["title"] : "",
441
+ title: optionalString(config["title"]) ?? "",
417
442
  description: target.content.text ?? "",
418
443
  auto_add_music: false,
419
444
  },
420
445
  source_info: {
421
446
  source: "PULL_FROM_URL",
422
- photo_cover_index:
423
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
424
- typeof config["photoCoverIndex"] === "number" ? config["photoCoverIndex"] : 0,
447
+ photo_cover_index: optionalNumber(config["photoCoverIndex"]) ?? 0,
425
448
  photo_images: media.map((item) => item.source.kind === "https-url" ? item.source.url : ""),
426
449
  },
427
450
  post_mode: draft ? "MEDIA_UPLOAD" : "DIRECT_POST",
@@ -478,14 +501,11 @@ export function tiktok(options) {
478
501
  if (state === "PUBLISH_COMPLETE") {
479
502
  const ids = result["publicaly_available_post_id"];
480
503
  const id = Array.isArray(ids) && ids.length === 1 ? ids[0] : undefined;
481
- const nativeId =
482
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
483
- typeof id === "string"
504
+ const nativeId = isString(id)
484
505
  ? id
485
- : // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
486
- typeof id === "number" && Number.isSafeInteger(id)
487
- ? String(id)
488
- : undefined;
506
+ : isFiniteNumber(id) && Number.isSafeInteger(id)
507
+ ? String(id)
508
+ : undefined;
489
509
  if (nativeId)
490
510
  return {
491
511
  ...base,
@@ -520,22 +540,22 @@ export function tiktok(options) {
520
540
  if (!row)
521
541
  return [];
522
542
  const fetchedAt = now();
523
- return ["like_count", "comment_count", "share_count", "view_count"].flatMap((field) =>
524
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
525
- typeof row[field] === "number" && Number.isFinite(row[field])
526
- ? [
527
- {
528
- name: field,
529
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
530
- value: row[field],
531
- unit: "count",
532
- period: "lifetime",
533
- fetchedAt,
534
- freshness: "unknown",
535
- source: "tiktok:video.query",
536
- },
537
- ]
538
- : []);
543
+ return ["like_count", "comment_count", "share_count", "view_count"].flatMap((field) => {
544
+ const value = row[field];
545
+ return isFiniteNumber(value)
546
+ ? [
547
+ {
548
+ name: field,
549
+ value,
550
+ unit: "count",
551
+ period: "lifetime",
552
+ fetchedAt,
553
+ freshness: "unknown",
554
+ source: "tiktok:video.query",
555
+ },
556
+ ]
557
+ : [];
558
+ });
539
559
  },
540
560
  async getAccountMetrics(account, context) {
541
561
  // https://developers.tiktok.com/doc/tiktok-api-v2-user-info/
@@ -551,41 +571,37 @@ export function tiktok(options) {
551
571
  message: "TikTok creator identity mismatch.",
552
572
  });
553
573
  const fetchedAt = now();
554
- return ["follower_count", "following_count", "likes_count", "video_count"].flatMap((field) =>
555
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
556
- typeof user[field] === "number" && Number.isFinite(user[field])
557
- ? [
558
- {
559
- name: field,
560
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
561
- value: user[field],
562
- unit: "count",
563
- period: "lifetime",
564
- fetchedAt,
565
- freshness: "unknown",
566
- source: "tiktok:user.info.stats",
567
- },
568
- ]
569
- : []);
574
+ return ["follower_count", "following_count", "likes_count", "video_count"].flatMap((field) => {
575
+ const value = user[field];
576
+ return isFiniteNumber(value)
577
+ ? [
578
+ {
579
+ name: field,
580
+ value,
581
+ unit: "count",
582
+ period: "lifetime",
583
+ fetchedAt,
584
+ freshness: "unknown",
585
+ source: "tiktok:user.info.stats",
586
+ },
587
+ ]
588
+ : [];
589
+ });
570
590
  },
571
591
  },
572
592
  native: {
573
593
  creatorInfo,
574
594
  async uploadDraft({ account, video, context }) {
575
595
  authorize(account, context);
576
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
577
596
  return data(await request("/v2/post/publish/inbox/video/init/", context, video));
578
597
  },
579
598
  async listVideos({ account, cursor, maxCount, context }) {
580
599
  authorize(account, context);
581
600
  const parsedCursor = cursor === undefined ? 0 : Number(cursor);
582
- const result = data(await request("/v2/video/list/", context, { cursor: parsedCursor, max_count: maxCount ?? 20 }, { fields: videoFields.join(",") }));
583
- // SAFETY: data() validates the provider response as a JSON object.
584
- return result;
601
+ return data(await request("/v2/video/list/", context, { cursor: parsedCursor, max_count: maxCount ?? 20 }, { fields: videoFields.join(",") }));
585
602
  },
586
603
  async publishStatus({ account, publishId, context }) {
587
604
  authorize(account, context);
588
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
589
605
  return data(await request("/v2/post/publish/status/fetch/", context, { publish_id: publishId }));
590
606
  },
591
607
  },
@@ -0,0 +1,9 @@
1
+ import type { WebhooksAdapter } from "../core/adapter.js";
2
+ import type { CapabilityDeclaration } from "../core/types.js";
3
+ import { type DirectWebhookPlatform, type VerifiedWebhook } from "../server/webhooks.js";
4
+ /** Shared adapter wiring for direct-platform webhook verification and decoding. */
5
+ export declare function directWebhooks(platform: DirectWebhookPlatform, verify: (input: {
6
+ readonly headers: Headers;
7
+ readonly body: Uint8Array;
8
+ }) => Promise<VerifiedWebhook>, now: () => string): WebhooksAdapter;
9
+ export declare function webhookCapability(platform: DirectWebhookPlatform, notes: string): CapabilityDeclaration;
@@ -0,0 +1,24 @@
1
+ import { decodePlatformWebhook, } from "../server/webhooks.js";
2
+ /** Shared adapter wiring for direct-platform webhook verification and decoding. */
3
+ export function directWebhooks(platform, verify, now) {
4
+ return {
5
+ async verify(input) {
6
+ // Throws `unauthorized` on any failure, matching the managed adapters.
7
+ await verify(input);
8
+ return { valid: true, method: "hmac" };
9
+ },
10
+ async decode(input, context) {
11
+ return {
12
+ ...(await decodePlatformWebhook({
13
+ platform,
14
+ backend: context.backendInstance,
15
+ body: input.body,
16
+ receivedAt: now(),
17
+ })),
18
+ };
19
+ },
20
+ };
21
+ }
22
+ export function webhookCapability(platform, notes) {
23
+ return { platform, operation: "webhooks.verify", availability: "available", notes };
24
+ }
@@ -1,5 +1,7 @@
1
1
  import { SocialError } from "../core/errors.js";
2
2
  import { managedHttp } from "../cloud/common.js";
3
+ import { definedFields } from "../core/fields.js";
4
+ import { isBoolean, isJsonObject } from "../transport/validation.js";
3
5
  async function mutate(action, tweetId, account, options, context) {
4
6
  if (account.kind !== "connected-account" ||
5
7
  account.version !== 1 ||
@@ -19,26 +21,19 @@ async function mutate(action, tweetId, account, options, context) {
19
21
  });
20
22
  const request = managedHttp("https://api.x.com", {
21
23
  apiKey: options.accessToken,
22
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
23
- ...(options.fetch ? { fetch: options.fetch } : {}),
24
+ ...definedFields({ fetch: options.fetch }),
24
25
  });
25
26
  const result = await request(`/2/users/${encodeURIComponent(options.userId)}/likes${action === "unlike" ? `/${tweetId}` : ""}`, context, action === "like" ? { tweet_id: tweetId } : undefined, {}, action === "like" ? "POST" : "DELETE");
26
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
27
- const data = result && typeof result === "object" && "data" in result ? result.data : undefined;
28
- if (!data ||
29
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
30
- typeof data !== "object" ||
31
- !("liked" in data) ||
32
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
33
- typeof data.liked !== "boolean" ||
34
- data.liked !== (action === "like"))
27
+ const data = isJsonObject(result) ? result["data"] : undefined;
28
+ const liked = isJsonObject(data) ? data["liked"] : undefined;
29
+ if (!isBoolean(liked) || liked !== (action === "like"))
35
30
  throw new SocialError({
36
31
  code: "ambiguous_outcome",
37
32
  operation: `x.${action}`,
38
33
  message: "X did not confirm the requested reaction state. Reconcile before retrying.",
39
34
  retryDisposition: { kind: "reconcile-first" },
40
35
  });
41
- return { liked: data.liked, tweetId, userId: options.userId };
36
+ return { liked, tweetId, userId: options.userId };
42
37
  }
43
38
  /** Requires like.write, tweet.read, users.read and the app's current API access. Never retries writes. */
44
39
  export function xLike(tweetId, account, options, context) {
@@ -0,0 +1,83 @@
1
+ import type { AdapterOperationContext, JsonObject, JsonValue } from "../core/types.js";
2
+ import type { XMediaField, XTweetExpansion, XTweetField, XUserField } from "./x.js";
3
+ /** X sends a `\r\n` keep-alive at least every 20 seconds and recommends a 20-second read timeout. */
4
+ export declare const xStreamDefaultStallTimeoutMs = 20000;
5
+ /** A filtered-stream rule as stored by X. */
6
+ export interface XStreamRule {
7
+ readonly id: string;
8
+ readonly value: string;
9
+ readonly tag?: string;
10
+ }
11
+ /** A rule to add. X limits values to 1,024 characters on pay-per-use and 2,048 on Enterprise. */
12
+ export interface XStreamRuleInput {
13
+ readonly value: string;
14
+ readonly tag?: string;
15
+ }
16
+ /** Result of one rule mutation. `errors` holds per-rule rejections that X returned with HTTP 200. */
17
+ export interface XStreamRulesUpdate {
18
+ readonly dryRun: boolean;
19
+ readonly rules: readonly XStreamRule[];
20
+ readonly summary?: JsonObject;
21
+ readonly errors: readonly JsonObject[];
22
+ }
23
+ export interface XMatchingRule {
24
+ readonly id: string;
25
+ readonly tag?: string;
26
+ }
27
+ /**
28
+ * One message from the filtered stream. `post` carries a matched Post. `error` carries an
29
+ * error-only message such as `operational-disconnect`; X usually closes the connection after it.
30
+ * `other` preserves message types this adapter does not recognize yet.
31
+ */
32
+ export type XStreamEvent = {
33
+ readonly kind: "post";
34
+ readonly post: JsonObject;
35
+ readonly matchingRules: readonly XMatchingRule[];
36
+ readonly includes?: JsonObject;
37
+ readonly errors?: readonly JsonObject[];
38
+ } | {
39
+ readonly kind: "error";
40
+ readonly errors: readonly JsonObject[];
41
+ } | {
42
+ readonly kind: "other";
43
+ readonly message: JsonObject;
44
+ };
45
+ export interface XStreamOptions {
46
+ readonly tweetFields?: readonly XTweetField[];
47
+ readonly expansions?: readonly XTweetExpansion[];
48
+ readonly userFields?: readonly XUserField[];
49
+ readonly mediaFields?: readonly XMediaField[];
50
+ /**
51
+ * Minutes (1-5) of Posts to replay after a short disconnection. Enterprise access only.
52
+ * `0` or omitted means no backfill; the SDK then leaves `backfill_minutes` off the request.
53
+ */
54
+ readonly backfillMinutes?: number;
55
+ /** Recovery window start (ISO 8601, within the last 24 hours). Enterprise access only. */
56
+ readonly startTime?: string;
57
+ /** Recovery window end (ISO 8601). X closes the connection after the window is replayed. */
58
+ readonly endTime?: string;
59
+ /** Fail with `timeout` when neither data nor a keep-alive arrives within this window. */
60
+ readonly stallTimeoutMs?: number;
61
+ }
62
+ interface StreamConfig {
63
+ readonly bearerToken: string;
64
+ readonly fetch?: typeof globalThis.fetch | undefined;
65
+ }
66
+ /** Parse one rule object from X, rejecting malformed identifiers. */
67
+ export declare function parseStreamRule(value: JsonValue | undefined, operation: string): XStreamRule;
68
+ /** Parse the rule-mutation response while keeping per-rule errors. */
69
+ export declare function parseRulesUpdate(value: JsonValue, dryRun: boolean): XStreamRulesUpdate;
70
+ /** Validate one rule for local bounds. X enforces the tier-specific limit. */
71
+ export declare function validateRuleInput(rule: XStreamRuleInput): void;
72
+ export declare function validateRuleIds(ids: readonly string[], operation: string, max: number): void;
73
+ /** Classify one newline-delimited message. */
74
+ export declare function parseStreamMessage(line: string): XStreamEvent;
75
+ /** Validate caller options and build the connection URL. Performs no I/O. */
76
+ export declare function streamUrl(options: XStreamOptions): URL;
77
+ /**
78
+ * Open one filtered-stream connection and yield its messages. The connection is made when
79
+ * iteration starts and closes when the caller stops iterating, the context signal aborts, the
80
+ * stall timeout elapses, or X ends the response. This function never reconnects.
81
+ */
82
+ export declare function readFilteredStream(config: StreamConfig, options: XStreamOptions, context: AdapterOperationContext): AsyncGenerator<XStreamEvent, void, undefined>;
83
+ export {};