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..c90e7ae195a --- /dev/null +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -0,0 +1,384 @@ +--- +title: TikTok +description: Access TikTok profiles and videos, and publish content +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* 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. + + + +## 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_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 + +| 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 | +| `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 | +| `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` + +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 | 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 | +| ↳ `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 | 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 | +| ↳ `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. | + + + +## 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/tools/tiktok/publish-video/route.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.ts new file mode 100644 index 00000000000..45bc251f4e2 --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.ts @@ -0,0 +1,215 @@ +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 + +/** 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) { + 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, + }) + const maxMb = Math.floor(TIKTOK_MAX_VIDEO_BYTES / (1024 * 1024)) + return NextResponse.json( + { + success: false, + error: `Video exceeds the ${maxMb}MB limit for file uploads. Use a public video URL instead to post larger files.`, + }, + { 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/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts new file mode 100644 index 00000000000..3106c947df1 --- /dev/null +++ b/apps/sim/app/api/webhooks/tiktok/route.ts @@ -0,0 +1,232 @@ +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' +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') + +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 + } + + 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) { + 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 new file mode 100644 index 00000000000..52dcd5ee084 --- /dev/null +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -0,0 +1,770 @@ +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' +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'] + +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_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: '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' }, + }, + + // --- 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' }, + }, + + ...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', + '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 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 { + ...(params.fields && { fields: params.fields }), + } + case 'tiktok_list_videos': + return { + ...(params.maxCount && { maxCount: Number(params.maxCount) }), + ...(params.cursor !== undefined && + params.cursor !== '' && { cursor: Number(params.cursor) }), + } + case 'tiktok_query_videos': + return { + videoIds: (params.videoIds || '') + .split(/[,\n]+/) + .map((id: string) => id.trim()) + .filter(Boolean), + } + case 'tiktok_query_creator_info': + return {} + case 'tiktok_direct_post_video': { + const file = normalizeFileInput(params.file, { single: true }) + return { + 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 !== undefined && + 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 { + source: params.videoSource || 'PULL_FROM_URL', + videoUrl: params.videoUrl, + file, + } + } + case 'tiktok_direct_post_photo': + return { + photoImages: (params.photoImages || '') + .split('\n') + .map((url: string) => url.trim()) + .filter(Boolean), + ...(params.photoCoverIndex !== undefined && + 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 { + photoImages: (params.photoImages || '') + .split('\n') + .map((url: string) => url.trim()) + .filter(Boolean), + ...(params.photoCoverIndex !== undefined && + params.photoCoverIndex !== '' && { + photoCoverIndex: Number(params.photoCoverIndex), + }), + title: params.title, + description: params.description, + } + case 'tiktok_get_post_status': + return { + publishId: params.publishId, + } + default: + return {} + } + }, + }, + }, + + 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: 'List of video IDs to query, one per line or comma-separated', + }, + 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' }, + }, + 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' }, + }, + 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: { + 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/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/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 d7903b9a380..67259e2cba6 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -343,6 +343,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..8c68fe67272 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,103 @@ "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": [ + { + "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", + "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/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/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..881ce7e03f7 --- /dev/null +++ b/apps/sim/tools/tiktok/get_user.ts @@ -0,0 +1,181 @@ +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_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count. Include avatar_url and avatar_large_url to receive the avatarFile output.', + }, + }, + + request: { + url: (params: TikTokGetUserParams) => { + const fields = params.fields || TIKTOK_USER_FIELDS + return `https://open.tiktokapis.com/v2/user/info/?fields=${encodeURIComponent(fields)}` + }, + method: 'GET', + headers: (params: TikTokGetUserParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + '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: '', + 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: '', + bioDescription: null, + profileDeepLink: null, + isVerified: null, + username: null, + followerCount: null, + followingCount: null, + likesCount: null, + videoCount: null, + }, + error: 'No user data returned', + } + } + + const avatarSourceUrl = user.avatar_large_url ?? user.avatar_url + const avatarFileName = `${user.username || user.open_id || 'tiktok-user'}-avatar.jpg` + + return { + success: true, + output: { + openId: user.open_id ?? '', + unionId: user.union_id ?? null, + displayName: user.display_name ?? '', + bioDescription: user.bio_description ?? null, + profileDeepLink: user.profile_deep_link ?? null, + isVerified: user.is_verified ?? null, + username: user.username ?? null, + followerCount: user.follower_count ?? null, + followingCount: user.following_count ?? null, + likesCount: user.likes_count ?? null, + videoCount: user.video_count ?? null, + ...(avatarSourceUrl && { + avatarFile: { + name: avatarFileName, + mimeType: 'image/jpeg', + url: avatarSourceUrl, + }, + }), + }, + } + }, + + outputs: { + openId: { + type: 'string', + description: 'Unique TikTok user ID for this application', + }, + unionId: { + type: 'string', + description: 'Unique TikTok user ID across all apps from the same developer', + optional: true, + }, + displayName: { + type: 'string', + description: 'User display name', + }, + bioDescription: { + type: 'string', + description: 'User bio description', + optional: true, + }, + profileDeepLink: { + type: 'string', + description: 'Deep link to user TikTok profile', + optional: true, + }, + isVerified: { + type: 'boolean', + description: 'Whether the account is verified', + optional: true, + }, + username: { + type: 'string', + description: 'TikTok username', + optional: true, + }, + followerCount: { + type: 'number', + description: 'Number of followers', + optional: true, + }, + followingCount: { + type: 'number', + description: 'Number of accounts the user follows', + optional: true, + }, + likesCount: { + type: 'number', + description: 'Total likes received across all videos', + optional: true, + }, + videoCount: { + type: 'number', + description: 'Total number of public videos', + optional: true, + }, + avatarFile: { + type: 'file', + description: + 'Downloadable copy of the profile avatar image (largest available variant), stored as a workflow file so it can be chained into file-consuming blocks (e.g. attached to an email).', + optional: true, + }, + }, +} 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..9ba27d18c59 --- /dev/null +++ b/apps/sim/tools/tiktok/list_videos.ts @@ -0,0 +1,131 @@ +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: + '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.', + 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..99758cb6c22 --- /dev/null +++ b/apps/sim/tools/tiktok/query_creator_info.ts @@ -0,0 +1,135 @@ +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', + }), + body: () => ({}), + }, + + 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..42e5d8dc544 --- /dev/null +++ b/apps/sim/tools/tiktok/query_videos.ts @@ -0,0 +1,117 @@ +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: + '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.', + 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..10706272688 --- /dev/null +++ b/apps/sim/tools/tiktok/types.ts @@ -0,0 +1,211 @@ +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 + bioDescription: string | null + profileDeepLink: string | null + isVerified: boolean | null + username: string | null + followerCount: number | null + followingCount: number | null + likesCount: number | null + videoCount: number | null + avatarFile?: { + name: string + mimeType: string + url: string + } + } +} + +/** + * List Videos + */ +export interface TikTokListVideosParams extends TikTokBaseParams { + maxCount?: number + cursor?: number +} + +export interface TikTokVideo { + id: string + title: string | null + coverImageUrl: string | null + embedLink: string | null + 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..273986c1972 --- /dev/null +++ b/apps/sim/tools/tiktok/utils.ts @@ -0,0 +1,70 @@ +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. + * `avatar_url` and `avatar_large_url` feed the file-typed `avatarFile` output. + */ +export const TIKTOK_USER_FIELDS = + 'open_id,union_id,avatar_url,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count' + +/** + * Fields requested from TikTok's `/v2/video/list/` and `/v2/video/query/` endpoints. + * All are available under the `video.list` scope. + */ +export const TIKTOK_VIDEO_FIELDS = + 'id,title,cover_image_url,embed_link,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 } +} 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 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 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',