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,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>
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 />'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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