Merge pull request #32 from trypostit/fix/facebook-reels-upload

fix(facebook): use upload_url + file_url for reel transfer phase
This commit is contained in:
Paulo Castellano 2026-05-12 20:51:34 -03:00 committed by GitHub
commit 154a202b84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 155 additions and 30 deletions

View file

@ -5,10 +5,12 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Exceptions\Social\ErrorCategory;
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
@ -212,36 +214,79 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $
private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array
{
// Upload video as reel
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
// Phase 1 (start) — graph endpoint returns video_id + upload_url.
$startResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
'upload_phase' => 'start',
'access_token' => $accessToken,
]);
if ($response->failed()) {
Log::error('Facebook reel upload start failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
if ($startResponse->failed()) {
$this->handleApiError($startResponse);
}
$data = $response->json();
$videoId = data_get($data, 'video_id');
$startData = $startResponse->json();
$videoId = data_get($startData, 'video_id');
$uploadUrl = data_get($startData, 'upload_url');
// Upload the video file (Facebook accepts URL in video_file_chunk)
$uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$videoId}", [
'upload_phase' => 'transfer',
'video_file_chunk' => $media->url,
'access_token' => $accessToken,
]);
if ($uploadResponse->failed()) {
Log::error('Facebook reel upload transfer failed', ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
if (! $videoId || ! $uploadUrl) {
throw new FacebookPublishException(
userMessage: 'Facebook did not return upload_url for reel start.',
category: ErrorCategory::ServerError,
platformErrorCode: null,
rawResponse: $startResponse->body(),
);
}
// Finish and publish the reel
// 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_');
try {
$download = Http::withOptions(['sink' => $tempFile])
->timeout(600)
->get($media->url);
if ($download->failed()) {
throw new FacebookPublishException(
userMessage: 'Could not download media for Facebook reel.',
category: ErrorCategory::ServerError,
platformErrorCode: (string) $download->status(),
rawResponse: null,
);
}
$fileSize = filesize($tempFile);
$stream = fopen($tempFile, 'rb');
try {
$uploadResponse = Http::withHeaders([
'Authorization' => "OAuth {$accessToken}",
'Offset' => '0',
'file_size' => (string) $fileSize,
])
->timeout(600)
->withBody($stream, $media->mime_type ?? 'video/mp4')
->post($uploadUrl);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
if ($uploadResponse->failed()) {
$this->handleApiError($uploadResponse);
}
} finally {
if (! unlink($tempFile)) {
Log::warning('Facebook reel temp file cleanup failed', ['path' => $tempFile]);
}
}
// Phase 3 (finish) — publish the reel.
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
'upload_phase' => 'finish',
'video_id' => $videoId,
@ -251,9 +296,6 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
]);
if ($finishResponse->failed()) {
Log::error('Facebook reel finish failed', [
'body' => $this->redactResponseBody($finishResponse->body()),
]);
$this->handleApiError($finishResponse);
}

View file

@ -4,6 +4,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\FacebookPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
@ -179,10 +180,13 @@
Http::fake([
'*/page_123/video_reels' => Http::sequence()
->push(['video_id' => 'reel_video_123'], 200)
->push([
'video_id' => 'reel_video_123',
'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123',
], 200)
->push(['id' => 'reel_123', 'success' => true], 200),
'*/reel_video_123' => Http::response(['success' => true], 200),
'*' => Http::response('', 200),
'*example.com/media/*' => Http::response('fake-video-binary-content', 200),
'*rupload.facebook.com/*' => Http::response(['success' => true], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
@ -190,6 +194,82 @@
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('reel_123');
expect($result['url'])->toBe('https://www.facebook.com/reel/reel_123');
// 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->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 ');
});
});
test('facebook publisher fails reel publish when start does not return upload_url', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->post->update([
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
// Missing upload_url in the start response — should not silently
// proceed to a broken transfer (which is what the old code did).
Http::fake([
'*/page_123/video_reels' => Http::response([
'video_id' => 'reel_video_123',
], 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(
FacebookPublishException::class,
'Facebook did not return upload_url for reel start.'
);
});
test('facebook publisher fails reel publish with typed exception when media download fails', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->post->update([
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
// start succeeds, but the media URL returns 404 — should surface as
// a typed FacebookPublishException (ServerError) instead of leaking
// a generic Exception that would land in the 'unknown' bucket.
Http::fake([
'*/page_123/video_reels' => Http::response([
'video_id' => 'reel_video_123',
'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123',
], 200),
'*example.com/media/*' => Http::response('', 404),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(
FacebookPublishException::class,
'Could not download media for Facebook reel.'
);
});
test('facebook publisher can publish image story', function () {
@ -376,10 +456,13 @@
Http::fake([
'*/page_123/video_reels' => Http::sequence()
->push(['video_id' => 'reel_video_cleanup_123'], 200)
->push([
'video_id' => 'reel_video_cleanup_123',
'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_cleanup_123',
], 200)
->push(['id' => 'reel_cleanup_456', 'success' => true], 200),
'*/reel_video_cleanup_123' => Http::response(['success' => true], 200),
'*' => Http::response('', 200),
'*example.com/media/*' => Http::response('fake-video', 200),
'*rupload.facebook.com/*' => Http::response(['success' => true], 200),
]);
$this->publisher->publish($this->postPlatform);