Merge pull request #59 from trypostit/fix/facebook-multi-image-form-encoding

fix(facebook): multi-image posts publish as text-only
This commit is contained in:
Paulo Castellano 2026-05-21 21:17:29 -03:00 committed by GitHub
commit ade899c010
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 52 additions and 13 deletions

View file

@ -9,6 +9,7 @@
use App\Exceptions\Social\FacebookPublishException;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -24,6 +25,15 @@ public function __construct()
$this->baseUrl = config('trypost.platforms.facebook.graph_api');
}
/**
* Graph API expects application/x-www-form-urlencoded (or multipart), not JSON.
* Sending JSON makes `message` work but silently drops `attached_media[*]` on /feed.
*/
private function facebookHttp(): PendingRequest
{
return $this->socialHttp()->asForm();
}
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
@ -78,7 +88,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
private function publishTextPost(string $pageId, string $accessToken, string $content): array
{
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", [
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", [
'message' => $content,
'access_token' => $accessToken,
]);
@ -111,7 +121,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['message'] = $content;
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
if ($response->failed()) {
Log::error('Facebook single image post failed', [
@ -140,7 +150,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
continue;
}
$uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'url' => $media->url,
'published' => 'false',
'access_token' => $accessToken,
@ -175,7 +185,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
$postData["attached_media[{$index}]"] = json_encode($media);
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData);
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData);
if ($response->failed()) {
Log::error('Facebook multi-image post failed', [
@ -205,7 +215,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $
$payload['description'] = $content;
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload);
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload);
if ($response->failed()) {
Log::error('Facebook video post failed', [
@ -227,7 +237,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $
private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array
{
// Phase 1 (start) — graph endpoint returns video_id + upload_url.
$startResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
$startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
'upload_phase' => 'start',
'access_token' => $accessToken,
]);
@ -310,7 +320,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
$finishPayload['description'] = $content;
}
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload);
$finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload);
if ($finishResponse->failed()) {
$this->handleApiError($finishResponse);
@ -331,7 +341,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
if ($isVideo) {
// Video story
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'start',
'access_token' => $accessToken,
]);
@ -347,7 +357,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
}
// Transfer the video (Facebook accepts URL in video_file_chunk)
$transferResponse = $this->socialHttp()->post("{$this->baseUrl}/{$videoId}", [
$transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [
'upload_phase' => 'transfer',
'video_file_chunk' => $media->url,
'access_token' => $accessToken,
@ -359,7 +369,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
}
// Finish the story
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
$finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [
'upload_phase' => 'finish',
'video_id' => $videoId,
'access_token' => $accessToken,
@ -378,7 +388,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
}
// Image story
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [
'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media),
'access_token' => $accessToken,
]);
@ -400,7 +410,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string
{
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'url' => $media->url,
'published' => 'false',
'access_token' => $accessToken,

View file

@ -126,7 +126,36 @@
expect($result['id'])->toBe('page_123_multi_post_789');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/feed');
return str_contains($request->url(), '/page_123/feed')
&& str_contains($request->header('Content-Type')[0] ?? '', 'application/x-www-form-urlencoded')
&& ($request->data()['attached_media[0]'] ?? null) === json_encode(['media_fbid' => 'photo_1'])
&& ($request->data()['attached_media[1]'] ?? null) === json_encode(['media_fbid' => 'photo_2']);
});
});
test('facebook publisher sends graph api requests as form-urlencoded not json', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& str_contains($request->header('Content-Type')[0] ?? '', 'application/x-www-form-urlencoded')
&& ! str_contains($request->header('Content-Type')[0] ?? '', 'application/json');
});
});