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:
commit
154a202b84
2 changed files with 155 additions and 30 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue