Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -245,7 +246,6 @@ public static function aiSupported(): array
self::BlueskyPost,
self::MastodonPost,
self::FacebookPost,
self::FacebookStory,
self::PinterestPin,
];
}
Expand Down
97 changes: 31 additions & 66 deletions app/Services/Social/FacebookPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,92 +337,57 @@ 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,
'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}",
];
}

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);
Expand Down
2 changes: 1 addition & 1 deletion lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion lang/es/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion lang/pt-BR/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 1 addition & 3 deletions resources/js/components/posts/create/AiPostWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
];

Expand Down Expand Up @@ -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 ||
Expand Down
4 changes: 2 additions & 2 deletions resources/js/composables/useMediaRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},

Expand Down
20 changes: 3 additions & 17 deletions tests/Feature/Services/Social/FacebookPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () {
Expand Down
3 changes: 3 additions & 0 deletions tests/Unit/Enums/ContentTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
16 changes: 16 additions & 0 deletions tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading