d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
235 lines
7.1 KiB
TypeScript
235 lines
7.1 KiB
TypeScript
import { truncate } from '@sim/utils/string'
|
|
import type { ZodType } from 'zod'
|
|
import { type TikTokApiVideo, tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
|
|
import type { TikTokApiError, TikTokPublishResponse, TikTokVideo } from '@/tools/tiktok/types'
|
|
|
|
/**
|
|
* Default fields requested from TikTok's `/v2/user/info/` endpoint, covering the
|
|
* `user.info.basic`, `user.info.profile`, and `user.info.stats` scopes.
|
|
* `avatar_url` and `avatar_large_url` feed the file-typed `avatarFile` output.
|
|
*/
|
|
export const TIKTOK_USER_FIELDS =
|
|
'open_id,union_id,avatar_url,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count'
|
|
|
|
/**
|
|
* Fields requested from TikTok's `/v2/video/list/` and `/v2/video/query/` endpoints.
|
|
* All are available under the `video.list` scope.
|
|
*/
|
|
export const TIKTOK_VIDEO_FIELDS =
|
|
'id,title,cover_image_url,embed_link,embed_html,duration,create_time,share_url,video_description,width,height,view_count,like_count,comment_count,share_count'
|
|
|
|
export function mapTikTokVideo(video: TikTokApiVideo): TikTokVideo {
|
|
return {
|
|
id: video.id ?? '',
|
|
title: video.title ?? null,
|
|
coverImageUrl: video.cover_image_url ?? null,
|
|
embedLink: video.embed_link ?? null,
|
|
embedHtml: video.embed_html ?? null,
|
|
duration: video.duration ?? null,
|
|
createTime: video.create_time ?? null,
|
|
shareUrl: video.share_url ?? null,
|
|
videoDescription: video.video_description ?? null,
|
|
width: video.width ?? null,
|
|
height: video.height ?? null,
|
|
viewCount: video.view_count ?? null,
|
|
likeCount: video.like_count ?? null,
|
|
commentCount: video.comment_count ?? null,
|
|
shareCount: video.share_count ?? null,
|
|
}
|
|
}
|
|
|
|
interface ParsedTikTokApiResponse<TData> {
|
|
data: TData | null
|
|
error: TikTokApiError | null
|
|
rawBody: string
|
|
}
|
|
|
|
interface ParsedJsonObject {
|
|
body: Record<string, unknown> | null
|
|
error: TikTokApiError | null
|
|
rawBody: string
|
|
}
|
|
|
|
interface TikTokPublishInitResult {
|
|
success: boolean
|
|
publishId: string
|
|
error?: string
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return isRecord(value) ? value : null
|
|
}
|
|
|
|
function parseTikTokError(value: unknown): TikTokApiError | null {
|
|
const error = asRecord(value)
|
|
if (!error) return null
|
|
|
|
const code = typeof error.code === 'string' ? error.code : null
|
|
if (!code) return null
|
|
|
|
return {
|
|
code,
|
|
...(typeof error.message === 'string' ? { message: error.message } : {}),
|
|
...(typeof error.log_id === 'string' ? { logId: error.log_id } : {}),
|
|
}
|
|
}
|
|
|
|
function httpError(response: Response, rawBody: string, message?: string): TikTokApiError {
|
|
return {
|
|
code: `http_${response.status}`,
|
|
message:
|
|
message ??
|
|
`TikTok request failed with HTTP ${response.status}: ${truncate(rawBody.trim(), 300)}`,
|
|
}
|
|
}
|
|
|
|
async function readJsonObject(response: Response): Promise<ParsedJsonObject> {
|
|
const rawBody = await response.text()
|
|
let parsed: unknown
|
|
|
|
try {
|
|
parsed = JSON.parse(rawBody)
|
|
} catch {
|
|
return {
|
|
body: null,
|
|
error: response.ok
|
|
? { code: 'invalid_response', message: 'TikTok returned an invalid JSON response' }
|
|
: httpError(response, rawBody),
|
|
rawBody,
|
|
}
|
|
}
|
|
|
|
const body = asRecord(parsed)
|
|
if (!body) {
|
|
return {
|
|
body: null,
|
|
error: { code: 'invalid_response', message: 'TikTok returned an unexpected response shape' },
|
|
rawBody,
|
|
}
|
|
}
|
|
|
|
return { body, error: null, rawBody }
|
|
}
|
|
|
|
function parseApiEnvelope<TData extends object>(
|
|
response: Response,
|
|
parsed: ParsedJsonObject,
|
|
dataSchema: ZodType<TData>
|
|
): ParsedTikTokApiResponse<TData> {
|
|
if (!parsed.body) {
|
|
return { data: null, error: parsed.error, rawBody: parsed.rawBody }
|
|
}
|
|
|
|
const providerError = parseTikTokError(parsed.body.error)
|
|
if (providerError?.code && providerError.code !== 'ok') {
|
|
return { data: null, error: providerError, rawBody: parsed.rawBody }
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
data: null,
|
|
error: httpError(response, parsed.rawBody, providerError?.message),
|
|
rawBody: parsed.rawBody,
|
|
}
|
|
}
|
|
|
|
if (parsed.body.data === null || parsed.body.data === undefined) {
|
|
return { data: null, error: null, rawBody: parsed.rawBody }
|
|
}
|
|
|
|
const dataResult = dataSchema.safeParse(parsed.body.data)
|
|
if (!dataResult.success) {
|
|
return {
|
|
data: null,
|
|
error: {
|
|
code: 'invalid_response',
|
|
message: 'TikTok returned an unexpected data shape',
|
|
},
|
|
rawBody: parsed.rawBody,
|
|
}
|
|
}
|
|
|
|
return { data: dataResult.data, error: null, rawBody: parsed.rawBody }
|
|
}
|
|
|
|
/** Reads and normalizes a typed TikTok API envelope. */
|
|
export async function readTikTokApiResponse<TData extends object>(
|
|
response: Response,
|
|
dataSchema: ZodType<TData>
|
|
): Promise<ParsedTikTokApiResponse<TData>> {
|
|
return parseApiEnvelope(response, await readJsonObject(response), dataSchema)
|
|
}
|
|
|
|
/** Enforces TikTok's bounded array request limits before making a network request. */
|
|
export function assertTikTokArrayLength(values: unknown[], label: string, maximum: number): void {
|
|
if (values.length === 0) {
|
|
throw new Error(`${label} must contain at least one item`)
|
|
}
|
|
if (values.length > maximum) {
|
|
throw new Error(`${label} supports at most ${maximum} items`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The internal Sim upload route and TikTok both return publish-init envelopes.
|
|
* Reading and normalization share one boundary.
|
|
*/
|
|
export async function readTikTokPublishInitResponse(
|
|
response: Response
|
|
): Promise<TikTokPublishInitResult> {
|
|
const parsed = await readJsonObject(response)
|
|
if (!parsed.body) {
|
|
return {
|
|
success: false,
|
|
publishId: '',
|
|
error: parsed.error?.message ?? 'Failed to read publish response',
|
|
}
|
|
}
|
|
|
|
if ('success' in parsed.body) {
|
|
if (parsed.body.success !== true) {
|
|
return {
|
|
success: false,
|
|
publishId: '',
|
|
error: typeof parsed.body.error === 'string' ? parsed.body.error : 'Failed to publish',
|
|
}
|
|
}
|
|
|
|
const output = asRecord(parsed.body.output)
|
|
const publishId = typeof output?.publishId === 'string' ? output.publishId : ''
|
|
return publishId
|
|
? { success: true, publishId }
|
|
: { success: false, publishId: '', error: 'No publish ID returned' }
|
|
}
|
|
|
|
const result = parseApiEnvelope(response, parsed, tiktokPublishInitApiDataSchema)
|
|
if (result.error) {
|
|
return {
|
|
success: false,
|
|
publishId: '',
|
|
error: result.error.message ?? 'Failed to initiate post',
|
|
}
|
|
}
|
|
|
|
return result.data?.publish_id
|
|
? { success: true, publishId: result.data.publish_id }
|
|
: { success: false, publishId: '', error: 'No publish ID returned' }
|
|
}
|
|
|
|
/** Converts a normalized publish result into the shared tool response shape. */
|
|
export function toTikTokPublishToolResponse(
|
|
result: TikTokPublishInitResult
|
|
): TikTokPublishResponse {
|
|
return result.success
|
|
? { success: true, output: { publishId: result.publishId } }
|
|
: {
|
|
success: false,
|
|
output: { publishId: '' },
|
|
error: result.error ?? 'Failed to initiate TikTok publish',
|
|
}
|
|
}
|