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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod'
/** Raw user object returned by TikTok's user info API. */
export const tiktokApiUserSchema = z.object({
open_id: z.string().optional(),
union_id: z.string().optional(),
avatar_url: z.string().optional(),
avatar_large_url: z.string().optional(),
display_name: z.string().optional(),
bio_description: z.string().optional(),
profile_deep_link: z.string().optional(),
is_verified: z.boolean().optional(),
username: z.string().optional(),
follower_count: z.number().optional(),
following_count: z.number().optional(),
likes_count: z.number().optional(),
video_count: z.number().optional(),
})
/** Data payload returned by TikTok's user info API. */
export const tiktokGetUserApiDataSchema = z.object({
user: tiktokApiUserSchema.optional(),
})
/** Raw video object returned by TikTok's video APIs. */
export const tiktokApiVideoSchema = z.object({
id: z.string().optional(),
title: z.string().optional(),
cover_image_url: z.string().optional(),
embed_link: z.string().optional(),
embed_html: z.string().optional(),
duration: z.number().optional(),
create_time: z.number().optional(),
share_url: z.string().optional(),
video_description: z.string().optional(),
width: z.number().optional(),
height: z.number().optional(),
view_count: z.number().optional(),
like_count: z.number().optional(),
comment_count: z.number().optional(),
share_count: z.number().optional(),
})
/** Data payload returned by TikTok's list/query video APIs. */
export const tiktokVideosApiDataSchema = z.object({
videos: z.array(tiktokApiVideoSchema).optional(),
cursor: z.number().optional(),
has_more: z.boolean().optional(),
})
/** Data payload returned by TikTok's creator info API. */
export const tiktokCreatorInfoApiDataSchema = z.object({
creator_avatar_url: z.string().optional(),
creator_username: z.string().optional(),
creator_nickname: z.string().optional(),
privacy_level_options: z.array(z.string()).optional(),
comment_disabled: z.boolean().optional(),
duet_disabled: z.boolean().optional(),
stitch_disabled: z.boolean().optional(),
max_video_post_duration_sec: z.number().optional(),
})
/** Data payload returned by TikTok's publish initialization APIs. */
export const tiktokPublishInitApiDataSchema = z.object({
publish_id: z.string().optional(),
upload_url: z.string().optional(),
})
/** Data payload returned by TikTok's post status API. */
export const tiktokPostStatusApiDataSchema = z.object({
status: z.string().optional(),
fail_reason: z.string().optional(),
uploaded_bytes: z.number().optional(),
downloaded_bytes: z.number().optional(),
})
export type TikTokApiVideo = z.infer<typeof tiktokApiVideoSchema>
+151
View File
@@ -0,0 +1,151 @@
import type {
TikTokDirectPostVideoParams,
TikTokDirectPostVideoResponse,
} from '@/tools/tiktok/types'
import { readTikTokPublishInitResponse, toTikTokPublishToolResponse } from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
function buildPostInfo(params: TikTokDirectPostVideoParams): Record<string, unknown> {
const postInfo: Record<string, unknown> = {
brand_content_toggle: params.brandContentToggle,
brand_organic_toggle: params.brandOrganicToggle ?? false,
privacy_level: params.privacyLevel,
disable_duet: params.disableDuet,
disable_stitch: params.disableStitch,
disable_comment: params.disableComment,
}
if (params.title) postInfo.title = params.title
if (params.videoCoverTimestampMs !== undefined) {
postInfo.video_cover_timestamp_ms = params.videoCoverTimestampMs
}
if (params.isAigc !== undefined) postInfo.is_aigc = params.isAigc
return postInfo
}
export const tiktokDirectPostVideoTool: ToolConfig<
TikTokDirectPostVideoParams,
TikTokDirectPostVideoResponse
> = {
id: 'tiktok_direct_post_video',
name: 'TikTok Direct Post Video',
description:
'Publish a video to TikTok by uploading a file from the workflow. Rate limit: 6 requests per minute per user.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.publish'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
file: {
type: 'file',
required: true,
visibility: 'user-only',
description: 'Video file to upload from the workflow. Maximum size: 250 MB.',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Video caption/description. Maximum 2200 characters.',
},
privacyLevel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Privacy level for the video. Options: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY. Must match one of the privacyLevelOptions returned by Query Creator Info. Note: unaudited apps (including sandbox apps) are restricted to SELF_ONLY.',
},
disableDuet: {
type: 'boolean',
required: true,
visibility: 'user-or-llm',
description: 'Whether to disable duet for this video. The user must choose explicitly.',
},
disableStitch: {
type: 'boolean',
required: true,
visibility: 'user-or-llm',
description: 'Whether to disable stitch for this video. The user must choose explicitly.',
},
disableComment: {
type: 'boolean',
required: true,
visibility: 'user-or-llm',
description: 'Whether to disable comments for this video. The user must choose explicitly.',
},
videoCoverTimestampMs: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Timestamp in milliseconds to use as the video cover image.',
},
isAigc: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Set to true if the video is AI-generated content (AIGC).',
},
brandContentToggle: {
type: 'boolean',
required: true,
visibility: 'user-or-llm',
description:
'Whether the video is a paid partnership promoting a third-party business. The user must choose explicitly. Branded content cannot be posted with Only Me privacy.',
},
brandOrganicToggle: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: "Set to true if the video is promoting the creator's own business.",
},
musicUsageConsent: {
type: 'string',
required: true,
visibility: 'user-only',
description:
"Must be 'accepted' after the user explicitly agrees to TikTok's Music Usage Confirmation.",
},
},
request: {
url: () => '/api/tools/tiktok/publish-video',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: TikTokDirectPostVideoParams) => {
const postInfo = buildPostInfo(params)
return {
accessToken: params.accessToken,
mode: 'direct',
file: params.file,
postInfo,
musicUsageConsent: params.musicUsageConsent,
}
},
},
transformResponse: async (response: Response): Promise<TikTokDirectPostVideoResponse> => {
const result = await readTikTokPublishInitResponse(response)
return toTikTokPublishToolResponse(result)
},
outputs: {
publishId: {
type: 'string',
description:
'Unique identifier for tracking the post status. Use this with the Get Post Status tool to check if the video was successfully published.',
},
},
}
+135
View File
@@ -0,0 +1,135 @@
import { tiktokPostStatusApiDataSchema } from '@/tools/tiktok/api-schemas'
import type { TikTokGetPostStatusParams, TikTokGetPostStatusResponse } from '@/tools/tiktok/types'
import { readTikTokApiResponse } from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
function emptyPostStatusOutput(): TikTokGetPostStatusResponse['output'] {
return {
status: '',
failReason: null,
publiclyAvailablePostId: [],
uploadedBytes: null,
downloadedBytes: null,
}
}
export const tiktokGetPostStatusTool: ToolConfig<
TikTokGetPostStatusParams,
TikTokGetPostStatusResponse
> = {
id: 'tiktok_get_post_status',
name: 'TikTok Get Post Status',
description:
'Check the status of a post initiated with Direct Post Video or Upload Video Draft. Use the publishId returned from the post request to track progress.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.publish', 'video.upload'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
publishId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The publish ID returned from a post/upload tool.',
},
},
request: {
url: () => 'https://open.tiktokapis.com/v2/post/publish/status/fetch/',
method: 'POST',
headers: (params: TikTokGetPostStatusParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
}),
body: (params: TikTokGetPostStatusParams) => {
const publishId = params.publishId.trim()
if (!publishId) throw new Error('publishId is required')
return { publish_id: publishId }
},
},
transformResponse: async (response: Response): Promise<TikTokGetPostStatusResponse> => {
/** TikTok's int64 post IDs must be extracted before JSON parsing rounds them. */
const {
data: statusData,
error,
rawBody,
} = await readTikTokApiResponse(response, tiktokPostStatusApiDataSchema)
const postIdsMatch = rawBody.match(/"publicaly_available_post_id"\s*:\s*\[([^\]]*)\]/)
const publiclyAvailablePostId = postIdsMatch
? postIdsMatch[1]
.split(',')
.map((id: string) => id.trim().replace(/^"|"$/g, ''))
.filter(Boolean)
: []
if (error) {
return {
success: false,
output: emptyPostStatusOutput(),
error: error.message || 'Failed to fetch post status',
}
}
if (!statusData) {
return {
success: false,
output: emptyPostStatusOutput(),
error: 'No status data returned',
}
}
return {
success: true,
output: {
status: statusData.status ?? '',
failReason: statusData.fail_reason ?? null,
publiclyAvailablePostId,
uploadedBytes: statusData.uploaded_bytes ?? null,
downloadedBytes: statusData.downloaded_bytes ?? null,
},
}
},
outputs: {
status: {
type: 'string',
description:
'Current status of the post. Values: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD (TikTok is processing the media), SEND_TO_USER_INBOX (draft delivered, awaiting user action), PUBLISH_COMPLETE (successfully posted), FAILED (check failReason).',
},
failReason: {
type: 'string',
description: 'Reason for failure if status is FAILED. Null otherwise.',
optional: true,
},
publiclyAvailablePostId: {
type: 'array',
description:
'Array of public post IDs (as strings) once the content is published and publicly viewable. Can be used to construct the TikTok post URL.',
items: {
type: 'string',
description: 'Public TikTok post ID',
},
},
uploadedBytes: {
type: 'number',
description: 'Number of bytes uploaded to TikTok for FILE_UPLOAD posts',
optional: true,
},
downloadedBytes: {
type: 'number',
description: 'Number of bytes TikTok reports as downloaded',
optional: true,
},
},
}
+173
View File
@@ -0,0 +1,173 @@
import { tiktokGetUserApiDataSchema } from '@/tools/tiktok/api-schemas'
import type { TikTokGetUserParams, TikTokGetUserResponse } from '@/tools/tiktok/types'
import { readTikTokApiResponse, TIKTOK_USER_FIELDS } from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
function emptyUserOutput(): TikTokGetUserResponse['output'] {
return {
openId: '',
unionId: null,
displayName: '',
bioDescription: null,
profileDeepLink: null,
isVerified: null,
username: null,
followerCount: null,
followingCount: null,
likesCount: null,
videoCount: null,
}
}
export const tiktokGetUserTool: ToolConfig<TikTokGetUserParams, TikTokGetUserResponse> = {
id: 'tiktok_get_user',
name: 'TikTok Get User',
description:
'Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['user.info.basic', 'user.info.profile', 'user.info.stats'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
fields: {
type: 'string',
required: false,
visibility: 'user-or-llm',
default: TIKTOK_USER_FIELDS,
description:
'Comma-separated list of fields to return. Available: 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. Include avatar_url and avatar_large_url to receive the avatarFile output.',
},
},
request: {
url: (params: TikTokGetUserParams) => {
const fields = params.fields || TIKTOK_USER_FIELDS
return `https://open.tiktokapis.com/v2/user/info/?fields=${encodeURIComponent(fields)}`
},
method: 'GET',
headers: (params: TikTokGetUserParams) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response): Promise<TikTokGetUserResponse> => {
const { data, error } = await readTikTokApiResponse(response, tiktokGetUserApiDataSchema)
if (error) {
return {
success: false,
output: emptyUserOutput(),
error: error.message || 'Failed to fetch user info',
}
}
const user = data?.user
if (!user) {
return {
success: false,
output: emptyUserOutput(),
error: 'No user data returned',
}
}
const avatarSourceUrl = user.avatar_large_url ?? user.avatar_url
const avatarFileName = `${user.username || user.open_id || 'tiktok-user'}-avatar.jpg`
return {
success: true,
output: {
openId: user.open_id ?? '',
unionId: user.union_id ?? null,
displayName: user.display_name ?? '',
bioDescription: user.bio_description ?? null,
profileDeepLink: user.profile_deep_link ?? null,
isVerified: user.is_verified ?? null,
username: user.username ?? null,
followerCount: user.follower_count ?? null,
followingCount: user.following_count ?? null,
likesCount: user.likes_count ?? null,
videoCount: user.video_count ?? null,
...(avatarSourceUrl && {
avatarFile: {
name: avatarFileName,
mimeType: 'image/jpeg',
url: avatarSourceUrl,
},
}),
},
}
},
outputs: {
openId: {
type: 'string',
description: 'Unique TikTok user ID for this application',
},
unionId: {
type: 'string',
description: 'Unique TikTok user ID across all apps from the same developer',
optional: true,
},
displayName: {
type: 'string',
description: 'User display name',
},
bioDescription: {
type: 'string',
description: 'User bio description',
optional: true,
},
profileDeepLink: {
type: 'string',
description: 'Deep link to user TikTok profile',
optional: true,
},
isVerified: {
type: 'boolean',
description: 'Whether the account is verified',
optional: true,
},
username: {
type: 'string',
description: 'TikTok username',
optional: true,
},
followerCount: {
type: 'number',
description: 'Number of followers',
optional: true,
},
followingCount: {
type: 'number',
description: 'Number of accounts the user follows',
optional: true,
},
likesCount: {
type: 'number',
description: 'Total likes received across all videos',
optional: true,
},
videoCount: {
type: 'number',
description: 'Total number of public videos',
optional: true,
},
avatarFile: {
type: 'file',
description:
'Downloadable copy of the profile avatar image (largest available variant), stored as a workflow file so it can be chained into file-consuming blocks (e.g. attached to an email).',
optional: true,
},
},
}
+17
View File
@@ -0,0 +1,17 @@
import { tiktokDirectPostVideoTool } from '@/tools/tiktok/direct_post_video'
import { tiktokGetPostStatusTool } from '@/tools/tiktok/get_post_status'
import { tiktokGetUserTool } from '@/tools/tiktok/get_user'
import { tiktokListVideosTool } from '@/tools/tiktok/list_videos'
import { tiktokQueryCreatorInfoTool } from '@/tools/tiktok/query_creator_info'
import { tiktokQueryVideosTool } from '@/tools/tiktok/query_videos'
import { tiktokUploadVideoDraftTool } from '@/tools/tiktok/upload_video_draft'
export * from '@/tools/tiktok/types'
export { tiktokGetUserTool }
export { tiktokListVideosTool }
export { tiktokQueryVideosTool }
export { tiktokQueryCreatorInfoTool }
export { tiktokDirectPostVideoTool }
export { tiktokUploadVideoDraftTool }
export { tiktokGetPostStatusTool }
+114
View File
@@ -0,0 +1,114 @@
import { tiktokVideosApiDataSchema } from '@/tools/tiktok/api-schemas'
import {
TIKTOK_VIDEO_OUTPUT_PROPERTIES,
type TikTokListVideosParams,
type TikTokListVideosResponse,
type TikTokVideo,
} from '@/tools/tiktok/types'
import { mapTikTokVideo, readTikTokApiResponse, TIKTOK_VIDEO_FIELDS } from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
export const tiktokListVideosTool: ToolConfig<TikTokListVideosParams, TikTokListVideosResponse> = {
id: 'tiktok_list_videos',
name: 'TikTok List Videos',
description:
"Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination.",
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.list'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
maxCount: {
type: 'number',
required: false,
visibility: 'user-or-llm',
default: 10,
description: 'Maximum number of videos to return (1-20)',
},
cursor: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Cursor for pagination (from previous response)',
},
},
request: {
url: () => `https://open.tiktokapis.com/v2/video/list/?fields=${TIKTOK_VIDEO_FIELDS}`,
method: 'POST',
headers: (params: TikTokListVideosParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: TikTokListVideosParams) => {
const maxCount = params.maxCount ?? 10
if (!Number.isInteger(maxCount) || maxCount < 1 || maxCount > 20) {
throw new Error('maxCount must be an integer between 1 and 20')
}
if (params.cursor !== undefined && (!Number.isInteger(params.cursor) || params.cursor < 0)) {
throw new Error('cursor must be a non-negative integer')
}
return {
max_count: maxCount,
...(params.cursor !== undefined && { cursor: params.cursor }),
}
},
},
transformResponse: async (response: Response): Promise<TikTokListVideosResponse> => {
const { data, error } = await readTikTokApiResponse(response, tiktokVideosApiDataSchema)
if (error) {
return {
success: false,
output: {
videos: [],
cursor: null,
hasMore: false,
},
error: error.message || 'Failed to fetch videos',
}
}
const videos: TikTokVideo[] = (data?.videos ?? []).map(mapTikTokVideo)
return {
success: true,
output: {
videos,
cursor: data?.cursor ?? null,
hasMore: data?.has_more ?? false,
},
}
},
outputs: {
videos: {
type: 'array',
description: 'List of TikTok videos',
items: {
type: 'object',
properties: TIKTOK_VIDEO_OUTPUT_PROPERTIES,
},
},
cursor: {
type: 'number',
description: 'Cursor for fetching the next page of results',
optional: true,
},
hasMore: {
type: 'boolean',
description: 'Whether there are more videos to fetch',
},
},
}
+133
View File
@@ -0,0 +1,133 @@
import { tiktokCreatorInfoApiDataSchema } from '@/tools/tiktok/api-schemas'
import type {
TikTokQueryCreatorInfoParams,
TikTokQueryCreatorInfoResponse,
} from '@/tools/tiktok/types'
import { readTikTokApiResponse } from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
function emptyCreatorInfoOutput(): TikTokQueryCreatorInfoResponse['output'] {
return {
creatorAvatarUrl: null,
creatorUsername: null,
creatorNickname: null,
privacyLevelOptions: [],
commentDisabled: false,
duetDisabled: false,
stitchDisabled: false,
maxVideoPostDurationSec: null,
}
}
export const tiktokQueryCreatorInfoTool: ToolConfig<
TikTokQueryCreatorInfoParams,
TikTokQueryCreatorInfoResponse
> = {
id: 'tiktok_query_creator_info',
name: 'TikTok Query Creator Info',
description:
'Check if the authenticated TikTok user can post content and retrieve their available privacy options, interaction settings, and maximum video duration. Required before any post per TikTok UX guidelines.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.publish'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
},
request: {
url: () => 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/',
method: 'POST',
headers: (params: TikTokQueryCreatorInfoParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
}),
body: () => ({}),
},
transformResponse: async (response: Response): Promise<TikTokQueryCreatorInfoResponse> => {
const { data: creatorInfo, error } = await readTikTokApiResponse(
response,
tiktokCreatorInfoApiDataSchema
)
if (error) {
return {
success: false,
output: emptyCreatorInfoOutput(),
error: error.message || 'Failed to query creator info',
}
}
if (!creatorInfo) {
return {
success: false,
output: emptyCreatorInfoOutput(),
error: 'No creator info returned',
}
}
return {
success: true,
output: {
creatorAvatarUrl: creatorInfo.creator_avatar_url ?? null,
creatorUsername: creatorInfo.creator_username ?? null,
creatorNickname: creatorInfo.creator_nickname ?? null,
privacyLevelOptions: creatorInfo.privacy_level_options ?? [],
commentDisabled: creatorInfo.comment_disabled ?? false,
duetDisabled: creatorInfo.duet_disabled ?? false,
stitchDisabled: creatorInfo.stitch_disabled ?? false,
maxVideoPostDurationSec: creatorInfo.max_video_post_duration_sec ?? null,
},
}
},
outputs: {
creatorAvatarUrl: {
type: 'string',
description: 'URL of the creator avatar',
optional: true,
},
creatorUsername: {
type: 'string',
description: 'TikTok username of the creator',
optional: true,
},
creatorNickname: {
type: 'string',
description: 'Display name/nickname of the creator',
optional: true,
},
privacyLevelOptions: {
type: 'array',
description:
'Available privacy levels for posting (e.g., PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)',
},
commentDisabled: {
type: 'boolean',
description: 'Whether the creator has disabled comments by default',
},
duetDisabled: {
type: 'boolean',
description: 'Whether the creator has disabled duets by default',
},
stitchDisabled: {
type: 'boolean',
description: 'Whether the creator has disabled stitches by default',
},
maxVideoPostDurationSec: {
type: 'number',
description: 'Maximum allowed video duration in seconds',
optional: true,
},
},
}
+100
View File
@@ -0,0 +1,100 @@
import { tiktokVideosApiDataSchema } from '@/tools/tiktok/api-schemas'
import {
TIKTOK_VIDEO_OUTPUT_PROPERTIES,
type TikTokQueryVideosParams,
type TikTokQueryVideosResponse,
type TikTokVideo,
} from '@/tools/tiktok/types'
import {
assertTikTokArrayLength,
mapTikTokVideo,
readTikTokApiResponse,
TIKTOK_VIDEO_FIELDS,
} from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
export const tiktokQueryVideosTool: ToolConfig<TikTokQueryVideosParams, TikTokQueryVideosResponse> =
{
id: 'tiktok_query_videos',
name: 'TikTok Query Videos',
description:
'Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.list'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
videoIds: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Array of video IDs to query (maximum 20)',
items: {
type: 'string',
description: 'TikTok video ID',
},
},
},
request: {
url: () => `https://open.tiktokapis.com/v2/video/query/?fields=${TIKTOK_VIDEO_FIELDS}`,
method: 'POST',
headers: (params: TikTokQueryVideosParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: TikTokQueryVideosParams) => {
const videoIds = params.videoIds.map((id) => id.trim()).filter(Boolean)
assertTikTokArrayLength(videoIds, 'videoIds', 20)
return {
filters: {
video_ids: videoIds,
},
}
},
},
transformResponse: async (response: Response): Promise<TikTokQueryVideosResponse> => {
const { data, error } = await readTikTokApiResponse(response, tiktokVideosApiDataSchema)
if (error) {
return {
success: false,
output: {
videos: [],
},
error: error.message || 'Failed to query videos',
}
}
const videos: TikTokVideo[] = (data?.videos ?? []).map(mapTikTokVideo)
return {
success: true,
output: {
videos,
},
}
},
outputs: {
videos: {
type: 'array',
description: 'List of queried TikTok videos',
items: {
type: 'object',
properties: TIKTOK_VIDEO_OUTPUT_PROPERTIES,
},
},
},
}
+208
View File
@@ -0,0 +1,208 @@
import type { UserFile } from '@/executor/types'
import type { OutputProperty, ToolResponse } from '@/tools/types'
/**
* Base params that include OAuth access token
*/
export interface TikTokBaseParams {
accessToken: string
}
/** Error envelope returned by TikTok APIs. */
export interface TikTokApiError {
code: string
message?: string
logId?: string
}
/**
* Get User Info
*/
export interface TikTokGetUserParams extends TikTokBaseParams {
fields?: string
}
export interface TikTokGetUserResponse extends ToolResponse {
output: {
openId: string
unionId: string | null
displayName: string
bioDescription: string | null
profileDeepLink: string | null
isVerified: boolean | null
username: string | null
followerCount: number | null
followingCount: number | null
likesCount: number | null
videoCount: number | null
avatarFile?: {
name: string
mimeType: string
url: string
}
}
}
/**
* List Videos
*/
export interface TikTokListVideosParams extends TikTokBaseParams {
maxCount?: number
cursor?: number
}
export interface TikTokVideo {
id: string
title: string | null
coverImageUrl: string | null
embedLink: string | null
embedHtml: string | null
duration: number | null
createTime: number | null
shareUrl: string | null
videoDescription: string | null
width: number | null
height: number | null
viewCount: number | null
likeCount: number | null
commentCount: number | null
shareCount: number | null
}
/**
* Shared output schema for video objects returned by list and query tools.
* Lives in types.ts so the docs generator can resolve the const reference.
*/
export const TIKTOK_VIDEO_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'Video ID' },
title: { type: 'string', description: 'Video title', optional: true },
coverImageUrl: {
type: 'string',
description:
'Signed TikTok CDN cover URL. It is public but time-limited, so consume it immediately.',
optional: true,
},
embedLink: { type: 'string', description: 'Embeddable video URL', optional: true },
embedHtml: { type: 'string', description: 'HTML embed markup for the video', optional: true },
duration: { type: 'number', description: 'Video duration in seconds', optional: true },
createTime: {
type: 'number',
description: 'Unix timestamp when the video was created',
optional: true,
},
shareUrl: { type: 'string', description: 'Shareable video URL', optional: true },
videoDescription: {
type: 'string',
description: 'Video description or caption',
optional: true,
},
width: { type: 'number', description: 'Video width in pixels', optional: true },
height: { type: 'number', description: 'Video height in pixels', optional: true },
viewCount: { type: 'number', description: 'Number of views', optional: true },
likeCount: { type: 'number', description: 'Number of likes', optional: true },
commentCount: { type: 'number', description: 'Number of comments', optional: true },
shareCount: { type: 'number', description: 'Number of shares', optional: true },
} satisfies Record<keyof TikTokVideo, OutputProperty>
export interface TikTokListVideosResponse extends ToolResponse {
output: {
videos: TikTokVideo[]
cursor: number | null
hasMore: boolean
}
}
/**
* Query Videos
*/
export interface TikTokQueryVideosParams extends TikTokBaseParams {
videoIds: string[]
}
export interface TikTokQueryVideosResponse extends ToolResponse {
output: {
videos: TikTokVideo[]
}
}
/**
* Query Creator Info - Check posting permissions and get privacy options
*/
export interface TikTokQueryCreatorInfoParams extends TikTokBaseParams {}
export interface TikTokQueryCreatorInfoResponse extends ToolResponse {
output: {
creatorAvatarUrl: string | null
creatorUsername: string | null
creatorNickname: string | null
privacyLevelOptions: string[]
commentDisabled: boolean
duetDisabled: boolean
stitchDisabled: boolean
maxVideoPostDurationSec: number | null
}
}
/**
* Direct Post Video - Publish an uploaded video to TikTok
*/
export interface TikTokDirectPostVideoParams extends TikTokBaseParams {
file: UserFile
title?: string
privacyLevel: string
disableDuet: boolean
disableStitch: boolean
disableComment: boolean
videoCoverTimestampMs?: number
isAigc?: boolean
brandContentToggle: boolean
brandOrganicToggle?: boolean
musicUsageConsent: 'accepted'
}
/** Shared response shape for TikTok publish and draft initialization tools. */
export interface TikTokPublishResponse extends ToolResponse {
output: {
publishId: string
}
}
export type TikTokDirectPostVideoResponse = TikTokPublishResponse
/**
* Upload Video Draft - Send a video to the user's TikTok inbox for manual editing/posting
*/
export interface TikTokUploadVideoDraftParams extends TikTokBaseParams {
file: UserFile
}
export type TikTokUploadVideoDraftResponse = TikTokPublishResponse
/**
* Get Post Status - Check status of a published/uploaded post
*/
export interface TikTokGetPostStatusParams extends TikTokBaseParams {
publishId: string
}
export interface TikTokGetPostStatusResponse extends ToolResponse {
output: {
status: string
failReason: string | null
publiclyAvailablePostId: string[]
uploadedBytes: number | null
downloadedBytes: number | null
}
}
/**
* Union type of all TikTok responses
*/
export type TikTokResponse =
| TikTokGetUserResponse
| TikTokListVideosResponse
| TikTokQueryVideosResponse
| TikTokQueryCreatorInfoResponse
| TikTokDirectPostVideoResponse
| TikTokUploadVideoDraftResponse
| TikTokGetPostStatusResponse
@@ -0,0 +1,64 @@
import type {
TikTokUploadVideoDraftParams,
TikTokUploadVideoDraftResponse,
} from '@/tools/tiktok/types'
import { readTikTokPublishInitResponse, toTikTokPublishToolResponse } from '@/tools/tiktok/utils'
import type { ToolConfig } from '@/tools/types'
export const tiktokUploadVideoDraftTool: ToolConfig<
TikTokUploadVideoDraftParams,
TikTokUploadVideoDraftResponse
> = {
id: 'tiktok_upload_video_draft',
name: 'TikTok Upload Video Draft',
description:
"Send an uploaded video to the authenticated user's TikTok inbox for manual editing and posting. The user must open TikTok and tap the inbox notification to complete the post. Rate limit: 6 requests per minute per user.",
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.upload'],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'TikTok OAuth access token',
},
file: {
type: 'file',
required: true,
visibility: 'user-only',
description: 'Video file to upload from the workflow. Maximum size: 250 MB.',
},
},
request: {
url: () => '/api/tools/tiktok/publish-video',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: TikTokUploadVideoDraftParams) => ({
accessToken: params.accessToken,
mode: 'draft',
file: params.file,
}),
},
transformResponse: async (response: Response): Promise<TikTokUploadVideoDraftResponse> => {
const result = await readTikTokPublishInitResponse(response)
return toTikTokPublishToolResponse(result)
},
outputs: {
publishId: {
type: 'string',
description:
'Unique identifier for tracking the draft status. Use this with the Get Post Status tool to check when the user has completed posting from their inbox.',
},
},
}
+83
View File
@@ -0,0 +1,83 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
import {
assertTikTokArrayLength,
mapTikTokVideo,
readTikTokApiResponse,
readTikTokPublishInitResponse,
} from '@/tools/tiktok/utils'
describe('TikTok tool utilities', () => {
it('turns non-JSON HTTP failures into a structured TikTok error', async () => {
const response = new Response('Bad gateway', { status: 502 })
await expect(readTikTokApiResponse(response, tiktokPublishInitApiDataSchema)).resolves.toEqual({
data: null,
error: {
code: 'http_502',
message: 'TikTok request failed with HTTP 502: Bad gateway',
},
rawBody: 'Bad gateway',
})
})
it('preserves internal publish route failures', async () => {
const response = Response.json(
{ success: false, output: { publishId: '' }, error: 'Upload failed' },
{ status: 400 }
)
await expect(readTikTokPublishInitResponse(response)).resolves.toEqual({
success: false,
publishId: '',
error: 'Upload failed',
})
})
it('validates TikTok response data before exposing it to tools', async () => {
const response = Response.json({
data: { publish_id: 123 },
error: { code: 'ok' },
})
await expect(readTikTokApiResponse(response, tiktokPublishInitApiDataSchema)).resolves.toEqual({
data: null,
error: {
code: 'invalid_response',
message: 'TikTok returned an unexpected data shape',
},
rawBody: '{"data":{"publish_id":123},"error":{"code":"ok"}}',
})
})
it('normalizes direct TikTok publish responses', async () => {
const response = Response.json({
data: { publish_id: 'publish-1' },
error: { code: 'ok' },
})
await expect(readTikTokPublishInitResponse(response)).resolves.toEqual({
success: true,
publishId: 'publish-1',
})
})
it('enforces TikTok array limits', () => {
expect(() => assertTikTokArrayLength([], 'videoIds', 20)).toThrow(
'videoIds must contain at least one item'
)
expect(() => assertTikTokArrayLength(Array.from({ length: 21 }), 'videoIds', 20)).toThrow(
'videoIds supports at most 20 items'
)
expect(() => assertTikTokArrayLength(['video-1'], 'videoIds', 20)).not.toThrow()
})
it('maps TikTok embed HTML', () => {
expect(mapTikTokVideo({ id: 'video-1', embed_html: '<blockquote />' }).embedHtml).toBe(
'<blockquote />'
)
})
})
+234
View File
@@ -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',
}
}