feat: publishing engine improvements — rate limit retry, inline token refresh, per-platform queues, proactive refresh

- Add HasSocialHttpClient trait with 429 rate limit retry (3 attempts, 5s delay)
- Integrate trait into all 10 publishers (YouTube uses Google SDK)
- Add inline token refresh retry in PublishToSocialPlatform job
- Add per-platform Horizon queues via Platform::queue() and Platform::allQueues()
- Add RefreshExpiringTokens hourly command for proactive token refresh
- Fix token leaks: redact response bodies in all Log::error calls
- Fix token leaks: remove $response->body() from exception messages
- Fix ConnectionVerifier: redact all refresh error logs
- Fix null checks on API response IDs (Instagram, Threads, Pinterest, Facebook)
- Fix PublishPost::failed() to mark post as failed
- Fix StoreChunkedMediaRequest: validate max 1GB total size
- Fix scheduled_at validation: string → date
- Fix StoreMediaRequest: images max 10MB, videos max 1GB, only MP4 video
This commit is contained in:
Paulo Castellano 2026-04-01 10:51:53 -03:00
parent 6a26707dc8
commit c48c774e23
30 changed files with 713 additions and 166 deletions

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 2 hours';
public function handle(): void
{
$count = 0;
SocialAccount::query()
->where('status', Status::Connected)
->whereNotNull('token_expires_at')
->where('token_expires_at', '<=', now()->addHours(2))
->where('token_expires_at', '>', now())
->chunk(50, function ($accounts) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);
$count++;
}
});
$this->info("Dispatched {$count} token refresh jobs.");
}
}

View file

@ -117,6 +117,19 @@ public function supportsTextOnly(): bool
};
}
public function queue(): string
{
return 'social-'.$this->value;
}
/**
* @return array<string>
*/
public static function allQueues(): array
{
return array_map(fn (self $platform) => $platform->queue(), self::cases());
}
public function isEnabled(): bool
{
return config("trypost.platforms.{$this->value}.enabled", true);

View file

@ -44,6 +44,21 @@ public function rules(): array
];
}
public function after(): array
{
return [
function ($validator) {
if ($validator->errors()->has('content_range')) {
return;
}
if ($this->totalSize() > 1073741824) { // 1GB
$validator->errors()->add('content_range', 'File size exceeds the maximum allowed (1GB).');
}
},
];
}
public function rangeStart(): int
{
return (int) $this->parsedRange()[1];

View file

@ -21,7 +21,7 @@ public function rules(): array
return [
'status' => ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))],
'synced' => ['required', 'boolean'],
'scheduled_at' => ['sometimes', 'nullable', 'string'],
'scheduled_at' => ['sometimes', 'nullable', 'date'],
'platforms' => ['required', 'array'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))],
'platforms.*.content' => ['nullable', 'string', 'max:63206'],

View file

@ -28,11 +28,13 @@ public function handle(): void
}
}
public function failed(\Throwable $exception): void
public function failed(?\Throwable $exception): void
{
Log::error('PublishPost job failed', [
'post_id' => $this->post->id,
'error' => $exception->getMessage(),
'error' => $exception?->getMessage(),
]);
$this->post->markAsFailed();
}
}

View file

@ -16,6 +16,7 @@
use App\Models\Post;
use App\Models\PostPlatform;
use App\Services\Social\BlueskyPublisher;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\FacebookPublisher;
use App\Services\Social\InstagramPublisher;
use App\Services\Social\LinkedInPagePublisher;
@ -38,7 +39,10 @@ class PublishToSocialPlatform implements ShouldQueue
public int $timeout = 600; // 10 minutes — large video uploads need time
public function __construct(public PostPlatform $postPlatform) {}
public function __construct(public PostPlatform $postPlatform)
{
$this->onQueue($postPlatform->platform->queue());
}
public function handle(): void
{
@ -68,33 +72,53 @@ public function handle(): void
$this->postPlatform->markAsPublishing();
$this->broadcastStatus();
try {
$publisher = $this->getPublisher();
$result = $publisher->publish($this->postPlatform);
$maxAttempts = 2; // Original attempt + 1 retry after token refresh
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
} catch (TokenExpiredException $e) {
Log::error('Token expired while publishing to social platform', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
'platform_error_code' => $e->platformErrorCode,
]);
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
$publisher = $this->getPublisher();
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
break;
} catch (TokenExpiredException $e) {
if ($attempt < $maxAttempts) {
try {
$this->refreshAccountToken();
$this->postPlatform->markAsFailed($e->getMessage());
$this->postPlatform->socialAccount->markAsDisconnected($e->getMessage());
} catch (SocialPublishException $e) {
Log::error('Social publish failed: '.$e->userMessage);
continue;
} catch (\Throwable $refreshError) {
Log::error('Token refresh failed during publish retry', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $refreshError->getMessage(),
]);
}
}
$this->postPlatform->markAsFailed($e->userMessage);
} catch (\Throwable $e) {
Log::error('Failed to publish to social platform', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
]);
// All attempts exhausted or refresh failed
Log::error('Token expired while publishing to social platform', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
'platform_error_code' => $e->platformErrorCode,
]);
$this->postPlatform->markAsFailed($e->getMessage());
$this->postPlatform->markAsFailed($e->getMessage());
$this->postPlatform->socialAccount->markAsDisconnected($e->getMessage());
break;
} catch (SocialPublishException $e) {
Log::error('Social publish failed: '.$e->userMessage);
$this->postPlatform->markAsFailed($e->userMessage);
break;
} catch (\Throwable $e) {
Log::error('Failed to publish to social platform', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
]);
$this->postPlatform->markAsFailed($e->getMessage());
break;
}
}
// Always check and update post status after each platform finishes
@ -104,6 +128,14 @@ public function handle(): void
$this->broadcastStatus();
}
private function refreshAccountToken(): void
{
$account = $this->postPlatform->socialAccount;
// Delegate to ConnectionVerifier which already has per-platform refresh logic
app(ConnectionVerifier::class)->verify($account);
}
private function broadcastStatus(): void
{
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class RefreshSocialToken implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public SocialAccount $account) {}
public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->verify($this->account);
} catch (\Throwable $e) {
Log::warning('Proactive token refresh failed', [
'account_id' => $this->account->id,
'platform' => $this->account->platform->value,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -10,12 +10,15 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BlueskyPublisher
{
use HasSocialHttpClient;
public function publish(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
@ -72,7 +75,7 @@ public function publish(PostPlatform $postPlatform): array
$record['facets'] = $facets;
}
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->post("{$service}/xrpc/com.atproto.repo.createRecord", [
'repo' => $account->platform_user_id,
'collection' => 'app.bsky.feed.post',
@ -82,7 +85,7 @@ public function publish(PostPlatform $postPlatform): array
if ($response->failed()) {
Log::error('Bluesky post failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
@ -130,7 +133,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
$stream = fopen($tempFile, 'r');
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->withHeaders(['Content-Type' => $mimeType])
->withBody($stream, $mimeType)
->post("{$service}/xrpc/com.atproto.repo.uploadBlob");
@ -142,7 +145,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
if ($response->failed()) {
Log::error('Bluesky blob upload failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
return null;
@ -266,7 +269,7 @@ public function refreshToken(SocialAccount $account): void
$service = $account->meta['service'] ?? 'https://bsky.social';
// Try refresh first
$response = Http::withToken($account->refresh_token)
$response = $this->socialHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
if ($response->successful()) {
@ -282,7 +285,6 @@ public function refreshToken(SocialAccount $account): void
Log::warning('Bluesky refresh token failed, trying re-authentication', [
'status' => $response->status(),
'body' => $response->body(),
]);
// If refresh fails, re-authenticate with stored credentials

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Concerns;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
trait HasSocialHttpClient
{
protected function socialHttp(): PendingRequest
{
return Http::retry(
times: 3,
sleepMilliseconds: 5000,
when: fn ($exception, $request) => $exception->response?->status() === 429,
throw: false,
)->timeout(120);
}
protected function redactResponseBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -75,7 +75,7 @@ private function refreshLinkedInToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh LinkedIn token');
}
@ -107,7 +107,7 @@ private function refreshXToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh X token');
}
@ -190,7 +190,7 @@ private function refreshYouTubeToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
@ -218,7 +218,7 @@ private function refreshTikTokToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh TikTok token');
}
@ -250,7 +250,7 @@ private function refreshPinterestToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Pinterest token');
}
@ -274,7 +274,7 @@ private function refreshThreadsToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Threads token');
}
@ -298,7 +298,7 @@ private function refreshInstagramToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $response->body()]);
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
@ -502,4 +502,23 @@ private function verifyMastodon(SocialAccount $account): bool
return $response->successful();
}
private function redactBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -7,12 +7,15 @@
use App\Enums\PostPlatform\ContentType;
use App\Exceptions\Social\FacebookPublishException;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FacebookPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.facebook.com/v24.0';
public function publish(PostPlatform $postPlatform): array
@ -65,7 +68,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
private function publishTextPost(string $pageId, string $accessToken, string $content): array
{
$response = Http::post("{$this->baseUrl}/{$pageId}/feed", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", [
'message' => $content,
'access_token' => $accessToken,
]);
@ -73,7 +76,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
if ($response->failed()) {
Log::error('Facebook text post failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -89,7 +92,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array
{
$response = Http::post("{$this->baseUrl}/{$pageId}/photos", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'message' => $content,
'url' => $media->url,
'access_token' => $accessToken,
@ -98,7 +101,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
if ($response->failed()) {
Log::error('Facebook single image post failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -122,7 +125,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
continue;
}
$uploadResponse = Http::post("{$this->baseUrl}/{$pageId}/photos", [
$uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'url' => $media->url,
'published' => 'false',
'access_token' => $accessToken,
@ -130,7 +133,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
if ($uploadResponse->failed()) {
Log::error('Facebook image upload failed', [
'body' => $uploadResponse->body(),
'body' => $this->redactResponseBody($uploadResponse->body()),
]);
continue;
@ -154,12 +157,12 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
$postData["attached_media[{$index}]"] = json_encode($media);
}
$response = Http::post("{$this->baseUrl}/{$pageId}/feed", $postData);
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData);
if ($response->failed()) {
Log::error('Facebook multi-image post failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -176,7 +179,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array
{
// Use resumable upload for videos
$response = Http::post("{$this->baseUrl}/{$pageId}/videos", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", [
'description' => $content,
'file_url' => $media->url,
'access_token' => $accessToken,
@ -185,7 +188,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $
if ($response->failed()) {
Log::error('Facebook video post failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -202,7 +205,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $
private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array
{
// Upload video as reel
$response = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
'upload_phase' => 'start',
'access_token' => $accessToken,
]);
@ -210,7 +213,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
if ($response->failed()) {
Log::error('Facebook reel upload start failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -241,7 +244,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
}
if ($uploadResponse->failed()) {
Log::error('Facebook reel upload transfer failed', ['body' => $uploadResponse->body()]);
Log::error('Facebook reel upload transfer failed', ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
} finally {
@ -249,7 +252,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
}
// Finish and publish the reel
$finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
'upload_phase' => 'finish',
'video_id' => $videoId,
'video_state' => 'PUBLISHED',
@ -259,7 +262,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
if ($finishResponse->failed()) {
Log::error('Facebook reel finish failed', [
'body' => $finishResponse->body(),
'body' => $this->redactResponseBody($finishResponse->body()),
]);
$this->handleApiError($finishResponse);
}
@ -279,7 +282,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
if ($isVideo) {
// Video story
$response = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'start',
'access_token' => $accessToken,
]);
@ -288,7 +291,11 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
$this->handleApiError($response);
}
$videoId = $response->json()['video_id'];
$videoId = $response->json()['video_id'] ?? null;
if (! $videoId) {
throw new \Exception('Facebook story upload failed: no video ID returned');
}
// Transfer the video
$tempFile = tempnam(sys_get_temp_dir(), 'fb_story_');
@ -313,7 +320,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
}
if ($transferResponse->failed()) {
Log::error('Facebook video story transfer failed', ['body' => $transferResponse->body()]);
Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]);
$this->handleApiError($transferResponse);
}
} finally {
@ -321,7 +328,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
}
// Finish the story
$finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'finish',
'video_id' => $videoId,
'access_token' => $accessToken,
@ -340,14 +347,14 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
}
// Image story
$response = Http::post("{$this->baseUrl}/{$pageId}/photo_stories", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [
'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media),
'access_token' => $accessToken,
]);
if ($response->failed()) {
Log::error('Facebook photo story failed', [
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -362,7 +369,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string
{
$response = Http::post("{$this->baseUrl}/{$pageId}/photos", [
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'url' => $media->url,
'published' => 'false',
'access_token' => $accessToken,

View file

@ -9,12 +9,15 @@
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class InstagramPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.instagram.com/v24.0';
public function publish(PostPlatform $postPlatform): array
@ -64,7 +67,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
'image_url' => $media->url,
'caption' => $content,
'access_token' => $accessToken,
@ -73,7 +76,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
if ($containerResponse->failed()) {
Log::error('Instagram container creation failed', [
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
@ -94,7 +97,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container for video/reel
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
'video_url' => $media->url,
'caption' => $content,
'media_type' => 'REELS',
@ -104,7 +107,7 @@ private function publishReel(string $instagramId, string $accessToken, ?string $
if ($containerResponse->failed()) {
Log::error('Instagram reel container creation failed', [
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
@ -138,12 +141,12 @@ private function publishStory(string $instagramId, string $accessToken, $media):
}
// Step 1: Create story container
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params);
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
if ($containerResponse->failed()) {
Log::error('Instagram story container creation failed', [
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
@ -181,11 +184,11 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$params['image_url'] = $media->url;
}
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params);
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
if ($containerResponse->failed()) {
Log::error('Instagram carousel item creation failed', [
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
continue;
@ -194,7 +197,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$childId = $containerResponse->json()['id'] ?? null;
if (! $childId) {
Log::error('Instagram carousel item creation returned no ID', ['body' => $containerResponse->body()]);
Log::error('Instagram carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]);
continue;
}
@ -212,7 +215,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
}
// Step 2: Create carousel container
$carouselResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
$carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
'media_type' => 'CAROUSEL',
'caption' => $content,
'children' => implode(',', $childContainers),
@ -221,7 +224,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
if ($carouselResponse->failed()) {
Log::error('Instagram carousel container creation failed', [
'body' => $carouselResponse->body(),
'body' => $this->redactResponseBody($carouselResponse->body()),
]);
$this->handleApiError($carouselResponse);
}
@ -241,7 +244,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
private function publishContainer(string $instagramId, string $accessToken, string $containerId): array
{
$publishResponse = Http::post("{$this->baseUrl}/{$instagramId}/media_publish", [
$publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [
'creation_id' => $containerId,
'access_token' => $accessToken,
]);
@ -249,15 +252,19 @@ private function publishContainer(string $instagramId, string $accessToken, stri
if ($publishResponse->failed()) {
Log::error('Instagram publish failed', [
'status' => $publishResponse->status(),
'body' => $publishResponse->body(),
'body' => $this->redactResponseBody($publishResponse->body()),
]);
$this->handleApiError($publishResponse);
}
$mediaId = $publishResponse->json()['id'];
$mediaId = $publishResponse->json()['id'] ?? null;
if (! $mediaId) {
throw new \Exception('Instagram publish failed: no media ID returned');
}
// Get permalink
$permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [
$permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [
'fields' => 'permalink',
'access_token' => $accessToken,
]);
@ -273,7 +280,7 @@ private function publishContainer(string $instagramId, string $accessToken, stri
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
{
for ($i = 0; $i < $maxAttempts; $i++) {
$statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [
$statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [
'fields' => 'status_code',
'access_token' => $accessToken,
]);
@ -308,9 +315,9 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('Instagram token refresh failed', ['body' => $response->body()]);
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token: '.$response->body());
throw new TokenExpiredException('Failed to refresh Instagram token');
}
$data = $response->json();

View file

@ -11,6 +11,7 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -18,6 +19,8 @@
class LinkedInPagePublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://api.linkedin.com';
private string $apiVersion = '202601';
@ -124,7 +127,7 @@ private function publishPost(string $organizationUrn, ?string $content, $media,
if ($response->failed()) {
Log::error('LinkedIn Page post creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -190,7 +193,7 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med
if ($response->failed()) {
Log::error('LinkedIn Page carousel post creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -211,13 +214,12 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med
private function getHttpClient(): PendingRequest
{
return Http::withToken($this->accessToken)
return $this->socialHttp()->withToken($this->accessToken)
->withHeaders([
'X-Restli-Protocol-Version' => '2.0.0',
'LinkedIn-Version' => $this->apiVersion,
'Content-Type' => 'application/json',
])
->timeout(300);
]);
}
private function uploadMedia($mediaItem, string $ownerUrn): ?string
@ -248,7 +250,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
]);
if ($initResponse->failed()) {
Log::error('LinkedIn Page image init failed', ['body' => $initResponse->body()]);
Log::error('LinkedIn Page image init failed', ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
@ -292,7 +294,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
}
if ($uploadResponse->failed()) {
Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]);
Log::error('LinkedIn Page image upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
@ -335,7 +337,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn):
]);
if ($initResponse->failed()) {
Log::error('LinkedIn Page video init failed', ['body' => $initResponse->body()]);
Log::error('LinkedIn Page video init failed', ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
@ -373,7 +375,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn):
if ($chunkResponse->failed()) {
Log::error('LinkedIn Page video chunk upload failed', [
'index' => $index,
'body' => $chunkResponse->body(),
'body' => $this->redactResponseBody($chunkResponse->body()),
]);
$this->handleApiError($chunkResponse);
}
@ -399,7 +401,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn):
]);
if ($finalizeResponse->failed()) {
Log::error('LinkedIn Page video finalize failed', ['body' => $finalizeResponse->body()]);
Log::error('LinkedIn Page video finalize failed', ['body' => $this->redactResponseBody($finalizeResponse->body())]);
$this->handleApiError($finalizeResponse);
}

View file

@ -11,6 +11,7 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -18,6 +19,8 @@
class LinkedInPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://api.linkedin.com';
private string $apiVersion = '202601';
@ -117,7 +120,7 @@ private function publishPost(string $personUrn, ?string $content, $media): array
if ($response->failed()) {
Log::error('LinkedIn post creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -177,7 +180,7 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl
if ($response->failed()) {
Log::error('LinkedIn carousel post creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -192,13 +195,12 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl
private function getHttpClient(): PendingRequest
{
return Http::withToken($this->accessToken)
return $this->socialHttp()->withToken($this->accessToken)
->withHeaders([
'X-Restli-Protocol-Version' => '2.0.0',
'LinkedIn-Version' => $this->apiVersion,
'Content-Type' => 'application/json',
])
->timeout(300);
]);
}
private function uploadMedia($mediaItem, string $ownerUrn): ?string
@ -229,7 +231,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
]);
if ($initResponse->failed()) {
Log::error('LinkedIn image init failed', ['body' => $initResponse->body()]);
Log::error('LinkedIn image init failed', ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
@ -273,7 +275,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
}
if ($uploadResponse->failed()) {
Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]);
Log::error('LinkedIn image upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
@ -316,7 +318,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn):
]);
if ($initResponse->failed()) {
Log::error('LinkedIn video init failed', ['body' => $initResponse->body()]);
Log::error('LinkedIn video init failed', ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
@ -354,7 +356,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn):
if ($chunkResponse->failed()) {
Log::error('LinkedIn video chunk upload failed', [
'index' => $index,
'body' => $chunkResponse->body(),
'body' => $this->redactResponseBody($chunkResponse->body()),
]);
$this->handleApiError($chunkResponse);
}
@ -380,7 +382,7 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn):
]);
if ($finalizeResponse->failed()) {
Log::error('LinkedIn video finalize failed', ['body' => $finalizeResponse->body()]);
Log::error('LinkedIn video finalize failed', ['body' => $this->redactResponseBody($finalizeResponse->body())]);
$this->handleApiError($finalizeResponse);
}

View file

@ -9,12 +9,15 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MastodonPublisher
{
use HasSocialHttpClient;
public function publish(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
@ -41,13 +44,13 @@ public function publish(PostPlatform $postPlatform): array
$payload['media_ids'] = $mediaIds;
}
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->post("{$instance}/api/v1/statuses", $payload);
if ($response->failed()) {
Log::error('Mastodon post failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -93,7 +96,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
$stream = fopen($tempFile, 'r');
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name)
->post("{$instance}/api/v1/media");
@ -104,7 +107,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
if ($response->failed()) {
Log::error('Mastodon media upload failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
return null;

View file

@ -10,12 +10,15 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PinterestPublisher
{
use HasSocialHttpClient;
private const API_BASE = 'https://api.pinterest.com/v5';
public function publish(PostPlatform $postPlatform): array
@ -98,13 +101,13 @@ private function publishImagePin(PostPlatform $postPlatform): array
$payload['alt_text'] = substr(data_get($postPlatform->meta, 'alt_text'), 0, 500);
}
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->post(self::API_BASE.'/pins', $payload);
if ($response->failed()) {
Log::error('Pinterest pin creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -133,7 +136,7 @@ private function publishVideoPin(PostPlatform $postPlatform): array
}
// Step 1: Register media upload
$registerResponse = Http::withToken($account->access_token)
$registerResponse = $this->socialHttp()->withToken($account->access_token)
->post(self::API_BASE.'/media', [
'media_type' => 'video',
]);
@ -141,13 +144,17 @@ private function publishVideoPin(PostPlatform $postPlatform): array
if ($registerResponse->failed()) {
Log::error('Pinterest media registration failed', [
'status' => $registerResponse->status(),
'body' => $registerResponse->body(),
'body' => $this->redactResponseBody($registerResponse->body()),
]);
$this->handleApiError($registerResponse);
}
$registerData = $registerResponse->json();
$mediaId = $registerData['media_id'];
$mediaId = $registerData['media_id'] ?? null;
if (! $mediaId) {
throw new \Exception('Pinterest media registration failed: no media ID returned');
}
// Step 2: Upload video to S3
$uploadParams = $registerData['upload_parameters'] ?? [];
@ -228,13 +235,13 @@ private function publishVideoPin(PostPlatform $postPlatform): array
$payload['media_source']['cover_image_url'] = data_get($postPlatform->meta, 'cover_image_url');
}
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->post(self::API_BASE.'/pins', $payload);
if ($response->failed()) {
Log::error('Pinterest video pin creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -286,13 +293,13 @@ private function publishCarousel(PostPlatform $postPlatform): array
$payload['link'] = data_get($postPlatform->meta, 'link');
}
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->post(self::API_BASE.'/pins', $payload);
if ($response->failed()) {
Log::error('Pinterest carousel creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -308,14 +315,14 @@ private function publishCarousel(PostPlatform $postPlatform): array
private function waitForMediaProcessing(SocialAccount $account, string $mediaId, int $maxAttempts = 30): void
{
for ($i = 0; $i < $maxAttempts; $i++) {
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->get(self::API_BASE."/media/{$mediaId}");
if ($response->failed()) {
Log::warning('Pinterest media status check failed', [
'media_id' => $mediaId,
'attempt' => $i,
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
sleep(3);
@ -353,7 +360,7 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('Pinterest token refresh failed', ['body' => $response->body()]);
Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
$this->handleApiError($response);
}
@ -376,13 +383,13 @@ public function getBoards(SocialAccount $account): array
$account->refresh();
}
$response = Http::withToken($account->access_token)
$response = $this->socialHttp()->withToken($account->access_token)
->get(self::API_BASE.'/boards', [
'page_size' => 100,
]);
if ($response->failed()) {
Log::error('Pinterest get boards failed', ['body' => $response->body()]);
Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]);
$this->handleApiError($response);
}

View file

@ -7,12 +7,15 @@
use App\Exceptions\Social\ThreadsPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.threads.net/v1.0';
public function publish(PostPlatform $postPlatform): array
@ -57,7 +60,7 @@ public function publish(PostPlatform $postPlatform): array
private function publishTextPost(string $userId, string $accessToken, string $content): array
{
// Step 1: Create container
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
'media_type' => 'TEXT',
'text' => $content,
'access_token' => $accessToken,
@ -66,12 +69,16 @@ private function publishTextPost(string $userId, string $accessToken, string $co
if ($containerResponse->failed()) {
Log::error('Threads container creation failed', [
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
$containerId = $containerResponse->json()['id'];
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Threads text container creation failed: no container ID returned');
}
// Step 2: Publish
return $this->publishContainer($userId, $accessToken, $containerId);
@ -80,7 +87,7 @@ private function publishTextPost(string $userId, string $accessToken, string $co
private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
'media_type' => 'IMAGE',
'image_url' => $media->url,
'text' => $content,
@ -90,12 +97,16 @@ private function publishImagePost(string $userId, string $accessToken, ?string $
if ($containerResponse->failed()) {
Log::error('Threads image container creation failed', [
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
$containerId = $containerResponse->json()['id'];
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Threads image container creation failed: no container ID returned');
}
// Step 2: Wait for image processing
$this->waitForMediaProcessing($containerId, $accessToken);
@ -107,7 +118,7 @@ private function publishImagePost(string $userId, string $accessToken, ?string $
private function publishVideoPost(string $userId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
'media_type' => 'VIDEO',
'video_url' => $media->url,
'text' => $content,
@ -117,12 +128,16 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $
if ($containerResponse->failed()) {
Log::error('Threads video container creation failed', [
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
$containerId = $containerResponse->json()['id'];
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Threads video container creation failed: no container ID returned');
}
// Wait for video processing
$this->waitForMediaProcessing($containerId, $accessToken);
@ -152,17 +167,23 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
$params['image_url'] = $media->url;
}
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", $params);
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
if ($containerResponse->failed()) {
Log::error('Threads carousel item creation failed', [
'body' => $containerResponse->body(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
continue;
}
$childId = $containerResponse->json()['id'];
$childId = $containerResponse->json()['id'] ?? null;
if (! $childId) {
Log::error('Threads carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]);
continue;
}
// Wait for media processing (both images and videos)
$this->waitForMediaProcessing($childId, $accessToken);
@ -175,7 +196,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
}
// Step 2: Create carousel container
$carouselResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
$carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
'media_type' => 'CAROUSEL',
'text' => $content,
'children' => implode(',', $childContainers),
@ -184,12 +205,16 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
if ($carouselResponse->failed()) {
Log::error('Threads carousel container creation failed', [
'body' => $carouselResponse->body(),
'body' => $this->redactResponseBody($carouselResponse->body()),
]);
$this->handleApiError($carouselResponse);
}
$carouselId = $carouselResponse->json()['id'];
$carouselId = $carouselResponse->json()['id'] ?? null;
if (! $carouselId) {
throw new \Exception('Threads carousel container creation failed: no container ID returned');
}
// Step 3: Publish carousel
return $this->publishContainer($userId, $accessToken, $carouselId);
@ -197,7 +222,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
private function publishContainer(string $userId, string $accessToken, string $containerId): array
{
$publishResponse = Http::post("{$this->baseUrl}/{$userId}/threads_publish", [
$publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads_publish", [
'creation_id' => $containerId,
'access_token' => $accessToken,
]);
@ -205,15 +230,19 @@ private function publishContainer(string $userId, string $accessToken, string $c
if ($publishResponse->failed()) {
Log::error('Threads publish failed', [
'status' => $publishResponse->status(),
'body' => $publishResponse->body(),
'body' => $this->redactResponseBody($publishResponse->body()),
]);
$this->handleApiError($publishResponse);
}
$mediaId = $publishResponse->json()['id'];
$mediaId = $publishResponse->json()['id'] ?? null;
if (! $mediaId) {
throw new \Exception('Threads publish failed: no media ID returned');
}
// Get permalink
$permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [
$permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [
'fields' => 'permalink',
'access_token' => $accessToken,
]);
@ -229,7 +258,7 @@ private function publishContainer(string $userId, string $accessToken, string $c
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
{
for ($i = 0; $i < $maxAttempts; $i++) {
$statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [
$statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [
'fields' => 'status,error_message',
'access_token' => $accessToken,
]);
@ -238,7 +267,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
Log::warning('Threads status check failed', [
'container_id' => $containerId,
'attempt' => $i,
'body' => $statusResponse->body(),
'body' => $this->redactResponseBody($statusResponse->body()),
]);
sleep(3);
@ -273,7 +302,7 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('Threads token refresh failed', ['body' => $response->body()]);
Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
$this->handleApiError($response);
}

View file

@ -8,6 +8,7 @@
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -15,6 +16,8 @@
class TikTokPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://open.tiktokapis.com/v2';
private string $accessToken;
@ -53,11 +56,10 @@ public function publish(PostPlatform $postPlatform): array
private function getHttpClient(): PendingRequest
{
return Http::withToken($this->accessToken)
return $this->socialHttp()->withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/json; charset=UTF-8',
])
->timeout(120);
]);
}
private function queryCreatorInfo(): array
@ -66,7 +68,7 @@ private function queryCreatorInfo(): array
->post("{$this->baseUrl}/post/publish/creator_info/query/");
if ($response->failed()) {
Log::warning('TikTok creator_info query failed', ['body' => $response->body()]);
Log::warning('TikTok creator_info query failed', ['body' => $this->redactResponseBody($response->body())]);
return ['privacy_level' => 'SELF_ONLY'];
}
@ -113,7 +115,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
if ($response->failed()) {
Log::error('TikTok video publish failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -168,7 +170,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
if ($response->failed()) {
Log::error('TikTok photo publish failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -203,7 +205,7 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20):
if ($response->failed()) {
Log::warning('TikTok status check failed', [
'attempt' => $i,
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
continue;
@ -254,7 +256,7 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $response->body()]);
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
$this->handleApiError($response);
}

View file

@ -10,6 +10,7 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -17,6 +18,8 @@
class XPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://api.x.com';
private string $accessToken;
@ -70,7 +73,7 @@ public function publish(PostPlatform $postPlatform): array
if ($response->failed()) {
Log::error('X post creation failed', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -86,12 +89,11 @@ public function publish(PostPlatform $postPlatform): array
private function getHttpClient(): PendingRequest
{
return Http::withToken($this->accessToken)
return $this->socialHttp()->withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])
->timeout(360);
]);
}
private function uploadMedia($mediaItem): ?array
@ -130,7 +132,7 @@ private function uploadMedia($mediaItem): ?array
}
// Simple upload for small images
$response = Http::withToken($this->accessToken)
$response = $this->socialHttp()->withToken($this->accessToken)
->timeout(360)
->attach(
'media',
@ -149,7 +151,7 @@ private function uploadMedia($mediaItem): ?array
if ($response->failed()) {
Log::error('X media upload error', [
'status' => $response->status(),
'body' => $response->body(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
@ -171,7 +173,7 @@ private function uploadMedia($mediaItem): ?array
private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array
{
// INIT
$initResponse = Http::withToken($this->accessToken)
$initResponse = $this->socialHttp()->withToken($this->accessToken)
->timeout(60)
->post("{$this->baseUrl}/2/media/upload/initialize", [
'media_type' => $mimeType,
@ -182,7 +184,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
if ($initResponse->failed()) {
Log::error('X chunked upload INIT error', [
'status' => $initResponse->status(),
'body' => $initResponse->body(),
'body' => $this->redactResponseBody($initResponse->body()),
]);
$this->handleApiError($initResponse);
}
@ -207,7 +209,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
break;
}
$appendResponse = Http::withToken($this->accessToken)
$appendResponse = $this->socialHttp()->withToken($this->accessToken)
->timeout(300)
->attach('media', $chunk, 'chunk'.$index)
->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [
@ -217,7 +219,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
if ($appendResponse->failed()) {
Log::error('X chunked upload APPEND error', [
'status' => $appendResponse->status(),
'body' => $appendResponse->body(),
'body' => $this->redactResponseBody($appendResponse->body()),
'segment' => $index,
]);
$this->handleApiError($appendResponse);
@ -230,14 +232,14 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
}
// FINALIZE - Use the new v2 endpoint
$finalizeResponse = Http::withToken($this->accessToken)
$finalizeResponse = $this->socialHttp()->withToken($this->accessToken)
->timeout(60)
->post("{$this->baseUrl}/2/media/upload/{$mediaId}/finalize");
if ($finalizeResponse->failed()) {
Log::error('X chunked upload FINALIZE error', [
'status' => $finalizeResponse->status(),
'body' => $finalizeResponse->body(),
'body' => $this->redactResponseBody($finalizeResponse->body()),
]);
$this->handleApiError($finalizeResponse);
}
@ -281,7 +283,7 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
->get("{$this->baseUrl}/2/media/{$mediaId}");
if ($response->failed()) {
Log::error('X media status check error: '.$response->body());
Log::error('X media status check error', ['body' => $this->redactResponseBody($response->body())]);
sleep(3);
continue;

View file

@ -8,6 +8,7 @@
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Google\Client as GoogleClient;
use Google\Service\YouTube;
use Google\Service\YouTube\Video;
@ -19,6 +20,8 @@
class YouTubePublisher
{
use HasSocialHttpClient;
private const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
public function publish(PostPlatform $postPlatform): array
@ -207,9 +210,9 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $response->body()]);
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token: '.$response->body());
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$data = $response->json();

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use Illuminate\Support\Str;
return [
@ -224,6 +225,21 @@
'timeout' => 630,
'nice' => 0,
],
'social-publishing' => [
'connection' => 'redis',
'queue' => Platform::allQueues(),
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 1,
'maxProcesses' => 3,
'timeout' => 630,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 256,
'tries' => 1,
'nice' => 0,
],
],
'environments' => [
@ -233,12 +249,22 @@
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
'social-publishing' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
'social-publishing' => [
'maxProcesses' => 3,
],
],
],
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,9 @@
use App\Console\Commands\CheckSocialConnections;
use App\Console\Commands\ProcessScheduledPosts;
use App\Console\Commands\RefreshExpiringTokens;
use Illuminate\Support\Facades\Schedule;
Schedule::command(ProcessScheduledPosts::class)->everyMinute();
Schedule::command(CheckSocialConnections::class)->daily();
Schedule::command(RefreshExpiringTokens::class)->hourly();

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Queue;
test('it dispatches refresh jobs for tokens expiring within 2 hours', function () {
Queue::fake();
$workspace = Workspace::factory()->create();
// Should be refreshed (expires in 1 hour)
$expiringSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::LinkedIn,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// Should NOT be refreshed (expires in 5 hours)
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHours(5),
]);
// Should NOT be refreshed (already expired)
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::TikTok,
'status' => Status::Connected,
'token_expires_at' => now()->subHour(),
]);
// Should NOT be refreshed (disconnected)
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Disconnected,
'token_expires_at' => now()->addHour(),
]);
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 1);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id);
});
test('it dispatches nothing when no tokens are expiring', function () {
Queue::fake();
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertNothingPushed();
});

View file

@ -15,6 +15,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
@ -259,6 +260,62 @@
Queue::assertPushed(SendNotification::class);
});
test('it retries with token refresh when token expires during publish', function () {
Event::fake();
$callCount = 0;
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')
->twice()
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount === 1) {
throw new TokenExpiredException('Token expired', '401');
}
return ['id' => 'post-123', 'url' => 'https://linkedin.com/post/123'];
});
$this->app->instance(LinkedInPublisher::class, $publisher);
$verifier = Mockery::mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
$this->app->instance(ConnectionVerifier::class, $verifier);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
expect($this->socialAccount->status)->not->toBe(AccountStatus::Disconnected);
});
test('it disconnects account when token refresh fails during publish retry', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')
->once()
->andThrow(new TokenExpiredException('Token expired', '401'));
$this->app->instance(LinkedInPublisher::class, $publisher);
$verifier = Mockery::mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new Exception('Refresh failed'));
$this->app->instance(ConnectionVerifier::class, $verifier);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->socialAccount->status)->toBe(AccountStatus::Disconnected);
});
test('publish to social platform skips if already published (idempotency)', function () {
Event::fake();

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->client = new class
{
use HasSocialHttpClient;
public function makeRequest(string $url): Response
{
return $this->socialHttp()->get($url);
}
};
});
it('retries on 429 responses', function () {
Http::fake([
'https://example.com/api' => Http::sequence()
->push('Rate limited', 429)
->push(['success' => true], 200),
]);
$response = $this->client->makeRequest('https://example.com/api');
expect($response->status())->toBe(200);
Http::assertSentCount(2);
});
it('does not retry on non-429 errors', function () {
Http::fake([
'https://example.com/api' => Http::response('Server error', 500),
]);
$response = $this->client->makeRequest('https://example.com/api');
expect($response->status())->toBe(500);
Http::assertSentCount(1);
});
it('gives up after 3 retries', function () {
Http::fake([
'https://example.com/api' => Http::sequence()
->push('Rate limited', 429)
->push('Rate limited', 429)
->push('Rate limited', 429),
]);
$response = $this->client->makeRequest('https://example.com/api');
expect($response->status())->toBe(429);
Http::assertSentCount(3);
});
it('returns successful response normally', function () {
Http::fake([
'https://example.com/api' => Http::response(['data' => 'ok'], 200),
]);
$response = $this->client->makeRequest('https://example.com/api');
expect($response->status())->toBe(200);
Http::assertSentCount(1);
});

View file

@ -217,3 +217,54 @@
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:1234567890');
});
test('linkedin page publisher can publish post with image using organization urn', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
]);
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/OrgFake';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/images')) {
return Http::response([
'value' => [
'uploadUrl' => $uploadUrl,
'image' => 'urn:li:image:OrgFakeImageUrn',
],
], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:9999999999']);
}
// Media download fallback
return Http::response('fake-image-content', 200);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:9999999999');
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/images'));
// Assert the post was created with organization URN as author
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')
&& ($request['author'] ?? '') === 'urn:li:organization:123456'
&& isset($request['content']['media']['id'])
);
});

View file

@ -163,3 +163,30 @@
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'YouTube Shorts require a title');
});
test('youtube publisher builds correct title with shorts tag', function () {
$publisher = new YouTubePublisher;
$reflection = new ReflectionClass($publisher);
$method = $reflection->getMethod('buildTitle');
$method->setAccessible(true);
// Short content: appends #Shorts
$title = $method->invoke($publisher, 'My awesome short video');
expect($title)->toBe('My awesome short video #Shorts');
// Long content: truncates to leave room for #Shorts tag (100 chars max)
$longContent = str_repeat('A', 200);
$title = $method->invoke($publisher, $longContent);
expect(strlen($title))->toBeLessThanOrEqual(100);
expect($title)->toEndWith(' #Shorts');
// Multi-line content: only uses first line before period
$multiLine = "First sentence. Second part.\nSecond line";
$title = $method->invoke($publisher, $multiLine);
expect($title)->toBe('First sentence #Shorts');
// Newline-separated: stops at newline
$newlineContent = "Title line\nMore content here";
$title = $method->invoke($publisher, $newlineContent);
expect($title)->toBe('Title line #Shorts');
});