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 <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-05-21 21:21:11 -03:00
parent ade899c010
commit 8dfa664fa6
10 changed files with 64 additions and 96 deletions

View file

@ -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,
];
}

View file

@ -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');
}
// 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,
]);
if ($transferResponse->failed()) {
Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]);
$this->handleApiError($transferResponse);
}
// Finish the story
$finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'finish',
'video_id' => $videoId,
'access_token' => $accessToken,
]);
if ($finishResponse->failed()) {
$this->handleApiError($finishResponse);
}
$storyId = $finishResponse->json()['post_id'] ?? $videoId;
return [
'id' => $storyId,
'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}",
];
if (! $media->isVideo()) {
throw new FacebookPublishException(
userMessage: 'Facebook Stories require a video file.',
category: ErrorCategory::MediaFormat,
);
}
// Image story
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [
'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media),
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'start',
'access_token' => $accessToken,
]);
if ($response->failed()) {
Log::error('Facebook photo story failed', [
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$storyId = $response->json()['post_id'] ?? $response->json()['id'];
$videoId = $response->json()['video_id'] ?? null;
if (! $videoId) {
throw new \Exception('Facebook story upload failed: no video ID returned');
}
$transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [
'upload_phase' => 'transfer',
'video_file_chunk' => $media->url,
'access_token' => $accessToken,
]);
if ($transferResponse->failed()) {
Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]);
$this->handleApiError($transferResponse);
}
$finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'finish',
'video_id' => $videoId,
'access_token' => $accessToken,
]);
if ($finishResponse->failed()) {
$this->handleApiError($finishResponse);
}
$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);

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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 ||

View file

@ -51,9 +51,9 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
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,
},

View file

@ -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 () {

View file

@ -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();
});

View file

@ -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 () {