From 4f297658549ce82035a96710149546bd333be1b5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 7 Jul 2026 20:08:03 -0700 Subject: [PATCH 01/12] feat(tiktok): add TikTok integration Adds TikTok as a full OAuth-based integration: provider registration (with TikTok's comma-separated scope and client_key requirements), 9 tools covering profile info, video listing/querying, creator info, direct video/photo posting (URL or file upload), inbox drafts, and post status polling, plus the TikTok block, icon, and generated docs. Co-authored-by: Cursor --- apps/docs/components/icons.tsx | 25 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/meta.json | 1 + .../content/docs/en/integrations/tiktok.mdx | 241 ++++++ .../api/tools/tiktok/publish-video/route.ts | 207 +++++ apps/sim/blocks/blocks/tiktok.ts | 753 ++++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/components/icons.tsx | 25 + apps/sim/lib/api/contracts/tiktok-tools.ts | 42 + apps/sim/lib/auth/auth.ts | 71 ++ apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 58 +- apps/sim/lib/oauth/oauth.ts | 45 +- apps/sim/lib/oauth/types.ts | 2 + apps/sim/lib/oauth/utils.ts | 8 + apps/sim/tools/registry.ts | 20 + apps/sim/tools/tiktok/direct_post_photo.ts | 156 ++++ apps/sim/tools/tiktok/direct_post_video.ts | 189 +++++ apps/sim/tools/tiktok/get_post_status.ts | 118 +++ apps/sim/tools/tiktok/get_user.ts | 189 +++++ apps/sim/tools/tiktok/index.ts | 21 + apps/sim/tools/tiktok/list_videos.ts | 130 +++ apps/sim/tools/tiktok/query_creator_info.ts | 134 ++++ apps/sim/tools/tiktok/query_videos.ts | 116 +++ apps/sim/tools/tiktok/types.ts | 209 +++++ apps/sim/tools/tiktok/upload_photo_draft.ts | 114 +++ apps/sim/tools/tiktok/upload_video_draft.ts | 106 +++ apps/sim/tools/tiktok/utils.ts | 69 ++ 29 files changed, 3056 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/tiktok.mdx create mode 100644 apps/sim/app/api/tools/tiktok/publish-video/route.ts create mode 100644 apps/sim/blocks/blocks/tiktok.ts create mode 100644 apps/sim/lib/api/contracts/tiktok-tools.ts create mode 100644 apps/sim/tools/tiktok/direct_post_photo.ts create mode 100644 apps/sim/tools/tiktok/direct_post_video.ts create mode 100644 apps/sim/tools/tiktok/get_post_status.ts create mode 100644 apps/sim/tools/tiktok/get_user.ts create mode 100644 apps/sim/tools/tiktok/index.ts create mode 100644 apps/sim/tools/tiktok/list_videos.ts create mode 100644 apps/sim/tools/tiktok/query_creator_info.ts create mode 100644 apps/sim/tools/tiktok/query_videos.ts create mode 100644 apps/sim/tools/tiktok/types.ts create mode 100644 apps/sim/tools/tiktok/upload_photo_draft.ts create mode 100644 apps/sim/tools/tiktok/upload_video_draft.ts create mode 100644 apps/sim/tools/tiktok/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ea1eeea8a10..43d9b552179 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1274,6 +1274,31 @@ export function xAIIcon(props: SVGProps) { ) } +export function TikTokIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function xIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index f5f3a71a108..79f56fb7686 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -212,6 +212,7 @@ import { TemporalIcon, TextractIcon, ThriveIcon, + TikTokIcon, TinybirdIcon, TrelloIcon, TriggerDevIcon, @@ -473,6 +474,7 @@ export const blockTypeToIconMap: Record = { textract: TextractIcon, textract_v2: TextractIcon, thrive: ThriveIcon, + tiktok: TikTokIcon, tinybird: TinybirdIcon, trello: TrelloIcon, trigger_dev: TriggerDevIcon, diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index ae36529830d..f84f371a448 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -214,6 +214,7 @@ "temporal", "textract", "thrive", + "tiktok", "tinybird", "trello", "trigger_dev", diff --git a/apps/docs/content/docs/en/integrations/tiktok.mdx b/apps/docs/content/docs/en/integrations/tiktok.mdx new file mode 100644 index 00000000000..a8a66d75525 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -0,0 +1,241 @@ +--- +title: TikTok +description: Access TikTok profiles and videos, and publish content +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos and photos directly to TikTok (or send them to the inbox as drafts) from a public URL or a file uploaded in the workflow, then track post status. + + + +## Actions + +### `tiktok_get_user` + +Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `fields` | string | No | Comma-separated list of fields to return. Available: open_id, union_id, avatar_url, avatar_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `openId` | string | Unique TikTok user ID for this application | +| `unionId` | string | Unique TikTok user ID across all apps from the same developer | +| `displayName` | string | User display name | +| `avatarUrl` | string | Profile image URL | +| `avatarUrl100` | string | Profile image URL \(100x100\) | +| `avatarLargeUrl` | string | Profile image URL \(large\) | +| `bioDescription` | string | User bio description | +| `profileDeepLink` | string | Deep link to user TikTok profile | +| `isVerified` | boolean | Whether the account is verified | +| `username` | string | TikTok username | +| `followerCount` | number | Number of followers | +| `followingCount` | number | Number of accounts the user follows | +| `likesCount` | number | Total likes received across all videos | +| `videoCount` | number | Total number of public videos | + +### `tiktok_list_videos` + +Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `maxCount` | number | No | Maximum number of videos to return \(1-20\) | +| `cursor` | number | No | Cursor for pagination \(from previous response\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `videos` | array | List of TikTok videos | +| ↳ `id` | string | Video ID | +| ↳ `title` | string | Video title | +| ↳ `coverImageUrl` | string | Cover image URL \(may expire\) | +| ↳ `embedLink` | string | Embeddable video URL | +| ↳ `duration` | number | Video duration in seconds | +| ↳ `createTime` | number | Unix timestamp when video was created | +| ↳ `shareUrl` | string | Shareable video URL | +| ↳ `videoDescription` | string | Video description/caption | +| ↳ `width` | number | Video width in pixels | +| ↳ `height` | number | Video height in pixels | +| ↳ `viewCount` | number | Number of views | +| ↳ `likeCount` | number | Number of likes | +| ↳ `commentCount` | number | Number of comments | +| ↳ `shareCount` | number | Number of shares | +| `cursor` | number | Cursor for fetching the next page of results | +| `hasMore` | boolean | Whether there are more videos to fetch | + +### `tiktok_query_videos` + +Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `videoIds` | array | Yes | Array of video IDs to query \(maximum 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `videos` | array | List of queried TikTok videos | +| ↳ `id` | string | Video ID | +| ↳ `title` | string | Video title | +| ↳ `coverImageUrl` | string | Cover image URL \(fresh URL\) | +| ↳ `embedLink` | string | Embeddable video URL | +| ↳ `duration` | number | Video duration in seconds | +| ↳ `createTime` | number | Unix timestamp when video was created | +| ↳ `shareUrl` | string | Shareable video URL | +| ↳ `videoDescription` | string | Video description/caption | +| ↳ `width` | number | Video width in pixels | +| ↳ `height` | number | Video height in pixels | +| ↳ `viewCount` | number | Number of views | +| ↳ `likeCount` | number | Number of likes | +| ↳ `commentCount` | number | Number of comments | +| ↳ `shareCount` | number | Number of shares | + +### `tiktok_query_creator_info` + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `creatorAvatarUrl` | string | URL of the creator avatar | +| `creatorUsername` | string | TikTok username of the creator | +| `creatorNickname` | string | Display name/nickname of the creator | +| `privacyLevelOptions` | array | Available privacy levels for posting \(e.g., PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY\) | +| `commentDisabled` | boolean | Whether the creator has disabled comments by default | +| `duetDisabled` | boolean | Whether the creator has disabled duets by default | +| `stitchDisabled` | boolean | Whether the creator has disabled stitches by default | +| `maxVideoPostDurationSec` | number | Maximum allowed video duration in seconds | + +### `tiktok_direct_post_video` + +Publish a video to TikTok, either by public URL (TikTok fetches it) or by uploading a file from the workflow. Rate limit: 6 requests per minute per user. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `source` | string | Yes | Media transfer method: 'PULL_FROM_URL' or 'FILE_UPLOAD'. | +| `videoUrl` | string | No | Public URL of the video to post \(used when source is PULL_FROM_URL\). The domain/URL prefix must be verified in the TikTok developer portal. | +| `file` | file | No | Video file to upload from the workflow \(used when source is FILE_UPLOAD\). | +| `title` | string | No | Video caption/description. Maximum 2200 characters. | +| `privacyLevel` | string | Yes | 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` | boolean | No | Disable duet for this video. Defaults to false. | +| `disableStitch` | boolean | No | Disable stitch for this video. Defaults to false. | +| `disableComment` | boolean | No | Disable comments for this video. Defaults to false. | +| `videoCoverTimestampMs` | number | No | Timestamp in milliseconds to use as the video cover image. | +| `isAigc` | boolean | No | Set to true if the video is AI-generated content \(AIGC\). | +| `brandContentToggle` | boolean | No | Set to true if the video is a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy. | +| `brandOrganicToggle` | boolean | No | Set to true if the video is promoting the creator's own business. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `publishId` | string | Unique identifier for tracking the post status. Use this with the Get Post Status tool to check if the video was successfully published. | + +### `tiktok_upload_video_draft` + +Send a video to the authenticated user's TikTok inbox (by public URL or uploaded file) 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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `source` | string | Yes | Media transfer method: 'PULL_FROM_URL' or 'FILE_UPLOAD'. | +| `videoUrl` | string | No | Public URL of the video to upload \(used when source is PULL_FROM_URL\). The domain/URL prefix must be verified in the TikTok developer portal. | +| `file` | file | No | Video file to upload from the workflow \(used when source is FILE_UPLOAD\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `publishId` | string | 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. | + +### `tiktok_direct_post_photo` + +Publish one or more photos to TikTok from public URLs. TikTok will fetch each image and post them as a photo carousel. Photos only support PULL_FROM_URL (no file upload). Rate limit: 6 requests per minute per user. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `photoImages` | array | Yes | Public, verified-domain URLs of the images to post \(up to 35\). Must be JPEG or WEBP — TikTok rejects PNG. | +| `photoCoverIndex` | number | No | Index \(starting from 0\) of the photo to use as the cover. | +| `title` | string | No | Post title. Maximum 90 characters. | +| `description` | string | No | Post description. Maximum 4000 characters. | +| `privacyLevel` | string | Yes | Privacy level for the post. 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. | +| `disableComment` | boolean | No | Disable comments for this post. Defaults to false. | +| `autoAddMusic` | boolean | No | Automatically add recommended music to the photo post. Defaults to false. | +| `brandContentToggle` | boolean | No | Set to true if the post is a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy. | +| `brandOrganicToggle` | boolean | No | Set to true if the post is promoting the creator's own business. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `publishId` | string | Unique identifier for tracking the post status. Use this with the Get Post Status tool to check if the photos were successfully published. | + +### `tiktok_upload_photo_draft` + +Send one or more photos (from public URLs) 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. Photos only support PULL_FROM_URL (no file upload). Rate limit: 6 requests per minute per user. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `photoImages` | array | Yes | Public, verified-domain URLs of the images to upload \(up to 35\). Must be JPEG or WEBP — TikTok rejects PNG. | +| `photoCoverIndex` | number | No | Index \(starting from 0\) of the photo to use as the cover. | +| `title` | string | No | Post title. Maximum 90 characters. | +| `description` | string | No | Post description. Maximum 4000 characters. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `publishId` | string | 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. | + +### `tiktok_get_post_status` + +Check the status of a post initiated with Direct Post Video, Upload Video Draft, Direct Post Photo, or Upload Photo Draft. Use the publishId returned from the post request to track progress. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `publishId` | string | Yes | The publish ID returned from a post/upload tool. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | 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` | string | Reason for failure if status is FAILED. Null otherwise. | +| `publiclyAvailablePostId` | array | Array of public post IDs \(as strings\) once the content is published and publicly viewable. Can be used to construct the TikTok post URL. | + + diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.ts new file mode 100644 index 00000000000..e2af7a0462f --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.ts @@ -0,0 +1,207 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { tiktokPublishVideoContract } from '@/lib/api/contracts/tiktok-tools' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getFileExtension, + getMimeTypeFromExtension, + processSingleFileToUserFile, +} from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import type { UserFile } from '@/executor/types' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('TikTokPublishVideoAPI') + +const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm']) + +/** TikTok requires each chunk between 5MB and 64MB; the final chunk absorbs the remainder (up to ~2x this size, well under the 128MB cap). Capped at 1000 chunks total, which this default comfortably satisfies up to TikTok's 4GB video size limit. */ +const DEFAULT_CHUNK_SIZE = 10_000_000 + +/** TikTok's documented maximum video file size. Enforced before downloading the file into + * memory so an oversized upload fails fast instead of materializing multiple GB in-process. */ +const TIKTOK_MAX_VIDEO_BYTES = 4 * 1024 * 1024 * 1024 + +function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } { + if (totalBytes <= DEFAULT_CHUNK_SIZE) { + return { chunkSize: totalBytes, totalChunkCount: 1 } + } + const totalChunkCount = Math.floor(totalBytes / DEFAULT_CHUNK_SIZE) + return { chunkSize: DEFAULT_CHUNK_SIZE, totalChunkCount } +} + +function resolveVideoMimeType(fileName: string, fileType: string | undefined): string { + if (fileType && TIKTOK_VIDEO_MIME_TYPES.has(fileType)) return fileType + const fromExtension = getMimeTypeFromExtension(getFileExtension(fileName)) + return TIKTOK_VIDEO_MIME_TYPES.has(fromExtension) ? fromExtension : 'video/mp4' +} + +async function uploadChunks( + uploadUrl: string, + buffer: Buffer, + mimeType: string, + requestId: string +): Promise { + const totalBytes = buffer.length + const { chunkSize, totalChunkCount } = computeChunkPlan(totalBytes) + + for (let i = 0; i < totalChunkCount; i++) { + const start = i * chunkSize + const isLastChunk = i === totalChunkCount - 1 + const end = isLastChunk ? totalBytes - 1 : start + chunkSize - 1 + const chunk = buffer.subarray(start, end + 1) + + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': mimeType, + 'Content-Length': String(chunk.length), + 'Content-Range': `bytes ${start}-${end}/${totalBytes}`, + }, + body: new Uint8Array(chunk), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] TikTok chunk upload failed`, { + chunkIndex: i, + status: response.status, + errorText, + }) + throw new Error( + `TikTok rejected video chunk ${i + 1}/${totalChunkCount}: ${response.status} ${errorText || response.statusText}` + ) + } + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized TikTok publish-video attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(tiktokPublishVideoContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + let userFile: UserFile + try { + userFile = processSingleFileToUserFile(data.file, requestId, logger) + } catch (error) { + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to process file') }, + { status: 400 } + ) + } + + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + logger.info(`[${requestId}] Downloading video from storage`, { + fileName: userFile.name, + size: userFile.size, + }) + + const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, { + maxBytes: TIKTOK_MAX_VIDEO_BYTES, + }) + const mimeType = resolveVideoMimeType(userFile.name, userFile.type) + const { chunkSize, totalChunkCount } = computeChunkPlan(fileBuffer.length) + + const initUrl = + data.mode === 'draft' + ? 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/' + : 'https://open.tiktokapis.com/v2/post/publish/video/init/' + + const initBody: Record = { + source_info: { + source: 'FILE_UPLOAD', + video_size: fileBuffer.length, + chunk_size: chunkSize, + total_chunk_count: totalChunkCount, + }, + } + if (data.mode === 'direct') { + initBody.post_info = data.postInfo ?? {} + } + + logger.info(`[${requestId}] Initializing TikTok video ${data.mode}`, { + videoSize: fileBuffer.length, + chunkSize, + totalChunkCount, + }) + + const initResponse = await fetch(initUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${data.accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: JSON.stringify(initBody), + }) + + const initData = await initResponse.json() + + if (initData.error?.code && initData.error.code !== 'ok') { + logger.error(`[${requestId}] TikTok init failed`, { error: initData.error }) + return NextResponse.json( + { success: false, error: initData.error.message || 'Failed to initialize TikTok upload' }, + { status: initResponse.status >= 400 ? initResponse.status : 502 } + ) + } + + const publishId: string | undefined = initData.data?.publish_id + const uploadUrl: string | undefined = initData.data?.upload_url + + if (!publishId || !uploadUrl) { + return NextResponse.json( + { success: false, error: 'TikTok did not return a publish ID and upload URL' }, + { status: 502 } + ) + } + + try { + await uploadChunks(uploadUrl, fileBuffer, mimeType, requestId) + } catch (error) { + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to upload video to TikTok') }, + { status: 502 } + ) + } + + logger.info(`[${requestId}] TikTok video upload complete`, { publishId }) + + return NextResponse.json({ success: true, output: { publishId } }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + logger.warn(`[${requestId}] Rejected oversized TikTok video upload`, { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }) + return NextResponse.json( + { success: false, error: `Video exceeds TikTok's maximum file size of 4GB` }, + { status: 413 } + ) + } + logger.error(`[${requestId}] Error publishing video to TikTok:`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Internal server error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts new file mode 100644 index 00000000000..06a262ecd0f --- /dev/null +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -0,0 +1,753 @@ +import { TikTokIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import type { TikTokResponse } from '@/tools/tiktok/types' + +const VIDEO_POST_OPERATIONS = ['tiktok_direct_post_video', 'tiktok_upload_video_draft'] +const PHOTO_POST_OPERATIONS = ['tiktok_direct_post_photo', 'tiktok_upload_photo_draft'] + +export const TikTokBlock: BlockConfig = { + type: 'tiktok', + name: 'TikTok', + description: 'Access TikTok profiles and videos, and publish content', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos and photos directly to TikTok (or send them to the inbox as drafts) from a public URL or a file uploaded in the workflow, then track post status.', + docsLink: 'https://docs.sim.ai/integrations/tiktok', + category: 'tools', + integrationType: IntegrationType.Communication, + bgColor: '#000000', + icon: TikTokIcon, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Get User Info', id: 'tiktok_get_user' }, + { label: 'List Videos', id: 'tiktok_list_videos' }, + { label: 'Query Videos', id: 'tiktok_query_videos' }, + { label: 'Query Creator Info', id: 'tiktok_query_creator_info' }, + { label: 'Direct Post Video', id: 'tiktok_direct_post_video' }, + { label: 'Upload Video Draft', id: 'tiktok_upload_video_draft' }, + { label: 'Direct Post Photo', id: 'tiktok_direct_post_photo' }, + { label: 'Upload Photo Draft', id: 'tiktok_upload_photo_draft' }, + { label: 'Get Post Status', id: 'tiktok_get_post_status' }, + ], + value: () => 'tiktok_get_user', + }, + + // --- OAuth Credential --- + { + id: 'credential', + title: 'TikTok Account', + type: 'oauth-input', + serviceId: 'tiktok', + canonicalParamId: 'oauthCredential', + mode: 'basic', + requiredScopes: getScopesForService('tiktok'), + placeholder: 'Select TikTok account', + required: true, + }, + { + id: 'manualCredential', + title: 'TikTok Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + + // --- Get User Info --- + { + id: 'fields', + title: 'Fields', + type: 'short-input', + placeholder: 'open_id,display_name,avatar_url,follower_count,video_count', + description: 'Comma-separated list of user fields to return. Leave empty for all fields.', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_get_user' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of TikTok user info fields based on the user request, choosing only from: open_id, union_id, avatar_url, avatar_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count. Return ONLY the comma-separated field names - no explanations, no extra text.', + placeholder: 'Describe which profile fields you need', + }, + }, + + // --- List Videos --- + { + id: 'maxCount', + title: 'Max Count', + type: 'short-input', + placeholder: '20', + description: 'Maximum number of videos to return (1-20).', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_list_videos' }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from previous response', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_list_videos' }, + }, + + // --- Query Videos --- + { + id: 'videoIds', + title: 'Video IDs', + type: 'long-input', + placeholder: 'Comma-separated video IDs (e.g., 7077642457847994444,7080217258529732386)', + condition: { field: 'operation', value: 'tiktok_query_videos' }, + required: { field: 'operation', value: 'tiktok_query_videos' }, + }, + + // --- Video source (Direct Post Video / Upload Video Draft) --- + { + id: 'videoSource', + title: 'Video Source', + type: 'dropdown', + options: [ + { label: 'Public URL', id: 'PULL_FROM_URL' }, + { label: 'Upload File', id: 'FILE_UPLOAD' }, + ], + value: () => 'PULL_FROM_URL', + condition: { field: 'operation', value: VIDEO_POST_OPERATIONS }, + }, + { + id: 'videoUrl', + title: 'Video URL', + type: 'short-input', + placeholder: 'https://example.com/video.mp4', + description: + 'Public URL of the video. The domain/URL prefix must be verified in the TikTok developer portal.', + condition: { + field: 'operation', + value: VIDEO_POST_OPERATIONS, + and: { field: 'videoSource', value: 'PULL_FROM_URL' }, + }, + required: { + field: 'operation', + value: VIDEO_POST_OPERATIONS, + and: { field: 'videoSource', value: 'PULL_FROM_URL' }, + }, + }, + { + id: 'videoFile', + title: 'Video File', + type: 'file-upload', + canonicalParamId: 'file', + mode: 'basic', + placeholder: 'Upload video', + acceptedTypes: '.mp4,.mov,.webm', + multiple: false, + condition: { + field: 'operation', + value: VIDEO_POST_OPERATIONS, + and: { field: 'videoSource', value: 'FILE_UPLOAD' }, + }, + required: { + field: 'operation', + value: VIDEO_POST_OPERATIONS, + and: { field: 'videoSource', value: 'FILE_UPLOAD' }, + }, + }, + { + id: 'videoFileRef', + title: 'Video File', + type: 'short-input', + canonicalParamId: 'file', + mode: 'advanced', + placeholder: 'Reference a video from a previous block (e.g., )', + condition: { + field: 'operation', + value: VIDEO_POST_OPERATIONS, + and: { field: 'videoSource', value: 'FILE_UPLOAD' }, + }, + required: { + field: 'operation', + value: VIDEO_POST_OPERATIONS, + and: { field: 'videoSource', value: 'FILE_UPLOAD' }, + }, + }, + + // --- Photo images (Direct Post Photo / Upload Photo Draft) --- + { + id: 'photoImages', + title: 'Photo URLs', + type: 'long-input', + placeholder: 'One public image URL per line (up to 35, JPEG or WEBP only — no PNG)', + description: + 'Public, verified-domain URLs of the images to post. Photos only support public URLs, not file upload.', + condition: { field: 'operation', value: PHOTO_POST_OPERATIONS }, + required: { field: 'operation', value: PHOTO_POST_OPERATIONS }, + }, + { + id: 'photoCoverIndex', + title: 'Cover Photo Index', + type: 'short-input', + placeholder: '0', + description: 'Index (starting from 0) of the photo to use as the cover.', + mode: 'advanced', + condition: { field: 'operation', value: PHOTO_POST_OPERATIONS }, + }, + + // --- Shared caption/title (video + photo posts) --- + { + id: 'title', + title: 'Title / Caption', + type: 'long-input', + placeholder: 'Caption with #hashtags and @mentions', + description: + 'Caption or title. Max 2200 characters for videos, 90 characters for photo posts.', + condition: { + field: 'operation', + value: [ + 'tiktok_direct_post_video', + 'tiktok_direct_post_photo', + 'tiktok_upload_photo_draft', + ], + }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + placeholder: 'Post description', + description: 'Post description. Max 4000 characters. Photo posts only.', + condition: { field: 'operation', value: PHOTO_POST_OPERATIONS }, + }, + + // --- Privacy & interaction settings (Direct Post Video / Direct Post Photo) --- + { + id: 'privacyLevel', + title: 'Privacy Level', + type: 'dropdown', + options: [ + { label: 'Public', id: 'PUBLIC_TO_EVERYONE' }, + { label: 'Friends', id: 'MUTUAL_FOLLOW_FRIENDS' }, + { label: 'Followers', id: 'FOLLOWER_OF_CREATOR' }, + { label: 'Only Me', id: 'SELF_ONLY' }, + ], + value: () => 'SELF_ONLY', + description: + 'Must match one of the privacyLevelOptions returned by Query Creator Info. Unaudited apps (including sandbox apps) are restricted to Only Me.', + condition: { + field: 'operation', + value: ['tiktok_direct_post_video', 'tiktok_direct_post_photo'], + }, + required: { + field: 'operation', + value: ['tiktok_direct_post_video', 'tiktok_direct_post_photo'], + }, + }, + { + id: 'disableComment', + title: 'Disable Comments', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['tiktok_direct_post_video', 'tiktok_direct_post_photo'], + }, + }, + { + id: 'disableDuet', + title: 'Disable Duet', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_direct_post_video' }, + }, + { + id: 'disableStitch', + title: 'Disable Stitch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_direct_post_video' }, + }, + { + id: 'videoCoverTimestampMs', + title: 'Cover Timestamp (ms)', + type: 'short-input', + placeholder: '1000', + description: 'Timestamp in milliseconds to use as the video cover image.', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_direct_post_video' }, + }, + { + id: 'isAigc', + title: 'AI-Generated Content', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_direct_post_video' }, + }, + { + id: 'autoAddMusic', + title: 'Auto-Add Music', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'tiktok_direct_post_photo' }, + }, + { + id: 'brandContentToggle', + title: 'Paid Partnership', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + description: + 'Disclose this post as a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy.', + mode: 'advanced', + condition: { + field: 'operation', + value: ['tiktok_direct_post_video', 'tiktok_direct_post_photo'], + }, + }, + { + id: 'brandOrganicToggle', + title: 'Promotes Own Business', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + description: "Disclose this post as promoting the creator's own business.", + mode: 'advanced', + condition: { + field: 'operation', + value: ['tiktok_direct_post_video', 'tiktok_direct_post_photo'], + }, + }, + + // --- Get Post Status --- + { + id: 'publishId', + title: 'Publish ID', + type: 'short-input', + placeholder: 'v_pub_file~v2-1.123456789', + condition: { field: 'operation', value: 'tiktok_get_post_status' }, + required: { field: 'operation', value: 'tiktok_get_post_status' }, + }, + ], + + tools: { + access: [ + 'tiktok_get_user', + 'tiktok_list_videos', + 'tiktok_query_videos', + 'tiktok_query_creator_info', + 'tiktok_direct_post_video', + 'tiktok_upload_video_draft', + 'tiktok_direct_post_photo', + 'tiktok_upload_photo_draft', + 'tiktok_get_post_status', + ], + config: { + tool: (params) => params.operation || 'tiktok_get_user', + params: (params) => { + const credential = params.oauthCredential + const operation = params.operation || 'tiktok_get_user' + const toBoolean = (value: unknown): boolean | undefined => + value === undefined || value === '' ? undefined : String(value).toLowerCase() === 'true' + + switch (operation) { + case 'tiktok_get_user': + return { + credential, + ...(params.fields && { fields: params.fields }), + } + case 'tiktok_list_videos': + return { + credential, + ...(params.maxCount && { maxCount: Number(params.maxCount) }), + ...(params.cursor && { cursor: Number(params.cursor) }), + } + case 'tiktok_query_videos': + return { + credential, + videoIds: (params.videoIds || '') + .split(',') + .map((id: string) => id.trim()) + .filter(Boolean), + } + case 'tiktok_query_creator_info': + return { credential } + case 'tiktok_direct_post_video': { + const file = normalizeFileInput(params.file, { single: true }) + return { + credential, + source: params.videoSource || 'PULL_FROM_URL', + videoUrl: params.videoUrl, + file, + title: params.title, + privacyLevel: params.privacyLevel || 'SELF_ONLY', + disableDuet: toBoolean(params.disableDuet), + disableStitch: toBoolean(params.disableStitch), + disableComment: toBoolean(params.disableComment), + ...(params.videoCoverTimestampMs && { + videoCoverTimestampMs: Number(params.videoCoverTimestampMs), + }), + isAigc: toBoolean(params.isAigc), + brandContentToggle: toBoolean(params.brandContentToggle), + brandOrganicToggle: toBoolean(params.brandOrganicToggle), + } + } + case 'tiktok_upload_video_draft': { + const file = normalizeFileInput(params.file, { single: true }) + return { + credential, + source: params.videoSource || 'PULL_FROM_URL', + videoUrl: params.videoUrl, + file, + } + } + case 'tiktok_direct_post_photo': + return { + credential, + photoImages: (params.photoImages || '') + .split('\n') + .map((url: string) => url.trim()) + .filter(Boolean), + ...(params.photoCoverIndex && { photoCoverIndex: Number(params.photoCoverIndex) }), + title: params.title, + description: params.description, + privacyLevel: params.privacyLevel || 'SELF_ONLY', + disableComment: toBoolean(params.disableComment), + autoAddMusic: toBoolean(params.autoAddMusic), + brandContentToggle: toBoolean(params.brandContentToggle), + brandOrganicToggle: toBoolean(params.brandOrganicToggle), + } + case 'tiktok_upload_photo_draft': + return { + credential, + photoImages: (params.photoImages || '') + .split('\n') + .map((url: string) => url.trim()) + .filter(Boolean), + ...(params.photoCoverIndex && { photoCoverIndex: Number(params.photoCoverIndex) }), + title: params.title, + description: params.description, + } + case 'tiktok_get_post_status': + return { + credential, + publishId: params.publishId, + } + default: + return { credential } + } + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + oauthCredential: { type: 'string', description: 'TikTok account credential' }, + fields: { type: 'string', description: 'Comma-separated list of user fields to return' }, + maxCount: { type: 'number', description: 'Maximum number of videos to return (1-20)' }, + cursor: { type: 'number', description: 'Pagination cursor from previous response' }, + videoIds: { type: 'string', description: 'Comma-separated list of video IDs to query' }, + videoSource: { + type: 'string', + description: 'Video transfer method (PULL_FROM_URL/FILE_UPLOAD)', + }, + videoUrl: { type: 'string', description: 'Public URL of the video' }, + file: { + type: 'json', + description: 'Video file to upload (uploaded file or reference from a previous block)', + }, + photoImages: { type: 'string', description: 'Newline-separated public photo URLs' }, + photoCoverIndex: { type: 'number', description: 'Index of the photo to use as cover' }, + title: { type: 'string', description: 'Video/photo caption or title' }, + description: { type: 'string', description: 'Photo post description' }, + privacyLevel: { type: 'string', description: 'Privacy level for the post' }, + disableComment: { type: 'string', description: 'Whether to disable comments' }, + disableDuet: { type: 'string', description: 'Whether to disable duet' }, + disableStitch: { type: 'string', description: 'Whether to disable stitch' }, + videoCoverTimestampMs: { type: 'number', description: 'Video cover timestamp in ms' }, + isAigc: { type: 'string', description: 'Whether the video is AI-generated content' }, + autoAddMusic: { type: 'string', description: 'Whether to auto-add recommended music' }, + brandContentToggle: { + type: 'string', + description: 'Whether the post is a paid partnership promoting a third-party business', + }, + brandOrganicToggle: { + type: 'string', + description: "Whether the post promotes the creator's own business", + }, + publishId: { type: 'string', description: 'Publish ID to check status for' }, + }, + + outputs: { + // Get User Info + openId: { + type: 'string', + description: 'Unique TikTok user ID for this application', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + unionId: { + type: 'string', + description: 'Unique TikTok user ID across all apps from the developer', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + displayName: { + type: 'string', + description: 'User display name', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + avatarUrl: { + type: 'string', + description: 'Profile image URL', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + avatarUrl100: { + type: 'string', + description: 'Profile image URL (100x100)', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + avatarLargeUrl: { + type: 'string', + description: 'Profile image URL (large)', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + bioDescription: { + type: 'string', + description: 'User bio description', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + profileDeepLink: { + type: 'string', + description: 'Deep link to the user TikTok profile', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + isVerified: { + type: 'boolean', + description: 'Whether the account is verified', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + username: { + type: 'string', + description: 'TikTok username', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + followerCount: { + type: 'number', + description: 'Number of followers', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + followingCount: { + type: 'number', + description: 'Number of accounts followed', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + likesCount: { + type: 'number', + description: 'Total likes received across all videos', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + videoCount: { + type: 'number', + description: 'Total number of public videos', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, + + // List/Query Videos + videos: { + type: 'json', + description: + 'Array of video objects (id, title, coverImageUrl, embedLink, duration, createTime, shareUrl, videoDescription, width, height, viewCount, likeCount, commentCount, shareCount)', + condition: { field: 'operation', value: ['tiktok_list_videos', 'tiktok_query_videos'] }, + }, + cursor: { + type: 'number', + description: 'Pagination cursor for fetching the next page', + condition: { field: 'operation', value: 'tiktok_list_videos' }, + }, + hasMore: { + type: 'boolean', + description: 'Whether more videos are available', + condition: { field: 'operation', value: 'tiktok_list_videos' }, + }, + + // Query Creator Info + creatorAvatarUrl: { + type: 'string', + description: 'URL of the creator avatar', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + creatorUsername: { + type: 'string', + description: 'TikTok username of the creator', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + creatorNickname: { + type: 'string', + description: 'Display name/nickname of the creator', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + privacyLevelOptions: { + type: 'json', + description: 'Available privacy levels for posting (array of strings)', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + commentDisabled: { + type: 'boolean', + description: 'Whether the creator disabled comments by default', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + duetDisabled: { + type: 'boolean', + description: 'Whether the creator disabled duets by default', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + stitchDisabled: { + type: 'boolean', + description: 'Whether the creator disabled stitches by default', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + maxVideoPostDurationSec: { + type: 'number', + description: 'Maximum allowed video duration in seconds', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + + // Direct Post / Upload Draft (video and photo) + publishId: { + type: 'string', + description: 'Publish ID for tracking post status', + condition: { + field: 'operation', + value: [...VIDEO_POST_OPERATIONS, ...PHOTO_POST_OPERATIONS], + }, + }, + + // Get Post Status + status: { + type: 'string', + description: + 'Post status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD, SEND_TO_USER_INBOX, PUBLISH_COMPLETE, or FAILED', + condition: { field: 'operation', value: 'tiktok_get_post_status' }, + }, + failReason: { + type: 'string', + description: 'Reason for failure if status is FAILED', + condition: { field: 'operation', value: 'tiktok_get_post_status' }, + }, + publiclyAvailablePostId: { + type: 'json', + description: 'Array of public post IDs once the content is published', + condition: { field: 'operation', value: 'tiktok_get_post_status' }, + }, + }, +} + +export const TikTokBlockMeta = { + tags: ['marketing', 'content-management'], + url: 'https://www.tiktok.com', + templates: [ + { + icon: TikTokIcon, + title: 'AI video auto-publisher', + prompt: + 'Build a workflow that takes a generated video file and publishes it directly to TikTok with an AI-written caption, then checks the post status until it completes.', + modules: ['agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation'], + }, + { + icon: TikTokIcon, + title: 'TikTok content calendar scheduler', + prompt: + 'Create a scheduled workflow that reads the next due row from a Tables-based content calendar and publishes the matching video or photo to TikTok at the right time.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation'], + }, + { + icon: TikTokIcon, + title: 'TikTok performance reporter', + prompt: + 'Build a workflow that lists recent TikTok videos, summarizes view and engagement trends with an agent, and posts the digest to Slack every week.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: TikTokIcon, + title: 'TikTok draft review pipeline', + prompt: + 'Create a workflow that uploads a new video to a TikTok inbox draft for review, then notifies the marketing team on Slack to approve and post it from the TikTok app.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['marketing', 'automation'], + alsoIntegrations: ['slack'], + }, + ], + skills: [ + { + name: 'publish-video-to-tiktok', + description: 'Publish a video directly to TikTok from a public URL or an uploaded file.', + content: + '# Publish a Video to TikTok\n\nPost a video straight to a connected TikTok account.\n\n## Steps\n1. Use the Direct Post Video operation with a connected TikTok Account.\n2. Before the first post, run Query Creator Info to confirm posting permissions and see the allowed privacy levels — sandbox/unaudited apps are restricted to Only Me.\n3. Choose the Video Source: Public URL (the domain must be verified in the TikTok developer portal) or Upload File (a file produced earlier in the workflow).\n4. Set the Title/Caption with hashtags and mentions, and pick a Privacy Level from the options Query Creator Info returned.\n5. Use the Get Post Status operation with the returned Publish ID to confirm the post completed.\n\n## Output\nReturn the Publish ID and the final status (PUBLISH_COMPLETE or FAILED with a reason).', + }, + { + name: 'send-video-draft-to-inbox', + description: "Send a video to the user's TikTok inbox for manual review before posting.", + content: + "# Send a TikTok Video Draft\n\nDeliver a video to the connected account's TikTok inbox so a human can review, edit, and publish it from the app.\n\n## Steps\n1. Use the Upload Video Draft operation with a connected TikTok Account.\n2. Choose the Video Source: Public URL or Upload File.\n3. Submit the draft — no caption or privacy level is set here, since the user finishes the post manually in the TikTok app.\n4. Use Get Post Status with the returned Publish ID to see when the user has acted on the inbox notification (SEND_TO_USER_INBOX until they do).\n\n## Output\nReturn the Publish ID so the draft's status can be tracked or referenced later.", + }, + { + name: 'publish-photo-carousel', + description: 'Publish a set of photos to TikTok as a photo carousel post.', + content: + '# Publish a Photo Carousel to TikTok\n\nPost a carousel of images to a connected TikTok account. Photos only support public URLs (no file upload).\n\n## Steps\n1. Run Query Creator Info first to confirm the account can post and to read the allowed privacy levels.\n2. Use the Direct Post Photo operation with a connected TikTok Account.\n3. Provide Photo URLs, one public JPEG or WEBP URL per line (PNG is rejected), up to 35 images.\n4. Set a Title (max 90 characters), an optional Description (max 4000 characters), the Cover Photo Index, and a Privacy Level.\n5. Check progress with Get Post Status using the returned Publish ID.\n\n## Output\nReturn the Publish ID and the final status of the photo post.', + }, + { + name: 'check-tiktok-post-status', + description: 'Poll the status of a TikTok post or draft until it completes or fails.', + content: + '# Check TikTok Post Status\n\nTrack the outcome of a post or draft submitted with any TikTok publish operation.\n\n## Steps\n1. Capture the Publish ID returned by Direct Post Video, Upload Video Draft, Direct Post Photo, or Upload Photo Draft.\n2. Call Get Post Status with that Publish ID.\n3. Branch on the returned status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD means still in progress, SEND_TO_USER_INBOX means a draft is waiting on the user, PUBLISH_COMPLETE means it succeeded, and FAILED means it did not (read failReason for why).\n4. Repeat on a delay for in-progress statuses until a terminal state is reached.\n\n## Output\nReturn the final status, failReason (if any), and the publiclyAvailablePostId once published.', + }, + { + name: 'summarize-tiktok-video-performance', + description: "List a creator's recent TikTok videos and summarize engagement for reporting.", + content: + "# Summarize TikTok Video Performance\n\nPull a creator's recent videos and turn the metadata into a readable report.\n\n## Steps\n1. Use List Videos with a connected TikTok Account to fetch recent videos (paginate with the Cursor if more than one page is needed).\n2. For specific videos already known by ID, use Query Videos instead to refresh their metadata.\n3. Ask an agent to summarize the results — highlight top performers by duration/engagement signals available in the metadata and note any patterns.\n4. Optionally use Get User Info alongside this to report overall follower and like counts.\n\n## Output\nA structured summary or table of videos with their titles, share URLs, and key metadata, suitable for posting to a report or chat.", + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index fd1d8de138a..365c62a3b12 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -293,6 +293,7 @@ import { TemporalBlock, TemporalBlockMeta } from '@/blocks/blocks/temporal' import { TextractBlock, TextractBlockMeta, TextractV2Block } from '@/blocks/blocks/textract' import { ThinkingBlock } from '@/blocks/blocks/thinking' import { ThriveBlock, ThriveBlockMeta } from '@/blocks/blocks/thrive' +import { TikTokBlock, TikTokBlockMeta } from '@/blocks/blocks/tiktok' import { TinybirdBlock, TinybirdBlockMeta } from '@/blocks/blocks/tinybird' import { TranslateBlock } from '@/blocks/blocks/translate' import { TrelloBlock, TrelloBlockMeta } from '@/blocks/blocks/trello' @@ -602,6 +603,7 @@ export const BLOCK_REGISTRY: Record = { textract_v2: TextractV2Block, thinking: ThinkingBlock, thrive: ThriveBlock, + tiktok: TikTokBlock, tinybird: TinybirdBlock, translate: TranslateBlock, trello: TrelloBlock, @@ -868,6 +870,7 @@ export const BLOCK_META_REGISTRY: Record = { temporal: TemporalBlockMeta, textract: TextractBlockMeta, thrive: ThriveBlockMeta, + tiktok: TikTokBlockMeta, tinybird: TinybirdBlockMeta, trello: TrelloBlockMeta, trigger_dev: TriggerDevBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index ea1eeea8a10..43d9b552179 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1274,6 +1274,31 @@ export function xAIIcon(props: SVGProps) { ) } +export function TikTokIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function xIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/tiktok-tools.ts b/apps/sim/lib/api/contracts/tiktok-tools.ts new file mode 100644 index 00000000000..569871c7258 --- /dev/null +++ b/apps/sim/lib/api/contracts/tiktok-tools.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { + type ContractBodyInput, + type ContractJsonResponse, + defineRouteContract, +} from '@/lib/api/contracts/types' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const tiktokPublishVideoPostInfoSchema = z.object({ + title: z.string().optional(), + privacy_level: z.string().optional(), + disable_duet: z.boolean().optional(), + disable_stitch: z.boolean().optional(), + disable_comment: z.boolean().optional(), + video_cover_timestamp_ms: z.number().optional(), + is_aigc: z.boolean().optional(), + brand_content_toggle: z.boolean().optional(), + brand_organic_toggle: z.boolean().optional(), +}) + +export const tiktokPublishVideoBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + mode: z.enum(['direct', 'draft']), + file: RawFileInputSchema, + postInfo: tiktokPublishVideoPostInfoSchema.optional().nullable(), +}) + +export const tiktokPublishVideoResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ publishId: z.string() }).optional(), + error: z.string().optional(), +}) + +export const tiktokPublishVideoContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/tiktok/publish-video', + body: tiktokPublishVideoBodySchema, + response: { mode: 'json', schema: tiktokPublishVideoResponseSchema }, +}) + +export type TikTokPublishVideoBody = ContractBodyInput +export type TikTokPublishVideoResponse = ContractJsonResponse diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 8accab00a40..ebead04caaa 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1938,6 +1938,77 @@ export const auth = betterAuth({ }, }, + { + providerId: 'tiktok', + clientId: env.TIKTOK_CLIENT_ID as string, + clientSecret: env.TIKTOK_CLIENT_SECRET as string, + authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/', + tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', + scopes: getCanonicalScopesForProvider('tiktok'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, + // TikTok requires the app identifier under `client_key`, not the standard + // `client_id`. The library always sends `client_id` too, but TikTok ignores + // unrecognized params, so adding `client_key` here (for both the authorize + // redirect and the token exchange) is sufficient without patching the library. + // + // TikTok also requires a comma-separated `scope` list, but the library always + // joins scopes with a space (no configurable separator in the generic-oauth + // plugin's authorize route). `additionalParams` is applied via + // `url.searchParams.set(key, value)` after the default scope is set, so + // overriding `scope` here replaces the space-joined value with a comma-joined + // one that TikTok can actually parse. + authorizationUrlParams: { + client_key: env.TIKTOK_CLIENT_ID as string, + scope: getCanonicalScopesForProvider('tiktok').join(','), + }, + tokenUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string }, + getUserInfo: async (tokens) => { + try { + const response = await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching TikTok user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + const user = profile.data?.user + + if (!user?.open_id) { + logger.error('Invalid TikTok profile response:', profile) + return null + } + + const now = new Date() + + return { + id: `${user.open_id}-${generateId()}`, + name: user.display_name || 'TikTok User', + email: `${user.open_id}@tiktok.user`, + image: user.avatar_url || undefined, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in TikTok getUserInfo:', { error }) + return null + } + }, + }, + { providerId: 'confluence', clientId: env.CONFLUENCE_CLIENT_ID as string, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 1d992f2b03c..2f34a0635fe 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -344,6 +344,8 @@ export const env = createEnv({ X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret + TIKTOK_CLIENT_ID: z.string().optional(), // TikTok OAuth client key (TikTok calls this "client_key") + TIKTOK_CLIENT_SECRET: z.string().optional(), // TikTok OAuth client secret CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID CONFLUENCE_CLIENT_SECRET: z.string().optional(), // Atlassian Confluence OAuth client secret JIRA_CLIENT_ID: z.string().optional(), // Atlassian Jira OAuth client ID diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index e9df1348307..b811f67514f 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -211,6 +211,7 @@ import { TemporalIcon, TextractIcon, ThriveIcon, + TikTokIcon, TinybirdIcon, TrelloIcon, TriggerDevIcon, @@ -447,6 +448,7 @@ export const blockTypeToIconMap: Record = { temporal: TemporalIcon, textract_v2: TextractIcon, thrive: ThriveIcon, + tiktok: TikTokIcon, tinybird: TinybirdIcon, trello: TrelloIcon, trigger_dev: TriggerDevIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 9d543582283..2f68b5470ba 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-07", + "updatedAt": "2026-07-08", "integrations": [ { "type": "onepassword", @@ -18608,6 +18608,62 @@ "integrationType": "hr", "tags": ["content-management", "knowledge-base", "automation"] }, + { + "type": "tiktok", + "slug": "tiktok", + "name": "TikTok", + "description": "Access TikTok profiles and videos, and publish content", + "longDescription": "Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos and photos directly to TikTok (or send them to the inbox as drafts) from a public URL or a file uploaded in the workflow, then track post status.", + "bgColor": "#000000", + "iconName": "TikTokIcon", + "docsUrl": "https://docs.sim.ai/integrations/tiktok", + "operations": [ + { + "name": "Get User Info", + "description": "Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics." + }, + { + "name": "List Videos", + "description": "Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination." + }, + { + "name": "Query Videos", + "description": "Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details." + }, + { + "name": "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." + }, + { + "name": "Direct Post Video", + "description": "Publish a video to TikTok, either by public URL (TikTok fetches it) or by uploading a file from the workflow. Rate limit: 6 requests per minute per user." + }, + { + "name": "Upload Video Draft", + "description": "Send a video to the authenticated user's TikTok inbox (by public URL or uploaded file) 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." + }, + { + "name": "Direct Post Photo", + "description": "Publish one or more photos to TikTok from public URLs. TikTok will fetch each image and post them as a photo carousel. Photos only support PULL_FROM_URL (no file upload). Rate limit: 6 requests per minute per user." + }, + { + "name": "Upload Photo Draft", + "description": "Send one or more photos (from public URLs) 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. Photos only support PULL_FROM_URL (no file upload). Rate limit: 6 requests per minute per user." + }, + { + "name": "Get Post Status", + "description": "Check the status of a post initiated with Direct Post Video, Upload Video Draft, Direct Post Photo, or Upload Photo Draft. Use the publishId returned from the post request to track progress." + } + ], + "operationCount": 9, + "triggers": [], + "triggerCount": 0, + "authType": "oauth", + "oauthServiceId": "tiktok", + "category": "tools", + "integrationType": "communication", + "tags": ["marketing", "content-management"] + }, { "type": "tinybird", "slug": "tinybird", diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 8f6b17100fe..578271fe85b 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -43,6 +43,7 @@ import { ShopifyIcon, SlackIcon, SpotifyIcon, + TikTokIcon, TrelloIcon, VertexIcon, WealthboxIcon, @@ -427,6 +428,28 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'x', }, + tiktok: { + name: 'TikTok', + icon: TikTokIcon, + services: { + tiktok: { + name: 'TikTok', + description: 'Read profile info and videos, and publish content to TikTok.', + providerId: 'tiktok', + icon: TikTokIcon, + baseProviderIcon: TikTokIcon, + scopes: [ + 'user.info.basic', + 'user.info.profile', + 'user.info.stats', + 'video.publish', + 'video.upload', + 'video.list', + ], + }, + }, + defaultService: 'tiktok', + }, atlassian: { name: 'Atlassian', icon: JiraIcon, @@ -1057,6 +1080,11 @@ interface ProviderAuthConfig { * instead of the default application/x-www-form-urlencoded. Used by Notion. */ useJsonBody?: boolean + /** + * Body param name to use for the client identifier instead of the standard `client_id`. + * TikTok requires `client_key` instead. + */ + clientIdParamName?: string } /** @@ -1093,6 +1121,21 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } + case 'tiktok': { + const { clientId, clientSecret } = getCredentials( + env.TIKTOK_CLIENT_ID, + env.TIKTOK_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://open.tiktokapis.com/v2/oauth/token/', + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + // TikTok requires `client_key` in the token request body instead of `client_id`. + clientIdParamName: 'client_key', + } + } case 'confluence': { const { clientId, clientSecret } = getCredentials( env.CONFLUENCE_CLIENT_ID, @@ -1455,7 +1498,7 @@ function buildAuthRequest( headers.Authorization = `Basic ${basicAuth}` } else { // Use body credentials - include client credentials in request body - bodyParams.client_id = config.clientId + bodyParams[config.clientIdParamName || 'client_id'] = config.clientId if (config.clientSecret) { bodyParams.client_secret = config.clientSecret } diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index bd1de917bf2..4e25f6ff1a2 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -29,6 +29,7 @@ export type OAuthProvider = | 'google-meet' | 'vertex-ai' | 'x' + | 'tiktok' | 'confluence' | 'airtable' | 'notion' @@ -81,6 +82,7 @@ export type OAuthService = | 'google-meet' | 'vertex-ai' | 'x' + | 'tiktok' | 'confluence' | 'airtable' | 'notion' diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 6d903a6cd1c..1ca1655e1f3 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -122,6 +122,14 @@ export const SCOPE_DESCRIPTIONS: Record = { 'mute.write': 'Mute and unmute users', 'offline.access': 'Access account when not using the application', + // TikTok scopes + 'user.info.basic': "Read a user's profile info (open id, avatar, display name)", + 'user.info.profile': "Read a user's profile info (bio, verification status, username)", + 'user.info.stats': "Read a user's stats (follower, following, likes, and video counts)", + 'video.publish': "Directly post content to a user's TikTok profile", + 'video.upload': "Share content to a creator's account as a draft for further edit and post", + 'video.list': "Read a user's public TikTok videos", + // Airtable scopes 'data.records:read': 'Read records', 'data.records:write': 'Write to records', diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4fcda0f1cd4..927dfaa2870 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -4050,6 +4050,17 @@ import { thriveUpdateUserSkillsTool, thriveUpdateUserTool, } from '@/tools/thrive' +import { + tiktokDirectPostPhotoTool, + tiktokDirectPostVideoTool, + tiktokGetPostStatusTool, + tiktokGetUserTool, + tiktokListVideosTool, + tiktokQueryCreatorInfoTool, + tiktokQueryVideosTool, + tiktokUploadPhotoDraftTool, + tiktokUploadVideoDraftTool, +} from '@/tools/tiktok' import { tinybirdAppendDatasourceTool, tinybirdDeleteDatasourceRowsTool, @@ -7143,6 +7154,15 @@ export const tools: Record = { thrive_remove_user_tags: thriveRemoveUserTagsTool, thrive_update_user_skills: thriveUpdateUserSkillsTool, thrive_get_skill_levels: thriveGetSkillLevelsTool, + tiktok_direct_post_photo: tiktokDirectPostPhotoTool, + tiktok_direct_post_video: tiktokDirectPostVideoTool, + tiktok_get_post_status: tiktokGetPostStatusTool, + tiktok_get_user: tiktokGetUserTool, + tiktok_list_videos: tiktokListVideosTool, + tiktok_query_creator_info: tiktokQueryCreatorInfoTool, + tiktok_query_videos: tiktokQueryVideosTool, + tiktok_upload_photo_draft: tiktokUploadPhotoDraftTool, + tiktok_upload_video_draft: tiktokUploadVideoDraftTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool, tinybird_query_pipe: tinybirdQueryPipeTool, diff --git a/apps/sim/tools/tiktok/direct_post_photo.ts b/apps/sim/tools/tiktok/direct_post_photo.ts new file mode 100644 index 00000000000..978be282259 --- /dev/null +++ b/apps/sim/tools/tiktok/direct_post_photo.ts @@ -0,0 +1,156 @@ +import type { + TikTokDirectPostPhotoParams, + TikTokDirectPostPhotoResponse, +} from '@/tools/tiktok/types' +import { parsePublishInitResponse } from '@/tools/tiktok/utils' +import type { ToolConfig } from '@/tools/types' + +export const tiktokDirectPostPhotoTool: ToolConfig< + TikTokDirectPostPhotoParams, + TikTokDirectPostPhotoResponse +> = { + id: 'tiktok_direct_post_photo', + name: 'TikTok Direct Post Photo', + description: + 'Publish one or more photos to TikTok from public URLs. TikTok will fetch each image and post them as a photo carousel. Photos only support PULL_FROM_URL (no file upload). 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', + }, + photoImages: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: + 'Public, verified-domain URLs of the images to post (up to 35). Must be JPEG or WEBP — TikTok rejects PNG.', + items: { + type: 'string', + description: 'Public image URL', + }, + }, + photoCoverIndex: { + type: 'number', + required: false, + visibility: 'user-or-llm', + default: 0, + description: 'Index (starting from 0) of the photo to use as the cover.', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post title. Maximum 90 characters.', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post description. Maximum 4000 characters.', + }, + privacyLevel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Privacy level for the post. 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.', + }, + disableComment: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Disable comments for this post. Defaults to false.', + }, + autoAddMusic: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Automatically add recommended music to the photo post. Defaults to false.', + }, + brandContentToggle: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Set to true if the post is a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy.', + }, + brandOrganicToggle: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: "Set to true if the post is promoting the creator's own business.", + }, + }, + + request: { + url: () => 'https://open.tiktokapis.com/v2/post/publish/content/init/', + method: 'POST', + headers: (params: TikTokDirectPostPhotoParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + }), + body: (params: TikTokDirectPostPhotoParams) => { + const postInfo: Record = { + privacy_level: params.privacyLevel, + } + + if (params.title) postInfo.title = params.title + if (params.description) postInfo.description = params.description + if (params.disableComment !== undefined) postInfo.disable_comment = params.disableComment + if (params.autoAddMusic !== undefined) postInfo.auto_add_music = params.autoAddMusic + if (params.brandContentToggle !== undefined) { + postInfo.brand_content_toggle = params.brandContentToggle + } + if (params.brandOrganicToggle !== undefined) { + postInfo.brand_organic_toggle = params.brandOrganicToggle + } + + return { + media_type: 'PHOTO', + post_mode: 'DIRECT_POST', + post_info: postInfo, + source_info: { + source: 'PULL_FROM_URL', + photo_images: params.photoImages, + photo_cover_index: params.photoCoverIndex ?? 0, + }, + } + }, + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + const result = parsePublishInitResponse(data) + + if (!result.success) { + return { + success: false, + output: { publishId: '' }, + error: result.error, + } + } + + return { + success: true, + output: { publishId: result.publishId }, + } + }, + + outputs: { + publishId: { + type: 'string', + description: + 'Unique identifier for tracking the post status. Use this with the Get Post Status tool to check if the photos were successfully published.', + }, + }, +} diff --git a/apps/sim/tools/tiktok/direct_post_video.ts b/apps/sim/tools/tiktok/direct_post_video.ts new file mode 100644 index 00000000000..12756817aaa --- /dev/null +++ b/apps/sim/tools/tiktok/direct_post_video.ts @@ -0,0 +1,189 @@ +import type { + TikTokDirectPostVideoParams, + TikTokDirectPostVideoResponse, +} from '@/tools/tiktok/types' +import { parsePublishInitResponse } from '@/tools/tiktok/utils' +import type { ToolConfig } from '@/tools/types' + +function buildPostInfo(params: TikTokDirectPostVideoParams): Record { + const postInfo: Record = { + privacy_level: params.privacyLevel, + } + + if (params.title) postInfo.title = params.title + if (params.disableDuet !== undefined) postInfo.disable_duet = params.disableDuet + if (params.disableStitch !== undefined) postInfo.disable_stitch = params.disableStitch + if (params.disableComment !== undefined) postInfo.disable_comment = params.disableComment + if (params.videoCoverTimestampMs !== undefined) { + postInfo.video_cover_timestamp_ms = params.videoCoverTimestampMs + } + if (params.isAigc !== undefined) postInfo.is_aigc = params.isAigc + if (params.brandContentToggle !== undefined) { + postInfo.brand_content_toggle = params.brandContentToggle + } + if (params.brandOrganicToggle !== undefined) { + postInfo.brand_organic_toggle = params.brandOrganicToggle + } + + return postInfo +} + +export const tiktokDirectPostVideoTool: ToolConfig< + TikTokDirectPostVideoParams, + TikTokDirectPostVideoResponse +> = { + id: 'tiktok_direct_post_video', + name: 'TikTok Direct Post Video', + description: + 'Publish a video to TikTok, either by public URL (TikTok fetches it) or 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', + }, + source: { + type: 'string', + required: true, + visibility: 'hidden', + description: "Media transfer method: 'PULL_FROM_URL' or 'FILE_UPLOAD'.", + }, + videoUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Public URL of the video to post (used when source is PULL_FROM_URL). The domain/URL prefix must be verified in the TikTok developer portal.', + }, + file: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Video file to upload from the workflow (used when source is FILE_UPLOAD).', + }, + 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: false, + visibility: 'user-or-llm', + description: 'Disable duet for this video. Defaults to false.', + }, + disableStitch: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Disable stitch for this video. Defaults to false.', + }, + disableComment: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Disable comments for this video. Defaults to false.', + }, + 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: false, + visibility: 'user-or-llm', + description: + 'Set to true if the video is a paid partnership promoting a third-party business. 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.", + }, + }, + + request: { + url: (params: TikTokDirectPostVideoParams) => + params.source === 'FILE_UPLOAD' + ? '/api/tools/tiktok/publish-video' + : 'https://open.tiktokapis.com/v2/post/publish/video/init/', + method: 'POST', + headers: (params: TikTokDirectPostVideoParams) => ({ + ...(params.source !== 'FILE_UPLOAD' && { Authorization: `Bearer ${params.accessToken}` }), + 'Content-Type': + params.source === 'FILE_UPLOAD' ? 'application/json' : 'application/json; charset=UTF-8', + }), + body: (params: TikTokDirectPostVideoParams) => { + const postInfo = buildPostInfo(params) + + if (params.source === 'FILE_UPLOAD') { + return { + accessToken: params.accessToken, + mode: 'direct', + file: params.file, + postInfo, + } + } + + return { + post_info: postInfo, + source_info: { + source: 'PULL_FROM_URL', + video_url: params.videoUrl, + }, + } + }, + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + const result = parsePublishInitResponse(data) + + if (!result.success) { + return { + success: false, + output: { publishId: '' }, + error: result.error, + } + } + + return { + success: true, + output: { publishId: result.publishId }, + } + }, + + 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.', + }, + }, +} diff --git a/apps/sim/tools/tiktok/get_post_status.ts b/apps/sim/tools/tiktok/get_post_status.ts new file mode 100644 index 00000000000..002cb6faa9c --- /dev/null +++ b/apps/sim/tools/tiktok/get_post_status.ts @@ -0,0 +1,118 @@ +import type { TikTokGetPostStatusParams, TikTokGetPostStatusResponse } from '@/tools/tiktok/types' +import type { ToolConfig } from '@/tools/types' + +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, Upload Video Draft, Direct Post Photo, or Upload Photo Draft. Use the publishId returned from the post request to track progress.', + 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', + }, + 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) => ({ + publish_id: params.publishId.trim(), + }), + }, + + transformResponse: async (response: Response): Promise => { + // TikTok returns publicaly_available_post_id entries as int64 numbers that exceed + // Number.MAX_SAFE_INTEGER, so they must be extracted from the raw body as strings + // before JSON.parse rounds them to nonexistent IDs. + const rawBody = await response.text() + const data = JSON.parse(rawBody) + 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 (data.error?.code !== 'ok' && data.error?.code) { + return { + success: false, + output: { + status: '', + failReason: null, + publiclyAvailablePostId: [], + }, + error: data.error?.message || 'Failed to fetch post status', + } + } + + const statusData = data.data + + if (!statusData) { + return { + success: false, + output: { + status: '', + failReason: null, + publiclyAvailablePostId: [], + }, + error: 'No status data returned', + } + } + + return { + success: true, + output: { + status: statusData.status ?? '', + failReason: statusData.fail_reason ?? null, + publiclyAvailablePostId, + }, + } + }, + + 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', + }, + }, + }, +} diff --git a/apps/sim/tools/tiktok/get_user.ts b/apps/sim/tools/tiktok/get_user.ts new file mode 100644 index 00000000000..58bad4bd741 --- /dev/null +++ b/apps/sim/tools/tiktok/get_user.ts @@ -0,0 +1,189 @@ +import type { TikTokGetUserParams, TikTokGetUserResponse } from '@/tools/tiktok/types' +import { TIKTOK_USER_FIELDS } from '@/tools/tiktok/utils' +import type { ToolConfig } from '@/tools/types' + +export const tiktokGetUserTool: ToolConfig = { + 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_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count', + }, + }, + + 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}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + + if (data.error?.code !== 'ok' && data.error?.code) { + return { + success: false, + output: { + openId: '', + unionId: null, + displayName: '', + avatarUrl: null, + avatarUrl100: null, + avatarLargeUrl: null, + bioDescription: null, + profileDeepLink: null, + isVerified: null, + username: null, + followerCount: null, + followingCount: null, + likesCount: null, + videoCount: null, + }, + error: data.error?.message || 'Failed to fetch user info', + } + } + + const user = data.data?.user + + if (!user) { + return { + success: false, + output: { + openId: '', + unionId: null, + displayName: '', + avatarUrl: null, + avatarUrl100: null, + avatarLargeUrl: null, + bioDescription: null, + profileDeepLink: null, + isVerified: null, + username: null, + followerCount: null, + followingCount: null, + likesCount: null, + videoCount: null, + }, + error: 'No user data returned', + } + } + + return { + success: true, + output: { + openId: user.open_id ?? '', + unionId: user.union_id ?? null, + displayName: user.display_name ?? '', + avatarUrl: user.avatar_url ?? null, + avatarUrl100: user.avatar_url_100 ?? null, + avatarLargeUrl: user.avatar_large_url ?? null, + 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, + }, + } + }, + + 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', + }, + avatarUrl: { + type: 'string', + description: 'Profile image URL', + optional: true, + }, + avatarUrl100: { + type: 'string', + description: 'Profile image URL (100x100)', + optional: true, + }, + avatarLargeUrl: { + type: 'string', + description: 'Profile image URL (large)', + optional: true, + }, + 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, + }, + }, +} diff --git a/apps/sim/tools/tiktok/index.ts b/apps/sim/tools/tiktok/index.ts new file mode 100644 index 00000000000..a87fd762726 --- /dev/null +++ b/apps/sim/tools/tiktok/index.ts @@ -0,0 +1,21 @@ +import { tiktokDirectPostPhotoTool } from '@/tools/tiktok/direct_post_photo' +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 { tiktokUploadPhotoDraftTool } from '@/tools/tiktok/upload_photo_draft' +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 { tiktokDirectPostPhotoTool } +export { tiktokUploadPhotoDraftTool } +export { tiktokGetPostStatusTool } diff --git a/apps/sim/tools/tiktok/list_videos.ts b/apps/sim/tools/tiktok/list_videos.ts new file mode 100644 index 00000000000..82de23a2d1a --- /dev/null +++ b/apps/sim/tools/tiktok/list_videos.ts @@ -0,0 +1,130 @@ +import type { + TikTokListVideosParams, + TikTokListVideosResponse, + TikTokVideo, +} from '@/tools/tiktok/types' +import { mapTikTokVideo, TIKTOK_VIDEO_FIELDS } from '@/tools/tiktok/utils' +import type { ToolConfig } from '@/tools/types' + +export const tiktokListVideosTool: ToolConfig = { + 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: 20, + 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) => ({ + max_count: params.maxCount || 20, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }), + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + + if (data.error?.code !== 'ok' && data.error?.code) { + return { + success: false, + output: { + videos: [], + cursor: null, + hasMore: false, + }, + error: data.error?.message || 'Failed to fetch videos', + } + } + + const videos: TikTokVideo[] = (data.data?.videos ?? []).map(mapTikTokVideo) + + return { + success: true, + output: { + videos, + cursor: data.data?.cursor ?? null, + hasMore: data.data?.has_more ?? false, + }, + } + }, + + outputs: { + videos: { + type: 'array', + description: 'List of TikTok videos', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Video ID' }, + title: { type: 'string', description: 'Video title', optional: true }, + coverImageUrl: { + type: 'string', + description: 'Cover image URL (may expire)', + optional: true, + }, + embedLink: { type: 'string', description: 'Embeddable video URL', optional: true }, + duration: { type: 'number', description: 'Video duration in seconds', optional: true }, + createTime: { + type: 'number', + description: 'Unix timestamp when video was created', + optional: true, + }, + shareUrl: { type: 'string', description: 'Shareable video URL', optional: true }, + videoDescription: { + type: 'string', + description: 'Video description/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 }, + }, + }, + }, + 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', + }, + }, +} diff --git a/apps/sim/tools/tiktok/query_creator_info.ts b/apps/sim/tools/tiktok/query_creator_info.ts new file mode 100644 index 00000000000..c65254c6a4e --- /dev/null +++ b/apps/sim/tools/tiktok/query_creator_info.ts @@ -0,0 +1,134 @@ +import type { + TikTokQueryCreatorInfoParams, + TikTokQueryCreatorInfoResponse, +} from '@/tools/tiktok/types' +import type { ToolConfig } from '@/tools/types' + +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', + }), + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + + if (data.error?.code !== 'ok' && data.error?.code) { + return { + success: false, + output: { + creatorAvatarUrl: null, + creatorUsername: null, + creatorNickname: null, + privacyLevelOptions: [], + commentDisabled: false, + duetDisabled: false, + stitchDisabled: false, + maxVideoPostDurationSec: null, + }, + error: data.error?.message || 'Failed to query creator info', + } + } + + const creatorInfo = data.data + + if (!creatorInfo) { + return { + success: false, + output: { + creatorAvatarUrl: null, + creatorUsername: null, + creatorNickname: null, + privacyLevelOptions: [], + commentDisabled: false, + duetDisabled: false, + stitchDisabled: false, + maxVideoPostDurationSec: null, + }, + 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, + }, + }, +} diff --git a/apps/sim/tools/tiktok/query_videos.ts b/apps/sim/tools/tiktok/query_videos.ts new file mode 100644 index 00000000000..759f011893f --- /dev/null +++ b/apps/sim/tools/tiktok/query_videos.ts @@ -0,0 +1,116 @@ +import type { + TikTokQueryVideosParams, + TikTokQueryVideosResponse, + TikTokVideo, +} from '@/tools/tiktok/types' +import { mapTikTokVideo, TIKTOK_VIDEO_FIELDS } from '@/tools/tiktok/utils' +import type { ToolConfig } from '@/tools/types' + +export const tiktokQueryVideosTool: ToolConfig = + { + 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) => ({ + filters: { + video_ids: params.videoIds, + }, + }), + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + + if (data.error?.code !== 'ok' && data.error?.code) { + return { + success: false, + output: { + videos: [], + }, + error: data.error?.message || 'Failed to query videos', + } + } + + const videos: TikTokVideo[] = (data.data?.videos ?? []).map(mapTikTokVideo) + + return { + success: true, + output: { + videos, + }, + } + }, + + outputs: { + videos: { + type: 'array', + description: 'List of queried TikTok videos', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Video ID' }, + title: { type: 'string', description: 'Video title', optional: true }, + coverImageUrl: { + type: 'string', + description: 'Cover image URL (fresh URL)', + optional: true, + }, + embedLink: { type: 'string', description: 'Embeddable video URL', optional: true }, + duration: { type: 'number', description: 'Video duration in seconds', optional: true }, + createTime: { + type: 'number', + description: 'Unix timestamp when video was created', + optional: true, + }, + shareUrl: { type: 'string', description: 'Shareable video URL', optional: true }, + videoDescription: { + type: 'string', + description: 'Video description/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 }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/tiktok/types.ts b/apps/sim/tools/tiktok/types.ts new file mode 100644 index 00000000000..bda54a26570 --- /dev/null +++ b/apps/sim/tools/tiktok/types.ts @@ -0,0 +1,209 @@ +import type { UserFile } from '@/executor/types' +import type { ToolResponse } from '@/tools/types' + +/** + * Base params that include OAuth access token + */ +export interface TikTokBaseParams { + accessToken: string +} + +/** + * Get User Info + */ +export interface TikTokGetUserParams extends TikTokBaseParams { + fields?: string +} + +export interface TikTokGetUserResponse extends ToolResponse { + output: { + openId: string + unionId: string | null + displayName: string + avatarUrl: string | null + avatarUrl100: string | null + avatarLargeUrl: string | null + bioDescription: string | null + profileDeepLink: string | null + isVerified: boolean | null + username: string | null + followerCount: number | null + followingCount: number | null + likesCount: number | null + videoCount: number | null + } +} + +/** + * 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 + 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 +} + +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 + } +} + +/** + * Shared media transfer source for video operations + */ +export type TikTokVideoSource = 'PULL_FROM_URL' | 'FILE_UPLOAD' + +/** + * Direct Post Video - Publish video (by URL or uploaded file) to TikTok + */ +export interface TikTokDirectPostVideoParams extends TikTokBaseParams { + source: TikTokVideoSource + videoUrl?: string + file?: UserFile + title?: string + privacyLevel: string + disableDuet?: boolean + disableStitch?: boolean + disableComment?: boolean + videoCoverTimestampMs?: number + isAigc?: boolean + brandContentToggle?: boolean + brandOrganicToggle?: boolean +} + +export interface TikTokDirectPostVideoResponse extends ToolResponse { + output: { + publishId: string + } +} + +/** + * Upload Video Draft - Send a video to the user's TikTok inbox for manual editing/posting + */ +export interface TikTokUploadVideoDraftParams extends TikTokBaseParams { + source: TikTokVideoSource + videoUrl?: string + file?: UserFile +} + +export interface TikTokUploadVideoDraftResponse extends ToolResponse { + output: { + publishId: string + } +} + +/** + * Direct Post Photo - Publish photo(s) from public URLs to TikTok + */ +export interface TikTokDirectPostPhotoParams extends TikTokBaseParams { + photoImages: string[] + photoCoverIndex?: number + title?: string + description?: string + privacyLevel: string + disableComment?: boolean + autoAddMusic?: boolean + brandContentToggle?: boolean + brandOrganicToggle?: boolean +} + +export interface TikTokDirectPostPhotoResponse extends ToolResponse { + output: { + publishId: string + } +} + +/** + * Upload Photo Draft - Send photo(s) to the user's TikTok inbox for manual editing/posting + */ +export interface TikTokUploadPhotoDraftParams extends TikTokBaseParams { + photoImages: string[] + photoCoverIndex?: number + title?: string + description?: string +} + +export interface TikTokUploadPhotoDraftResponse extends ToolResponse { + output: { + publishId: string + } +} + +/** + * 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[] + } +} + +/** + * Union type of all TikTok responses + */ +export type TikTokResponse = + | TikTokGetUserResponse + | TikTokListVideosResponse + | TikTokQueryVideosResponse + | TikTokQueryCreatorInfoResponse + | TikTokDirectPostVideoResponse + | TikTokUploadVideoDraftResponse + | TikTokDirectPostPhotoResponse + | TikTokUploadPhotoDraftResponse + | TikTokGetPostStatusResponse diff --git a/apps/sim/tools/tiktok/upload_photo_draft.ts b/apps/sim/tools/tiktok/upload_photo_draft.ts new file mode 100644 index 00000000000..a002e490120 --- /dev/null +++ b/apps/sim/tools/tiktok/upload_photo_draft.ts @@ -0,0 +1,114 @@ +import type { + TikTokUploadPhotoDraftParams, + TikTokUploadPhotoDraftResponse, +} from '@/tools/tiktok/types' +import { parsePublishInitResponse } from '@/tools/tiktok/utils' +import type { ToolConfig } from '@/tools/types' + +export const tiktokUploadPhotoDraftTool: ToolConfig< + TikTokUploadPhotoDraftParams, + TikTokUploadPhotoDraftResponse +> = { + id: 'tiktok_upload_photo_draft', + name: 'TikTok Upload Photo Draft', + description: + "Send one or more photos (from public URLs) 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. Photos only support PULL_FROM_URL (no file upload). 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', + }, + photoImages: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: + 'Public, verified-domain URLs of the images to upload (up to 35). Must be JPEG or WEBP — TikTok rejects PNG.', + items: { + type: 'string', + description: 'Public image URL', + }, + }, + photoCoverIndex: { + type: 'number', + required: false, + visibility: 'user-or-llm', + default: 0, + description: 'Index (starting from 0) of the photo to use as the cover.', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post title. Maximum 90 characters.', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post description. Maximum 4000 characters.', + }, + }, + + request: { + url: () => 'https://open.tiktokapis.com/v2/post/publish/content/init/', + method: 'POST', + headers: (params: TikTokUploadPhotoDraftParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + }), + body: (params: TikTokUploadPhotoDraftParams) => { + const postInfo: Record = {} + + if (params.title) postInfo.title = params.title + if (params.description) postInfo.description = params.description + + return { + media_type: 'PHOTO', + post_mode: 'MEDIA_UPLOAD', + post_info: postInfo, + source_info: { + source: 'PULL_FROM_URL', + photo_images: params.photoImages, + photo_cover_index: params.photoCoverIndex ?? 0, + }, + } + }, + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + const result = parsePublishInitResponse(data) + + if (!result.success) { + return { + success: false, + output: { publishId: '' }, + error: result.error, + } + } + + return { + success: true, + output: { publishId: result.publishId }, + } + }, + + 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.', + }, + }, +} diff --git a/apps/sim/tools/tiktok/upload_video_draft.ts b/apps/sim/tools/tiktok/upload_video_draft.ts new file mode 100644 index 00000000000..ef1b03e41d0 --- /dev/null +++ b/apps/sim/tools/tiktok/upload_video_draft.ts @@ -0,0 +1,106 @@ +import type { + TikTokUploadVideoDraftParams, + TikTokUploadVideoDraftResponse, +} from '@/tools/tiktok/types' +import { parsePublishInitResponse } 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 a video to the authenticated user's TikTok inbox (by public URL or uploaded file) 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', + }, + source: { + type: 'string', + required: true, + visibility: 'hidden', + description: "Media transfer method: 'PULL_FROM_URL' or 'FILE_UPLOAD'.", + }, + videoUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Public URL of the video to upload (used when source is PULL_FROM_URL). The domain/URL prefix must be verified in the TikTok developer portal.', + }, + file: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Video file to upload from the workflow (used when source is FILE_UPLOAD).', + }, + }, + + request: { + url: (params: TikTokUploadVideoDraftParams) => + params.source === 'FILE_UPLOAD' + ? '/api/tools/tiktok/publish-video' + : 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', + method: 'POST', + headers: (params: TikTokUploadVideoDraftParams) => ({ + ...(params.source !== 'FILE_UPLOAD' && { Authorization: `Bearer ${params.accessToken}` }), + 'Content-Type': + params.source === 'FILE_UPLOAD' ? 'application/json' : 'application/json; charset=UTF-8', + }), + body: (params: TikTokUploadVideoDraftParams) => { + if (params.source === 'FILE_UPLOAD') { + return { + accessToken: params.accessToken, + mode: 'draft', + file: params.file, + } + } + + return { + source_info: { + source: 'PULL_FROM_URL', + video_url: params.videoUrl, + }, + } + }, + }, + + transformResponse: async (response: Response): Promise => { + const data = await response.json() + const result = parsePublishInitResponse(data) + + if (!result.success) { + return { + success: false, + output: { publishId: '' }, + error: result.error, + } + } + + return { + success: true, + output: { publishId: result.publishId }, + } + }, + + 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.', + }, + }, +} diff --git a/apps/sim/tools/tiktok/utils.ts b/apps/sim/tools/tiktok/utils.ts new file mode 100644 index 00000000000..21583a3811a --- /dev/null +++ b/apps/sim/tools/tiktok/utils.ts @@ -0,0 +1,69 @@ +import type { 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. + */ +export const TIKTOK_USER_FIELDS = + 'open_id,union_id,avatar_url,avatar_url_100,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,duration,create_time,share_url,video_description,width,height,view_count,like_count,comment_count,share_count' + +export function mapTikTokVideo(video: Record): TikTokVideo { + return { + id: (video.id as string) ?? '', + title: (video.title as string) ?? null, + coverImageUrl: (video.cover_image_url as string) ?? null, + embedLink: (video.embed_link as string) ?? null, + duration: (video.duration as number) ?? null, + createTime: (video.create_time as number) ?? null, + shareUrl: (video.share_url as string) ?? null, + videoDescription: (video.video_description as string) ?? null, + width: (video.width as number) ?? null, + height: (video.height as number) ?? null, + viewCount: (video.view_count as number) ?? null, + likeCount: (video.like_count as number) ?? null, + commentCount: (video.comment_count as number) ?? null, + shareCount: (video.share_count as number) ?? null, + } +} + +/** + * Video/photo publish-init tools can hit TikTok directly (PULL_FROM_URL, calling + * TikTok's own `/init/` endpoints) or an internal Sim route (FILE_UPLOAD, which + * downloads the workflow file and performs the chunked PUT before responding). + * The two paths return different envelopes, so responses are normalized here. + */ +export function parsePublishInitResponse(data: Record): { + success: boolean + publishId: string + error?: string +} { + // Internal route shape: { success, output: { publishId }, error } + if ('success' in data) { + const success = Boolean(data.success) + if (!success) { + return { success: false, publishId: '', error: (data.error as string) || 'Failed to publish' } + } + const output = data.output as { publishId?: string } | undefined + return { success: true, publishId: output?.publishId ?? '' } + } + + // Direct TikTok `/init/` response shape: { data: { publish_id }, error: { code, message } } + const error = data.error as { code?: string; message?: string } | undefined + if (error?.code && error.code !== 'ok') { + return { success: false, publishId: '', error: error.message || 'Failed to initiate post' } + } + + const publishId = (data.data as { publish_id?: string } | undefined)?.publish_id + if (!publishId) { + return { success: false, publishId: '', error: 'No publish ID returned' } + } + + return { success: true, publishId } +} From 909144ba2dc1a74e280fd676097a3b3b8922d424 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 09:53:19 -0700 Subject: [PATCH 02/12] fix(tiktok): add avatarFile output to Get User Info Adds a file-typed avatarFile output (sourced from the largest available avatar URL) alongside the existing string avatar fields, so the profile picture can be materialized as a UserFile and chained into file-consuming blocks (e.g. attached to an email), per PR review feedback. Co-authored-by: Cursor --- .../docs/content/docs/en/integrations/tiktok.mdx | 1 + apps/sim/blocks/blocks/tiktok.ts | 6 ++++++ apps/sim/tools/tiktok/get_user.ts | 16 ++++++++++++++++ apps/sim/tools/tiktok/types.ts | 5 +++++ 4 files changed, 28 insertions(+) diff --git a/apps/docs/content/docs/en/integrations/tiktok.mdx b/apps/docs/content/docs/en/integrations/tiktok.mdx index a8a66d75525..fcdbb70997a 100644 --- a/apps/docs/content/docs/en/integrations/tiktok.mdx +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -46,6 +46,7 @@ Get the authenticated TikTok user profile information including display name, av | `followingCount` | number | Number of accounts the user follows | | `likesCount` | number | Total likes received across all videos | | `videoCount` | number | Total number of public videos | +| `avatarFile` | file | 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\). | ### `tiktok_list_videos` diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index 06a262ecd0f..710f6b36bae 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -584,6 +584,12 @@ export const TikTokBlock: BlockConfig = { description: 'Total number of public videos', condition: { field: 'operation', value: 'tiktok_get_user' }, }, + avatarFile: { + type: 'file', + description: + 'Downloadable copy of the profile avatar image, stored as a workflow file so it can be chained into file-consuming blocks (e.g. attached to an email).', + condition: { field: 'operation', value: 'tiktok_get_user' }, + }, // List/Query Videos videos: { diff --git a/apps/sim/tools/tiktok/get_user.ts b/apps/sim/tools/tiktok/get_user.ts index 58bad4bd741..4787288efc4 100644 --- a/apps/sim/tools/tiktok/get_user.ts +++ b/apps/sim/tools/tiktok/get_user.ts @@ -95,6 +95,9 @@ export const tiktokGetUserTool: ToolConfig Date: Wed, 8 Jul 2026 11:22:44 -0700 Subject: [PATCH 03/12] fix(tiktok): lower upload memory cap, drop redundant avatar string outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap the file-upload video buffer at 250MB instead of TikTok's 4GB ceiling — relaying that much through this server's memory per request isn't safe under concurrent load, and larger files can still go through the PULL_FROM_URL path, which never buffers on our server. Also drop the now-redundant avatarUrl/avatarUrl100/avatarLargeUrl string outputs from Get User Info in favor of the file-typed avatarFile output alone, since the feature is unreleased and the raw URL is still reachable via avatarFile.url. Cover image URLs on List/Query Videos are confirmed to be signed, expiring TikTok CDN links; left as strings (no file-output conversion path exists for fields nested inside array items) but documented the expiry behavior more clearly. Co-authored-by: Cursor --- .../content/docs/en/integrations/tiktok.mdx | 7 ++---- .../api/tools/tiktok/publish-video/route.ts | 16 +++++++++---- apps/sim/blocks/blocks/tiktok.ts | 15 ------------ apps/sim/tools/tiktok/get_user.ts | 24 ------------------- apps/sim/tools/tiktok/list_videos.ts | 3 ++- apps/sim/tools/tiktok/query_videos.ts | 3 ++- apps/sim/tools/tiktok/types.ts | 3 --- 7 files changed, 18 insertions(+), 53 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/tiktok.mdx b/apps/docs/content/docs/en/integrations/tiktok.mdx index fcdbb70997a..fbc086772c0 100644 --- a/apps/docs/content/docs/en/integrations/tiktok.mdx +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -35,9 +35,6 @@ Get the authenticated TikTok user profile information including display name, av | `openId` | string | Unique TikTok user ID for this application | | `unionId` | string | Unique TikTok user ID across all apps from the same developer | | `displayName` | string | User display name | -| `avatarUrl` | string | Profile image URL | -| `avatarUrl100` | string | Profile image URL \(100x100\) | -| `avatarLargeUrl` | string | Profile image URL \(large\) | | `bioDescription` | string | User bio description | | `profileDeepLink` | string | Deep link to user TikTok profile | | `isVerified` | boolean | Whether the account is verified | @@ -66,7 +63,7 @@ Get a list of the authenticated user's TikTok videos with cover images, titles, | `videos` | array | List of TikTok videos | | ↳ `id` | string | Video ID | | ↳ `title` | string | Video title | -| ↳ `coverImageUrl` | string | Cover image URL \(may expire\) | +| ↳ `coverImageUrl` | string | Signed cover image URL from TikTok CDN. Publicly fetchable without auth, but time-limited \(embeds an x-expires param\) — use it right away rather than storing it for later. | | ↳ `embedLink` | string | Embeddable video URL | | ↳ `duration` | number | Video duration in seconds | | ↳ `createTime` | number | Unix timestamp when video was created | @@ -98,7 +95,7 @@ Query specific TikTok videos by their IDs to get fresh metadata including cover | `videos` | array | List of queried TikTok videos | | ↳ `id` | string | Video ID | | ↳ `title` | string | Video title | -| ↳ `coverImageUrl` | string | Cover image URL \(fresh URL\) | +| ↳ `coverImageUrl` | string | Signed cover image URL from TikTok CDN. Publicly fetchable without auth, but time-limited \(embeds an x-expires param\) — use it right away rather than storing it for later. | | ↳ `embedLink` | string | Embeddable video URL | | ↳ `duration` | number | Video duration in seconds | | ↳ `createTime` | number | Unix timestamp when video was created | diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.ts index e2af7a0462f..45bc251f4e2 100644 --- a/apps/sim/app/api/tools/tiktok/publish-video/route.ts +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.ts @@ -25,9 +25,13 @@ const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/ /** TikTok requires each chunk between 5MB and 64MB; the final chunk absorbs the remainder (up to ~2x this size, well under the 128MB cap). Capped at 1000 chunks total, which this default comfortably satisfies up to TikTok's 4GB video size limit. */ const DEFAULT_CHUNK_SIZE = 10_000_000 -/** TikTok's documented maximum video file size. Enforced before downloading the file into - * memory so an oversized upload fails fast instead of materializing multiple GB in-process. */ -const TIKTOK_MAX_VIDEO_BYTES = 4 * 1024 * 1024 * 1024 +/** Maximum size this route will buffer in memory for a single file-upload request. TikTok's + * own limit is 4GB, but relaying that much through this server's memory per request isn't + * safe under concurrent load. Enforced before downloading the file so an oversized upload + * fails fast with a clean 413 instead of materializing hundreds of MB to multiple GB + * in-process. Users with larger files can use the "Public URL" source instead, since + * PULL_FROM_URL never routes bytes through this process. */ +const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024 function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } { if (totalBytes <= DEFAULT_CHUNK_SIZE) { @@ -193,8 +197,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { maxBytes: error.maxBytes, observedBytes: error.observedBytes, }) + const maxMb = Math.floor(TIKTOK_MAX_VIDEO_BYTES / (1024 * 1024)) return NextResponse.json( - { success: false, error: `Video exceeds TikTok's maximum file size of 4GB` }, + { + success: false, + error: `Video exceeds the ${maxMb}MB limit for file uploads. Use a public video URL instead to post larger files.`, + }, { status: 413 } ) } diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index 710f6b36bae..8fa2c2de912 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -529,21 +529,6 @@ export const TikTokBlock: BlockConfig = { description: 'User display name', condition: { field: 'operation', value: 'tiktok_get_user' }, }, - avatarUrl: { - type: 'string', - description: 'Profile image URL', - condition: { field: 'operation', value: 'tiktok_get_user' }, - }, - avatarUrl100: { - type: 'string', - description: 'Profile image URL (100x100)', - condition: { field: 'operation', value: 'tiktok_get_user' }, - }, - avatarLargeUrl: { - type: 'string', - description: 'Profile image URL (large)', - condition: { field: 'operation', value: 'tiktok_get_user' }, - }, bioDescription: { type: 'string', description: 'User bio description', diff --git a/apps/sim/tools/tiktok/get_user.ts b/apps/sim/tools/tiktok/get_user.ts index 4787288efc4..e7247a0cd9d 100644 --- a/apps/sim/tools/tiktok/get_user.ts +++ b/apps/sim/tools/tiktok/get_user.ts @@ -54,9 +54,6 @@ export const tiktokGetUserTool: ToolConfig Date: Wed, 8 Jul 2026 11:34:47 -0700 Subject: [PATCH 04/12] chore(ci): bump API validation route-count baseline for TikTok publish-video route The TikTok integration adds one new Zod-backed internal API route (app/api/tools/tiktok/publish-video), which trips the route-count ratchet in check-api-validation-contracts.ts. Bumping totalRoutes and zodRoutes from 917 to 918 (nonZodRoutes stays 0) to acknowledge the new route is properly validated. Co-authored-by: Cursor --- scripts/check-api-validation-contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 436d4081dec..7a9037152d1 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 917, - zodRoutes: 917, + totalRoutes: 918, + zodRoutes: 918, nonZodRoutes: 0, } as const From d8608aa7fa10f3e06b7be7245c13d107535d8624 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 11:44:01 -0700 Subject: [PATCH 05/12] chore(tiktok): drop unused avatar_url_100 from default user fields After removing the avatar string outputs, avatar_url_100 was still requested from TikTok's user info endpoint but never surfaced anywhere. Removed it from the default field list and the field descriptions, and noted that avatar_url/avatar_large_url feed the avatarFile output. Co-authored-by: Cursor --- apps/docs/content/docs/en/integrations/tiktok.mdx | 2 +- apps/sim/blocks/blocks/tiktok.ts | 2 +- apps/sim/tools/tiktok/get_user.ts | 2 +- apps/sim/tools/tiktok/utils.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/tiktok.mdx b/apps/docs/content/docs/en/integrations/tiktok.mdx index fbc086772c0..f0c2e3c8582 100644 --- a/apps/docs/content/docs/en/integrations/tiktok.mdx +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -26,7 +26,7 @@ Get the authenticated TikTok user profile information including display name, av | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `fields` | string | No | Comma-separated list of fields to return. Available: open_id, union_id, avatar_url, avatar_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count | +| `fields` | string | No | 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. | #### Output diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index 8fa2c2de912..cfc920557ac 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -74,7 +74,7 @@ export const TikTokBlock: BlockConfig = { wandConfig: { enabled: true, prompt: - 'Generate a comma-separated list of TikTok user info fields based on the user request, choosing only from: open_id, union_id, avatar_url, avatar_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count. Return ONLY the comma-separated field names - no explanations, no extra text.', + 'Generate a comma-separated list of TikTok user info fields based on the user request, choosing only from: 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. Return ONLY the comma-separated field names - no explanations, no extra text.', placeholder: 'Describe which profile fields you need', }, }, diff --git a/apps/sim/tools/tiktok/get_user.ts b/apps/sim/tools/tiktok/get_user.ts index e7247a0cd9d..881ce7e03f7 100644 --- a/apps/sim/tools/tiktok/get_user.ts +++ b/apps/sim/tools/tiktok/get_user.ts @@ -28,7 +28,7 @@ export const tiktokGetUserTool: ToolConfig Date: Wed, 8 Jul 2026 11:52:32 -0700 Subject: [PATCH 06/12] fix(tiktok): stop returning raw 'credential' subBlock id from tools.config.params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block's params function built a local `credential` variable from params.oauthCredential and returned it under the key `credential` in every switch case. That literal token is the raw subBlock id, which is deleted after canonical transformation into `oauthCredential` — the blocks.test.ts canonical-param-validation suite flags any params function that still references it. It was also redundant: oauthCredential is already part of the base resolved inputs, which the executor merges into the tool call before config.params overrides are applied, so the OAuth token resolution (which reads contextParams.oauthCredential) worked regardless. Removed the explicit credential plumbing, matching the convention already used by other OAuth blocks like dropbox.ts. Co-authored-by: Cursor --- apps/sim/blocks/blocks/tiktok.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index cfc920557ac..c0553372a37 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -379,7 +379,6 @@ export const TikTokBlock: BlockConfig = { config: { tool: (params) => params.operation || 'tiktok_get_user', params: (params) => { - const credential = params.oauthCredential const operation = params.operation || 'tiktok_get_user' const toBoolean = (value: unknown): boolean | undefined => value === undefined || value === '' ? undefined : String(value).toLowerCase() === 'true' @@ -387,29 +386,25 @@ export const TikTokBlock: BlockConfig = { switch (operation) { case 'tiktok_get_user': return { - credential, ...(params.fields && { fields: params.fields }), } case 'tiktok_list_videos': return { - credential, ...(params.maxCount && { maxCount: Number(params.maxCount) }), ...(params.cursor && { cursor: Number(params.cursor) }), } case 'tiktok_query_videos': return { - credential, videoIds: (params.videoIds || '') .split(',') .map((id: string) => id.trim()) .filter(Boolean), } case 'tiktok_query_creator_info': - return { credential } + return {} case 'tiktok_direct_post_video': { const file = normalizeFileInput(params.file, { single: true }) return { - credential, source: params.videoSource || 'PULL_FROM_URL', videoUrl: params.videoUrl, file, @@ -429,7 +424,6 @@ export const TikTokBlock: BlockConfig = { case 'tiktok_upload_video_draft': { const file = normalizeFileInput(params.file, { single: true }) return { - credential, source: params.videoSource || 'PULL_FROM_URL', videoUrl: params.videoUrl, file, @@ -437,7 +431,6 @@ export const TikTokBlock: BlockConfig = { } case 'tiktok_direct_post_photo': return { - credential, photoImages: (params.photoImages || '') .split('\n') .map((url: string) => url.trim()) @@ -453,7 +446,6 @@ export const TikTokBlock: BlockConfig = { } case 'tiktok_upload_photo_draft': return { - credential, photoImages: (params.photoImages || '') .split('\n') .map((url: string) => url.trim()) @@ -464,11 +456,10 @@ export const TikTokBlock: BlockConfig = { } case 'tiktok_get_post_status': return { - credential, publishId: params.publishId, } default: - return { credential } + return {} } }, }, From c856f339e332b65d6c4ab8828109719fef830ae9 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 12:11:14 -0700 Subject: [PATCH 07/12] fix(tiktok): send empty JSON body on Query Creator Info POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query_creator_info had no request.body function, and formatRequestParams() only attaches a body when tool.request.body is defined at all — so despite sending Content-Type: application/json, the request went out with no body whatsoever. Added body: () => ({}), matching the convention already used by other parameterless-POST tools in this codebase (Google Vault, Supabase, Square, Gmail, etc.). Co-authored-by: Cursor --- apps/sim/tools/tiktok/query_creator_info.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/tools/tiktok/query_creator_info.ts b/apps/sim/tools/tiktok/query_creator_info.ts index c65254c6a4e..99758cb6c22 100644 --- a/apps/sim/tools/tiktok/query_creator_info.ts +++ b/apps/sim/tools/tiktok/query_creator_info.ts @@ -36,6 +36,7 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json; charset=UTF-8', }), + body: () => ({}), }, transformResponse: async (response: Response): Promise => { From e52669a95a1f9bdd2081436fa4e8aa4ff039a014 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 12:21:05 -0700 Subject: [PATCH 08/12] fix(tiktok): stop dropping valid zero values in optional numeric fields cursor, photoCoverIndex, and videoCoverTimestampMs all used a truthy check (params.x && {...}) to decide whether to include an optional numeric override, which drops a legitimate 0 (first page has no cursor issue aside, photoCoverIndex 0 is TikTok's own default cover photo, and timestamp 0 is a valid first-frame cover). Switched to explicit undefined/empty-string checks, matching the !== undefined convention the underlying tools already use. In today's resolution pipeline these fields always arrive as strings (even chained block references get stringified by the template resolver), and a non-empty string like "0" is truthy, so this wasn't actively broken end-to-end - but it was relying on that subtlety rather than being correct by construction, and was inconsistent with the tools' own undefined checks. Co-authored-by: Cursor --- apps/sim/blocks/blocks/tiktok.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index c0553372a37..378971cf111 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -391,7 +391,8 @@ export const TikTokBlock: BlockConfig = { case 'tiktok_list_videos': return { ...(params.maxCount && { maxCount: Number(params.maxCount) }), - ...(params.cursor && { cursor: Number(params.cursor) }), + ...(params.cursor !== undefined && + params.cursor !== '' && { cursor: Number(params.cursor) }), } case 'tiktok_query_videos': return { @@ -413,9 +414,10 @@ export const TikTokBlock: BlockConfig = { disableDuet: toBoolean(params.disableDuet), disableStitch: toBoolean(params.disableStitch), disableComment: toBoolean(params.disableComment), - ...(params.videoCoverTimestampMs && { - videoCoverTimestampMs: Number(params.videoCoverTimestampMs), - }), + ...(params.videoCoverTimestampMs !== undefined && + params.videoCoverTimestampMs !== '' && { + videoCoverTimestampMs: Number(params.videoCoverTimestampMs), + }), isAigc: toBoolean(params.isAigc), brandContentToggle: toBoolean(params.brandContentToggle), brandOrganicToggle: toBoolean(params.brandOrganicToggle), @@ -435,7 +437,10 @@ export const TikTokBlock: BlockConfig = { .split('\n') .map((url: string) => url.trim()) .filter(Boolean), - ...(params.photoCoverIndex && { photoCoverIndex: Number(params.photoCoverIndex) }), + ...(params.photoCoverIndex !== undefined && + params.photoCoverIndex !== '' && { + photoCoverIndex: Number(params.photoCoverIndex), + }), title: params.title, description: params.description, privacyLevel: params.privacyLevel || 'SELF_ONLY', @@ -450,7 +455,10 @@ export const TikTokBlock: BlockConfig = { .split('\n') .map((url: string) => url.trim()) .filter(Boolean), - ...(params.photoCoverIndex && { photoCoverIndex: Number(params.photoCoverIndex) }), + ...(params.photoCoverIndex !== undefined && + params.photoCoverIndex !== '' && { + photoCoverIndex: Number(params.photoCoverIndex), + }), title: params.title, description: params.description, } From 68bb6226738d21aa51a3a5fac4f21f80b98a3215 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 12:26:05 -0700 Subject: [PATCH 09/12] fix(tiktok): accept newline-separated video IDs in Query Videos videoIds is a long-input (multiline textarea), the same widget used for the newline-separated photoImages field on this block, but its parser only split on commas. Entering one ID per line - the natural pattern for a multiline field, and the one already used elsewhere on this block - produced a single concatenated garbage string instead of an array, so TikTok's query would fail or return nothing. Now splits on commas or newlines, and updated the placeholder/description to reflect both formats. Co-authored-by: Cursor --- apps/sim/blocks/blocks/tiktok.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index 378971cf111..dc63e449ede 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -103,7 +103,7 @@ export const TikTokBlock: BlockConfig = { id: 'videoIds', title: 'Video IDs', type: 'long-input', - placeholder: 'Comma-separated video IDs (e.g., 7077642457847994444,7080217258529732386)', + placeholder: 'One video ID per line or comma-separated (e.g., 7077642457847994444)', condition: { field: 'operation', value: 'tiktok_query_videos' }, required: { field: 'operation', value: 'tiktok_query_videos' }, }, @@ -397,7 +397,7 @@ export const TikTokBlock: BlockConfig = { case 'tiktok_query_videos': return { videoIds: (params.videoIds || '') - .split(',') + .split(/[,\n]+/) .map((id: string) => id.trim()) .filter(Boolean), } @@ -479,7 +479,10 @@ export const TikTokBlock: BlockConfig = { fields: { type: 'string', description: 'Comma-separated list of user fields to return' }, maxCount: { type: 'number', description: 'Maximum number of videos to return (1-20)' }, cursor: { type: 'number', description: 'Pagination cursor from previous response' }, - videoIds: { type: 'string', description: 'Comma-separated list of video IDs to query' }, + videoIds: { + type: 'string', + description: 'List of video IDs to query, one per line or comma-separated', + }, videoSource: { type: 'string', description: 'Video transfer method (PULL_FROM_URL/FILE_UPLOAD)', From 83841a384e8bf320d65dab043c0a714b6f5bb69b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 17:21:21 -0700 Subject: [PATCH 10/12] feat(tiktok): add app-level webhook ingress and triggers --- .../content/docs/en/integrations/tiktok.mdx | 145 ++++++++++ apps/sim/app/api/webhooks/tiktok/route.ts | 200 ++++++++++++++ apps/sim/blocks/blocks/tiktok.ts | 24 ++ apps/sim/lib/api/contracts/webhooks.ts | 33 +++ apps/sim/lib/integrations/integrations.json | 45 +++- apps/sim/lib/webhooks/providers/registry.ts | 2 + .../sim/lib/webhooks/providers/tiktok.test.ts | 247 ++++++++++++++++++ apps/sim/lib/webhooks/providers/tiktok.ts | 214 +++++++++++++++ apps/sim/lib/webhooks/tiktok-fanout.test.ts | 22 ++ apps/sim/lib/webhooks/tiktok-fanout.ts | 100 +++++++ apps/sim/proxy.ts | 5 +- apps/sim/triggers/registry.ts | 18 ++ .../triggers/tiktok/authorization_removed.ts | 25 ++ apps/sim/triggers/tiktok/index.ts | 8 + .../triggers/tiktok/post_inbox_delivered.ts | 28 ++ .../triggers/tiktok/post_no_longer_public.ts | 28 ++ .../tiktok/post_publicly_available.ts | 28 ++ .../triggers/tiktok/post_publish_complete.ts | 41 +++ .../triggers/tiktok/post_publish_failed.ts | 25 ++ apps/sim/triggers/tiktok/utils.ts | 145 ++++++++++ .../tiktok/video_publish_completed.ts | 28 ++ .../triggers/tiktok/video_upload_failed.ts | 25 ++ scripts/check-api-validation-contracts.ts | 4 +- scripts/generate-docs.ts | 1 + 24 files changed, 1436 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/api/webhooks/tiktok/route.ts create mode 100644 apps/sim/lib/webhooks/providers/tiktok.test.ts create mode 100644 apps/sim/lib/webhooks/providers/tiktok.ts create mode 100644 apps/sim/lib/webhooks/tiktok-fanout.test.ts create mode 100644 apps/sim/lib/webhooks/tiktok-fanout.ts create mode 100644 apps/sim/triggers/tiktok/authorization_removed.ts create mode 100644 apps/sim/triggers/tiktok/index.ts create mode 100644 apps/sim/triggers/tiktok/post_inbox_delivered.ts create mode 100644 apps/sim/triggers/tiktok/post_no_longer_public.ts create mode 100644 apps/sim/triggers/tiktok/post_publicly_available.ts create mode 100644 apps/sim/triggers/tiktok/post_publish_complete.ts create mode 100644 apps/sim/triggers/tiktok/post_publish_failed.ts create mode 100644 apps/sim/triggers/tiktok/utils.ts create mode 100644 apps/sim/triggers/tiktok/video_publish_completed.ts create mode 100644 apps/sim/triggers/tiktok/video_upload_failed.ts diff --git a/apps/docs/content/docs/en/integrations/tiktok.mdx b/apps/docs/content/docs/en/integrations/tiktok.mdx index f0c2e3c8582..c90e7ae195a 100644 --- a/apps/docs/content/docs/en/integrations/tiktok.mdx +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -10,6 +10,15 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" color="#000000" /> +{/* MANUAL-CONTENT-START:intro */} +TikTok webhooks use a single app-level Callback URL configured in the [TikTok Developer Portal](https://developers.tiktok.com/): + +`https://www.sim.ai/api/webhooks/tiktok` + +(Use your environment’s `{APP_URL}/api/webhooks/tiktok` for staging or local ngrok.) You do not paste a unique per-workflow URL into TikTok. In Sim, connect a TikTok account on the trigger and deploy the workflow; events for that `user_openid` fan out to matching workflows. +{/* MANUAL-CONTENT-END */} + + ## Usage Instructions Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos and photos directly to TikTok (or send them to the inbox as drafts) from a public URL or a file uploaded in the workflow, then track post status. @@ -237,3 +246,139 @@ Check the status of a post initiated with Direct Post Video, Upload Video Draft, | `publiclyAvailablePostId` | array | Array of public post IDs \(as strings\) once the content is published and publicly viewable. Can be used to construct the TikTok post URL. | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### TikTok Authorization Removed + +Trigger when a user deauthorizes your TikTok app + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | TikTok webhook event name | +| `createTime` | number | UTC epoch seconds when the event occurred | +| `userOpenId` | string | TikTok user open_id for the connected account | +| `clientKey` | string | TikTok app client_key that received the event | +| `reason` | number | Revocation reason \(0 unknown, 1 user disconnect, 2 account deleted, 3 age change, 4 banned, 5 developer revoke\) | + + +--- + +### TikTok Post Inbox Delivered + +Trigger when a draft notification is delivered to the creator inbox + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post No Longer Public + +Trigger when a post is no longer publicly viewable on TikTok + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post Publicly Available + +Trigger when a published post becomes publicly viewable on TikTok + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post Publish Complete + +Trigger when a TikTok Content Posting publish completes + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post Publish Failed + +Trigger when a TikTok Content Posting publish fails + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Video Publish Completed + +Trigger when a Share Kit / Video Kit upload is published by the user + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | TikTok webhook event name | +| `createTime` | number | UTC epoch seconds when the event occurred | +| `userOpenId` | string | TikTok user open_id for the connected account | +| `clientKey` | string | TikTok app client_key that received the event | +| `shareId` | string | Share Kit / Video Kit share_id | + + +--- + +### TikTok Video Upload Failed + +Trigger when a Share Kit / Video Kit upload fails in TikTok + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | TikTok webhook event name | +| `createTime` | number | UTC epoch seconds when the event occurred | +| `userOpenId` | string | TikTok user open_id for the connected account | +| `clientKey` | string | TikTok app client_key that received the event | +| `shareId` | string | Share Kit / Video Kit share_id | + diff --git a/apps/sim/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts new file mode 100644 index 00000000000..8194ff5ce68 --- /dev/null +++ b/apps/sim/app/api/webhooks/tiktok/route.ts @@ -0,0 +1,200 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + tiktokWebhookEnvelopeSchema, + tiktokWebhookHeadersSchema, +} from '@/lib/api/contracts/webhooks' +import { + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, + isWorkspaceApiExecutionEntitled, +} from '@/lib/billing/core/api-access' +import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' +import { generateRequestId } from '@/lib/core/utils/request' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' +import { + checkWebhookPreprocessing, + handlePreDeploymentVerification, + queueWebhookExecution, + shouldSkipWebhookEvent, +} from '@/lib/webhooks/processor' +import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok' +import { findTikTokWebhooksForOpenId } from '@/lib/webhooks/tiktok-fanout' +import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('TikTokWebhookIngress') + +const TIKTOK_BODY_LABEL = 'TikTok webhook body' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 60 + +async function readTikTokBody(req: Request): Promise { + assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, TIKTOK_BODY_LABEL) + const buffer = await readStreamToBufferWithLimit(req.body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: TIKTOK_BODY_LABEL, + }) + return new TextDecoder().decode(buffer) +} + +/** + * App-level TikTok webhook Callback URL. + * Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok). + * Verifies TikTok-Signature once, then fans out by user_openid → credential → workflows. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const ticket = tryAdmit() + if (!ticket) { + return admissionRejectedResponse() + } + + const requestId = generateRequestId() + const receivedAt = Date.now() + + try { + let rawBody: string + try { + rawBody = await readTikTokBody(request) + } catch (bodyError) { + if (isPayloadSizeLimitError(bodyError)) { + logger.warn(`[${requestId}] Rejected oversized TikTok webhook body`, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + observedBytes: bodyError.observedBytes, + }) + return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) + } + throw bodyError + } + + const headersResult = tiktokWebhookHeadersSchema.safeParse({ + 'tiktok-signature': request.headers.get('TikTok-Signature'), + }) + if (!headersResult.success) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const authError = verifyTikTokSignature( + rawBody, + headersResult.data['tiktok-signature'], + requestId + ) + if (authError) { + return authError + } + + let parsedJson: unknown + try { + parsedJson = rawBody ? JSON.parse(rawBody) : {} + } catch { + logger.warn(`[${requestId}] TikTok webhook body is not valid JSON`) + // Ack to avoid retry storms on malformed payloads after a valid signature. + return NextResponse.json({ ok: true }) + } + + const envelopeResult = tiktokWebhookEnvelopeSchema.safeParse(parsedJson) + if (!envelopeResult.success) { + logger.warn(`[${requestId}] Invalid TikTok webhook envelope`, { + issues: envelopeResult.error.issues, + }) + return NextResponse.json({ ok: true }) + } + + const envelope = envelopeResult.data + const matches = await findTikTokWebhooksForOpenId(envelope.user_openid, requestId) + + if (matches.length === 0) { + logger.info(`[${requestId}] No matching TikTok webhooks; acknowledging`, { + event: envelope.event, + userOpenIdPrefix: envelope.user_openid.slice(0, 12), + }) + return NextResponse.json({ ok: true }) + } + + let processed = 0 + for (const { webhook: foundWebhook, workflow: foundWorkflow } of matches) { + // Schema allows null provider; fan-out already filtered to provider = 'tiktok'. + const webhookRecord = { + ...foundWebhook, + provider: foundWebhook.provider ?? 'tiktok', + providerConfig: + (foundWebhook.providerConfig as Record | null) ?? undefined, + } + + if ( + foundWorkflow.workspaceId && + !(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId)) + ) { + logger.warn(`[${requestId}] Workspace not entitled for TikTok webhook`, { + webhookId: webhookRecord.id, + workspaceId: foundWorkflow.workspaceId, + }) + continue + } + + const preprocessResult = await checkWebhookPreprocessing( + foundWorkflow, + webhookRecord, + requestId + ) + if (preprocessResult.error) { + logger.warn(`[${requestId}] Preprocessing failed for TikTok webhook`, { + webhookId: webhookRecord.id, + }) + continue + } + + if (webhookRecord.blockId) { + const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId) + if (!blockExists) { + const preDeploymentResponse = handlePreDeploymentVerification(webhookRecord, requestId) + if (preDeploymentResponse) { + continue + } + logger.info( + `[${requestId}] Trigger block ${webhookRecord.blockId} not found in deployment for workflow ${foundWorkflow.id}` + ) + continue + } + } + + if (shouldSkipWebhookEvent(webhookRecord, envelope, requestId)) { + continue + } + + await queueWebhookExecution(webhookRecord, foundWorkflow, envelope, request, { + requestId, + path: webhookRecord.path, + actorUserId: preprocessResult.actorUserId, + executionId: preprocessResult.executionId, + correlation: preprocessResult.correlation, + receivedAt, + }) + processed += 1 + } + + if (processed === 0 && matches.length > 0) { + logger.info(`[${requestId}] TikTok webhooks matched but none processed`, { + matchCount: matches.length, + hint: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, + }) + } + + return NextResponse.json({ ok: true, webhooksProcessed: processed }) + } catch (error) { + logger.error(`[${requestId}] TikTok webhook ingress error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + // Still 200 after accept path failures that aren't auth — TikTok retries on non-200. + return NextResponse.json({ ok: true }) + } finally { + ticket.release() + } +}) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index dc63e449ede..52dcd5ee084 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import type { TikTokResponse } from '@/tools/tiktok/types' +import { getTrigger } from '@/triggers' const VIDEO_POST_OPERATIONS = ['tiktok_direct_post_video', 'tiktok_upload_video_draft'] const PHOTO_POST_OPERATIONS = ['tiktok_direct_post_photo', 'tiktok_upload_photo_draft'] @@ -362,8 +363,31 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_get_post_status' }, required: { field: 'operation', value: 'tiktok_get_post_status' }, }, + + ...getTrigger('tiktok_post_publish_complete').subBlocks, + ...getTrigger('tiktok_post_publish_failed').subBlocks, + ...getTrigger('tiktok_post_inbox_delivered').subBlocks, + ...getTrigger('tiktok_post_publicly_available').subBlocks, + ...getTrigger('tiktok_post_no_longer_public').subBlocks, + ...getTrigger('tiktok_video_publish_completed').subBlocks, + ...getTrigger('tiktok_video_upload_failed').subBlocks, + ...getTrigger('tiktok_authorization_removed').subBlocks, ], + triggers: { + enabled: true, + available: [ + 'tiktok_post_publish_complete', + 'tiktok_post_publish_failed', + 'tiktok_post_inbox_delivered', + 'tiktok_post_publicly_available', + 'tiktok_post_no_longer_public', + 'tiktok_video_publish_completed', + 'tiktok_video_upload_failed', + 'tiktok_authorization_removed', + ], + }, + tools: { access: [ 'tiktok_get_user', diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index 4903d558614..bc499f343b7 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -258,3 +258,36 @@ export const webhookTriggerPostContract = defineRouteContract({ schema: z.unknown(), }, }) + +/** + * TikTok app-level webhook ingress. Signature is verified from the raw body + * before this schema runs; `content` remains a JSON string per TikTok docs. + */ +export const tiktokWebhookEnvelopeSchema = z.object({ + client_key: z.string(), + event: z.string(), + create_time: z.union([z.number(), z.string()]), + user_openid: z.string(), + content: z.string(), +}) + +export const tiktokWebhookHeadersSchema = z.object({ + 'tiktok-signature': z.string().min(1), +}) + +export const tiktokWebhookResponseSchema = z.object({ + ok: z.literal(true), + webhooksProcessed: z.number().optional(), +}) + +export const tiktokWebhookContract = defineRouteContract({ + method: 'POST', + path: '/api/webhooks/tiktok', + headers: tiktokWebhookHeadersSchema, + // Body is validated after HMAC verification against the raw payload. + body: tiktokWebhookEnvelopeSchema, + response: { + mode: 'json', + schema: tiktokWebhookResponseSchema, + }, +}) diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 2f68b5470ba..8c68fe67272 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -18656,8 +18656,49 @@ } ], "operationCount": 9, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "tiktok_post_publish_complete", + "name": "TikTok Post Publish Complete", + "description": "Trigger when a TikTok Content Posting publish completes" + }, + { + "id": "tiktok_post_publish_failed", + "name": "TikTok Post Publish Failed", + "description": "Trigger when a TikTok Content Posting publish fails" + }, + { + "id": "tiktok_post_inbox_delivered", + "name": "TikTok Post Inbox Delivered", + "description": "Trigger when a draft notification is delivered to the creator inbox" + }, + { + "id": "tiktok_post_publicly_available", + "name": "TikTok Post Publicly Available", + "description": "Trigger when a published post becomes publicly viewable on TikTok" + }, + { + "id": "tiktok_post_no_longer_public", + "name": "TikTok Post No Longer Public", + "description": "Trigger when a post is no longer publicly viewable on TikTok" + }, + { + "id": "tiktok_video_publish_completed", + "name": "TikTok Video Publish Completed", + "description": "Trigger when a Share Kit / Video Kit upload is published by the user" + }, + { + "id": "tiktok_video_upload_failed", + "name": "TikTok Video Upload Failed", + "description": "Trigger when a Share Kit / Video Kit upload fails in TikTok" + }, + { + "id": "tiktok_authorization_removed", + "name": "TikTok Authorization Removed", + "description": "Trigger when a user deauthorizes your TikTok app" + } + ], + "triggerCount": 8, "authType": "oauth", "oauthServiceId": "tiktok", "category": "tools", diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 1965a9663c7..dc0f2bffcc2 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -47,6 +47,7 @@ import { slackHandler } from '@/lib/webhooks/providers/slack' import { stripeHandler } from '@/lib/webhooks/providers/stripe' import { tableProviderHandler } from '@/lib/webhooks/providers/table' import { telegramHandler } from '@/lib/webhooks/providers/telegram' +import { tiktokHandler } from '@/lib/webhooks/providers/tiktok' import { twilioHandler } from '@/lib/webhooks/providers/twilio' import { twilioVoiceHandler } from '@/lib/webhooks/providers/twilio-voice' import { typeformHandler } from '@/lib/webhooks/providers/typeform' @@ -108,6 +109,7 @@ const PROVIDER_HANDLERS: Record = { stripe: stripeHandler, table: tableProviderHandler, telegram: telegramHandler, + tiktok: tiktokHandler, twilio: twilioHandler, twilio_voice: twilioVoiceHandler, typeform: typeformHandler, diff --git a/apps/sim/lib/webhooks/providers/tiktok.test.ts b/apps/sim/lib/webhooks/providers/tiktok.test.ts new file mode 100644 index 00000000000..63b6e1a18bd --- /dev/null +++ b/apps/sim/lib/webhooks/providers/tiktok.test.ts @@ -0,0 +1,247 @@ +import crypto from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { + parseTikTokContent, + parseTikTokSignatureHeader, + tiktokHandler, + verifyTikTokSignature, +} from '@/lib/webhooks/providers/tiktok' +import { isTikTokEventMatch } from '@/triggers/tiktok/utils' + +function signTikTokBody(secret: string, timestamp: string, rawBody: string): string { + return crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex') +} + +function requestWithTikTokSignature(signatureHeader: string): NextRequest { + return new NextRequest('http://localhost/api/webhooks/tiktok', { + headers: { + 'TikTok-Signature': signatureHeader, + }, + }) +} + +describe('parseTikTokSignatureHeader', () => { + it('parses t and s from the header', () => { + expect( + parseTikTokSignatureHeader( + 't=1633174587,s=18494715036ac4416a1d0a673871a2edbcfc94d94bd88ccd2c5ec9b3425afe66' + ) + ).toEqual({ + timestamp: '1633174587', + signature: '18494715036ac4416a1d0a673871a2edbcfc94d94bd88ccd2c5ec9b3425afe66', + }) + }) + + it('returns null for missing or malformed headers', () => { + expect(parseTikTokSignatureHeader(null)).toBeNull() + expect(parseTikTokSignatureHeader('')).toBeNull() + expect(parseTikTokSignatureHeader('t=123')).toBeNull() + expect(parseTikTokSignatureHeader('s=abc')).toBeNull() + }) +}) + +describe('verifyTikTokSignature', () => { + const secret = 'tiktok-client-secret' + const rawBody = JSON.stringify({ + client_key: 'key', + event: 'post.publish.complete', + create_time: 1615338610, + user_openid: 'act.example', + content: '{"publish_id":"p1","publish_type":"DIRECT_POST"}', + }) + + it('accepts a valid signature within the skew window', () => { + const now = Math.floor(Date.now() / 1000) + const timestamp = String(now) + const signature = signTikTokBody(secret, timestamp, rawBody) + const result = verifyTikTokSignature( + rawBody, + `t=${timestamp},s=${signature}`, + 'tt-1', + secret, + now + ) + expect(result).toBeNull() + }) + + it('rejects an invalid signature', () => { + const now = Math.floor(Date.now() / 1000) + const result = verifyTikTokSignature( + rawBody, + `t=${now},s=${'0'.repeat(64)}`, + 'tt-2', + secret, + now + ) + expect(result?.status).toBe(401) + }) + + it('rejects when the client secret is missing', () => { + const now = Math.floor(Date.now() / 1000) + const result = verifyTikTokSignature(rawBody, `t=${now},s=abc`, 'tt-3', undefined, now) + expect(result?.status).toBe(401) + }) + + it('rejects a stale timestamp', () => { + const now = Math.floor(Date.now() / 1000) + const stale = String(now - 600) + const signature = signTikTokBody(secret, stale, rawBody) + const result = verifyTikTokSignature(rawBody, `t=${stale},s=${signature}`, 'tt-4', secret, now) + expect(result?.status).toBe(401) + }) + + it('rejects a missing signature header', () => { + const result = verifyTikTokSignature(rawBody, null, 'tt-5', secret) + expect(result?.status).toBe(401) + }) +}) + +describe('parseTikTokContent', () => { + it('parses a JSON string content field', () => { + expect(parseTikTokContent('{"publish_id":"p1","publish_type":"DIRECT_POST"}')).toEqual({ + publish_id: 'p1', + publish_type: 'DIRECT_POST', + }) + }) + + it('returns an empty object for invalid JSON', () => { + expect(parseTikTokContent('{not-json')).toEqual({}) + }) +}) + +describe('isTikTokEventMatch', () => { + it('matches documented event names including TikTok typo', () => { + expect(isTikTokEventMatch('tiktok_post_publish_complete', 'post.publish.complete')).toBe(true) + expect( + isTikTokEventMatch( + 'tiktok_post_no_longer_public', + 'post.publish.no_longer_publicaly_available' + ) + ).toBe(true) + expect(isTikTokEventMatch('tiktok_post_publish_complete', 'post.publish.failed')).toBe(false) + }) +}) + +describe('tiktokHandler', () => { + it('verifyAuth delegates to signature verification', async () => { + const secret = 'tiktok-client-secret' + const rawBody = '{"event":"authorization.removed"}' + const now = Math.floor(Date.now() / 1000) + const timestamp = String(now) + const signature = signTikTokBody(secret, timestamp, rawBody) + + // verifyAuth uses env.TIKTOK_CLIENT_SECRET; exercise via verifyTikTokSignature path above. + // Handler still requires a request with the header for the dedicated ingress. + const res = await tiktokHandler.verifyAuth!({ + request: requestWithTikTokSignature(`t=${timestamp},s=${signature}`), + rawBody, + requestId: 'tt-handler', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + // Without env secret this may 401; signature path is covered above. + expect(res === null || res.status === 401).toBe(true) + }) + + it('matchEvent filters by trigger id', async () => { + const match = await tiktokHandler.matchEvent!({ + body: { event: 'post.publish.complete' }, + request: new NextRequest('http://localhost'), + requestId: 'tt-match', + providerConfig: { triggerId: 'tiktok_post_publish_complete' }, + webhook: {}, + workflow: {}, + }) + expect(match).toBe(true) + + const skip = await tiktokHandler.matchEvent!({ + body: { event: 'post.publish.failed' }, + request: new NextRequest('http://localhost'), + requestId: 'tt-skip', + providerConfig: { triggerId: 'tiktok_post_publish_complete' }, + webhook: {}, + workflow: {}, + }) + expect(skip).toBe(false) + }) + + it('formatInput flattens envelope and content fields', async () => { + const { input } = await tiktokHandler.formatInput!({ + body: { + client_key: 'ck', + event: 'post.publish.failed', + create_time: 1615338610, + user_openid: 'act.user', + content: '{"publish_id":"pub-1","publish_type":"DIRECT_POST","reason":"spam_risk"}', + }, + webhook: {}, + workflow: { id: 'w1', userId: 'u1' }, + headers: {}, + requestId: 'tt-fmt', + }) + + expect(input).toEqual({ + event: 'post.publish.failed', + createTime: 1615338610, + userOpenId: 'act.user', + clientKey: 'ck', + publishId: 'pub-1', + publishType: 'DIRECT_POST', + failReason: 'spam_risk', + }) + }) + + it('formatInput maps authorization.removed reason', async () => { + const { input } = await tiktokHandler.formatInput!({ + body: { + client_key: 'ck', + event: 'authorization.removed', + create_time: 1615338610, + user_openid: 'act.user', + content: '{"reason": 1 }', + }, + webhook: {}, + workflow: { id: 'w1', userId: 'u1' }, + headers: {}, + requestId: 'tt-auth', + }) + + expect(input).toMatchObject({ + event: 'authorization.removed', + reason: 1, + }) + }) + + it('extractIdempotencyId uses publish_id when present', () => { + expect( + tiktokHandler.extractIdempotencyId!({ + event: 'post.publish.complete', + user_openid: 'act.user', + create_time: 1, + content: '{"publish_id":"pub-1"}', + }) + ).toBe('post.publish.complete:act.user:pub-1') + }) + + it('extractIdempotencyId falls back to share_id then create_time', () => { + expect( + tiktokHandler.extractIdempotencyId!({ + event: 'video.publish.completed', + user_openid: 'act.user', + create_time: 99, + content: '{"share_id":"share-1"}', + }) + ).toBe('video.publish.completed:act.user:share-1') + + expect( + tiktokHandler.extractIdempotencyId!({ + event: 'authorization.removed', + user_openid: 'act.user', + create_time: 42, + content: '{"reason":1}', + }) + ).toBe('authorization.removed:act.user:42') + }) +}) diff --git a/apps/sim/lib/webhooks/providers/tiktok.ts b/apps/sim/lib/webhooks/providers/tiktok.ts new file mode 100644 index 00000000000..6d5691e2dea --- /dev/null +++ b/apps/sim/lib/webhooks/providers/tiktok.ts @@ -0,0 +1,214 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { NextResponse } from 'next/server' +import { env } from '@/lib/core/config/env' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:TikTok') + +/** TikTok recommends rejecting replayed signatures; 5 minutes matches Linear/common practice. */ +export const TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 5 * 60 + +/** Same pattern as auth.ts TikTok accountId suffix strip (`${open_id}-${uuid}`). */ +const UUID_SUFFIX_RE = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Strip `${open_id}-${uuid}` → open_id for webhook fan-out matching. */ +export function tiktokOpenIdFromAccountId(accountId: string): string { + return accountId.replace(UUID_SUFFIX_RE, '') +} + +export interface TikTokSignatureParts { + timestamp: string + signature: string +} + +/** + * Parse `TikTok-Signature: t=,s=` (comma-separated prefix=value pairs). + */ +export function parseTikTokSignatureHeader(header: string | null): TikTokSignatureParts | null { + if (!header) return null + + let timestamp: string | undefined + let signature: string | undefined + + for (const part of header.split(',')) { + const trimmed = part.trim() + const eq = trimmed.indexOf('=') + if (eq <= 0) continue + const prefix = trimmed.slice(0, eq) + const value = trimmed.slice(eq + 1) + if (prefix === 't') timestamp = value + if (prefix === 's') signature = value + } + + if (!timestamp || !signature) return null + return { timestamp, signature } +} + +/** + * Verify TikTok webhook HMAC-SHA256 of `${t}.${rawBody}` with the app client secret. + * Returns null on success, or a 401 NextResponse on failure. + */ +export function verifyTikTokSignature( + rawBody: string, + signatureHeader: string | null, + requestId: string, + clientSecret: string | undefined = env.TIKTOK_CLIENT_SECRET, + nowSeconds: number = Math.floor(Date.now() / 1000) +): NextResponse | null { + if (!clientSecret) { + logger.warn(`[${requestId}] TikTok webhook missing TIKTOK_CLIENT_SECRET`) + return new NextResponse('Unauthorized - TikTok client secret not configured', { status: 401 }) + } + + const parts = parseTikTokSignatureHeader(signatureHeader) + if (!parts) { + logger.warn(`[${requestId}] TikTok webhook missing or malformed TikTok-Signature header`) + return new NextResponse('Unauthorized - Missing TikTok signature', { status: 401 }) + } + + const timestampSeconds = Number(parts.timestamp) + if (!Number.isFinite(timestampSeconds)) { + logger.warn(`[${requestId}] TikTok webhook signature timestamp is not a number`) + return new NextResponse('Unauthorized - Invalid TikTok signature timestamp', { status: 401 }) + } + + if (Math.abs(nowSeconds - timestampSeconds) > TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS) { + logger.warn(`[${requestId}] TikTok webhook signature timestamp outside allowed skew`, { + skewSeconds: TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS, + timestampSeconds, + nowSeconds, + }) + return new NextResponse('Unauthorized - TikTok signature timestamp skew too large', { + status: 401, + }) + } + + const signedPayload = `${parts.timestamp}.${rawBody}` + const computed = hmacSha256Hex(signedPayload, clientSecret) + if (!safeCompare(computed, parts.signature)) { + logger.warn(`[${requestId}] TikTok signature verification failed`) + return new NextResponse('Unauthorized - Invalid TikTok signature', { status: 401 }) + } + + return null +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +/** + * Parse the TikTok envelope `content` field (a JSON string) into an object. + */ +export function parseTikTokContent(content: unknown): Record { + if (typeof content !== 'string' || content.length === 0) { + return asRecord(content) ?? {} + } + try { + return asRecord(JSON.parse(content)) ?? {} + } catch { + logger.warn('Failed to parse TikTok webhook content JSON string') + return {} + } +} + +function stringField(obj: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = obj[key] + if (typeof value === 'string' && value.length > 0) return value + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + } + return undefined +} + +export const tiktokHandler: WebhookProviderHandler = { + async verifyAuth({ request, rawBody, requestId }: AuthContext): Promise { + return verifyTikTokSignature(rawBody, request.headers.get('TikTok-Signature'), requestId) + }, + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId) return true + + const { isTikTokEventMatch } = await import('@/triggers/tiktok/utils') + const obj = body as Record + const event = typeof obj.event === 'string' ? obj.event : undefined + if (!isTikTokEventMatch(triggerId, event)) { + logger.debug( + `[${requestId}] TikTok event mismatch for trigger ${triggerId}. Event: ${event}. Skipping.` + ) + return false + } + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const envelope = asRecord(body) ?? {} + const content = parseTikTokContent(envelope.content) + const event = typeof envelope.event === 'string' ? envelope.event : '' + + const input: Record = { + event, + createTime: envelope.create_time ?? null, + userOpenId: typeof envelope.user_openid === 'string' ? envelope.user_openid : '', + clientKey: typeof envelope.client_key === 'string' ? envelope.client_key : '', + } + + const publishId = stringField(content, 'publish_id') + if (publishId) input.publishId = publishId + + const publishType = stringField(content, 'publish_type') + if (publishType) input.publishType = publishType + + const postId = stringField(content, 'post_id') + if (postId) input.postId = postId + + const shareId = stringField(content, 'share_id') + if (shareId) input.shareId = shareId + + if (event === 'post.publish.failed') { + const failReason = stringField(content, 'fail_reason', 'reason') + if (failReason) input.failReason = failReason + } + + if (event === 'authorization.removed') { + const reason = content.reason + if (typeof reason === 'number' || typeof reason === 'string') { + input.reason = reason + } + } + + return { input } + }, + + extractIdempotencyId(body: unknown) { + const envelope = asRecord(body) + if (!envelope) return null + + const event = typeof envelope.event === 'string' ? envelope.event : null + const userOpenId = typeof envelope.user_openid === 'string' ? envelope.user_openid : null + if (!event || !userOpenId) return null + + const content = parseTikTokContent(envelope.content) + const publishId = stringField(content, 'publish_id') + const shareId = stringField(content, 'share_id') + const createTime = + typeof envelope.create_time === 'number' || typeof envelope.create_time === 'string' + ? String(envelope.create_time) + : null + + const unique = publishId ?? shareId ?? createTime + if (!unique) return null + + return `${event}:${userOpenId}:${unique}` + }, +} diff --git a/apps/sim/lib/webhooks/tiktok-fanout.test.ts b/apps/sim/lib/webhooks/tiktok-fanout.test.ts new file mode 100644 index 00000000000..40513eb262f --- /dev/null +++ b/apps/sim/lib/webhooks/tiktok-fanout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { tiktokOpenIdFromAccountId } from '@/lib/webhooks/providers/tiktok' + +describe('tiktokOpenIdFromAccountId', () => { + it('strips the UUID suffix from OAuth accountId storage', () => { + expect( + tiktokOpenIdFromAccountId( + 'act.example12345Example12345Example-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ) + ).toBe('act.example12345Example12345Example') + }) + + it('leaves values without a UUID suffix unchanged', () => { + expect(tiktokOpenIdFromAccountId('act.plain')).toBe('act.plain') + }) + + it('matches webhook user_openid to the stored prefix', () => { + const userOpenId = 'act.example12345Example12345Example' + const stored = `${userOpenId}-11111111-2222-3333-4444-555555555555` + expect(tiktokOpenIdFromAccountId(stored)).toBe(userOpenId) + }) +}) diff --git a/apps/sim/lib/webhooks/tiktok-fanout.ts b/apps/sim/lib/webhooks/tiktok-fanout.ts new file mode 100644 index 00000000000..b154fe0858c --- /dev/null +++ b/apps/sim/lib/webhooks/tiktok-fanout.ts @@ -0,0 +1,100 @@ +import { account, credential, db, webhook, workflow, workflowDeploymentVersion } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, isNull, like, or } from 'drizzle-orm' +import { tiktokOpenIdFromAccountId } from '@/lib/webhooks/providers/tiktok' + +const logger = createLogger('TikTokWebhookFanout') + +function escapeLikePattern(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} + +/** + * TikTok OAuth stores account.accountId as `${open_id}-${uuid}` (see auth.ts). + * Match webhook `user_openid` to those rows via prefix, then resolve credentials + * and active TikTok webhook configs whose providerConfig.credentialId matches. + */ +export async function findTikTokWebhooksForOpenId( + userOpenId: string, + requestId: string +): Promise< + Array<{ webhook: typeof webhook.$inferSelect; workflow: typeof workflow.$inferSelect }> +> { + if (!userOpenId) { + return [] + } + + const likePattern = `${escapeLikePattern(userOpenId)}-%` + const accountRows = await db + .select({ id: account.id, accountId: account.accountId }) + .from(account) + .where(and(eq(account.providerId, 'tiktok'), like(account.accountId, likePattern))) + + const accounts = accountRows.filter( + (row) => tiktokOpenIdFromAccountId(row.accountId) === userOpenId + ) + + if (accounts.length === 0) { + logger.info(`[${requestId}] No TikTok accounts matched user_openid`, { + userOpenIdPrefix: userOpenId.slice(0, 12), + }) + return [] + } + + const accountIds = accounts.map((row) => row.id) + const credentials = await db + .select({ id: credential.id }) + .from(credential) + .where(and(eq(credential.type, 'oauth'), inArray(credential.accountId, accountIds))) + + if (credentials.length === 0) { + logger.info(`[${requestId}] No TikTok credentials for matched accounts`, { + accountCount: accounts.length, + }) + return [] + } + + const credentialIds = new Set(credentials.map((row) => row.id)) + + const results = await db + .select({ + webhook: webhook, + workflow: workflow, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, 'tiktok'), + eq(webhook.isActive, true), + isNull(webhook.archivedAt), + isNull(workflow.archivedAt), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) + + const matched = results.filter((row) => { + const config = (row.webhook.providerConfig as Record | null) ?? {} + const credentialId = config.credentialId + return typeof credentialId === 'string' && credentialIds.has(credentialId) + }) + + logger.info(`[${requestId}] TikTok fan-out matched webhooks`, { + userOpenIdPrefix: userOpenId.slice(0, 12), + accountCount: accounts.length, + credentialCount: credentials.length, + webhookCount: matched.length, + }) + + return matched +} diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 741e30081db..5bc9afa6e7f 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -196,7 +196,10 @@ function handleInvitationRedirects( function handleSecurityFiltering(request: NextRequest): NextResponse | null { const userAgent = request.headers.get('user-agent') || '' const { pathname } = request.nextUrl - const isWebhookEndpoint = pathname.startsWith('/api/webhooks/trigger/') + const isWebhookEndpoint = + pathname.startsWith('/api/webhooks/trigger/') || + pathname.startsWith('/api/webhooks/tiktok') || + pathname.startsWith('/api/webhooks/agentmail') const isMcpEndpoint = pathname.startsWith('/api/mcp/') const isMcpOauthDiscoveryEndpoint = pathname.startsWith('/.well-known/oauth-authorization-server') || diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 67416d77974..e8dd16ccbd0 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -383,6 +383,16 @@ import { slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' import { tableNewRowTrigger } from '@/triggers/table' import { telegramWebhookTrigger } from '@/triggers/telegram' +import { + tiktokAuthorizationRemovedTrigger, + tiktokPostInboxDeliveredTrigger, + tiktokPostNoLongerPublicTrigger, + tiktokPostPubliclyAvailableTrigger, + tiktokPostPublishCompleteTrigger, + tiktokPostPublishFailedTrigger, + tiktokVideoPublishCompletedTrigger, + tiktokVideoUploadFailedTrigger, +} from '@/triggers/tiktok' import { twilioSmsReceivedTrigger, twilioSmsStatusTrigger } from '@/triggers/twilio' import { twilioVoiceWebhookTrigger } from '@/triggers/twilio_voice' import { typeformWebhookTrigger } from '@/triggers/typeform' @@ -672,6 +682,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { stripe_webhook: stripeWebhookTrigger, table_new_row: tableNewRowTrigger, telegram_webhook: telegramWebhookTrigger, + tiktok_post_publish_complete: tiktokPostPublishCompleteTrigger, + tiktok_post_publish_failed: tiktokPostPublishFailedTrigger, + tiktok_post_inbox_delivered: tiktokPostInboxDeliveredTrigger, + tiktok_post_publicly_available: tiktokPostPubliclyAvailableTrigger, + tiktok_post_no_longer_public: tiktokPostNoLongerPublicTrigger, + tiktok_video_publish_completed: tiktokVideoPublishCompletedTrigger, + tiktok_video_upload_failed: tiktokVideoUploadFailedTrigger, + tiktok_authorization_removed: tiktokAuthorizationRemovedTrigger, typeform_webhook: typeformWebhookTrigger, whatsapp_webhook: whatsappWebhookTrigger, google_forms_webhook: googleFormsWebhookTrigger, diff --git a/apps/sim/triggers/tiktok/authorization_removed.ts b/apps/sim/triggers/tiktok/authorization_removed.ts new file mode 100644 index 00000000000..9845b6e13a1 --- /dev/null +++ b/apps/sim/triggers/tiktok/authorization_removed.ts @@ -0,0 +1,25 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokAuthorizationRemovedOutputs, + buildTikTokTriggerSubBlocks, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokAuthorizationRemovedTrigger: TriggerConfig = { + id: 'tiktok_authorization_removed', + name: 'TikTok Authorization Removed', + provider: 'tiktok', + description: 'Trigger when a user deauthorizes your TikTok app', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks('tiktok_authorization_removed', 'authorization.removed'), + + outputs: buildTikTokAuthorizationRemovedOutputs(), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/index.ts b/apps/sim/triggers/tiktok/index.ts new file mode 100644 index 00000000000..87d4219c68c --- /dev/null +++ b/apps/sim/triggers/tiktok/index.ts @@ -0,0 +1,8 @@ +export { tiktokAuthorizationRemovedTrigger } from './authorization_removed' +export { tiktokPostInboxDeliveredTrigger } from './post_inbox_delivered' +export { tiktokPostNoLongerPublicTrigger } from './post_no_longer_public' +export { tiktokPostPubliclyAvailableTrigger } from './post_publicly_available' +export { tiktokPostPublishCompleteTrigger } from './post_publish_complete' +export { tiktokPostPublishFailedTrigger } from './post_publish_failed' +export { tiktokVideoPublishCompletedTrigger } from './video_publish_completed' +export { tiktokVideoUploadFailedTrigger } from './video_upload_failed' diff --git a/apps/sim/triggers/tiktok/post_inbox_delivered.ts b/apps/sim/triggers/tiktok/post_inbox_delivered.ts new file mode 100644 index 00000000000..ab729fdaa3d --- /dev/null +++ b/apps/sim/triggers/tiktok/post_inbox_delivered.ts @@ -0,0 +1,28 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokPostingOutputs, + buildTikTokTriggerSubBlocks, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokPostInboxDeliveredTrigger: TriggerConfig = { + id: 'tiktok_post_inbox_delivered', + name: 'TikTok Post Inbox Delivered', + provider: 'tiktok', + description: 'Trigger when a draft notification is delivered to the creator inbox', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks( + 'tiktok_post_inbox_delivered', + 'post.publish.inbox_delivered' + ), + + outputs: buildTikTokPostingOutputs(), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/post_no_longer_public.ts b/apps/sim/triggers/tiktok/post_no_longer_public.ts new file mode 100644 index 00000000000..2adc1796b9c --- /dev/null +++ b/apps/sim/triggers/tiktok/post_no_longer_public.ts @@ -0,0 +1,28 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokPostingOutputs, + buildTikTokTriggerSubBlocks, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokPostNoLongerPublicTrigger: TriggerConfig = { + id: 'tiktok_post_no_longer_public', + name: 'TikTok Post No Longer Public', + provider: 'tiktok', + description: 'Trigger when a post is no longer publicly viewable on TikTok', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks( + 'tiktok_post_no_longer_public', + 'post.publish.no_longer_publicaly_available' + ), + + outputs: buildTikTokPostingOutputs({ includePostId: true }), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/post_publicly_available.ts b/apps/sim/triggers/tiktok/post_publicly_available.ts new file mode 100644 index 00000000000..54fc9caca42 --- /dev/null +++ b/apps/sim/triggers/tiktok/post_publicly_available.ts @@ -0,0 +1,28 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokPostingOutputs, + buildTikTokTriggerSubBlocks, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokPostPubliclyAvailableTrigger: TriggerConfig = { + id: 'tiktok_post_publicly_available', + name: 'TikTok Post Publicly Available', + provider: 'tiktok', + description: 'Trigger when a published post becomes publicly viewable on TikTok', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks( + 'tiktok_post_publicly_available', + 'post.publish.publicly_available' + ), + + outputs: buildTikTokPostingOutputs({ includePostId: true }), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/post_publish_complete.ts b/apps/sim/triggers/tiktok/post_publish_complete.ts new file mode 100644 index 00000000000..e77eb2d91bc --- /dev/null +++ b/apps/sim/triggers/tiktok/post_publish_complete.ts @@ -0,0 +1,41 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokPostingOutputs, + buildTikTokTriggerSubBlocks, + TIKTOK_WEBHOOK_HEADERS, + tiktokTriggerOptions, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Primary TikTok trigger — includes the trigger-type dropdown. + * Fires when Content Posting completes (direct post or inbox draft published). + */ +export const tiktokPostPublishCompleteTrigger: TriggerConfig = { + id: 'tiktok_post_publish_complete', + name: 'TikTok Post Publish Complete', + provider: 'tiktok', + description: 'Trigger when a TikTok Content Posting publish completes', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: tiktokTriggerOptions, + value: () => 'tiktok_post_publish_complete', + required: true, + }, + ...buildTikTokTriggerSubBlocks('tiktok_post_publish_complete', 'post.publish.complete'), + ], + + outputs: buildTikTokPostingOutputs(), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/post_publish_failed.ts b/apps/sim/triggers/tiktok/post_publish_failed.ts new file mode 100644 index 00000000000..a60d0251668 --- /dev/null +++ b/apps/sim/triggers/tiktok/post_publish_failed.ts @@ -0,0 +1,25 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokPostingOutputs, + buildTikTokTriggerSubBlocks, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokPostPublishFailedTrigger: TriggerConfig = { + id: 'tiktok_post_publish_failed', + name: 'TikTok Post Publish Failed', + provider: 'tiktok', + description: 'Trigger when a TikTok Content Posting publish fails', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks('tiktok_post_publish_failed', 'post.publish.failed'), + + outputs: buildTikTokPostingOutputs({ includeFailReason: true }), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/utils.ts b/apps/sim/triggers/tiktok/utils.ts new file mode 100644 index 00000000000..bbb265de48b --- /dev/null +++ b/apps/sim/triggers/tiktok/utils.ts @@ -0,0 +1,145 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +export const tiktokTriggerOptions = [ + { label: 'Post Publish Complete', id: 'tiktok_post_publish_complete' }, + { label: 'Post Publish Failed', id: 'tiktok_post_publish_failed' }, + { label: 'Post Inbox Delivered', id: 'tiktok_post_inbox_delivered' }, + { label: 'Post Publicly Available', id: 'tiktok_post_publicly_available' }, + { label: 'Post No Longer Public', id: 'tiktok_post_no_longer_public' }, + { label: 'Video Publish Completed', id: 'tiktok_video_publish_completed' }, + { label: 'Video Upload Failed', id: 'tiktok_video_upload_failed' }, + { label: 'Authorization Removed', id: 'tiktok_authorization_removed' }, +] + +/** Map Sim trigger ids to TikTok `event` strings (documented spelling preserved). */ +export const TIKTOK_TRIGGER_EVENT_MAP: Record = { + tiktok_post_publish_complete: 'post.publish.complete', + tiktok_post_publish_failed: 'post.publish.failed', + tiktok_post_inbox_delivered: 'post.publish.inbox_delivered', + tiktok_post_publicly_available: 'post.publish.publicly_available', + tiktok_post_no_longer_public: 'post.publish.no_longer_publicaly_available', + tiktok_video_publish_completed: 'video.publish.completed', + tiktok_video_upload_failed: 'video.upload.failed', + tiktok_authorization_removed: 'authorization.removed', +} + +export function isTikTokEventMatch(triggerId: string, event: string | undefined): boolean { + if (!event) return false + const expected = TIKTOK_TRIGGER_EVENT_MAP[triggerId] + return expected === event +} + +export function tiktokSetupInstructions(eventLabel: string): string { + const instructions = [ + 'Note: TikTok allows one Callback URL per app. Sim registers /api/webhooks/tiktok once in the TikTok Developer Portal — you do not paste a unique webhook URL into TikTok.', + 'Connect the TikTok account that should receive this event using the credential selector above.', + `This trigger listens for ${eventLabel} events for that connected account.`, + 'Deploy the workflow to activate the trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Builds subBlocks for a TikTok trigger: OAuth credential + setup instructions. + * Omits the per-workflow Webhook URL field — TikTok uses a fixed app-level Callback URL. + */ +export function buildTikTokTriggerSubBlocks( + triggerId: string, + eventLabel: string +): SubBlockConfig[] { + return [ + { + id: 'triggerCredentials', + title: 'TikTok Account', + type: 'oauth-input', + serviceId: 'tiktok', + mode: 'trigger', + required: true, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: tiktokSetupInstructions(eventLabel), + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +function buildCommonOutputs(): Record { + return { + event: { type: 'string', description: 'TikTok webhook event name' }, + createTime: { + type: 'number', + description: 'UTC epoch seconds when the event occurred', + }, + userOpenId: { + type: 'string', + description: 'TikTok user open_id for the connected account', + }, + clientKey: { + type: 'string', + description: 'TikTok app client_key that received the event', + }, + } +} + +export function buildTikTokPostingOutputs(options?: { + includeFailReason?: boolean + includePostId?: boolean +}): Record { + const outputs: Record = { + ...buildCommonOutputs(), + publishId: { type: 'string', description: 'Content Posting API publish_id' }, + publishType: { + type: 'string', + description: 'Publish type (e.g. DIRECT_POST, INBOX_SHARE)', + }, + } + if (options?.includePostId) { + outputs.postId = { + type: 'string', + description: 'TikTok post_id when the post is publicly available', + } + } + if (options?.includeFailReason) { + outputs.failReason = { + type: 'string', + description: 'Failure reason enum from TikTok when publishing fails', + } + } + return outputs +} + +export function buildTikTokVideoKitOutputs(): Record { + return { + ...buildCommonOutputs(), + shareId: { type: 'string', description: 'Share Kit / Video Kit share_id' }, + } +} + +export function buildTikTokAuthorizationRemovedOutputs(): Record { + return { + ...buildCommonOutputs(), + reason: { + type: 'number', + description: + 'Revocation reason (0 unknown, 1 user disconnect, 2 account deleted, 3 age change, 4 banned, 5 developer revoke)', + }, + } +} + +export const TIKTOK_WEBHOOK_HEADERS = { + 'Content-Type': 'application/json', + 'TikTok-Signature': 't=,s=', +} as const diff --git a/apps/sim/triggers/tiktok/video_publish_completed.ts b/apps/sim/triggers/tiktok/video_publish_completed.ts new file mode 100644 index 00000000000..9cd74477337 --- /dev/null +++ b/apps/sim/triggers/tiktok/video_publish_completed.ts @@ -0,0 +1,28 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokTriggerSubBlocks, + buildTikTokVideoKitOutputs, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokVideoPublishCompletedTrigger: TriggerConfig = { + id: 'tiktok_video_publish_completed', + name: 'TikTok Video Publish Completed', + provider: 'tiktok', + description: 'Trigger when a Share Kit / Video Kit upload is published by the user', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks( + 'tiktok_video_publish_completed', + 'video.publish.completed' + ), + + outputs: buildTikTokVideoKitOutputs(), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/apps/sim/triggers/tiktok/video_upload_failed.ts b/apps/sim/triggers/tiktok/video_upload_failed.ts new file mode 100644 index 00000000000..767d3b7ff1d --- /dev/null +++ b/apps/sim/triggers/tiktok/video_upload_failed.ts @@ -0,0 +1,25 @@ +import { TikTokIcon } from '@/components/icons' +import { + buildTikTokTriggerSubBlocks, + buildTikTokVideoKitOutputs, + TIKTOK_WEBHOOK_HEADERS, +} from '@/triggers/tiktok/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const tiktokVideoUploadFailedTrigger: TriggerConfig = { + id: 'tiktok_video_upload_failed', + name: 'TikTok Video Upload Failed', + provider: 'tiktok', + description: 'Trigger when a Share Kit / Video Kit upload fails in TikTok', + version: '1.0.0', + icon: TikTokIcon, + + subBlocks: buildTikTokTriggerSubBlocks('tiktok_video_upload_failed', 'video.upload.failed'), + + outputs: buildTikTokVideoKitOutputs(), + + webhook: { + method: 'POST', + headers: { ...TIKTOK_WEBHOOK_HEADERS }, + }, +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7a9037152d1..1aa4dc0495d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 918, - zodRoutes: 918, + totalRoutes: 919, + zodRoutes: 919, nonZodRoutes: 0, } as const diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 3ad1c9e3938..1abf4e2d3af 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -119,6 +119,7 @@ const TRIGGER_PROVIDER_DISPLAY_NAMES: Record = { slack: 'Slack', stripe: 'Stripe', telegram: 'Telegram', + tiktok: 'TikTok', twilio_voice: 'Twilio Voice', typeform: 'Typeform', vercel: 'Vercel', From 1e2e927c0343bcf9a00a84aa90552667e70453c5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 18:03:26 -0700 Subject: [PATCH 11/12] fix(tiktok): only count actually queued webhook executions Co-authored-by: Cursor --- apps/sim/app/api/webhooks/tiktok/route.ts | 50 +++++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts index 8194ff5ce68..3106c947df1 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.ts @@ -27,6 +27,21 @@ import { import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok' import { findTikTokWebhooksForOpenId } from '@/lib/webhooks/tiktok-fanout' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' +import { isTikTokEventMatch } from '@/triggers/tiktok/utils' + +/** + * queueWebhookExecution returns 200 for both real queues and event mismatches. + * Only count responses that indicate work was actually enqueued. + */ +async function didQueueWebhookExecution(response: NextResponse): Promise { + if (!response.ok) return false + try { + const payload = (await response.clone().json()) as Record + return payload.message === 'Webhook processed' + } catch { + return false + } +} const logger = createLogger('TikTokWebhookIngress') @@ -169,15 +184,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { continue } - await queueWebhookExecution(webhookRecord, foundWorkflow, envelope, request, { - requestId, - path: webhookRecord.path, - actorUserId: preprocessResult.actorUserId, - executionId: preprocessResult.executionId, - correlation: preprocessResult.correlation, - receivedAt, - }) - processed += 1 + const triggerId = webhookRecord.providerConfig?.triggerId + if ( + typeof triggerId === 'string' && + triggerId.length > 0 && + !isTikTokEventMatch(triggerId, envelope.event) + ) { + continue + } + + const queueResponse = await queueWebhookExecution( + webhookRecord, + foundWorkflow, + envelope, + request, + { + requestId, + path: webhookRecord.path, + actorUserId: preprocessResult.actorUserId, + executionId: preprocessResult.executionId, + correlation: preprocessResult.correlation, + receivedAt, + } + ) + if (await didQueueWebhookExecution(queueResponse)) { + processed += 1 + } } if (processed === 0 && matches.length > 0) { From 84bd3f6409fe1bc665331bfba973600575a0754f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 8 Jul 2026 18:07:09 -0700 Subject: [PATCH 12/12] chore(tiktok): bump API validation baseline for staging merge Co-authored-by: Cursor --- scripts/check-api-validation-contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1aa4dc0495d..23c532a1dea 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 919, - zodRoutes: 919, + totalRoutes: 921, + zodRoutes: 921, nonZodRoutes: 0, } as const