@opencoredev/social-sdk 0.2.1 → 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 +87 -0
- package/dist/platforms/x.js +648 -120
- 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
|
@@ -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>;
|
package/dist/platforms/tiktok.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
|
|
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 =
|
|
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
|
-
|
|
123
|
-
if (typeof config[key] !== "boolean")
|
|
120
|
+
if (!isBoolean(config[key]))
|
|
124
121
|
fail(`tiktok.${key}`, `Explicit ${key} choice is required.`);
|
|
125
|
-
|
|
126
|
-
if (!draft && (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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 &&
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
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
|
|
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 {};
|