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).
This commit is contained in:
Paulo Castellano 2026-05-09 11:35:55 -03:00
parent facc02a30a
commit 9b42701a9d
2 changed files with 95 additions and 8 deletions

View file

@ -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<string, mixed>
*/
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<string, mixed>
*/
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;

View file

@ -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']);
});
});