@volter/twin-tiktok 0.1.37 → 1.0.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/README.md +25 -308
- package/business/spec/SOURCE.md +17 -0
- package/business/spec/openapi.json +1045 -0
- package/business/spec/sources.json +84 -0
- package/business/src/fetch.ts +14 -0
- package/business/src/generated/surface.gen.json +1 -0
- package/business/src/generated/ui.gen.json +1 -0
- package/business/src/index.ts +3 -0
- package/business/src/manifest.ts +38 -0
- package/business/src/semantics/index.ts +16 -0
- package/business/src/semantics/open_api.ts +142 -0
- package/business/src/semantics/shared.ts +119 -0
- package/business/src/semantics/states.ts +3 -0
- package/dist/business/spec/SOURCE.md +17 -0
- package/dist/business/spec/openapi.json +1045 -0
- package/dist/business/spec/sources.json +84 -0
- package/dist/business/src/fetch.d.ts +2 -0
- package/dist/business/src/fetch.js +13 -0
- package/dist/business/src/fetch.ts +14 -0
- package/dist/business/src/generated/surface.gen.json +1 -0
- package/dist/business/src/generated/ui.gen.json +1 -0
- package/dist/business/src/index.d.ts +2 -0
- package/dist/business/src/index.js +3 -0
- package/dist/business/src/index.ts +3 -0
- package/dist/business/src/manifest.d.ts +2 -0
- package/dist/business/src/manifest.js +33 -0
- package/dist/business/src/manifest.ts +38 -0
- package/dist/business/src/semantics/index.d.ts +2 -0
- package/dist/business/src/semantics/index.js +12 -0
- package/dist/business/src/semantics/index.ts +16 -0
- package/dist/business/src/semantics/open_api.d.ts +10 -0
- package/dist/business/src/semantics/open_api.js +162 -0
- package/dist/business/src/semantics/open_api.ts +142 -0
- package/dist/business/src/semantics/shared.d.ts +31 -0
- package/dist/business/src/semantics/shared.js +109 -0
- package/dist/business/src/semantics/shared.ts +119 -0
- package/dist/business/src/semantics/states.d.ts +8 -0
- package/dist/business/src/semantics/states.js +3 -0
- package/dist/business/src/semantics/states.ts +3 -0
- package/dist/generated/pack-facts.json +55 -0
- package/dist/journeys/coverage.json +220 -0
- package/dist/journeys/customer-life.json +2950 -0
- package/dist/journeys/customer-life.outline.md +59 -0
- package/dist/journeys/decisions.json +161 -0
- package/dist/journeys/demand.json +142 -0
- package/dist/journeys/score.json +294 -0
- package/dist/journeys/unreachable.json +13 -0
- package/dist/open/spec/SOURCE.md +20 -0
- package/dist/open/spec/openapi.json +1417 -0
- package/dist/open/spec/sources.json +124 -0
- package/dist/open/src/engine/mp4.d.ts +6 -0
- package/dist/open/src/engine/mp4.js +55 -0
- package/dist/open/src/engine/mp4.ts +48 -0
- package/dist/open/src/fetch.d.ts +2 -0
- package/dist/open/src/fetch.js +17 -0
- package/dist/open/src/fetch.ts +18 -0
- package/dist/open/src/generated/surface.gen.json +1 -0
- package/dist/open/src/generated/ui.gen.json +1 -0
- package/dist/open/src/index.d.ts +2 -0
- package/dist/open/src/index.js +3 -0
- package/dist/open/src/index.ts +3 -0
- package/dist/open/src/manifest.d.ts +2 -0
- package/dist/open/src/manifest.js +53 -0
- package/dist/open/src/manifest.ts +59 -0
- package/dist/open/src/screens/authorize.d.ts +2 -0
- package/dist/open/src/screens/authorize.js +53 -0
- package/dist/open/src/screens/authorize.ts +53 -0
- package/dist/open/src/semantics/doors.d.ts +10 -0
- package/dist/open/src/semantics/doors.js +118 -0
- package/dist/open/src/semantics/doors.ts +121 -0
- package/dist/open/src/semantics/index.d.ts +2 -0
- package/dist/open/src/semantics/index.js +16 -0
- package/dist/open/src/semantics/index.ts +20 -0
- package/dist/open/src/semantics/oauth.d.ts +2 -0
- package/dist/open/src/semantics/oauth.js +30 -0
- package/dist/open/src/semantics/oauth.ts +28 -0
- package/dist/open/src/semantics/post.d.ts +6 -0
- package/dist/open/src/semantics/post.js +66 -0
- package/dist/open/src/semantics/post.ts +60 -0
- package/dist/open/src/semantics/shared.d.ts +51 -0
- package/dist/open/src/semantics/shared.js +196 -0
- package/dist/open/src/semantics/shared.ts +197 -0
- package/dist/open/src/semantics/states.d.ts +20 -0
- package/dist/open/src/semantics/states.js +15 -0
- package/dist/open/src/semantics/states.ts +23 -0
- package/dist/open/src/semantics/user.d.ts +2 -0
- package/dist/open/src/semantics/user.js +24 -0
- package/dist/open/src/semantics/user.ts +24 -0
- package/dist/open/src/semantics/video.d.ts +4 -0
- package/dist/open/src/semantics/video.js +112 -0
- package/dist/open/src/semantics/video.ts +97 -0
- package/dist/src/cli.js +11 -34
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +9 -0
- package/dist/src/index.d.ts +5 -22
- package/dist/src/index.js +13 -167
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +28 -0
- package/dist/src/semantics/shared.d.ts +49 -0
- package/dist/src/semantics/shared.js +61 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +7 -0
- package/generated/pack-facts.json +55 -0
- package/journeys/coverage.json +220 -0
- package/journeys/customer-life.json +2950 -0
- package/journeys/customer-life.outline.md +59 -0
- package/journeys/decisions.json +161 -0
- package/journeys/demand.json +142 -0
- package/journeys/score.json +294 -0
- package/journeys/unreachable.json +13 -0
- package/open/spec/SOURCE.md +20 -0
- package/open/spec/openapi.json +1417 -0
- package/open/spec/sources.json +124 -0
- package/open/src/engine/mp4.ts +48 -0
- package/open/src/fetch.ts +18 -0
- package/open/src/generated/surface.gen.json +1 -0
- package/open/src/generated/ui.gen.json +1 -0
- package/open/src/index.ts +3 -0
- package/open/src/manifest.ts +59 -0
- package/open/src/screens/authorize.ts +53 -0
- package/open/src/semantics/doors.ts +121 -0
- package/open/src/semantics/index.ts +20 -0
- package/open/src/semantics/oauth.ts +28 -0
- package/open/src/semantics/post.ts +60 -0
- package/open/src/semantics/shared.ts +197 -0
- package/open/src/semantics/states.ts +23 -0
- package/open/src/semantics/user.ts +24 -0
- package/open/src/semantics/video.ts +97 -0
- package/package.json +13 -41
- package/src/cli.ts +11 -32
- package/src/fetch.ts +10 -0
- package/src/index.ts +15 -270
- package/src/manifest.ts +33 -0
- package/src/semantics/shared.ts +78 -0
- package/src/server.ts +8 -0
- package/client/tiktok-consent.tsx +0 -154
- package/client/tiktok-mirror.css +0 -137
- package/client/tiktok-mirror.tsx +0 -497
- package/dist/client/tiktok-consent.bundle.js +0 -18
- package/dist/client/tiktok-consent.d.ts +0 -47
- package/dist/client/tiktok-consent.js +0 -20
- package/dist/client/tiktok-consent.tsx +0 -154
- package/dist/client/tiktok-mirror.bundle.js +0 -484
- package/dist/client/tiktok-mirror.css +0 -137
- package/dist/client/tiktok-mirror.d.ts +0 -42
- package/dist/client/tiktok-mirror.js +0 -325
- package/dist/client/tiktok-mirror.tsx +0 -497
- package/dist/src/tiktok-blobs.d.ts +0 -66
- package/dist/src/tiktok-blobs.js +0 -161
- package/dist/src/tiktok-budget.d.ts +0 -56
- package/dist/src/tiktok-budget.js +0 -136
- package/dist/src/tiktok-capabilities.d.ts +0 -7
- package/dist/src/tiktok-capabilities.js +0 -1855
- package/dist/src/tiktok-conformance.d.ts +0 -11
- package/dist/src/tiktok-conformance.js +0 -498
- package/dist/src/tiktok-connector.d.ts +0 -158
- package/dist/src/tiktok-connector.js +0 -600
- package/dist/src/tiktok-consent-ui.d.ts +0 -19
- package/dist/src/tiktok-consent-ui.js +0 -127
- package/dist/src/tiktok-errors.d.ts +0 -78
- package/dist/src/tiktok-errors.js +0 -175
- package/dist/src/tiktok-ids.d.ts +0 -16
- package/dist/src/tiktok-ids.js +0 -48
- package/dist/src/tiktok-media.d.ts +0 -7
- package/dist/src/tiktok-media.js +0 -86
- package/dist/src/tiktok-mirror-ui.d.ts +0 -61
- package/dist/src/tiktok-mirror-ui.js +0 -187
- package/dist/src/tiktok-pkce.d.ts +0 -25
- package/dist/src/tiktok-pkce.js +0 -56
- package/dist/src/tiktok-posting.d.ts +0 -100
- package/dist/src/tiktok-posting.js +0 -599
- package/dist/src/tiktok-sample-mp4.d.ts +0 -10
- package/dist/src/tiktok-sample-mp4.js +0 -55
- package/dist/src/tiktok-scopes.d.ts +0 -29
- package/dist/src/tiktok-scopes.js +0 -106
- package/dist/src/tiktok-server.d.ts +0 -28
- package/dist/src/tiktok-server.js +0 -99
- package/dist/src/tiktok-store.d.ts +0 -164
- package/dist/src/tiktok-store.js +0 -451
- package/dist/src/tiktok-twin.d.ts +0 -70
- package/dist/src/tiktok-twin.js +0 -1197
- package/dist/src/tiktok-user.d.ts +0 -28
- package/dist/src/tiktok-user.js +0 -174
- package/src/tiktok-blobs.ts +0 -217
- package/src/tiktok-budget.ts +0 -163
- package/src/tiktok-capabilities.ts +0 -2022
- package/src/tiktok-conformance.ts +0 -526
- package/src/tiktok-connector.ts +0 -637
- package/src/tiktok-consent-ui.ts +0 -146
- package/src/tiktok-errors.ts +0 -197
- package/src/tiktok-ids.ts +0 -51
- package/src/tiktok-journey.uitest.ts +0 -305
- package/src/tiktok-media.ts +0 -89
- package/src/tiktok-mirror-ui.ts +0 -193
- package/src/tiktok-pkce.ts +0 -61
- package/src/tiktok-posting.ts +0 -617
- package/src/tiktok-sample-mp4.ts +0 -54
- package/src/tiktok-scopes.ts +0 -122
- package/src/tiktok-server.ts +0 -110
- package/src/tiktok-store.ts +0 -543
- package/src/tiktok-twin.ts +0 -1361
- package/src/tiktok-user.ts +0 -137
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mp4Facts } from '../engine/mp4.ts';
|
|
2
|
+
// What every family of the open lane shares beyond the vendor's (../../../src/semantics/shared.ts): the open platform's
|
|
3
|
+
// envelope, the scopes its apps are granted, and who a request is.
|
|
4
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
5
|
+
import { ACCESS, ACCESS_TTL, APP, CHUNK, CODE, PERSON, PUBLISH, REFRESH, REFRESH_TTL, VIDEO, appByKey, bySha, clientSecret, credential, mediaUrl, next, openIdOf, privacyOptions, seconds, sha256, type Row, unionIdOf, videoMoment, videoPage } from '../../../src/semantics/shared.ts';
|
|
6
|
+
|
|
7
|
+
export * from '../../../src/semantics/shared.ts';
|
|
8
|
+
export type { HandlerContext };
|
|
9
|
+
|
|
10
|
+
/** The scopes an app on the developer site is granted: every product scope Postiz asks for (the World's app is set up
|
|
11
|
+
* with the products it uses). */
|
|
12
|
+
export const APP_SCOPES = ['user.info.basic', 'user.info.profile', 'user.info.stats', 'video.list', 'video.publish', 'video.upload'];
|
|
13
|
+
/** The scopes a business app on the ads portal is granted: those Postiz's TikTok Business provider asks for. */
|
|
14
|
+
export const BUSINESS_SCOPES = ['user.info.basic', 'user.info.username', 'user.info.stats', 'video.list', 'video.upload', 'video.publish'];
|
|
15
|
+
|
|
16
|
+
// ── the envelope ───────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info "Any code other than"
|
|
18
|
+
/** An answer: `{data, error: {code: 'ok', message: '', log_id}}`. */
|
|
19
|
+
export const logId = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, what: string): string =>
|
|
20
|
+
`${ctx.occurredAt.replace(/\D/g, '').slice(0, 14)}${ctx.crypto.digest('sha256', `${ctx.occurredAt}:${what}`, 'hex').slice(0, 20).toUpperCase()}`;
|
|
21
|
+
export const ok = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, data: unknown): Response =>
|
|
22
|
+
Response.json({ data, error: { code: 'ok', message: '', log_id: logId(ctx, JSON.stringify(data)) } });
|
|
23
|
+
/** A refusal in the same envelope. */
|
|
24
|
+
export const refuse = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, status: number, code: string, message: string): Response =>
|
|
25
|
+
Response.json({ data: {}, error: { code, message, log_id: logId(ctx, code) } }, { status });
|
|
26
|
+
|
|
27
|
+
/** Who a request is: the access token it bears, live, with its app, its person and its scopes. */
|
|
28
|
+
export type Caller = { token: Row; app: Row; person: Row; scopes: string[]; openId: string };
|
|
29
|
+
export function callerOf(ctx: HandlerContext): Caller | undefined {
|
|
30
|
+
const value = /^Bearer\s+(\S+)$/i.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
31
|
+
const token = value ? bySha(ctx, ACCESS, value) : undefined;
|
|
32
|
+
if (!token || token.kind === 'business' || token.revoked === true || Number(token.expires_at) <= seconds(ctx.occurredAt)) return undefined;
|
|
33
|
+
const app = ctx.row(APP, String(token.app));
|
|
34
|
+
const person = ctx.row(PERSON, String(token.person));
|
|
35
|
+
if (!app || !person) return undefined;
|
|
36
|
+
return { token, app, person, scopes: String(token.scope).split(','), openId: openIdOf(ctx, app, person) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info "The access_token is invalid or has expired."
|
|
40
|
+
export const invalidToken = (ctx: HandlerContext): Response => refuse(ctx, 401, 'access_token_invalid', 'The access token is invalid or not found in the request.');
|
|
41
|
+
/** The caller, or the refusal: no live token (401 access_token_invalid), or none of the scopes named (401
|
|
42
|
+
* scope_not_authorized). */
|
|
43
|
+
export function authorized(ctx: HandlerContext, ...scopes: string[]): Caller | Response {
|
|
44
|
+
const c = callerOf(ctx);
|
|
45
|
+
if (!c) return invalidToken(ctx);
|
|
46
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info "The access_token does not bear user's grant on"
|
|
47
|
+
if (scopes.length && !scopes.some((s) => c.scopes.includes(s))) return refuse(ctx, 401, 'scope_not_authorized', `The user did not authorize the scope required for completing this request (${scopes.join(' or ')}).`);
|
|
48
|
+
return c;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
export const oauthError = (ctx: HandlerContext, error: string, description: string): Response =>
|
|
53
|
+
Response.json({ error, error_description: description, log_id: logId(ctx, error) }, { status: error === 'invalid_client' ? 401 : 400 });
|
|
54
|
+
|
|
55
|
+
/** Whether a code's PKCE challenge refuses the verifier: TikTok's desktop flow sends the challenge as the verifier's
|
|
56
|
+
* SHA-256 in hex; a code made without one is not checked. */
|
|
57
|
+
export const pkceRefused = (ctx: HandlerContext, code: Row, verifier: unknown): boolean =>
|
|
58
|
+
typeof code.challenge === 'string' && ctx.crypto.digest('sha256', String(verifier ?? ''), 'hex') !== code.challenge;
|
|
59
|
+
|
|
60
|
+
export async function issue(ctx: HandlerContext, app: Row, person: Row, scope: string): Promise<Response> {
|
|
61
|
+
const now = seconds(ctx.occurredAt);
|
|
62
|
+
const access = await credential(ctx, ACCESS, 'act', { app: app.id, person: person.id, scope, expires_at: now + ACCESS_TTL, revoked: false });
|
|
63
|
+
const refresh = await credential(ctx, REFRESH, 'rft', { app: app.id, person: person.id, scope, access: access.id, expires_at: now + REFRESH_TTL, used: false });
|
|
64
|
+
return Response.json({
|
|
65
|
+
access_token: access.value, expires_in: ACCESS_TTL, open_id: openIdOf(ctx, app, person), refresh_expires_in: REFRESH_TTL,
|
|
66
|
+
refresh_token: refresh.value, scope, token_type: 'Bearer',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info "The set of user fields to request for"
|
|
71
|
+
export const FIELD_SCOPE: Record<string, string> = {
|
|
72
|
+
open_id: 'user.info.basic', union_id: 'user.info.basic', avatar_url: 'user.info.basic', avatar_url_100: 'user.info.basic', avatar_large_url: 'user.info.basic', display_name: 'user.info.basic',
|
|
73
|
+
bio_description: 'user.info.profile', profile_deep_link: 'user.info.profile', is_verified: 'user.info.profile', username: 'user.info.profile',
|
|
74
|
+
follower_count: 'user.info.stats', following_count: 'user.info.stats', likes_count: 'user.info.stats', video_count: 'user.info.stats',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const videosObj = (v: unknown): Row => (v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {});
|
|
78
|
+
|
|
79
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-video-query "choose from"
|
|
80
|
+
export const FIELDS = ['id', 'create_time', 'cover_image_url', 'share_url', 'video_description', 'duration', 'height', 'width', 'title', 'embed_html', 'embed_link', 'like_count', 'comment_count', 'share_count', 'view_count', 'is_aigc'];
|
|
81
|
+
|
|
82
|
+
export function fieldsOf(ctx: HandlerContext): string[] | Response {
|
|
83
|
+
const fields = String(new URL(ctx.call.request.url).searchParams.get('fields') ?? '').split(',').map((f) => f.trim()).filter(Boolean);
|
|
84
|
+
const unknown = fields.filter((f) => !FIELDS.includes(f));
|
|
85
|
+
return !fields.length || unknown.length ? refuse(ctx, 400, 'invalid_params', `Invalid fields: ${unknown.join(',') || '(none)'}`) : fields;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const project = (ctx: HandlerContext, v: Row, fields: string[]): Row => {
|
|
89
|
+
const served = ctx.get(VIDEO, String(v.id)) ?? {};
|
|
90
|
+
return Object.fromEntries(fields.map((f) => [f, served[f]]));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const publishObj = (v: unknown): Row => (v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {});
|
|
94
|
+
|
|
95
|
+
export const MB = 1024 * 1024;
|
|
96
|
+
|
|
97
|
+
export const UPLOAD_HOST = 'https://open-upload.tiktokapis.com';
|
|
98
|
+
|
|
99
|
+
export const invalid = (ctx: HandlerContext, message: string): Response => refuse(ctx, 400, 'invalid_param', message);
|
|
100
|
+
|
|
101
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "Each chunk must be at least 5 MB but no greater than 64 MB, except for the final chunk, which can be greater than"
|
|
102
|
+
/** The chunk plan's refusal, or undefined when it holds: whole below 5 MB, chunks of 5–64 MB, floor(size/chunk) of
|
|
103
|
+
* them, 1–1000, at most 4 GB in all, and the final chunk (the whole of a one-chunk plan) at most 128 MB. */
|
|
104
|
+
export function chunkRefusal(size: number, chunk: number, count: number): string | undefined {
|
|
105
|
+
if (!Number.isInteger(size) || size <= 0) return 'video_size must be a positive integer';
|
|
106
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "Maximum of 4GB"
|
|
107
|
+
if (size > 4 * 1024 * MB) return 'video_size is over 4GB';
|
|
108
|
+
if (!Number.isInteger(chunk) || chunk <= 0 || !Number.isInteger(count)) return 'chunk_size and total_chunk_count are required for FILE_UPLOAD';
|
|
109
|
+
if (size < 5 * MB) return chunk === size && count === 1 ? undefined : 'a video under 5 MB is uploaded whole: chunk_size equal to video_size, total_chunk_count 1';
|
|
110
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "should be equal to"
|
|
111
|
+
if (count !== Math.floor(size / chunk)) return 'total_chunk_count must be video_size divided by chunk_size, rounded down';
|
|
112
|
+
if (count < 1 || count > 1000) return 'total_chunk_count must be between 1 and 1000';
|
|
113
|
+
if (count > 1 && (chunk < 5 * MB || chunk > 64 * MB)) return 'chunk_size must be between 5 MB and 64 MB';
|
|
114
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "which can be greater than"
|
|
115
|
+
if (size - chunk * (count - 1) > 128 * MB) return 'the final chunk must be at most 128 MB';
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function initVideo(ctx: HandlerContext, direct: boolean): Promise<Response> {
|
|
120
|
+
const c = authorized(ctx, direct ? 'video.publish' : 'video.upload');
|
|
121
|
+
if (c instanceof Response) return c;
|
|
122
|
+
const b = publishObj(ctx.body);
|
|
123
|
+
const post = publishObj(b.post_info);
|
|
124
|
+
const source = publishObj(b.source_info);
|
|
125
|
+
if (direct) {
|
|
126
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post "privacy_level is not specified or not among the options from the privacy_level_options returned in /publish/creator_info/query/ API."
|
|
127
|
+
if (!privacyOptions(c.person).includes(String(post.privacy_level))) return refuse(ctx, 403, 'privacy_level_option_mismatch', 'privacy_level is not specified or not among the options from the privacy_level_options returned in /publish/creator_info/query/ API.');
|
|
128
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post "The maximum length is 2200 in UTF-16 runes."
|
|
129
|
+
if (typeof post.title === 'string' && post.title.length > 2200) return invalid(ctx, 'title is over 2200 characters');
|
|
130
|
+
}
|
|
131
|
+
// Where the twin stops: Postiz uploads its videos (FILE_UPLOAD); a video pulled from a URL is the twin's gap, refused
|
|
132
|
+
// as such, never answered as though pulled
|
|
133
|
+
if (source.source === 'PULL_FROM_URL') return invalid(ctx, 'the twin does not model PULL_FROM_URL video posts');
|
|
134
|
+
if (source.source !== 'FILE_UPLOAD') return invalid(ctx, 'source must be FILE_UPLOAD or PULL_FROM_URL');
|
|
135
|
+
const size = Number(source.video_size);
|
|
136
|
+
const bad = chunkRefusal(size, Number(source.chunk_size), Number(source.total_chunk_count));
|
|
137
|
+
if (bad) return invalid(ctx, bad);
|
|
138
|
+
const n = await next(ctx, 'publish');
|
|
139
|
+
const id = `${direct ? 'v_pub_file' : 'v_inbox_file'}~v2.${n}`;
|
|
140
|
+
const uploadId = String(7_300_000_000_000_000_000n + BigInt(n));
|
|
141
|
+
const token = (await ctx.secret(`tiktok-upload-token:${id}`)).replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
|
|
142
|
+
await ctx.write(PUBLISH, id, {
|
|
143
|
+
status: 'PROCESSING_UPLOAD', fail_reason: '', publicaly_available_post_id: [], uploaded_bytes: 0,
|
|
144
|
+
_person: c.person.id, _app: c.app.id, _kind: direct ? 'direct' : 'inbox', _media: 'VIDEO', _post_info: direct ? post : {},
|
|
145
|
+
_video_size: size, _chunk_size: Number(source.chunk_size), _chunks: Number(source.total_chunk_count),
|
|
146
|
+
_upload_id: uploadId, _upload_token_sha256: sha256(ctx, token), _upload_expires: seconds(ctx.occurredAt) + 3600,
|
|
147
|
+
}, direct ? 'videoInit' : 'inboxVideoInit');
|
|
148
|
+
return ok(ctx, { publish_id: id, upload_url: `${UPLOAD_HOST}/video/?upload_id=${uploadId}&upload_token=${token}` });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A pull from URLs: each must be under a URL property the app verified (403 url_ownership_unverified); the vendor
|
|
152
|
+
* pulls them through the World (ctx.vendorFetch, the World's one network) and a URL the World does not serve fails the
|
|
153
|
+
* post (photo_pull_failed, video_pull_failed). */
|
|
154
|
+
export async function pull(ctx: HandlerContext, c: Caller, kind: 'direct' | 'inbox', media: 'PHOTO', urls: string[], post: Row): Promise<Response> {
|
|
155
|
+
const app = ctx.row(APP, String(c.app.id))!;
|
|
156
|
+
const props = (app.url_properties as string[] | undefined) ?? [];
|
|
157
|
+
const verified = (url: string): boolean => {
|
|
158
|
+
let u: URL;
|
|
159
|
+
try { u = new URL(url); } catch { return false; }
|
|
160
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "The media URL must use"
|
|
161
|
+
if (u.protocol !== 'https:') return false;
|
|
162
|
+
return props.some((p) => (p.includes('/') ? url.startsWith(p) : u.hostname === p || u.hostname.endsWith(`.${p}`)));
|
|
163
|
+
};
|
|
164
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post "the developer must verify the ownership of the URL prefix or domain"
|
|
165
|
+
if (!urls.every(verified)) return refuse(ctx, 403, 'url_ownership_unverified', 'To use PULL_FROM_URL as the media transfer method, the developer must verify the ownership of the URL prefix or domain.');
|
|
166
|
+
const n = await next(ctx, 'publish');
|
|
167
|
+
const id = `p_${kind === 'direct' ? 'pub' : 'inbox'}_url~v2.${n}`;
|
|
168
|
+
await ctx.write(PUBLISH, id, {
|
|
169
|
+
status: 'PROCESSING_DOWNLOAD', fail_reason: '', publicaly_available_post_id: [], downloaded_bytes: 0,
|
|
170
|
+
_person: c.person.id, _app: c.app.id, _kind: kind, _media: media, _post_info: post, _urls: urls,
|
|
171
|
+
}, 'contentInit');
|
|
172
|
+
let downloaded = 0;
|
|
173
|
+
let failed = false;
|
|
174
|
+
for (const url of urls) {
|
|
175
|
+
try {
|
|
176
|
+
const r = await ctx.vendorFetch(url, { method: 'GET' });
|
|
177
|
+
if (!r.ok) { failed = true; break; }
|
|
178
|
+
downloaded += (await r.arrayBuffer()).byteLength;
|
|
179
|
+
} catch { failed = true; break; }
|
|
180
|
+
}
|
|
181
|
+
const to = failed ? 'FAILED' : kind === 'direct' ? 'PUBLISH_COMPLETE' : 'SEND_TO_USER_INBOX';
|
|
182
|
+
const refused = ctx.legal(PUBLISH, 'status', 'contentInit', 'PROCESSING_DOWNLOAD', to, id, 'vendor');
|
|
183
|
+
if (refused) throw new Error(refused.message);
|
|
184
|
+
const postId = !failed && kind === 'direct' && post.privacy_level === 'PUBLIC_TO_EVERYONE' ? String(7_400_000_000_000_000_000 + (await next(ctx, 'video')) * 1024) : undefined;
|
|
185
|
+
await ctx.write(PUBLISH, id, {
|
|
186
|
+
status: to, downloaded_bytes: downloaded, ...(failed ? { fail_reason: 'photo_pull_failed' } : {}),
|
|
187
|
+
...(postId ? { publicaly_available_post_id: [Number(postId)] } : {}),
|
|
188
|
+
}, 'contentInit');
|
|
189
|
+
return ok(ctx, { publish_id: id });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A chunk that is not the last: its progress kept, answered 206 with the bytes so far. */
|
|
193
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "There are additional chunks yet to be uploaded."
|
|
194
|
+
export async function partialChunk(ctx: HandlerContext, id: string, now: number, size: number): Promise<Response> {
|
|
195
|
+
await ctx.write(PUBLISH, id, { uploaded_bytes: now }, 'uploadVideo');
|
|
196
|
+
return new Response(null, { status: 206, headers: { 'content-range': `bytes 0-${now - 1}/${size}` } });
|
|
197
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// TikTok's state machines, shared by its lanes (architecture, "State"; a lane imports its vendor's states): a post's
|
|
2
|
+
// status, whether it came through the Content Posting API (the open lane) or the API for Business (the business lane).
|
|
3
|
+
// Its transitions name no operation: each is the vendor's move whichever lane's call it follows.
|
|
4
|
+
import type { StateField } from '@volter/world-core';
|
|
5
|
+
|
|
6
|
+
const STATUS = 'https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status';
|
|
7
|
+
|
|
8
|
+
/** A post's `status`: an upload begins PROCESSING_UPLOAD ("Indicates that the upload is in process"), a pull from URL
|
|
9
|
+
* PROCESSING_DOWNLOAD (written so at the post's start); the vendor finishes it when the last chunk arrives or the pull
|
|
10
|
+
* ends. Not made by the twin: a post the creator makes from an inbox draft (PUBLISH_COMPLETE after SEND_TO_USER_INBOX,
|
|
11
|
+
* the creator's move in the app), a moderation that fails a post, and a cancelled pull (the cancel is unmodeled). */
|
|
12
|
+
export const publishStatus: StateField = {
|
|
13
|
+
initial: 'PROCESSING_UPLOAD',
|
|
14
|
+
transitions: [
|
|
15
|
+
{ actor: 'vendor', from: ['PROCESSING_UPLOAD', 'PROCESSING_DOWNLOAD'], to: 'PUBLISH_COMPLETE', source: `${STATUS} "it indicates that the content has been posted"` },
|
|
16
|
+
{ actor: 'vendor', from: ['PROCESSING_UPLOAD', 'PROCESSING_DOWNLOAD'], to: 'SEND_TO_USER_INBOX', source: `${STATUS} "Indicates that a notification has been sent to creator's inbox to complete the draft post using TikTok's editing flow."` },
|
|
17
|
+
{ actor: 'vendor', from: ['PROCESSING_DOWNLOAD'], to: 'FAILED', source: `${STATUS} "Indicates that an error has occurred and the entire process has failed."` },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const publish = { state: { status: publishStatus }, notState: ['fail_reason', 'reason'] };
|
|
22
|
+
|
|
23
|
+
export const states = { publish };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
import { ACCESS, ACCESS_TTL, APP, CHUNK, CODE, FIELDS, FIELD_SCOPE, MB, PERSON, PUBLISH, REFRESH, REFRESH_TTL, UPLOAD_HOST, VIDEO, appByKey, authorized, bySha, chunkRefusal, clientSecret, credential, fieldsOf, initVideo, invalid, issue, logId, mediaUrl, next, oauthError, ok, openIdOf, partialChunk, pkceRefused, privacyOptions, project, publishObj, pull, refuse, seconds, sha256, type Caller, type Row, unionIdOf, videoMoment, videoPage, videosObj } from './shared.ts';
|
|
3
|
+
import { mp4Facts } from '../engine/mp4.ts';
|
|
4
|
+
|
|
5
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info "endpoint returns some basic information for a given TikTok user."
|
|
6
|
+
export async function userInfo(ctx: HandlerContext): Promise<Response> {
|
|
7
|
+
const c = authorized(ctx);
|
|
8
|
+
if (c instanceof Response) return c;
|
|
9
|
+
const fields = String(new URL(ctx.call.request.url).searchParams.get('fields') ?? '').split(',').map((f) => f.trim()).filter(Boolean);
|
|
10
|
+
const unknown = fields.filter((f) => !(f in FIELD_SCOPE));
|
|
11
|
+
if (!fields.length || unknown.length) return refuse(ctx, 400, 'invalid_params', `Invalid fields: ${unknown.join(',') || '(none)'}`);
|
|
12
|
+
const missing = [...new Set(fields.map((f) => FIELD_SCOPE[f]!))].filter((s) => !c.scopes.includes(s));
|
|
13
|
+
if (missing.length) return refuse(ctx, 401, 'scope_not_authorized', `The user did not authorize the scope required for completing this request (${missing.join(',')}).`);
|
|
14
|
+
const p = c.person;
|
|
15
|
+
// "The total number of publicly posted videos by the user": the person's public videos, counted from the set
|
|
16
|
+
const videos = ctx.rowsRaw(VIDEO).filter((v) => v.deleted !== true && v._person === p.id && v._public === true).length;
|
|
17
|
+
const avatar = mediaUrl(ctx, `avatar-${String(p.id)}`);
|
|
18
|
+
const all: Row = {
|
|
19
|
+
open_id: openIdOf(ctx, c.app, p), union_id: unionIdOf(ctx, p), avatar_url: avatar, avatar_url_100: avatar, avatar_large_url: avatar, display_name: p.display_name,
|
|
20
|
+
bio_description: p.bio_description ?? '', profile_deep_link: `https://www.tiktok.com/@${String(p.username)}`, is_verified: p.is_verified === true, username: p.username,
|
|
21
|
+
follower_count: Number(p.follower_count ?? 0), following_count: Number(p.following_count ?? 0), likes_count: Number(p.likes_count ?? 0), video_count: videos,
|
|
22
|
+
};
|
|
23
|
+
return ok(ctx, { user: Object.fromEntries(fields.map((f) => [f, all[f]])) });
|
|
24
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
import { ACCESS, ACCESS_TTL, APP, CHUNK, CODE, FIELDS, FIELD_SCOPE, MB, PERSON, PUBLISH, REFRESH, REFRESH_TTL, UPLOAD_HOST, VIDEO, appByKey, authorized, bySha, chunkRefusal, clientSecret, credential, fieldsOf, initVideo, invalid, issue, logId, mediaUrl, next, oauthError, ok, openIdOf, partialChunk, pkceRefused, privacyOptions, project, publishObj, pull, refuse, seconds, sha256, type Caller, type Row, unionIdOf, videoMoment, videoPage, videosObj } from './shared.ts';
|
|
3
|
+
import { mp4Facts } from '../engine/mp4.ts';
|
|
4
|
+
|
|
5
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-video-list "sorted by"
|
|
6
|
+
export async function videoList(ctx: HandlerContext): Promise<Response> {
|
|
7
|
+
const c = authorized(ctx, 'video.list');
|
|
8
|
+
if (c instanceof Response) return c;
|
|
9
|
+
const fields = fieldsOf(ctx);
|
|
10
|
+
if (fields instanceof Response) return fields;
|
|
11
|
+
const b = videosObj(ctx.body);
|
|
12
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-video-list "Default is 10. Maximum is 20."
|
|
13
|
+
const max = b.max_count === undefined ? 10 : Number(b.max_count);
|
|
14
|
+
if (!Number.isInteger(max) || max < 1 || max > 20) return refuse(ctx, 400, 'invalid_params', 'max_count must be between 1 and 20');
|
|
15
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-video-list "the cursor value is a"
|
|
16
|
+
const cursor = b.cursor === undefined ? Infinity : Number(b.cursor);
|
|
17
|
+
const { page, cursor: after, more } = videoPage(ctx, String(c.person.id), cursor, max);
|
|
18
|
+
return ok(ctx, { videos: page.map((v) => project(ctx, v, fields)), cursor: after, has_more: more });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-video-query "verifies that the videos belong to the user and returns video details."
|
|
22
|
+
export async function videoQuery(ctx: HandlerContext): Promise<Response> {
|
|
23
|
+
const c = authorized(ctx, 'video.list');
|
|
24
|
+
if (c instanceof Response) return c;
|
|
25
|
+
const fields = fieldsOf(ctx);
|
|
26
|
+
if (fields instanceof Response) return fields;
|
|
27
|
+
const ids = Array.isArray(videosObj(videosObj(ctx.body).filters).video_ids) ? (videosObj(videosObj(ctx.body).filters).video_ids as unknown[]).map(String) : [];
|
|
28
|
+
// source: https://developers.tiktok.com/doc/tiktok-api-v2-video-query "Up to 20 video IDs can be included per request."
|
|
29
|
+
if (!ids.length || ids.length > 20) return refuse(ctx, 400, 'invalid_params', 'filters.video_ids must hold 1 to 20 ids');
|
|
30
|
+
const mine = ctx.rowsRaw(VIDEO).filter((v) => v.deleted !== true && v._person === c.person.id && ids.includes(String(v.id)));
|
|
31
|
+
return ok(ctx, { videos: mine.map((v) => project(ctx, v, fields)) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "All parts are uploaded."
|
|
35
|
+
export async function uploadVideo(ctx: HandlerContext): Promise<Response> {
|
|
36
|
+
const q = new URL(ctx.call.request.url).searchParams;
|
|
37
|
+
const p = ctx.rowsRaw(PUBLISH).find((x) => x.deleted !== true && x._upload_id === q.get('upload_id'));
|
|
38
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "TikTok cannot find a valid upload task given the"
|
|
39
|
+
if (!p || p._upload_token_sha256 !== sha256(ctx, q.get('upload_token') ?? '')) return new Response(null, { status: 404 });
|
|
40
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "has expired."
|
|
41
|
+
if (Number(p._upload_expires) <= seconds(ctx.occurredAt)) return new Response(null, { status: 403 });
|
|
42
|
+
const size = Number(p._video_size);
|
|
43
|
+
const id = String(p.id);
|
|
44
|
+
// the chunks received are one subject each; the progress is their sum, never a running total
|
|
45
|
+
const received = (): Row[] => ctx.rowsRaw(CHUNK).filter((k) => k.publish === id);
|
|
46
|
+
const uploaded = received().reduce((n, k) => n + Number(k.bytes), 0);
|
|
47
|
+
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(ctx.call.request.headers.get('content-range') ?? '');
|
|
48
|
+
const bytes = new Uint8Array(await ctx.call.request.clone().arrayBuffer());
|
|
49
|
+
const progress = { 'content-range': `bytes 0-${Math.max(uploaded - 1, 0)}/${size}` };
|
|
50
|
+
if (!range) return new Response(null, { status: 400, headers: progress });
|
|
51
|
+
const [first, last, total] = [Number(range[1]), Number(range[2]), Number(range[3])];
|
|
52
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "does not reflect the actual upload progress."
|
|
53
|
+
if (p.status !== 'PROCESSING_UPLOAD' || first !== uploaded || total !== size) return new Response(null, { status: 416, headers: progress });
|
|
54
|
+
const chunk = Number(p._chunk_size);
|
|
55
|
+
const index = Math.floor(first / chunk);
|
|
56
|
+
const expected = index === Number(p._chunks) - 1 ? size - first : chunk;
|
|
57
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "does not reflect the true byte size of the binary in the request body."
|
|
58
|
+
if (last - first + 1 !== bytes.byteLength || bytes.byteLength !== expected) return new Response(null, { status: 400, headers: progress });
|
|
59
|
+
await ctx.blobs.put(`publish:${id}:${index}`, bytes);
|
|
60
|
+
await ctx.record(CHUNK, { publish: id, index, first, bytes: bytes.byteLength }, `${id}:${index}`);
|
|
61
|
+
const now = received().reduce((n, k) => n + Number(k.bytes), 0);
|
|
62
|
+
if (now < size) return partialChunk(ctx, id, now, size);
|
|
63
|
+
// the last chunk: the whole file is kept, and the post moves
|
|
64
|
+
const parts: Uint8Array[] = [];
|
|
65
|
+
for (let i = 0; i < Number(p._chunks); i++) parts.push((await ctx.blobs.get(`publish:${id}:${i}`)) ?? new Uint8Array(0));
|
|
66
|
+
const whole = new Uint8Array(size);
|
|
67
|
+
let at = 0;
|
|
68
|
+
for (const part of parts) { whole.set(part, at); at += part.byteLength; }
|
|
69
|
+
const direct = p._kind === 'direct';
|
|
70
|
+
const to = direct ? 'PUBLISH_COMPLETE' : 'SEND_TO_USER_INBOX';
|
|
71
|
+
const refused = ctx.legal(PUBLISH, 'status', 'uploadVideo', 'PROCESSING_UPLOAD', to, id, 'vendor');
|
|
72
|
+
if (refused) throw new Error(refused.message);
|
|
73
|
+
const post = publishObj(p._post_info);
|
|
74
|
+
let postIds: number[] = [];
|
|
75
|
+
if (direct) {
|
|
76
|
+
const facts = mp4Facts(whole);
|
|
77
|
+
const n = await next(ctx, 'video');
|
|
78
|
+
const videoId = String(7_400_000_000_000_000_000 + n * 1024);
|
|
79
|
+
const person = ctx.row('_person', String(p._person));
|
|
80
|
+
const isPublic = post.privacy_level === 'PUBLIC_TO_EVERYONE';
|
|
81
|
+
const title = typeof post.title === 'string' ? post.title : '';
|
|
82
|
+
// a video as the Video Object answers it ("Max length: 150" for title and description)
|
|
83
|
+
await ctx.write(VIDEO, videoId, {
|
|
84
|
+
id: videoId, create_time: seconds(ctx.occurredAt), _moment: videoMoment(ctx, String(person?.id ?? p._person)), cover_image_url: mediaUrl(ctx, `cover-${videoId}`), share_url: `https://www.tiktok.com/@${String(person?.username)}/video/${videoId}`,
|
|
85
|
+
video_description: title.slice(0, 150), duration: facts.duration, height: facts.height, width: facts.width, title: title.slice(0, 150),
|
|
86
|
+
embed_html: `<blockquote class="tiktok-embed" cite="https://www.tiktok.com/@${String(person?.username)}/video/${videoId}" data-video-id="${videoId}"></blockquote>`,
|
|
87
|
+
embed_link: `https://www.tiktok.com/player/v1/${videoId}`, like_count: 0, comment_count: 0, share_count: 0, view_count: 0, is_aigc: post.is_aigc === true,
|
|
88
|
+
_person: p._person, _public: isPublic, _publish: id,
|
|
89
|
+
}, 'video.publish');
|
|
90
|
+
// source: https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status "is returned only if the post is published for public viewership"
|
|
91
|
+
if (isPublic) postIds = [Number(videoId)];
|
|
92
|
+
}
|
|
93
|
+
await ctx.write(PUBLISH, id, { status: to, uploaded_bytes: now, publicaly_available_post_id: postIds }, 'uploadVideo');
|
|
94
|
+
await ctx.blobs.put(`publish:${id}`, whole);
|
|
95
|
+
for (let i = 0; i < Number(p._chunks); i++) await ctx.blobs.remove(`publish:${id}:${i}`);
|
|
96
|
+
return new Response(null, { status: 201, headers: { 'content-range': `bytes 0-${now - 1}/${size}` } });
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,40 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-tiktok",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TikTok twin (Protocol 3): Login Kit's authorize page and tokens, the Content Posting API (videos by chunked upload, direct and to the inbox; photos pulled from verified URLs; post status) and the Display API (user info and stats, videos), as Postiz calls them. Built on @volter/world-core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
7
|
-
"local",
|
|
8
|
-
"mock",
|
|
9
|
-
"mirror",
|
|
10
|
-
"simulator",
|
|
11
|
-
"fixtures",
|
|
12
|
-
"testing",
|
|
13
|
-
"oauth",
|
|
14
|
-
"oauth2",
|
|
15
7
|
"tiktok",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"identity",
|
|
19
|
-
"sign-in-with-tiktok"
|
|
8
|
+
"content-posting",
|
|
9
|
+
"login-kit"
|
|
20
10
|
],
|
|
21
11
|
"author": "Volter (https://github.com/volter-ai)",
|
|
22
12
|
"license": "Apache-2.0",
|
|
23
13
|
"files": [
|
|
24
14
|
"src",
|
|
25
|
-
"
|
|
15
|
+
"journeys",
|
|
16
|
+
"business",
|
|
17
|
+
"open",
|
|
26
18
|
"README.md",
|
|
27
19
|
"LICENSE",
|
|
28
20
|
"!**/*.test.ts",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
21
|
+
"dist",
|
|
22
|
+
"generated"
|
|
31
23
|
],
|
|
32
24
|
"repository": {
|
|
33
25
|
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/volter-ai/twin.git",
|
|
35
|
-
"directory": "
|
|
26
|
+
"url": "git+https://github.com/volter-ai/twin-packs-p3.git",
|
|
27
|
+
"directory": "tiktok"
|
|
36
28
|
},
|
|
37
|
-
"homepage": "https://
|
|
29
|
+
"homepage": "https://github.com/volter-ai/twin-packs-p3/tree/main/tiktok#readme",
|
|
38
30
|
"type": "module",
|
|
39
31
|
"exports": {
|
|
40
32
|
".": {
|
|
@@ -45,28 +37,8 @@
|
|
|
45
37
|
"bin": {
|
|
46
38
|
"world-tiktok": "dist/src/cli.js"
|
|
47
39
|
},
|
|
48
|
-
"scripts": {
|
|
49
|
-
"test": "bun test src/*.test.ts",
|
|
50
|
-
"typecheck": "tsc --noEmit",
|
|
51
|
-
"build": "node ../../../scripts/publish/build.mjs",
|
|
52
|
-
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
53
|
-
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
54
|
-
},
|
|
55
|
-
"dependencies": {
|
|
56
|
-
"react": "^19.2.7",
|
|
57
|
-
"react-dom": "^19.2.7"
|
|
58
|
-
},
|
|
59
40
|
"peerDependencies": {
|
|
60
|
-
"@volter/world-core": "
|
|
61
|
-
},
|
|
62
|
-
"devDependencies": {
|
|
63
|
-
"@types/bun": "^1.2.20",
|
|
64
|
-
"@types/node": "^24.0.0",
|
|
65
|
-
"@types/react": "^19.2.17",
|
|
66
|
-
"@types/react-dom": "^19.2.3",
|
|
67
|
-
"@volter/world-core": "2.0.37",
|
|
68
|
-
"@volter/world-tooling": "0.1.0",
|
|
69
|
-
"typescript": "^5.9.0"
|
|
41
|
+
"@volter/world-core": "^3.0.38"
|
|
70
42
|
},
|
|
71
43
|
"engines": {
|
|
72
44
|
"node": ">=22.3"
|
package/src/cli.ts
CHANGED
|
@@ -1,43 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// world-tiktok
|
|
3
|
-
//
|
|
4
|
-
// `serve` starts the twin. `mirror` starts the same twin with tiktok.com's profile and player in
|
|
5
|
-
// front of it (tiktok-mirror-ui.ts: the shell at `/`, everything else the twin's own fetch adapter),
|
|
6
|
-
// and prints both screens an operator asks to "see": the mirror, and a ready-to-open authorize URL
|
|
7
|
-
// for the authorization page the twin serves at the vendor's own path.
|
|
2
|
+
// world-tiktok: serve the tiktok twin.
|
|
8
3
|
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
9
4
|
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
10
|
-
import {
|
|
11
|
-
import { createTiktokMirrorServer } from './tiktok-mirror-ui.ts';
|
|
12
|
-
import { DEFAULT_CLIENT_KEY, defaultRedirectUris } from './tiktok-store.ts';
|
|
5
|
+
import { createTiktokTwinServer } from './server.ts';
|
|
13
6
|
|
|
14
7
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
15
8
|
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
16
9
|
const root = optionValue(rest, '--root') || undefined;
|
|
17
|
-
const readOnly = hasFlag(rest, '--read-only');
|
|
10
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
11
|
+
// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to
|
|
12
|
+
const database = optionValue(rest, '--database') || undefined;
|
|
13
|
+
// the World's scenario file, for a model vendor's turns
|
|
14
|
+
const scenarioPath = optionValue(rest, '--scenario') || undefined;
|
|
18
15
|
|
|
19
|
-
if (cmd === 'serve' || cmd ===
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const origin = `http://127.0.0.1:${s.port}`;
|
|
23
|
-
process.stdout.write(`tiktok twin (Login Kit OAuth + Display API + Content Posting)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
|
|
24
|
-
if (cmd === 'mirror') {
|
|
25
|
-
process.stdout.write(`mirror (tiktok.com profile + player): ${origin}/\n`);
|
|
26
|
-
const url = new URL(`${origin}/v2/auth/authorize`);
|
|
27
|
-
url.searchParams.set('client_key', DEFAULT_CLIENT_KEY);
|
|
28
|
-
url.searchParams.set('response_type', 'code');
|
|
29
|
-
url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]!);
|
|
30
|
-
url.searchParams.set('scope', 'user.info.basic,user.info.profile,video.list');
|
|
31
|
-
url.searchParams.set('state', 'twin-demo-state');
|
|
32
|
-
process.stdout.write(`authorization page: ${url.toString()}\n`);
|
|
33
|
-
}
|
|
16
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
17
|
+
const s = await createTiktokTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });
|
|
18
|
+
process.stdout.write(`tiktok twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
34
19
|
await keepProcessAlive();
|
|
35
|
-
} else if (cmd === 'conformance') {
|
|
36
|
-
// dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
|
|
37
|
-
const { checkTikTokConformance } = await import('./tiktok-conformance.ts');
|
|
38
|
-
const report = await checkTikTokConformance({ ...(root ? { root } : {}) });
|
|
39
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
40
|
-
if (!report.ok) process.exitCode = 1;
|
|
41
20
|
} else {
|
|
42
|
-
process.stdout.write('Usage: world-tiktok serve
|
|
21
|
+
process.stdout.write('Usage: world-tiktok serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\n');
|
|
43
22
|
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the vendor's discovery door and
|
|
2
|
+
// sends each request to the lane its manifest's routes name (architecture, "Other wires: lanes").
|
|
3
|
+
import { createVendorFetch, type DerivedFetch, type PackFetchOptions } from '@volter/world-core';
|
|
4
|
+
import { manifest } from './manifest.ts';
|
|
5
|
+
import { createBusinessLaneFetch, manifest as business_manifest } from '../business/src/index.ts';
|
|
6
|
+
import { createOpenLaneFetch, manifest as open_manifest } from '../open/src/index.ts';
|
|
7
|
+
|
|
8
|
+
export function createTiktokFetch(options: PackFetchOptions = {}): DerivedFetch {
|
|
9
|
+
return createVendorFetch(manifest, { "business": { fetch: createBusinessLaneFetch(options), manifest: business_manifest }, "open": { fetch: createOpenLaneFetch(options), manifest: open_manifest } });
|
|
10
|
+
}
|