trypost/app/Services/Social/TikTokPublisher.php
Paulo Castellano 4546425532
Resume in-flight Instagram and TikTok publishes without duplicates (#281)
* Improve asynchronous social publishing reliability

* fix: resume asynchronous social publishes

* fix: preserve publish checkpoints across retries

* fix: harden resumable publish lifecycle

* fix: clean retry resources on terminal failures

* test: cover resumable social publishing edge cases

* feat: add failed post retry command

* chore: remove retry command ai rule

* fix: require confirmation for post retries

* chore: remove ai rules index

* chore: remove ai social rule

* refactor: clarify TikTok derivative path validation

* refactor: simplify social publishing retries

* refactor: further simplify social publishing retries

* refactor: retry all failed post platforms

* style: import throwable in social retries

* refactor: decouple TikTok cleanup from image format

* refactor: extract missing publish scopes

* refactor: encapsulate missing scope failure

* fix: resume failed publishes and treat Instagram rate limits as transient

Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not
start a duplicate remote post. Classify Meta BUC 400s on Instagram status
polls as retryable via GraphError.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover resume paths and transient Instagram rate limits

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resume posts:retry only for in-flight publish failures

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resume posts:retry via ErrorCategory instead of string lists

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: handle Instagram PUBLISHED and EXPIRED container statuses

Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: recover published Instagram stories from /stories

Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover Instagram EXPIRED retry and published recovery paths

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: stop guessing Instagram media ids from recent /media

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: checkpoint TikTok publish_id and keep in-flight photo derivatives

Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover remaining TikTok in-flight derivative edge cases

Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: map TikTok publish statuses with an official enum

Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: share in-flight publish checkpoint keys

Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: retry Instagram media_publish after transient Graph failures

A 500/code 2 after Meta already published left the job Failed as unknown.
Treat that as still-processing so resume can confirm PUBLISHED instead of posting again.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resume Instagram publish after dropped Graph connections

A timeout or connection reset after Meta already published was marked unknown.
Treat it as still-processing so resume can confirm PUBLISHED instead of posting again.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:34:53 -03:00

498 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\SocialAccount\Platform;
use App\Enums\TikTok\PublishStatus;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use App\Support\Social\PublishCheckpoint;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class TikTokPublisher
{
use HasSocialHttpClient;
private const int STATUS_RETRY_DELAY_SECONDS = 30;
private const int STATUS_MAX_RETRIES = 120;
private string $baseUrl;
private string $accessToken;
public function __construct()
{
$this->baseUrl = config('trypost.platforms.tiktok.api');
}
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
$pendingPublishId = PublishCheckpoint::tiktokPublishId($postPlatform->error_context);
if ($pendingPublishId !== null) {
return $this->completePublishWithCleanup(
$postPlatform,
$pendingPublishId,
PublishCheckpoint::tiktokDerivativePaths($postPlatform->error_context),
);
}
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new TikTokPublishException(
userMessage: 'TikTok requires media (video or photos) to publish.',
category: ErrorCategory::MediaFormat,
);
}
$firstMedia = $media->first();
$isVideo = $firstMedia->isVideo();
$isImage = $firstMedia->isImage();
if ($isVideo) {
return $this->publishVideo($postPlatform, $firstMedia, $content);
}
if ($isImage) {
return $this->publishPhotos($postPlatform, $media, $content);
}
throw new TikTokPublishException(
userMessage: 'TikTok only supports video or image content.',
category: ErrorCategory::MediaFormat,
);
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
/**
* Resolve the user-selected privacy_level from meta, throwing when missing.
* TikTok UX Guideline Point 2b forbids any default — the user must pick
* explicitly. The FormRequest validates this upstream; this is the safety
* net for queue/job paths that bypass the request layer.
*/
private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): string
{
$privacyLevel = data_get($postPlatform->meta ?? [], 'privacy_level');
if (blank($privacyLevel)) {
throw new TikTokPublishException(
userMessage: 'TikTok privacy level is required. Please open the post and pick a visibility option.',
category: ErrorCategory::ContentPolicy,
);
}
return (string) $privacyLevel;
}
/**
* 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
{
$meta = $postPlatform->meta ?? [];
$postInfo = [
'title' => $content ?? '',
'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform),
'disable_duet' => ! data_get($meta, 'allow_duet', false),
'disable_comment' => ! data_get($meta, 'allow_comments', false),
'disable_stitch' => ! data_get($meta, 'allow_stitch', false),
];
if (data_get($meta, 'is_aigc', false)) {
$postInfo['is_aigc'] = 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;
}
/**
* 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
{
$meta = $postPlatform->meta ?? [];
$postInfo = [
'description' => $content ?? '',
'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform),
'disable_comment' => ! data_get($meta, 'allow_comments', false),
];
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
{
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/video/init/", [
'post_info' => $this->buildVideoPostInfo($postPlatform, $content),
'source_info' => [
'source' => 'PULL_FROM_URL',
'video_url' => $media->url,
],
]);
if ($response->failed()) {
Log::error('TikTok video publish failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$data = $response->json();
$publishId = $this->requirePublishId(data_get($data, 'data.publish_id'));
$this->rememberPublishId($postPlatform, $publishId);
return $this->completePublish($postPlatform, $publishId);
}
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array
{
$images = $mediaCollection->filter(fn ($m) => $m->isImage())->values();
if ($images->isEmpty()) {
throw new TikTokPublishException(
userMessage: 'No valid images found for TikTok photo post',
category: ErrorCategory::MediaFormat,
);
}
$derivatives = [];
try {
$photoUrls = [];
foreach ($images as $image) {
[$url, $derivativePath] = $this->resolvePhotoUrl($image);
$photoUrls[] = $url;
if ($derivativePath !== null) {
$derivatives[] = $derivativePath;
}
}
$postInfo = $this->buildPhotoPostInfo($postPlatform, $content);
// Auto add music is only for photos.
$meta = $postPlatform->meta ?? [];
if (data_get($meta, 'auto_add_music', false)) {
$postInfo['auto_add_music'] = true;
}
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/content/init/", [
'post_info' => $postInfo,
'source_info' => [
'source' => 'PULL_FROM_URL',
'photo_cover_index' => 0,
'photo_images' => $photoUrls,
],
'post_mode' => 'DIRECT_POST',
'media_type' => 'PHOTO',
]);
if ($response->failed()) {
Log::error('TikTok photo publish failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$publishId = $this->requirePublishId(data_get($response->json(), 'data.publish_id'));
$this->rememberPublishId($postPlatform, $publishId, $derivatives);
} catch (Throwable $e) {
app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives);
throw $e;
}
return $this->completePublishWithCleanup($postPlatform, $publishId, $derivatives);
}
/**
* Resolve the URL TikTok will PULL_FROM_URL for a single photo. TikTok rejects
* images wider than 1080px with picture_size_check_failed, and because the
* platform fetches the bytes from us we cannot optimize them in-flight like
* the upload-based publishers do. So an oversized image is rendered to a
* spec-compliant JPEG derivative hosted on our public disk and that URL is
* handed to TikTok instead. Images already within spec pass through untouched.
*
* @return array{0: string, 1: string|null} the URL to publish, and the
* storage path of any derivative
* created (null when passed through)
*/
private function resolvePhotoUrl(MediaItem $image): array
{
$maxWidth = app(MediaOptimizer::class)->maxWidthForPlatform(Platform::TikTok);
$width = $image->width();
if ($maxWidth !== null && $width !== null && $width <= $maxWidth) {
return [$image->url, null];
}
return $this->renderCompliantPhoto($image);
}
/**
* Download the image, resize it to TikTok's spec, and host the copy on the
* public disk so TikTok can pull it.
*
* @return array{0: string, 1: string} the derivative's public URL and its
* storage path (for later cleanup)
*/
private function renderCompliantPhoto(MediaItem $image): array
{
$tempInput = tempnam(sys_get_temp_dir(), 'tiktok_photo_');
try {
$download = Http::sink($tempInput)->timeout(120)->get($image->url);
if ($download->failed()) {
throw new TikTokPublishException(
userMessage: 'Failed to download image for TikTok resizing',
category: ErrorCategory::ServerError,
);
}
return $this->hostResizedPhoto($tempInput);
} finally {
@unlink($tempInput);
}
}
/**
* Resize the downloaded file to TikTok's spec and host the copy on the public
* disk. Decoder/storage failures are surfaced as a categorized publish
* exception instead of leaking as an uncategorized error.
*
* @return array{0: string, 1: string} the derivative's public URL and storage path
*/
private function hostResizedPhoto(string $tempInput): array
{
try {
$optimized = app(MediaOptimizer::class)->optimizeImage($tempInput, Platform::TikTok);
try {
$path = TikTokPhotoDerivativeCleaner::DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($optimized));
} finally {
@unlink($optimized);
}
return [Storage::url($path), $path];
} catch (Throwable $e) {
Log::error('TikTok photo resize/host failed', [
'exception' => $e->getMessage(),
]);
throw new TikTokPublishException(
userMessage: 'Failed to prepare image for TikTok.',
category: ErrorCategory::ServerError,
);
}
}
private function waitForPublishStatus(string $publishId): array
{
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/status/fetch/", [
'publish_id' => $publishId,
]);
if ($response->failed()) {
if ($response->status() !== 429 && ! $response->serverError()) {
$this->handleApiError($response);
}
throw $this->pendingPublishException($publishId, $response->status());
}
$data = $response->json();
$status = PublishStatus::tryFrom((string) data_get($data, 'data.status', ''));
return match ($status) {
PublishStatus::PublishComplete => data_get($data, 'data', []),
PublishStatus::Failed => throw TikTokPublishException::fromFailReason(
(string) data_get($data, 'data.fail_reason', 'Unknown error'),
json_encode($data),
),
default => throw $this->pendingPublishException($publishId),
};
}
private function requirePublishId(mixed $publishId): string
{
$resolved = is_string($publishId) && $publishId !== '' ? $publishId : null;
if ($resolved === null) {
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}
return $resolved;
}
/**
* Persist the publish_id before status polling so a crash after /init/
* can resume without creating a second publish.
*
* @param list<string> $derivatives
*/
private function rememberPublishId(PostPlatform $postPlatform, string $publishId, array $derivatives = []): void
{
$context = [
...($postPlatform->error_context ?? []),
PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId,
];
if ($derivatives !== []) {
$context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives;
}
$postPlatform->update([
'error_context' => $context,
]);
}
private function pendingPublishException(string $publishId, ?int $httpStatus = null): PlatformUnavailableException
{
return new PlatformUnavailableException(
message: "TikTok is still processing publish_id {$publishId}",
httpStatus: $httpStatus,
context: [PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId],
retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS,
maxRetries: self::STATUS_MAX_RETRIES,
);
}
/**
* Finish an in-flight publish and prune hosted photos only when TikTok
* confirmed the attempt is dead, or when it completed. Resumable
* interruptions (still processing, expired token, unexpected crash)
* must keep the files so a later status poll can still PULL_FROM_URL.
*
* @param array<array-key, mixed> $derivatives
* @return array<string, mixed>
*/
private function completePublishWithCleanup(PostPlatform $postPlatform, string $publishId, array $derivatives): array
{
$retainDerivatives = true;
try {
$result = $this->completePublish($postPlatform, $publishId);
$retainDerivatives = false;
return $result;
} catch (PlatformUnavailableException $e) {
$e->context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives;
throw $e;
} catch (TikTokPublishException $e) {
$retainDerivatives = false;
throw $e;
} finally {
if (! $retainDerivatives) {
app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives);
}
}
}
private function completePublish(PostPlatform $postPlatform, string $publishId): array
{
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
$postId = is_string($postId) && $postId !== '' ? $postId : null;
return [
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
}
private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): ?string
{
$username = $account->username;
if ($username && $postId) {
return "https://www.tiktok.com/@{$username}/video/{$postId}";
}
if ($username) {
return "https://www.tiktok.com/@{$username}";
}
return null;
}
private function handleApiError(Response $response): never
{
throw TikTokPublishException::fromApiResponse($response);
}
}