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:
parent
ade899c010
commit
8dfa664fa6
10 changed files with 64 additions and 96 deletions
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue