Skip to content

Commit 5d3809a

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragenticecrasher321
authored
feat(tiktok): add tiktok trigger, block (#5504)
* feat(tiktok): add TikTok integration Adds TikTok as a full OAuth-based integration: provider registration (with TikTok's comma-separated scope and client_key requirements), 9 tools covering profile info, video listing/querying, creator info, direct video/photo posting (URL or file upload), inbox drafts, and post status polling, plus the TikTok block, icon, and generated docs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tiktok): add avatarFile output to Get User Info Adds a file-typed avatarFile output (sourced from the largest available avatar URL) alongside the existing string avatar fields, so the profile picture can be materialized as a UserFile and chained into file-consuming blocks (e.g. attached to an email), per PR review feedback. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tiktok): lower upload memory cap, drop redundant avatar string outputs Cap the file-upload video buffer at 250MB instead of TikTok's 4GB ceiling — relaying that much through this server's memory per request isn't safe under concurrent load, and larger files can still go through the PULL_FROM_URL path, which never buffers on our server. Also drop the now-redundant avatarUrl/avatarUrl100/avatarLargeUrl string outputs from Get User Info in favor of the file-typed avatarFile output alone, since the feature is unreleased and the raw URL is still reachable via avatarFile.url. Cover image URLs on List/Query Videos are confirmed to be signed, expiring TikTok CDN links; left as strings (no file-output conversion path exists for fields nested inside array items) but documented the expiry behavior more clearly. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(ci): bump API validation route-count baseline for TikTok publish-video route The TikTok integration adds one new Zod-backed internal API route (app/api/tools/tiktok/publish-video), which trips the route-count ratchet in check-api-validation-contracts.ts. Bumping totalRoutes and zodRoutes from 917 to 918 (nonZodRoutes stays 0) to acknowledge the new route is properly validated. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(tiktok): drop unused avatar_url_100 from default user fields After removing the avatar string outputs, avatar_url_100 was still requested from TikTok's user info endpoint but never surfaced anywhere. Removed it from the default field list and the field descriptions, and noted that avatar_url/avatar_large_url feed the avatarFile output. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tiktok): stop returning raw 'credential' subBlock id from tools.config.params The block's params function built a local `credential` variable from params.oauthCredential and returned it under the key `credential` in every switch case. That literal token is the raw subBlock id, which is deleted after canonical transformation into `oauthCredential` — the blocks.test.ts canonical-param-validation suite flags any params function that still references it. It was also redundant: oauthCredential is already part of the base resolved inputs, which the executor merges into the tool call before config.params overrides are applied, so the OAuth token resolution (which reads contextParams.oauthCredential) worked regardless. Removed the explicit credential plumbing, matching the convention already used by other OAuth blocks like dropbox.ts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tiktok): send empty JSON body on Query Creator Info POST query_creator_info had no request.body function, and formatRequestParams() only attaches a body when tool.request.body is defined at all — so despite sending Content-Type: application/json, the request went out with no body whatsoever. Added body: () => ({}), matching the convention already used by other parameterless-POST tools in this codebase (Google Vault, Supabase, Square, Gmail, etc.). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tiktok): stop dropping valid zero values in optional numeric fields cursor, photoCoverIndex, and videoCoverTimestampMs all used a truthy check (params.x && {...}) to decide whether to include an optional numeric override, which drops a legitimate 0 (first page has no cursor issue aside, photoCoverIndex 0 is TikTok's own default cover photo, and timestamp 0 is a valid first-frame cover). Switched to explicit undefined/empty-string checks, matching the !== undefined convention the underlying tools already use. In today's resolution pipeline these fields always arrive as strings (even chained block references get stringified by the template resolver), and a non-empty string like "0" is truthy, so this wasn't actively broken end-to-end - but it was relying on that subtlety rather than being correct by construction, and was inconsistent with the tools' own undefined checks. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tiktok): accept newline-separated video IDs in Query Videos videoIds is a long-input (multiline textarea), the same widget used for the newline-separated photoImages field on this block, but its parser only split on commas. Entering one ID per line - the natural pattern for a multiline field, and the one already used elsewhere on this block - produced a single concatenated garbage string instead of an array, so TikTok's query would fail or return nothing. Now splits on commas or newlines, and updated the placeholder/description to reflect both formats. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(tiktok): add app-level webhook ingress and triggers * fix(tiktok): only count actually queued webhook executions Co-authored-by: Cursor <cursoragent@cursor.com> * chore(tiktok): bump API validation baseline for staging merge Co-authored-by: Cursor <cursoragent@cursor.com> * cleanup code * fix type issues * misc code cleanup * remove photos and add upload for videos * move shared video output properties to types.ts so docs generation resolves them Co-authored-by: Cursor <cursoragent@cursor.com> * hide TikTok from toolbar and docs until the integration is ready to ship Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): ratchet API validation baseline to 924 after staging merge Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent bbff34a commit 5d3809a

74 files changed

Lines changed: 21977 additions & 295 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,31 @@ export function xAIIcon(props: SVGProps<SVGSVGElement>) {
12741274
)
12751275
}
12761276

1277+
export function TikTokIcon(props: SVGProps<SVGSVGElement>) {
1278+
return (
1279+
<svg
1280+
xmlns='http://www.w3.org/2000/svg'
1281+
viewBox='0 0 520.48 520.48'
1282+
width='1em'
1283+
height='1em'
1284+
{...props}
1285+
>
1286+
<path
1287+
fill='#ff004f'
1288+
d='m392.19,206.7v49.13c-8.6-.84-19.74-2.78-32.09-7.31-16.13-5.91-28.13-13.99-35.98-20.25v99.32l-.19-.31c.14,1.96.19,3.97.19,5.99,0,49.33-40.13,89.48-89.48,89.48s-89.48-40.15-89.48-89.48,40.13-89.5,89.48-89.5c4.82,0,9.57.39,14.2,1.13v48.43c-4.45-1.6-9.22-2.45-14.2-2.45-23.36,0-42.38,19-42.38,42.38s19.02,42.38,42.38,42.38,42.38-19.02,42.38-42.38c0-.88-.02-1.75-.08-2.63v-193.01h49.11c.18,4.16.35,8.34.53,12.51.33,8.19,3.25,16.05,8.32,22.49,5.97,7.57,14.76,16.36,27.13,23.38,11.57,6.56,22.45,9.38,30.15,10.7Z'
1289+
/>
1290+
<path
1291+
fill='#00f7ef'
1292+
d='m375.32,166.81v49.13c-8.6-.84-19.74-2.78-32.09-7.31-16.13-5.91-28.13-13.99-35.98-20.25v99.32l-.19-.31c.14,1.96.19,3.97.19,5.99,0,49.33-40.13,89.48-89.48,89.48s-89.48-40.15-89.48-89.48,40.13-89.5,89.48-89.5c4.82,0,9.57.39,14.2,1.13v48.43c-4.45-1.6-9.22-2.45-14.2-2.45-23.36,0-42.38,19-42.38,42.38s19.02,42.38,42.38,42.38,42.38-19.02,42.38-42.38c0-.88-.02-1.75-.08-2.63V97.74h49.11c.18,4.16.35,8.34.53,12.51.33,8.19,3.25,16.05,8.33,22.49,5.97,7.57,14.76,16.36,27.13,23.38,11.57,6.56,22.45,9.38,30.15,10.7Z'
1293+
/>
1294+
<path
1295+
fill='#fff'
1296+
d='m386.06,188.29v49.13c-8.6-.84-19.74-2.78-32.09-7.31-16.13-5.91-28.13-13.99-35.98-20.25v99.32l-.19-.31c.14,1.96.19,3.97.19,5.99,0,49.33-40.13,89.48-89.48,89.48s-89.48-40.15-89.48-89.48,40.13-89.5,89.48-89.5c4.82,0,9.57.39,14.2,1.13v48.43c-4.45-1.6-9.22-2.45-14.2-2.45-23.36,0-42.38,19-42.38,42.38s19.02,42.38,42.38,42.38,42.38-19.02,42.38-42.38c0-.88-.02-1.75-.08-2.63V119.22h49.11c.18,4.16.35,8.34.53,12.51.33,8.19,3.25,16.05,8.32,22.49,5.97,7.57,14.76,16.36,27.13,23.38,11.57,6.56,22.45,9.38,30.15,10.7Z'
1297+
/>
1298+
</svg>
1299+
)
1300+
}
1301+
12771302
export function xIcon(props: SVGProps<SVGSVGElement>) {
12781303
return (
12791304
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='1em' height='1em' {...props}>

apps/sim/app/api/tools/jupyter/upload/route.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
buildJupyterAuthHeaders,
1919
encodeJupyterPath,
2020
normalizeJupyterServerUrl,
21+
parseJupyterContentModel,
2122
UnsafeJupyterPathError,
2223
} from '@/tools/jupyter/utils'
2324

@@ -128,17 +129,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
128129
)
129130
}
130131

131-
const uploaded = await response.json()
132+
const uploaded = parseJupyterContentModel(await response.json())
133+
if (!uploaded) {
134+
logger.error(`[${requestId}] Jupyter returned an invalid upload response`)
135+
return NextResponse.json(
136+
{ success: false, error: 'Jupyter returned an invalid upload response' },
137+
{ status: 502 }
138+
)
139+
}
140+
141+
const uploadedName = uploaded.name ?? fileName
142+
const uploadedPath = uploaded.path ?? destinationPath
143+
const uploadedSize = uploaded.size ?? fileBuffer.length
144+
const lastModified = uploaded.lastModified ?? null
132145

133-
logger.info(`[${requestId}] File uploaded to Jupyter: ${uploaded.path}`)
146+
logger.info(`[${requestId}] File uploaded to Jupyter: ${uploadedPath}`)
134147

135148
return NextResponse.json({
136149
success: true,
137150
output: {
138-
name: uploaded.name ?? fileName,
139-
path: uploaded.path ?? destinationPath,
140-
size: uploaded.size ?? fileBuffer.length,
141-
lastModified: uploaded.last_modified ?? null,
151+
name: uploadedName,
152+
path: uploadedPath,
153+
size: uploadedSize,
154+
lastModified,
142155
},
143156
})
144157
} catch (error) {
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { tiktokPublishVideoContract } from '@/lib/api/contracts/tiktok-tools'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import {
11+
getFileExtension,
12+
getMimeTypeFromExtension,
13+
processSingleFileToUserFile,
14+
} from '@/lib/uploads/utils/file-utils'
15+
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
16+
import { assertToolFileAccess } from '@/app/api/files/authorization'
17+
import type { UserFile } from '@/executor/types'
18+
import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
19+
import { readTikTokApiResponse } from '@/tools/tiktok/utils'
20+
21+
export const dynamic = 'force-dynamic'
22+
23+
const logger = createLogger('TikTokPublishVideoAPI')
24+
25+
const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm'])
26+
27+
/** 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. */
28+
const DEFAULT_CHUNK_SIZE = 10_000_000
29+
const TIKTOK_ERROR_RESPONSE_MAX_BYTES = 64 * 1024
30+
31+
/** Maximum size this route will buffer in memory for a single file-upload request. TikTok's
32+
* own limit is 4GB, but relaying that much through this server's memory per request isn't
33+
* safe under concurrent load. Enforced before downloading the file so an oversized upload
34+
* fails fast with a clean 413 instead of materializing hundreds of MB to multiple GB
35+
* in-process. */
36+
const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024
37+
38+
function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } {
39+
if (totalBytes <= DEFAULT_CHUNK_SIZE) {
40+
return { chunkSize: totalBytes, totalChunkCount: 1 }
41+
}
42+
const totalChunkCount = Math.floor(totalBytes / DEFAULT_CHUNK_SIZE)
43+
return { chunkSize: DEFAULT_CHUNK_SIZE, totalChunkCount }
44+
}
45+
46+
function resolveVideoMimeType(fileName: string, fileType: string | undefined): string | null {
47+
if (fileType && TIKTOK_VIDEO_MIME_TYPES.has(fileType)) return fileType
48+
const fromExtension = getMimeTypeFromExtension(getFileExtension(fileName))
49+
return TIKTOK_VIDEO_MIME_TYPES.has(fromExtension) ? fromExtension : null
50+
}
51+
52+
async function validateDirectPostSettings(
53+
accessToken: string,
54+
postInfo: { privacy_level: string; brand_content_toggle: boolean },
55+
requestId: string
56+
): Promise<string | null> {
57+
if (postInfo.brand_content_toggle && postInfo.privacy_level === 'SELF_ONLY') {
58+
return 'Branded content cannot use Only Me privacy.'
59+
}
60+
61+
const response = await fetch('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', {
62+
method: 'POST',
63+
headers: {
64+
Authorization: `Bearer ${accessToken}`,
65+
'Content-Type': 'application/json; charset=UTF-8',
66+
},
67+
body: '{}',
68+
})
69+
const data = await response.json()
70+
if (!response.ok || (data.error?.code && data.error.code !== 'ok')) {
71+
logger.warn(`[${requestId}] TikTok creator-info preflight failed`, {
72+
status: response.status,
73+
code: data.error?.code,
74+
})
75+
return data.error?.message || 'TikTok creator information could not be verified.'
76+
}
77+
78+
const privacyOptions: unknown = data.data?.privacy_level_options
79+
if (
80+
!Array.isArray(privacyOptions) ||
81+
!privacyOptions.some((option) => option === postInfo.privacy_level)
82+
) {
83+
return `The selected privacy level (${postInfo.privacy_level}) is not currently available for this TikTok account. Run Query Creator Info and choose one of the returned options.`
84+
}
85+
86+
return null
87+
}
88+
89+
async function uploadChunks(
90+
uploadUrl: string,
91+
buffer: Buffer,
92+
mimeType: string,
93+
requestId: string
94+
): Promise<void> {
95+
const totalBytes = buffer.length
96+
const { chunkSize, totalChunkCount } = computeChunkPlan(totalBytes)
97+
98+
for (let i = 0; i < totalChunkCount; i++) {
99+
const start = i * chunkSize
100+
const isLastChunk = i === totalChunkCount - 1
101+
const end = isLastChunk ? totalBytes - 1 : start + chunkSize - 1
102+
const chunk = buffer.subarray(start, end + 1)
103+
104+
const response = await fetch(uploadUrl, {
105+
method: 'PUT',
106+
headers: {
107+
'Content-Type': mimeType,
108+
'Content-Length': String(chunk.length),
109+
'Content-Range': `bytes ${start}-${end}/${totalBytes}`,
110+
},
111+
body: new Uint8Array(chunk),
112+
})
113+
114+
if (!response.ok) {
115+
const errorText = await readResponseTextWithLimit(response, {
116+
maxBytes: TIKTOK_ERROR_RESPONSE_MAX_BYTES,
117+
label: 'TikTok upload error response',
118+
}).catch(() => 'Error response exceeded the allowed size')
119+
logger.error(`[${requestId}] TikTok chunk upload failed`, {
120+
chunkIndex: i,
121+
status: response.status,
122+
errorText,
123+
})
124+
throw new Error(
125+
`TikTok rejected video chunk ${i + 1}/${totalChunkCount}: ${response.status} ${errorText || response.statusText}`
126+
)
127+
}
128+
}
129+
}
130+
131+
export const POST = withRouteHandler(async (request: NextRequest) => {
132+
const requestId = generateRequestId()
133+
134+
try {
135+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
136+
if (!authResult.success || !authResult.userId) {
137+
logger.warn(`[${requestId}] Unauthorized TikTok publish-video attempt: ${authResult.error}`)
138+
return NextResponse.json(
139+
{ success: false, error: authResult.error || 'Authentication required' },
140+
{ status: 401 }
141+
)
142+
}
143+
144+
const parsed = await parseRequest(tiktokPublishVideoContract, request, {})
145+
if (!parsed.success) return parsed.response
146+
const data = parsed.data.body
147+
148+
let userFile: UserFile
149+
try {
150+
userFile = processSingleFileToUserFile(data.file, requestId, logger)
151+
} catch (error) {
152+
return NextResponse.json(
153+
{ success: false, error: getErrorMessage(error, 'Failed to process file') },
154+
{ status: 400 }
155+
)
156+
}
157+
158+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
159+
if (denied) return denied
160+
161+
if (data.mode === 'direct') {
162+
const settingsError = await validateDirectPostSettings(
163+
data.accessToken,
164+
data.postInfo,
165+
requestId
166+
)
167+
if (settingsError) {
168+
return NextResponse.json({ success: false, error: settingsError }, { status: 400 })
169+
}
170+
}
171+
172+
const mimeType = resolveVideoMimeType(userFile.name, userFile.type)
173+
if (!mimeType) {
174+
return NextResponse.json(
175+
{
176+
success: false,
177+
error: 'Unsupported video type. TikTok accepts MP4, MOV/QuickTime, or WebM files.',
178+
},
179+
{ status: 400 }
180+
)
181+
}
182+
183+
logger.info(`[${requestId}] Downloading video from storage`, {
184+
fileName: userFile.name,
185+
size: userFile.size,
186+
})
187+
188+
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, {
189+
maxBytes: TIKTOK_MAX_VIDEO_BYTES,
190+
})
191+
if (fileBuffer.length === 0) {
192+
return NextResponse.json(
193+
{ success: false, error: 'The video file is empty.' },
194+
{ status: 400 }
195+
)
196+
}
197+
const { chunkSize, totalChunkCount } = computeChunkPlan(fileBuffer.length)
198+
199+
const initUrl =
200+
data.mode === 'draft'
201+
? 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/'
202+
: 'https://open.tiktokapis.com/v2/post/publish/video/init/'
203+
204+
const initBody: Record<string, unknown> = {
205+
source_info: {
206+
source: 'FILE_UPLOAD',
207+
video_size: fileBuffer.length,
208+
chunk_size: chunkSize,
209+
total_chunk_count: totalChunkCount,
210+
},
211+
}
212+
if (data.mode === 'direct') {
213+
initBody.post_info = data.postInfo
214+
}
215+
216+
logger.info(`[${requestId}] Initializing TikTok video ${data.mode}`, {
217+
videoSize: fileBuffer.length,
218+
chunkSize,
219+
totalChunkCount,
220+
})
221+
222+
const initResponse = await fetch(initUrl, {
223+
method: 'POST',
224+
headers: {
225+
Authorization: `Bearer ${data.accessToken}`,
226+
'Content-Type': 'application/json; charset=UTF-8',
227+
},
228+
body: JSON.stringify(initBody),
229+
})
230+
231+
const { data: initData, error: initError } = await readTikTokApiResponse(
232+
initResponse,
233+
tiktokPublishInitApiDataSchema
234+
)
235+
236+
if (initError) {
237+
logger.error(`[${requestId}] TikTok init failed`, { error: initError })
238+
return NextResponse.json(
239+
{ success: false, error: initError.message || 'Failed to initialize TikTok upload' },
240+
{ status: initResponse.status >= 400 ? initResponse.status : 502 }
241+
)
242+
}
243+
244+
const publishId = initData?.publish_id
245+
const uploadUrl = initData?.upload_url
246+
247+
if (!publishId || !uploadUrl) {
248+
return NextResponse.json(
249+
{ success: false, error: 'TikTok did not return a publish ID and upload URL' },
250+
{ status: 502 }
251+
)
252+
}
253+
254+
try {
255+
await uploadChunks(uploadUrl, fileBuffer, mimeType, requestId)
256+
} catch (error) {
257+
return NextResponse.json(
258+
{ success: false, error: getErrorMessage(error, 'Failed to upload video to TikTok') },
259+
{ status: 502 }
260+
)
261+
}
262+
263+
logger.info(`[${requestId}] TikTok video upload complete`, { publishId })
264+
265+
return NextResponse.json({ success: true, output: { publishId } })
266+
} catch (error) {
267+
if (isPayloadSizeLimitError(error)) {
268+
logger.warn(`[${requestId}] Rejected oversized TikTok video upload`, {
269+
maxBytes: error.maxBytes,
270+
observedBytes: error.observedBytes,
271+
})
272+
const maxMb = Math.floor(TIKTOK_MAX_VIDEO_BYTES / (1024 * 1024))
273+
return NextResponse.json(
274+
{
275+
success: false,
276+
error: `Video exceeds the ${maxMb}MB limit for file uploads.`,
277+
},
278+
{ status: 413 }
279+
)
280+
}
281+
logger.error(`[${requestId}] Error publishing video to TikTok:`, error)
282+
return NextResponse.json(
283+
{ success: false, error: getErrorMessage(error, 'Internal server error') },
284+
{ status: 500 }
285+
)
286+
}
287+
})

0 commit comments

Comments
 (0)