Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ vi.mock('@/app/api/files/authorization', () => ({
assertToolFileAccess: mockAssertToolFileAccess,
}))

vi.mock('@/app/api/tools/tiktok/publish-video/upload', () => ({
vi.mock('@/app/api/tools/tiktok/upload-video-draft/upload', () => ({
computeTikTokChunkPlan: mockComputeTikTokChunkPlan,
getStoredVideoSize: mockGetStoredVideoSize,
streamStoredVideoToTikTok: mockStreamStoredVideoToTikTok,
TIKTOK_MAX_VIDEO_BYTES: 250 * 1024 * 1024,
}))

import { POST } from '@/app/api/tools/tiktok/publish-video/route'
import { POST } from '@/app/api/tools/tiktok/upload-video-draft/route'

const file = {
key: 'workspace/workspace-1/video.mp4',
Expand All @@ -40,7 +40,7 @@ const file = {
type: 'video/mp4',
}

describe('POST /api/tools/tiktok/publish-video', () => {
describe('POST /api/tools/tiktok/upload-video-draft', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
Expand All @@ -67,7 +67,6 @@ describe('POST /api/tools/tiktok/publish-video', () => {
vi.stubGlobal('fetch', fetchMock)
const request = createMockRequest('POST', {
accessToken: 'access-token',
mode: 'draft',
file,
})

Expand Down Expand Up @@ -104,35 +103,6 @@ describe('POST /api/tools/tiktok/publish-video', () => {
})
})

it('keeps Direct Post unavailable until per-post approval exists', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)

const response = await POST(
createMockRequest('POST', {
accessToken: 'access-token',
mode: 'direct',
file,
musicUsageConsent: 'accepted',
postInfo: {
privacy_level: 'SELF_ONLY',
disable_duet: true,
disable_stitch: true,
disable_comment: true,
brand_content_toggle: false,
},
})
)

expect(response.status).toBe(400)
await expect(response.json()).resolves.toMatchObject({
success: false,
error: expect.stringMatching(/Direct Post is not available/),
})
expect(mockGetStoredVideoSize).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})

it('returns 413 when the storage object exceeds the relay limit', async () => {
mockGetStoredVideoSize.mockRejectedValue(
new PayloadSizeLimitError({
Expand All @@ -145,7 +115,6 @@ describe('POST /api/tools/tiktok/publish-video', () => {
const response = await POST(
createMockRequest('POST', {
accessToken: 'access-token',
mode: 'draft',
file,
})
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { tiktokUploadVideoDraftContract } 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'
Expand All @@ -19,15 +19,15 @@ import {
getStoredVideoSize,
streamStoredVideoToTikTok,
TIKTOK_MAX_VIDEO_BYTES,
} from '@/app/api/tools/tiktok/publish-video/upload'
} from '@/app/api/tools/tiktok/upload-video-draft/upload'
import type { UserFile } from '@/executor/types'
import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
import { readTikTokApiResponse } from '@/tools/tiktok/utils'

export const dynamic = 'force-dynamic'
export const maxDuration = 900

const logger = createLogger('TikTokPublishVideoAPI')
const logger = createLogger('TikTokUploadVideoDraftAPI')

const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm'])

Expand All @@ -43,28 +43,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized TikTok publish-video attempt: ${authResult.error}`)
logger.warn(
`[${requestId}] Unauthorized TikTok upload-video-draft attempt: ${authResult.error}`
)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}

const parsed = await parseRequest(tiktokPublishVideoContract, request, {})
const parsed = await parseRequest(tiktokUploadVideoDraftContract, request, {})
if (!parsed.success) return parsed.response
const data = parsed.data.body

if (data.mode === 'direct') {
return NextResponse.json(
{
success: false,
error:
'TikTok Direct Post is not available until per-post human approval is implemented. Use Upload Video Draft instead.',
},
{ status: 400 }
)
}

let userFile: UserFile
try {
userFile = processSingleFileToUserFile(data.file, requestId, logger)
Expand Down Expand Up @@ -146,7 +137,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (initError) {
logger.error(`[${requestId}] TikTok init failed`, { error: initError })
return NextResponse.json(
{ success: false, error: initError.message || 'Failed to initialize TikTok upload' },
{
success: false,
error: initError.message || initError.code || 'Failed to initialize TikTok upload',
},
{ status: initResponse.status >= 400 ? initResponse.status : 502 }
)
}
Expand Down Expand Up @@ -203,7 +197,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 499 }
)
}
logger.error(`[${requestId}] Error publishing video to TikTok:`, error)
logger.error(`[${requestId}] Error uploading TikTok video draft:`, error)
return NextResponse.json(
{ success: false, error: getErrorMessage(error, 'Internal server error') },
{ status: 500 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
getStoredVideoSize,
streamStoredVideoToTikTok,
TIKTOK_MAX_VIDEO_BYTES,
} from '@/app/api/tools/tiktok/publish-video/upload'
} from '@/app/api/tools/tiktok/upload-video-draft/upload'

const baseStreamOptions = {
key: 'workspace/workspace-1/video.mp4',
Expand Down
10 changes: 2 additions & 8 deletions apps/sim/blocks/blocks/tiktok.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ describe('TikTokBlock', () => {
const buildParams = TikTokBlock.tools.config.params!
const selectTool = TikTokBlock.tools.config.tool!

it('keeps direct posting unavailable until one-use human approval is supported', () => {
expect(TikTokBlock.tools.access).not.toContain('tiktok_direct_post_video')
expect(() => selectTool({ operation: 'tiktok_direct_post_video' })).toThrow(
it('rejects unsupported operations', () => {
expect(() => selectTool({ operation: 'tiktok_unknown_operation' })).toThrow(
'Unsupported TikTok operation'
)
})
Expand All @@ -37,21 +36,16 @@ describe('TikTokBlock', () => {
const inputs = {
operation: 'tiktok_upload_video_draft',
file,
privacyLevel: 'PUBLIC_TO_EVERYONE',
musicUsageConsent: 'accepted',
videoIds: 'stale-video-id',
}
const finalInputs = { ...inputs, ...buildParams(inputs) }

expect(finalInputs.file).toEqual(file)
expect(finalInputs.privacyLevel).toBeUndefined()
expect(finalInputs.musicUsageConsent).toBeUndefined()
expect(finalInputs.videoIds).toBeUndefined()
})

it('declares file-like outputs with canonical block output types', () => {
expect(TikTokBlock.outputs.avatarFile.type).toBe('file')
expect(TikTokBlock.outputs.creatorAvatarFile.type).toBe('file')
expect(TikTokBlock.outputs.videos.type).toBe('array')
expect(TikTokBlock.outputs.publiclyAvailablePostId.type).toBe('array')
})
Expand Down
81 changes: 7 additions & 74 deletions apps/sim/blocks/blocks/tiktok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import { normalizeFileInput } from '@/blocks/utils'
import type { TikTokResponse } from '@/tools/tiktok/types'
import { getTrigger } from '@/triggers'

const VIDEO_UPLOAD_OPERATIONS = ['tiktok_upload_video_draft']
const TIKTOK_TOOL_IDS = new Set([
'tiktok_get_user',
'tiktok_list_videos',
'tiktok_query_videos',
'tiktok_query_creator_info',
'tiktok_upload_video_draft',
'tiktok_get_post_status',
])
Expand All @@ -22,16 +20,6 @@ const TIKTOK_OPERATION_INPUT_KEYS = [
'videoIds',
'file',
'publishId',
'title',
'privacyLevel',
'disableDuet',
'disableStitch',
'disableComment',
'videoCoverTimestampMs',
'isAigc',
'brandContentToggle',
'brandOrganicToggle',
'musicUsageConsent',
] as const

export const TikTokBlock: BlockConfig<TikTokResponse> = {
Expand All @@ -47,7 +35,7 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
bgColor: '#000000',
icon: TikTokIcon,
triggerAllowed: true,
hideFromToolbar: true,
hideFromToolbar: false,

subBlocks: [
{
Expand All @@ -58,7 +46,6 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
{ 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: 'Upload Video Draft', id: 'tiktok_upload_video_draft' },
{ label: 'Get Post Status', id: 'tiktok_get_post_status' },
],
Expand Down Expand Up @@ -140,8 +127,8 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
multiple: false,
maxSize: 250,
description: 'MP4, MOV, or WebM video up to 250 MB.',
condition: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS },
required: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS },
condition: { field: 'operation', value: 'tiktok_upload_video_draft' },
required: { field: 'operation', value: 'tiktok_upload_video_draft' },
},
{
id: 'videoFileRef',
Expand All @@ -150,8 +137,8 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
canonicalParamId: 'file',
mode: 'advanced',
placeholder: 'Reference a video from a previous block',
condition: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS },
required: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS },
condition: { field: 'operation', value: 'tiktok_upload_video_draft' },
required: { field: 'operation', value: 'tiktok_upload_video_draft' },
},

{
Expand All @@ -168,8 +155,6 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
...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,
],

Expand All @@ -181,8 +166,6 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
'tiktok_post_inbox_delivered',
'tiktok_post_publicly_available',
'tiktok_post_no_longer_public',
'tiktok_video_publish_completed',
'tiktok_video_upload_failed',
'tiktok_authorization_removed',
],
},
Expand All @@ -192,7 +175,6 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
'tiktok_get_user',
'tiktok_list_videos',
'tiktok_query_videos',
'tiktok_query_creator_info',
'tiktok_upload_video_draft',
'tiktok_get_post_status',
],
Expand Down Expand Up @@ -227,8 +209,6 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
.map((id: string) => id.trim())
.filter(Boolean)
break
case 'tiktok_query_creator_info':
break
case 'tiktok_upload_video_draft': {
result.file = normalizeFileInput(params.file, { single: true })
break
Expand Down Expand Up @@ -340,59 +320,12 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
condition: { field: 'operation', value: 'tiktok_list_videos' },
},

creatorAvatarUrl: {
type: 'string',
description: 'Provider URL of the creator avatar; expires after two hours',
condition: { field: 'operation', value: 'tiktok_query_creator_info' },
},
creatorAvatarFile: {
type: 'file',
description:
'Canonical workflow file containing the creator avatar, suitable for downstream attachments',
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: 'array',
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' },
},

publishId: {
type: 'string',
description: 'Publish ID for tracking post status',
description: 'Draft upload ID for tracking post status',
condition: {
field: 'operation',
value: VIDEO_UPLOAD_OPERATIONS,
value: 'tiktok_upload_video_draft',
},
},

Expand Down
Loading
Loading