2026-01-15 17:24:39 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
namespace App\Services\Social;
|
|
|
|
|
|
2026-07-11 12:25:07 +00:00
|
|
|
use App\Enums\SocialAccount\Platform;
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
use App\Exceptions\Social\ErrorCategory;
|
2026-08-13 15:18:19 +00:00
|
|
|
use App\Exceptions\Social\ThreadsMediaContainerNotFoundException;
|
2026-04-01 00:04:39 +00:00
|
|
|
use App\Exceptions\Social\ThreadsPublishException;
|
2026-01-15 17:24:39 +00:00
|
|
|
use App\Models\PostPlatform;
|
2026-04-01 13:51:53 +00:00
|
|
|
use App\Services\Social\Concerns\HasSocialHttpClient;
|
2026-01-17 02:46:30 +00:00
|
|
|
use Illuminate\Http\Client\Response;
|
2026-01-15 17:24:39 +00:00
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-08-13 15:18:19 +00:00
|
|
|
use Illuminate\Support\Sleep;
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
class ThreadsPublisher
|
|
|
|
|
{
|
2026-04-01 13:51:53 +00:00
|
|
|
use HasSocialHttpClient;
|
|
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
private const int MEDIA_PUBLICATION_MAX_ATTEMPTS = 3;
|
|
|
|
|
|
|
|
|
|
private const int MEDIA_PROCESSING_POLL_SECONDS = 3;
|
|
|
|
|
|
|
|
|
|
private const int MEDIA_READY_GRACE_SECONDS = 2;
|
|
|
|
|
|
2026-05-02 16:49:20 +00:00
|
|
|
private string $baseUrl;
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$this->baseUrl = config('trypost.platforms.threads.graph_api');
|
|
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
public function publish(PostPlatform $postPlatform): array
|
|
|
|
|
{
|
2026-04-01 14:25:25 +00:00
|
|
|
$this->validateContentLength($postPlatform);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
|
2026-04-01 14:25:25 +00:00
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
$account = $postPlatform->socialAccount;
|
|
|
|
|
|
2026-07-03 12:18:45 +00:00
|
|
|
if ($account->needsProactiveTokenRefresh()) {
|
2026-05-19 12:18:58 +00:00
|
|
|
app(ConnectionVerifier::class)->refreshToken($account);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$userId = $account->platform_user_id;
|
|
|
|
|
$accessToken = $account->access_token;
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$media = $postPlatform->post->mediaItems;
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
// Text only post
|
|
|
|
|
if ($media->isEmpty()) {
|
2026-04-01 14:25:25 +00:00
|
|
|
if (empty($content)) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads text posts require content. Please add text to your post.',
|
|
|
|
|
category: ErrorCategory::MediaFormat,
|
|
|
|
|
);
|
2026-03-29 20:04:01 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 14:25:25 +00:00
|
|
|
return $this->publishTextPost($userId, $accessToken, $content);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$firstMedia = $media->first();
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
$isVideo = $firstMedia->isVideo();
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
// Single media
|
|
|
|
|
if ($media->count() === 1) {
|
|
|
|
|
if ($isVideo) {
|
2026-08-13 15:18:19 +00:00
|
|
|
return $this->publishMediaWithRetry(
|
|
|
|
|
fn (): array => $this->publishVideoPost($userId, $accessToken, $content, $firstMedia),
|
|
|
|
|
);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
return $this->publishMediaWithRetry(
|
|
|
|
|
fn (): array => $this->publishImagePost($userId, $accessToken, $content, $firstMedia),
|
|
|
|
|
);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Multiple media - carousel
|
2026-08-13 15:18:19 +00:00
|
|
|
return $this->publishMediaWithRetry(
|
|
|
|
|
fn (): array => $this->publishCarousel($userId, $accessToken, $content, $media),
|
|
|
|
|
);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function publishTextPost(string $userId, string $accessToken, string $content): array
|
|
|
|
|
{
|
|
|
|
|
// Step 1: Create container
|
2026-04-01 13:51:53 +00:00
|
|
|
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
|
2026-01-15 17:24:39 +00:00
|
|
|
'media_type' => 'TEXT',
|
|
|
|
|
'text' => $content,
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($containerResponse->failed()) {
|
|
|
|
|
Log::error('Threads container creation failed', [
|
|
|
|
|
'status' => $containerResponse->status(),
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($containerResponse->body()),
|
2026-01-15 17:24:39 +00:00
|
|
|
]);
|
2026-04-01 00:43:30 +00:00
|
|
|
$this->handleApiError($containerResponse);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$containerId = $containerResponse->json()['id'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (! $containerId) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads did not accept the post. Please try again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-04-01 13:51:53 +00:00
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
// Step 2: Publish
|
|
|
|
|
return $this->publishContainer($userId, $accessToken, $containerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array
|
2026-01-15 17:24:39 +00:00
|
|
|
{
|
|
|
|
|
// Step 1: Create container
|
2026-07-11 12:25:07 +00:00
|
|
|
$params = [
|
2026-01-15 17:24:39 +00:00
|
|
|
'media_type' => 'IMAGE',
|
|
|
|
|
'image_url' => $media->url,
|
|
|
|
|
'text' => $content,
|
|
|
|
|
'access_token' => $accessToken,
|
2026-07-11 12:25:07 +00:00
|
|
|
];
|
|
|
|
|
|
2026-07-11 13:34:57 +00:00
|
|
|
$alt = $media->altTextFor(Platform::Threads);
|
2026-07-11 13:02:50 +00:00
|
|
|
|
|
|
|
|
if ($alt !== null) {
|
2026-07-11 12:25:07 +00:00
|
|
|
$params['alt_text'] = $alt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
if ($containerResponse->failed()) {
|
|
|
|
|
Log::error('Threads image container creation failed', [
|
|
|
|
|
'status' => $containerResponse->status(),
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($containerResponse->body()),
|
2026-01-15 17:24:39 +00:00
|
|
|
]);
|
2026-04-01 00:43:30 +00:00
|
|
|
$this->handleApiError($containerResponse);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$containerId = $containerResponse->json()['id'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (! $containerId) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads did not accept the image. Please try again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-04-01 13:51:53 +00:00
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
2026-01-16 15:36:52 +00:00
|
|
|
// Step 2: Wait for image processing
|
|
|
|
|
$this->waitForMediaProcessing($containerId, $accessToken);
|
|
|
|
|
|
|
|
|
|
// Step 3: Publish
|
2026-01-15 17:24:39 +00:00
|
|
|
return $this->publishContainer($userId, $accessToken, $containerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
private function publishVideoPost(string $userId, string $accessToken, ?string $content, $media): array
|
2026-01-15 17:24:39 +00:00
|
|
|
{
|
|
|
|
|
// Step 1: Create container
|
2026-04-01 13:51:53 +00:00
|
|
|
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
|
2026-01-15 17:24:39 +00:00
|
|
|
'media_type' => 'VIDEO',
|
|
|
|
|
'video_url' => $media->url,
|
|
|
|
|
'text' => $content,
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($containerResponse->failed()) {
|
|
|
|
|
Log::error('Threads video container creation failed', [
|
|
|
|
|
'status' => $containerResponse->status(),
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($containerResponse->body()),
|
2026-01-15 17:24:39 +00:00
|
|
|
]);
|
2026-04-01 00:43:30 +00:00
|
|
|
$this->handleApiError($containerResponse);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$containerId = $containerResponse->json()['id'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (! $containerId) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads did not accept the video. Please try again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-04-01 13:51:53 +00:00
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
// Wait for video processing
|
|
|
|
|
$this->waitForMediaProcessing($containerId, $accessToken);
|
|
|
|
|
|
|
|
|
|
// Step 2: Publish
|
|
|
|
|
return $this->publishContainer($userId, $accessToken, $containerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
private function publishCarousel(string $userId, string $accessToken, ?string $content, $mediaCollection): array
|
2026-01-15 17:24:39 +00:00
|
|
|
{
|
|
|
|
|
// Step 1: Create containers for each media item
|
|
|
|
|
$childContainers = [];
|
|
|
|
|
|
|
|
|
|
foreach ($mediaCollection as $media) {
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
$isVideo = $media->isVideo();
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
$params = [
|
|
|
|
|
'is_carousel_item' => 'true',
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if ($isVideo) {
|
|
|
|
|
$params['media_type'] = 'VIDEO';
|
|
|
|
|
$params['video_url'] = $media->url;
|
|
|
|
|
} else {
|
|
|
|
|
$params['media_type'] = 'IMAGE';
|
|
|
|
|
$params['image_url'] = $media->url;
|
2026-07-11 12:25:07 +00:00
|
|
|
|
2026-07-11 13:34:57 +00:00
|
|
|
$alt = $media->altTextFor(Platform::Threads);
|
2026-07-11 13:02:50 +00:00
|
|
|
|
|
|
|
|
if ($alt !== null) {
|
2026-07-11 12:25:07 +00:00
|
|
|
$params['alt_text'] = $alt;
|
|
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
if ($containerResponse->failed()) {
|
|
|
|
|
Log::error('Threads carousel item creation failed', [
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($containerResponse->body()),
|
2026-01-15 17:24:39 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$childId = $containerResponse->json()['id'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (! $childId) {
|
|
|
|
|
Log::error('Threads carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]);
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
2026-01-16 15:36:52 +00:00
|
|
|
// Wait for media processing (both images and videos)
|
|
|
|
|
$this->waitForMediaProcessing($childId, $accessToken);
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
$childContainers[] = $childId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($childContainers)) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Failed to create any carousel items',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: Create carousel container
|
2026-04-01 13:51:53 +00:00
|
|
|
$carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
|
2026-01-15 17:24:39 +00:00
|
|
|
'media_type' => 'CAROUSEL',
|
|
|
|
|
'text' => $content,
|
|
|
|
|
'children' => implode(',', $childContainers),
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($carouselResponse->failed()) {
|
|
|
|
|
Log::error('Threads carousel container creation failed', [
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($carouselResponse->body()),
|
2026-01-15 17:24:39 +00:00
|
|
|
]);
|
2026-04-01 00:43:30 +00:00
|
|
|
$this->handleApiError($carouselResponse);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$carouselId = $carouselResponse->json()['id'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (! $carouselId) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads did not accept the carousel. Please try again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-04-01 13:51:53 +00:00
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
$this->waitForMediaProcessing($carouselId, $accessToken);
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
// Step 3: Publish carousel
|
|
|
|
|
return $this->publishContainer($userId, $accessToken, $carouselId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
/**
|
|
|
|
|
* @param callable(): array{id: string, url: ?string} $publish
|
|
|
|
|
* @return array{id: string, url: ?string}
|
|
|
|
|
*/
|
|
|
|
|
private function publishMediaWithRetry(callable $publish): array
|
|
|
|
|
{
|
|
|
|
|
for ($attempt = 1; $attempt <= self::MEDIA_PUBLICATION_MAX_ATTEMPTS; $attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
return $publish();
|
|
|
|
|
} catch (ThreadsMediaContainerNotFoundException $exception) {
|
|
|
|
|
if ($attempt === self::MEDIA_PUBLICATION_MAX_ATTEMPTS) {
|
|
|
|
|
throw $exception;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Log::warning('Threads media container was not found; recreating publication flow', [
|
|
|
|
|
'attempt' => $attempt,
|
|
|
|
|
'max_attempts' => self::MEDIA_PUBLICATION_MAX_ATTEMPTS,
|
|
|
|
|
...$exception->context(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads did not accept the post. Please publish again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
private function publishContainer(string $userId, string $accessToken, string $containerId): array
|
|
|
|
|
{
|
2026-04-01 13:51:53 +00:00
|
|
|
$publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads_publish", [
|
2026-01-15 17:24:39 +00:00
|
|
|
'creation_id' => $containerId,
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($publishResponse->failed()) {
|
2026-08-13 15:18:19 +00:00
|
|
|
if (ThreadsMediaContainerNotFoundException::matches($publishResponse)) {
|
|
|
|
|
throw ThreadsMediaContainerNotFoundException::fromApiResponse($publishResponse);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
Log::error('Threads publish failed', [
|
|
|
|
|
'status' => $publishResponse->status(),
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($publishResponse->body()),
|
2026-01-15 17:24:39 +00:00
|
|
|
]);
|
2026-08-13 15:18:19 +00:00
|
|
|
|
2026-04-01 00:43:30 +00:00
|
|
|
$this->handleApiError($publishResponse);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:51:53 +00:00
|
|
|
$mediaId = $publishResponse->json()['id'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (! $mediaId) {
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads did not accept the post. Please publish again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-04-01 13:51:53 +00:00
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
// Get permalink
|
2026-04-01 13:51:53 +00:00
|
|
|
$permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [
|
2026-01-15 17:24:39 +00:00
|
|
|
'fields' => 'permalink',
|
|
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$permalink = $permalinkResponse->json()['permalink'] ?? null;
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'id' => $mediaId,
|
|
|
|
|
'url' => $permalink,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
|
|
|
|
|
{
|
|
|
|
|
for ($i = 0; $i < $maxAttempts; $i++) {
|
2026-04-01 13:51:53 +00:00
|
|
|
$statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [
|
2026-01-16 15:36:52 +00:00
|
|
|
'fields' => 'status,error_message',
|
2026-01-15 17:24:39 +00:00
|
|
|
'access_token' => $accessToken,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($statusResponse->failed()) {
|
2026-01-16 15:36:52 +00:00
|
|
|
Log::warning('Threads status check failed', [
|
|
|
|
|
'container_id' => $containerId,
|
|
|
|
|
'attempt' => $i,
|
2026-04-01 13:51:53 +00:00
|
|
|
'body' => $this->redactResponseBody($statusResponse->body()),
|
2026-01-16 15:36:52 +00:00
|
|
|
]);
|
2026-08-13 15:18:19 +00:00
|
|
|
Sleep::for(self::MEDIA_PROCESSING_POLL_SECONDS)->seconds();
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 15:36:52 +00:00
|
|
|
$data = $statusResponse->json();
|
2026-03-31 03:40:18 +00:00
|
|
|
$status = data_get($data, 'status', 'UNKNOWN');
|
2026-01-15 17:24:39 +00:00
|
|
|
|
|
|
|
|
if ($status === 'FINISHED') {
|
2026-08-13 15:18:19 +00:00
|
|
|
Sleep::for(self::MEDIA_READY_GRACE_SECONDS)->seconds();
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($status === 'ERROR') {
|
2026-03-31 03:40:18 +00:00
|
|
|
$errorMessage = data_get($data, 'error_message', 'Unknown error');
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads media processing failed. Please try a different file.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
Sleep::for(self::MEDIA_PROCESSING_POLL_SECONDS)->seconds();
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 15:36:52 +00:00
|
|
|
Log::warning('Threads media processing timeout', ['container_id' => $containerId]);
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
throw new ThreadsPublishException(
|
|
|
|
|
userMessage: 'Threads took too long to process the media. Please try again.',
|
|
|
|
|
category: ErrorCategory::ServerError,
|
|
|
|
|
);
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 00:43:30 +00:00
|
|
|
private function handleApiError(Response $response): never
|
2026-01-17 02:46:30 +00:00
|
|
|
{
|
2026-04-01 00:04:39 +00:00
|
|
|
throw ThreadsPublishException::fromApiResponse($response);
|
2026-01-17 02:46:30 +00:00
|
|
|
}
|
2026-01-15 17:24:39 +00:00
|
|
|
}
|