From 8dfa664fa67d682f80eff76be64c4dd498eb7efc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 21:21:11 -0300 Subject: [PATCH] fix(facebook): restrict Page Stories to video-only Facebook Stories do not work reliably with images via the Graph API. Align validation and publisher with video-only (like Reel), while Instagram Story continues to accept image or video. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 2 +- app/Services/Social/FacebookPublisher.php | 97 ++++++------------- lang/en/posts.php | 2 +- lang/es/posts.php | 2 +- lang/pt-BR/posts.php | 2 +- .../components/posts/create/AiPostWizard.vue | 4 +- resources/js/composables/useMediaRules.ts | 4 +- .../Services/Social/FacebookPublisherTest.php | 20 +--- tests/Unit/Enums/ContentTypeTest.php | 3 + .../ContentTypeCompatibleWithMediaTest.php | 16 +++ 10 files changed, 60 insertions(+), 92 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 6c7b075a..3dc02193 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -192,6 +192,7 @@ public function supportsImage(): bool { return match ($this) { self::InstagramReel => false, + self::FacebookReel, self::FacebookStory => false, self::TikTokVideo => false, self::TikTokPhoto => true, self::YouTubeShort => false, @@ -245,7 +246,6 @@ public static function aiSupported(): array self::BlueskyPost, self::MastodonPost, self::FacebookPost, - self::FacebookStory, self::PinterestPin, ]; } diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 5fbfb4ea..b7133e36 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -337,70 +337,50 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte private function publishStory(string $pageId, string $accessToken, $media): array { - $isVideo = $media->isVideo(); - - if ($isVideo) { - // Video story - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'start', - 'access_token' => $accessToken, - ]); - - if ($response->failed()) { - $this->handleApiError($response); - } - - $videoId = $response->json()['video_id'] ?? null; - - if (! $videoId) { - throw new \Exception('Facebook story upload failed: no video ID returned'); - } + if (! $media->isVideo()) { + throw new FacebookPublishException( + userMessage: 'Facebook Stories require a video file.', + category: ErrorCategory::MediaFormat, + ); + } - // Transfer the video (Facebook accepts URL in video_file_chunk) - $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ - 'upload_phase' => 'transfer', - 'video_file_chunk' => $media->url, - 'access_token' => $accessToken, - ]); + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ + 'upload_phase' => 'start', + 'access_token' => $accessToken, + ]); - if ($transferResponse->failed()) { - Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); - $this->handleApiError($transferResponse); - } + if ($response->failed()) { + $this->handleApiError($response); + } - // Finish the story - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'finish', - 'video_id' => $videoId, - 'access_token' => $accessToken, - ]); + $videoId = $response->json()['video_id'] ?? null; - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); - } + if (! $videoId) { + throw new \Exception('Facebook story upload failed: no video ID returned'); + } - $storyId = $finishResponse->json()['post_id'] ?? $videoId; + $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ + 'upload_phase' => 'transfer', + 'video_file_chunk' => $media->url, + 'access_token' => $accessToken, + ]); - return [ - 'id' => $storyId, - 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", - ]; + if ($transferResponse->failed()) { + Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); + $this->handleApiError($transferResponse); } - // Image story - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [ - 'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media), + $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, 'access_token' => $accessToken, ]); - if ($response->failed()) { - Log::error('Facebook photo story failed', [ - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); + if ($finishResponse->failed()) { + $this->handleApiError($finishResponse); } - $storyId = $response->json()['post_id'] ?? $response->json()['id']; + $storyId = $finishResponse->json()['post_id'] ?? $videoId; return [ 'id' => $storyId, @@ -408,21 +388,6 @@ private function publishStory(string $pageId, string $accessToken, $media): arra ]; } - private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string - { - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $media->url, - 'published' => 'false', - 'access_token' => $accessToken, - ]); - - if ($response->failed()) { - $this->handleApiError($response); - } - - return $response->json()['id']; - } - private function handleApiError(Response $response): never { throw FacebookPublishException::fromApiResponse($response); diff --git a/lang/en/posts.php b/lang/en/posts.php index 26249bae..8488c29a 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -435,7 +435,7 @@ ], 'facebook_story' => [ 'label' => 'Story', - 'description' => 'Disappears after 24 hours', + 'description' => 'Vertical video story, up to 60 seconds', ], 'tiktok_video' => [ 'label' => 'Video', diff --git a/lang/es/posts.php b/lang/es/posts.php index 389a2d1b..cf988ea8 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -435,7 +435,7 @@ ], 'facebook_story' => [ 'label' => 'Historia', - 'description' => 'Desaparece después de 24 horas', + 'description' => 'Historia en video vertical, hasta 60 segundos', ], 'tiktok_video' => [ 'label' => 'Video', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 4265dca3..6dc86360 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -435,7 +435,7 @@ ], 'facebook_story' => [ 'label' => 'Story', - 'description' => 'Desaparece após 24 horas', + 'description' => 'Story em vídeo vertical, até 60 segundos', ], 'tiktok_video' => [ 'label' => 'Vídeo', diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 479cc12d..2ea974ba 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -70,7 +70,6 @@ const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [ { value: ContentType.ThreadsPost, platforms: ['threads'] }, { value: ContentType.MastodonPost, platforms: ['mastodon'] }, { value: ContentType.FacebookPost, platforms: ['facebook'] }, - { value: ContentType.FacebookStory, platforms: ['facebook'] }, { value: ContentType.PinterestPin, platforms: ['pinterest'] }, ]; @@ -100,8 +99,7 @@ const isCarousel = computed(() => selectedFormat.value === ContentType.Instagram const requiresImage = computed(() => selectedFormat.value === ContentType.FacebookPost || selectedFormat.value === ContentType.PinterestPin || - selectedFormat.value === ContentType.InstagramStory || - selectedFormat.value === ContentType.FacebookStory, + selectedFormat.value === ContentType.InstagramStory, ); const supportsOptionalImages = computed(() => selectedFormat.value === ContentType.InstagramFeed || diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 46ff62c3..8c3b08e9 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -51,9 +51,9 @@ const CONTENT_TYPE_RULES: Record = { aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, facebook_story: { - maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true, + maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxImageBytes: 4 * MB, maxVideoDurationSec: 60, + maxVideoBytes: 1 * GB, maxVideoDurationSec: 60, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 9fc2fd7b..cad4cfc0 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -301,11 +301,10 @@ ); }); -test('facebook publisher can publish image story', function () { +test('facebook publisher rejects image story', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); $this->post->update([ - 'media' => [ [ 'id' => 'test-media-story', @@ -315,23 +314,10 @@ 'original_filename' => 'story.jpg', ], ], - - ]); - - Http::fake([ - '*/page_123/photos' => Http::response([ - 'id' => 'photo_story_123', - ], 200), - '*/page_123/photo_stories' => Http::response([ - 'post_id' => 'story_post_123', - ], 200), ]); - $result = $this->publisher->publish($this->postPlatform); - - expect($result)->toHaveKey('id'); - expect($result['id'])->toBe('story_post_123'); - expect($result['url'])->toContain('/stories/page_123/'); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook Stories require a video file.'); }); test('facebook publisher can publish video story', function () { diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index b89726aa..b2a34d3c 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -74,8 +74,11 @@ test('content type supports image correctly', function () { expect(ContentType::InstagramFeed->supportsImage())->toBeTrue(); + expect(ContentType::InstagramStory->supportsImage())->toBeTrue(); expect(ContentType::LinkedInPost->supportsImage())->toBeTrue(); expect(ContentType::InstagramReel->supportsImage())->toBeFalse(); + expect(ContentType::FacebookReel->supportsImage())->toBeFalse(); + expect(ContentType::FacebookStory->supportsImage())->toBeFalse(); expect(ContentType::TikTokVideo->supportsImage())->toBeFalse(); expect(ContentType::YouTubeShort->supportsImage())->toBeFalse(); }); diff --git a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php index 8c6a9385..81fd06a0 100644 --- a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php +++ b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php @@ -68,6 +68,22 @@ function runMediaRule(string $contentType, array $media): array expect(runMediaRule(ContentType::TikTokVideo->value, $media))->toBe([]); expect(runMediaRule(ContentType::YouTubeShort->value, $media))->toBe([]); expect(runMediaRule(ContentType::InstagramReel->value, $media))->toBe([]); + expect(runMediaRule(ContentType::FacebookStory->value, $media))->toBe([]); +}); + +test('facebook story rejects images', function () { + $media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']]; + + $errors = runMediaRule(ContentType::FacebookStory->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain('does not support images'); +}); + +test('instagram story accepts images', function () { + $media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']]; + + expect(runMediaRule(ContentType::InstagramStory->value, $media))->toBe([]); }); test('detects media type from mime when type field is missing', function () {