diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 64811901..14ecdcb1 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -9,6 +9,7 @@ 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 @@ -237,26 +238,43 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte $this->handleApiError($startResponse); } - // Phase 2 (transfer, hosted-file flow) — POST to upload_url on - // rupload.facebook.com with `file_url` in the JSON body and - // `Authorization: OAuth …` header. The previous implementation - // POSTed to the graph endpoint with a made-up `video_file_chunk` - // body field; Facebook accepted the request but never fetched - // the video, so finish failed with error_subcode 1363130 - // ("Video Upload Is Missing"). - $uploadResponse = $this->socialHttp() - ->withHeaders(['Authorization' => "OAuth {$accessToken}"]) - ->asJson() - ->post($uploadUrl, [ - 'file_url' => $media->url, - ]); + // Phase 2 (transfer, local-file flow) — download our hosted + // media then POST raw bytes to upload_url with the Offset and + // file_size headers Facebook requires (the docs describe a + // hosted-file shortcut with `file_url` in the body, but rupload + // rejects it with "Header Offset not convertable to unsigned + // long" — the headers are required either way). + $tempFile = tempnam(sys_get_temp_dir(), 'fb_reel_'); - if ($uploadResponse->failed()) { - Log::error('Facebook reel upload transfer failed', [ - 'status' => $uploadResponse->status(), - 'body' => $this->redactResponseBody($uploadResponse->body()), - ]); - $this->handleApiError($uploadResponse); + try { + $download = Http::withOptions(['sink' => $tempFile]) + ->timeout(600) + ->get($media->url); + + if ($download->failed()) { + throw new \Exception('Failed to download reel media: HTTP '.$download->status()); + } + + $fileSize = filesize($tempFile); + + $uploadResponse = Http::withHeaders([ + 'Authorization' => "OAuth {$accessToken}", + 'Offset' => '0', + 'file_size' => (string) $fileSize, + ]) + ->timeout(600) + ->withBody(file_get_contents($tempFile), $media->mime_type ?? 'video/mp4') + ->post($uploadUrl); + + if ($uploadResponse->failed()) { + Log::error('Facebook reel upload transfer failed', [ + 'status' => $uploadResponse->status(), + 'body' => $this->redactResponseBody($uploadResponse->body()), + ]); + $this->handleApiError($uploadResponse); + } + } finally { + @unlink($tempFile); } // Phase 3 (finish) — publish the reel. diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 842a47b4..7f817d68 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -185,6 +185,7 @@ 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123', ], 200) ->push(['id' => 'reel_123', 'success' => true], 200), + '*example.com/media/*' => Http::response('fake-video-binary-content', 200), '*rupload.facebook.com/*' => Http::response(['success' => true], 200), ]); @@ -194,15 +195,16 @@ expect($result['id'])->toBe('reel_123'); expect($result['url'])->toBe('https://www.facebook.com/reel/reel_123'); - // Assert the transfer phase: POST to upload_url (rupload host) with - // file_url JSON body and OAuth header — not to the graph endpoint - // with the (legacy/wrong) video_file_chunk body param. + // Assert the transfer phase: POST raw bytes to upload_url (rupload + // host) with OAuth header and the required Offset + file_size + // headers Facebook's rupload validator demands. Http::assertSent(function ($request) { if (! str_contains($request->url(), 'rupload.facebook.com')) { return false; } - return $request['file_url'] === 'https://example.com/media/2026-01/reel.mp4' + return ($request->header('Offset')[0] ?? null) === '0' + && ($request->header('file_size')[0] ?? null) === (string) strlen('fake-video-binary-content') && str_starts_with($request->header('Authorization')[0] ?? '', 'OAuth '); }); }); @@ -423,6 +425,7 @@ 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_cleanup_123', ], 200) ->push(['id' => 'reel_cleanup_456', 'success' => true], 200), + '*example.com/media/*' => Http::response('fake-video', 200), '*rupload.facebook.com/*' => Http::response(['success' => true], 200), ]);