chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user