From 9b42701a9d552865d8ed84fbfe9c19ebde8d7108 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 9 May 2026 11:35:55 -0300 Subject: [PATCH] fix(tiktok): use description field for photo posts (4000 char cap) TikTok's photo endpoint caps the 'title' field at 90 UTF-16 runes but allows 'description' up to 4000. Today the publisher always sends the caption in 'title', which would silently fail for any photo caption longer than 90 chars. Split buildPostInfo into buildVideoPostInfo (uses title) and buildPhotoPostInfo (uses description, omits Duet/ Stitch/AIGC since they don't apply to photos). --- app/Services/Social/TikTokPublisher.php | 49 ++++++++++++++--- .../Services/Social/TikTokPublisherTest.php | 54 ++++++++++++++++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index e2b90893..52209a8b 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -97,7 +97,14 @@ private function queryCreatorInfo(SocialAccount $account): array ]; } - private function buildPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + /** + * Build the post_info payload for a VIDEO post. TikTok's video endpoint + * accepts the caption in the `title` field (capped at 2200 chars by the + * platform's maxContentLength). + * + * @return array + */ + private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array { $meta = $postPlatform->meta ?? []; @@ -127,13 +134,45 @@ private function buildPostInfo(PostPlatform $postPlatform, ?string $content, arr return $postInfo; } + /** + * Build the post_info payload for a PHOTO carousel. TikTok's photo endpoint + * accepts the caption in the `description` field (cap 4000 UTF-16 runes). + * The `title` field is a separate 90-char headline that we don't expose, + * so we omit it. Duet/Stitch and is_aigc do not apply to photo posts. + * + * @return array + */ + private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + { + $meta = $postPlatform->meta ?? []; + + $privacyLevel = data_get($meta, 'privacy_level') + ?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY'); + + $postInfo = [ + 'description' => $content ?? '', + 'privacy_level' => $privacyLevel, + 'disable_comment' => ! data_get($meta, 'allow_comments', true), + ]; + + if (data_get($meta, 'brand_content_toggle', false)) { + $postInfo['brand_content_toggle'] = true; + } + + if (data_get($meta, 'brand_organic_toggle', false)) { + $postInfo['brand_organic_toggle'] = true; + } + + return $postInfo; + } + private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array { $creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount); $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ - 'post_info' => $this->buildPostInfo($postPlatform, $content, $creatorInfo), + 'post_info' => $this->buildVideoPostInfo($postPlatform, $content, $creatorInfo), 'source_info' => [ 'source' => 'PULL_FROM_URL', 'video_url' => $media->url, @@ -186,11 +225,9 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st $creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount); - $postInfo = $this->buildPostInfo($postPlatform, $content, $creatorInfo); - // Photos don't support duet/stitch/is_aigc - unset($postInfo['disable_duet'], $postInfo['disable_stitch'], $postInfo['is_aigc']); + $postInfo = $this->buildPhotoPostInfo($postPlatform, $content, $creatorInfo); - // Auto add music is only for photos + // Auto add music is only for photos. $meta = $postPlatform->meta ?? []; if (data_get($meta, 'auto_add_music', false)) { $postInfo['auto_add_music'] = true; diff --git a/tests/Unit/Services/Social/TikTokPublisherTest.php b/tests/Unit/Services/Social/TikTokPublisherTest.php index 0fb975ec..bf94f68b 100644 --- a/tests/Unit/Services/Social/TikTokPublisherTest.php +++ b/tests/Unit/Services/Social/TikTokPublisherTest.php @@ -114,7 +114,13 @@ expect($result['id'])->toBe('pub_photo_123'); Http::assertSent(function ($request) { - return str_contains($request->url(), '/post/publish/content/init/'); + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + $body = json_decode($request->body(), true); + + return data_get($body, 'post_info.description') === 'Check out this TikTok video!' + && ! isset($body['post_info']['title']); }); }); @@ -481,7 +487,9 @@ return $postInfo['auto_add_music'] === true && ! isset($postInfo['disable_duet']) && ! isset($postInfo['disable_stitch']) - && ! isset($postInfo['is_aigc']); + && ! isset($postInfo['is_aigc']) + && ! isset($postInfo['title']) + && isset($postInfo['description']); }); }); @@ -577,3 +585,45 @@ && ! isset($postInfo['brand_content_toggle']); }); }); + +test('tiktok publisher sends video caption in title field, never description', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ], + ], + 'content' => 'My video caption', + ]); + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + + Http::fake([ + 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + 'data' => [ + 'privacy_level_options' => ['SELF_ONLY'], + ], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_video_123'], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_video_123'], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/video/init/')) { + return false; + } + $body = json_decode($request->body(), true); + + return data_get($body, 'post_info.title') === 'My video caption' + && ! isset($body['post_info']['description']); + }); +});