diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index 64f9c98b..1e3fd111 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -43,6 +43,15 @@ public function isImage(): bool return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']); } + public function isDocument(): bool + { + if ($this->mime_type) { + return $this->mime_type === 'application/pdf'; + } + + return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf'; + } + /** * @param array $data */ @@ -63,6 +72,7 @@ public static function fromArray(array $data): self 'webp' => 'image/webp', 'mp4' => 'video/mp4', 'mov' => 'video/quicktime', + 'pdf' => 'application/pdf', default => null, }; } diff --git a/app/Enums/Media/Type.php b/app/Enums/Media/Type.php index 935da839..9eb95433 100644 --- a/app/Enums/Media/Type.php +++ b/app/Enums/Media/Type.php @@ -8,12 +8,14 @@ enum Type: string { case Image = 'image'; case Video = 'video'; + case Document = 'document'; public function label(): string { return match ($this) { self::Image => 'Imagem', self::Video => 'Vídeo', + self::Document => 'Documento', }; } @@ -31,6 +33,11 @@ public function label(): string * server-side transcoding, accepting WebM would just produce * platform-specific publish failures down the line. * + * Document accepts PDF only — the swipeable LinkedIn document + * (carousel) format. PPTX/DOCX are also valid LinkedIn documents + * but are converted server-side by LinkedIn and lose fonts, so we + * keep the surface to PDF. + * * @return array */ public function allowedMimeTypes(): array @@ -38,6 +45,7 @@ public function allowedMimeTypes(): array return match ($this) { self::Image => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], self::Video => ['video/mp4', 'video/quicktime'], + self::Document => ['application/pdf'], }; } @@ -52,6 +60,7 @@ public function extensions(): array return match ($this) { self::Image => ['jpg', 'jpeg', 'png', 'gif', 'webp'], self::Video => ['mp4', 'mov'], + self::Document => ['pdf'], }; } diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 02a27e9b..985fa647 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -16,10 +16,12 @@ enum ContentType: string // LinkedIn case LinkedInPost = 'linkedin_post'; case LinkedInCarousel = 'linkedin_carousel'; + case LinkedInDocument = 'linkedin_document'; // LinkedIn Page case LinkedInPagePost = 'linkedin_page_post'; case LinkedInPageCarousel = 'linkedin_page_carousel'; + case LinkedInPageDocument = 'linkedin_page_document'; // Facebook case FacebookPost = 'facebook_post'; @@ -70,6 +72,7 @@ public function label(): string self::InstagramStory => 'Story', self::LinkedInPost, self::LinkedInPagePost => 'Post', self::LinkedInCarousel, self::LinkedInPageCarousel => 'Carousel', + self::LinkedInDocument, self::LinkedInPageDocument => 'Document', self::FacebookPost => 'Post', self::FacebookReel => 'Reel', self::FacebookStory => 'Story', @@ -97,8 +100,8 @@ public function platform(): SocialPlatform { return match ($this) { self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram, - self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn, - self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage, + self::LinkedInPost, self::LinkedInCarousel, self::LinkedInDocument => SocialPlatform::LinkedIn, + self::LinkedInPagePost, self::LinkedInPageCarousel, self::LinkedInPageDocument => SocialPlatform::LinkedInPage, self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook, self::TikTokVideo, self::TikTokPhoto => SocialPlatform::TikTok, self::YouTubeShort => SocialPlatform::YouTube, @@ -166,6 +169,7 @@ public function maxMediaCount(): int self::InstagramReel, self::InstagramStory => 1, self::LinkedInPost, self::LinkedInPagePost => 1, self::LinkedInCarousel, self::LinkedInPageCarousel => 20, + self::LinkedInDocument, self::LinkedInPageDocument => 1, self::FacebookPost => 10, self::FacebookReel, self::FacebookStory => 1, self::TikTokVideo => 1, @@ -188,6 +192,7 @@ public function supportsVideo(): bool self::InstagramFeed, self::InstagramReel, self::InstagramStory => true, self::LinkedInPost, self::LinkedInPagePost => true, self::LinkedInCarousel, self::LinkedInPageCarousel => false, + self::LinkedInDocument, self::LinkedInPageDocument => false, self::FacebookPost, self::FacebookReel, self::FacebookStory => true, self::TikTokVideo => true, self::TikTokPhoto => false, @@ -212,10 +217,24 @@ public function supportsImage(): bool self::TikTokPhoto => true, self::YouTubeShort => false, self::PinterestVideoPin => false, + self::LinkedInDocument, self::LinkedInPageDocument => false, default => true, }; } + /** + * Whether this content type carries a document (PDF) attachment instead of + * images/videos — currently the LinkedIn swipeable document ("carousel"). + * A document post is PDF-only: it never combines with images or videos. + */ + public function supportsDocument(): bool + { + return match ($this) { + self::LinkedInDocument, self::LinkedInPageDocument => true, + default => false, + }; + } + /** * Whether a single post may carry images and a video together. Most * targets accept only one or the other; this is conservative and lists diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index d2e18c43..e6d94f09 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -94,7 +94,7 @@ public function color(): string public function allowedMediaTypes(): array { return match ($this) { - self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video], + self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video, MediaType::Document], self::X => [MediaType::Image, MediaType::Video], self::TikTok => [MediaType::Video], self::YouTube => [MediaType::Video], diff --git a/app/Http/Requests/Api/Post/StoreMediaRequest.php b/app/Http/Requests/Api/Post/StoreMediaRequest.php index d31a265b..77075a7c 100644 --- a/app/Http/Requests/Api/Post/StoreMediaRequest.php +++ b/app/Http/Requests/Api/Post/StoreMediaRequest.php @@ -22,6 +22,7 @@ public function rules(): array $allowedMimes = [ ...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes(), + ...MediaType::Document->allowedMimeTypes(), ]; return [ diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index 812c8ac9..f07237ed 100644 --- a/app/Http/Requests/Api/StoreUploadRequest.php +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -22,6 +22,7 @@ public function rules(): array $allowed = implode(',', array_merge( MediaType::Image->allowedMimeTypes(), MediaType::Video->allowedMimeTypes(), + MediaType::Document->allowedMimeTypes(), )); $maxKb = (int) config('ai.mcp.upload.max_size_mb') * 1024; diff --git a/app/Http/Requests/App/Asset/StoreAssetRequest.php b/app/Http/Requests/App/Asset/StoreAssetRequest.php index 7f649b0c..ddc07b10 100644 --- a/app/Http/Requests/App/Asset/StoreAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreAssetRequest.php @@ -16,7 +16,7 @@ public function authorize(): bool public function rules(): array { - $allowedMimes = [...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes()]; + $allowedMimes = [...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes(), ...MediaType::Document->allowedMimeTypes()]; return [ // Use the largest per-type cap as the upper bound; per-type diff --git a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php index 84ebf16f..3244d682 100644 --- a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php @@ -39,7 +39,7 @@ protected function prepareForValidation(): void */ public function rules(): array { - $allowedSuffixes = collect([MediaType::Image, MediaType::Video]) + $allowedSuffixes = collect([MediaType::Image, MediaType::Video, MediaType::Document]) ->flatMap(fn (MediaType $type) => $type->extensions()) ->map(fn (string $ext) => '.'.$ext) ->all(); diff --git a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php index 97a00463..faab44f5 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Download images or videos from public URLs and attach them to a post. Each URL is fetched, stored, and registered as a Media record on the workspace. Allowed types are intersected with the platforms enabled on the post (e.g. nothing accepted if no platform supports the media type).')] +#[Description('Download images, videos, or PDF documents from public URLs and attach them to a post. Each URL is fetched, stored, and registered as a Media record on the workspace. Allowed types are intersected with the platforms enabled on the post (e.g. nothing accepted if no platform supports the media type).')] class AttachMediaFromUrlTool extends Tool { public function handle(Request $request): Response|ResponseFactory @@ -53,7 +53,7 @@ public function schema(JsonSchema $schema): array 'urls' => $schema->array() ->items($schema->string()) ->required() - ->description('Public HTTP/HTTPS URLs of images or videos. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, video/webm.'), + ->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), ]; } } diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index fb17667b..2b380e50 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image or video, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] class RequestMediaUploadTool extends Tool { public function handle(Request $request): Response|ResponseFactory diff --git a/app/Models/Media.php b/app/Models/Media.php index f60f24fb..8a379037 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -81,6 +81,15 @@ public function isImage(): bool return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']); } + public function isDocument(): bool + { + if ($this->mime_type) { + return $this->mime_type === 'application/pdf'; + } + + return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf'; + } + public function getTemporaryUrl(int $expirationMinutes = 60): string { return Storage::temporaryUrl( diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index c1b9cb5e..81316edc 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -164,6 +164,10 @@ private function getMediaType(string $mimeType): string return 'video'; } + if ($mimeType === 'application/pdf') { + return 'document'; + } + throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}"); } diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 2fd2e5f1..1c3e0313 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -53,6 +53,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $hasImage = collect($media)->contains(fn ($item) => $this->isImage((array) $item)); $hasVideo = collect($media)->contains(fn ($item) => $this->isVideo((array) $item)); + $hasDocument = collect($media)->contains(fn ($item) => $this->isDocument((array) $item)); if ($hasImage && ! $contentType->supportsImage()) { $fail("{$contentType->label()} does not support images."); @@ -62,6 +63,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail("{$contentType->label()} does not support videos."); } + if ($hasDocument && ! $contentType->supportsDocument()) { + $fail("{$contentType->label()} does not support PDF documents."); + } + if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) { $fail("{$contentType->label()} can't combine an image and a video in the same post."); } @@ -90,4 +95,16 @@ private function isVideo(array $item): bool return str_starts_with((string) data_get($item, 'mime_type', ''), 'video/'); } + + /** + * @param array $item + */ + private function isDocument(array $item): bool + { + if (data_get($item, 'type') === MediaType::Document->value) { + return true; + } + + return data_get($item, 'mime_type') === 'application/pdf'; + } } diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index c1a186f6..9adb038c 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -63,6 +63,7 @@ public function publish(PostPlatform $postPlatform): array try { return match ($contentType) { ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), + ContentType::LinkedInPageDocument => $this->publishDocument($organizationUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform), $this->account), ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), }; @@ -89,6 +90,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, return match ($contentType) { ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), + ContentType::LinkedInPageDocument => $this->publishDocument($organizationUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform), $this->account), ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), }; @@ -219,6 +221,77 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med ]; } + /** + * The title shown on a LinkedIn document (PDF carousel) post. Falls back + * to the uploaded file name, then a generic label, so it's never empty. + */ + private function resolveDocumentTitle(PostPlatform $postPlatform): string + { + $title = data_get($postPlatform->meta, 'document_title'); + + if (filled($title)) { + return (string) $title; + } + + return $postPlatform->post->mediaItems->first()?->original_filename ?? 'Document'; + } + + private function publishDocument(string $organizationUrn, ?string $content, $mediaCollection, string $title, $account): array + { + $document = $mediaCollection->first(fn ($media) => $media->isDocument()); + + if (! $document) { + throw new \Exception('No PDF document for LinkedIn Page document post'); + } + + $documentUrn = $this->uploadDocument($document, $organizationUrn); + + if (! $documentUrn) { + throw new \Exception('LinkedIn Page document upload failed'); + } + + $payload = [ + 'author' => $organizationUrn, + 'commentary' => $content ?? '', + 'visibility' => 'PUBLIC', + 'distribution' => [ + 'feedDistribution' => 'MAIN_FEED', + 'targetEntities' => [], + 'thirdPartyDistributionChannels' => [], + ], + 'lifecycleState' => 'PUBLISHED', + 'content' => [ + 'media' => [ + 'id' => $documentUrn, + 'title' => $title, + ], + ], + ]; + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/rest/posts", $payload); + + if ($response->failed()) { + Log::error('LinkedIn Page document post creation failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $postId = $response->header('x-restli-id'); + + $username = $account->username; + $postUrl = $username + ? "https://www.linkedin.com/company/{$username}/posts/" + : "https://www.linkedin.com/feed/update/{$postId}"; + + return [ + 'id' => $postId ?? 'unknown', + 'url' => $postId ? $postUrl : null, + ]; + } + private function getHttpClient(): PendingRequest { return $this->socialHttp()->withToken($this->accessToken) @@ -450,6 +523,98 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) Log::warning('LinkedIn Page video processing timeout, proceeding anyway'); } + private function uploadDocument($mediaItem, string $ownerUrn): ?string + { + // Step 1: Initialize upload + $initResponse = $this->getHttpClient() + ->post("{$this->baseUrl}/rest/documents?action=initializeUpload", [ + 'initializeUploadRequest' => [ + 'owner' => $ownerUrn, + ], + ]); + + if ($initResponse->failed()) { + Log::error('LinkedIn Page document init failed', ['body' => $this->redactResponseBody($initResponse->body())]); + $this->handleApiError($initResponse); + } + + $initData = $initResponse->json(); + $uploadUrl = $initData['value']['uploadUrl'] ?? null; + $documentUrn = $initData['value']['document'] ?? null; + + if (! $uploadUrl || ! $documentUrn) { + throw new \Exception('LinkedIn Page document upload init missing uploadUrl or document URN'); + } + + // Step 2: Upload the PDF in a single request (Documents API is not chunked) + $tempFile = tempnam(sys_get_temp_dir(), 'lip_document_'); + + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $stream = fopen($tempFile, 'r'); + + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/pdf', + ]) + ->timeout(600) + ->withBody($stream, 'application/pdf') + ->put($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($uploadResponse->failed()) { + Log::error('LinkedIn Page document upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); + } + } finally { + @unlink($tempFile); + } + + // Step 3: Wait for processing (SYNCHRONOUS_UPLOAD is not supported) + $this->waitForDocumentProcessing($documentUrn); + + return $documentUrn; + } + + private function waitForDocumentProcessing(string $documentUrn, int $maxAttempts = 30): void + { + $encodedUrn = urlencode($documentUrn); + + for ($i = 0; $i < $maxAttempts; $i++) { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/rest/documents/{$encodedUrn}"); + + if ($response->failed()) { + Log::warning('LinkedIn Page document status check failed', ['attempt' => $i]); + sleep(5); + + continue; + } + + $status = data_get($response->json(), 'status', 'UNKNOWN'); + + if ($status === 'AVAILABLE') { + return; + } + + if ($status === 'PROCESSING_FAILED') { + throw new \Exception('LinkedIn Page document processing failed'); + } + + sleep(5); + } + + Log::warning('LinkedIn Page document processing timeout, proceeding anyway'); + } + private function handleApiError(Response $response): never { throw LinkedInPublishException::fromApiResponse($response); diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 8053d458..dee69839 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -57,6 +57,7 @@ public function publish(PostPlatform $postPlatform): array try { return match ($contentType) { ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems), + ContentType::LinkedInDocument => $this->publishDocument($personUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform)), ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems), default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), }; @@ -82,6 +83,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, return match ($contentType) { ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems), + ContentType::LinkedInDocument => $this->publishDocument($personUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform)), ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems), default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), }; @@ -200,6 +202,73 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl ]; } + /** + * The title shown on a LinkedIn document (PDF carousel) post. Falls back + * to the uploaded file name, then a generic label, so it's never empty — + * LinkedIn renders the title beneath the document. + */ + private function resolveDocumentTitle(PostPlatform $postPlatform): string + { + $title = data_get($postPlatform->meta, 'document_title'); + + if (filled($title)) { + return (string) $title; + } + + return $postPlatform->post->mediaItems->first()?->original_filename ?? 'Document'; + } + + private function publishDocument(string $personUrn, ?string $content, $mediaCollection, string $title): array + { + $document = $mediaCollection->first(fn ($media) => $media->isDocument()); + + if (! $document) { + throw new \Exception('No PDF document for LinkedIn document post'); + } + + $documentUrn = $this->uploadDocument($document, $personUrn); + + if (! $documentUrn) { + throw new \Exception('LinkedIn document upload failed'); + } + + $payload = [ + 'author' => $personUrn, + 'commentary' => $content ?? '', + 'visibility' => 'PUBLIC', + 'distribution' => [ + 'feedDistribution' => 'MAIN_FEED', + 'targetEntities' => [], + 'thirdPartyDistributionChannels' => [], + ], + 'lifecycleState' => 'PUBLISHED', + 'content' => [ + 'media' => [ + 'id' => $documentUrn, + 'title' => $title, + ], + ], + ]; + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/rest/posts", $payload); + + if ($response->failed()) { + Log::error('LinkedIn document post creation failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $postId = $response->header('x-restli-id'); + + return [ + 'id' => $postId ?? 'unknown', + 'url' => $postId ? "https://www.linkedin.com/feed/update/{$postId}" : null, + ]; + } + private function getHttpClient(): PendingRequest { return $this->socialHttp()->withToken($this->accessToken) @@ -431,6 +500,98 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) Log::warning('LinkedIn video processing timeout, proceeding anyway'); } + private function uploadDocument($mediaItem, string $ownerUrn): ?string + { + // Step 1: Initialize upload + $initResponse = $this->getHttpClient() + ->post("{$this->baseUrl}/rest/documents?action=initializeUpload", [ + 'initializeUploadRequest' => [ + 'owner' => $ownerUrn, + ], + ]); + + if ($initResponse->failed()) { + Log::error('LinkedIn document init failed', ['body' => $this->redactResponseBody($initResponse->body())]); + $this->handleApiError($initResponse); + } + + $initData = $initResponse->json(); + $uploadUrl = $initData['value']['uploadUrl'] ?? null; + $documentUrn = $initData['value']['document'] ?? null; + + if (! $uploadUrl || ! $documentUrn) { + throw new \Exception('LinkedIn document upload init missing uploadUrl or document URN'); + } + + // Step 2: Upload the PDF in a single request (Documents API is not chunked) + $tempFile = tempnam(sys_get_temp_dir(), 'li_document_'); + + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $stream = fopen($tempFile, 'r'); + + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/pdf', + ]) + ->timeout(600) + ->withBody($stream, 'application/pdf') + ->put($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($uploadResponse->failed()) { + Log::error('LinkedIn document upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); + } + } finally { + @unlink($tempFile); + } + + // Step 3: Wait for processing (SYNCHRONOUS_UPLOAD is not supported) + $this->waitForDocumentProcessing($documentUrn); + + return $documentUrn; + } + + private function waitForDocumentProcessing(string $documentUrn, int $maxAttempts = 30): void + { + $encodedUrn = urlencode($documentUrn); + + for ($i = 0; $i < $maxAttempts; $i++) { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/rest/documents/{$encodedUrn}"); + + if ($response->failed()) { + Log::warning('LinkedIn document status check failed', ['attempt' => $i]); + sleep(5); + + continue; + } + + $status = data_get($response->json(), 'status', 'UNKNOWN'); + + if ($status === 'AVAILABLE') { + return; + } + + if ($status === 'PROCESSING_FAILED') { + throw new \Exception('LinkedIn document processing failed'); + } + + sleep(5); + } + + Log::warning('LinkedIn document processing timeout, proceeding anyway'); + } + private function handleApiError(Response $response): never { throw LinkedInPublishException::fromApiResponse($response); diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index d7a12682..00f6d7b2 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -45,6 +45,9 @@ public static function rules(): array // Instagram / Facebook 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + // LinkedIn — title shown on a document (PDF carousel) post + 'platforms.*.meta.document_title' => ['sometimes', 'nullable', 'string', 'max:300'], + // TikTok 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(self::TIKTOK_PRIVACY_LEVELS)], 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], diff --git a/config/trypost.php b/config/trypost.php index 761be98f..1b204b7e 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -46,6 +46,8 @@ 'max_size_mb' => [ 'image' => (int) env('MEDIA_IMAGE_MAX_SIZE_MB', 10), 'video' => (int) env('MEDIA_VIDEO_MAX_SIZE_MB', 1024), + // LinkedIn caps document (PDF carousel) uploads at 100MB. + 'document' => (int) env('MEDIA_DOCUMENT_MAX_SIZE_MB', 100), ], ], diff --git a/lang/en/posts.php b/lang/en/posts.php index d3f3f7dd..29b1c5fb 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -143,6 +143,7 @@ 'variant' => [ 'post' => 'Post', 'carousel' => 'Carousel', + 'document' => 'Document', ], ], 'pinterest' => [ @@ -188,9 +189,11 @@ 'min_files_required' => 'This post type requires at least :min media files (you have :current).', 'no_video_allowed' => 'This post type does not accept videos.', 'no_image_allowed' => 'This post type accepts only videos.', + 'no_document_allowed' => 'This post type does not accept PDF documents.', 'gif_not_allowed' => 'This platform does not accept GIF. Remove the GIF or choose a different network.', 'image_too_large' => 'Image exceeds the :max limit for this post type (yours is :current).', 'video_too_large' => 'Video exceeds the :max limit for this post type (yours is :current).', + 'document_too_large' => 'PDF exceeds the :max limit for this post type (yours is :current).', 'video_too_long' => 'Video is :current long, but this post type allows up to :max.', 'aspect_ratio_too_narrow' => 'Aspect ratio :current is too tall for this post type (min :min).', 'aspect_ratio_too_wide' => 'Aspect ratio :current is too wide for this post type (max :max).', @@ -472,6 +475,14 @@ 'label' => 'Carousel', 'description' => 'Swipeable images', ], + 'linkedin_document' => [ + 'label' => 'Document', + 'description' => 'Swipeable PDF carousel', + ], + 'linkedin_page_document' => [ + 'label' => 'Document', + 'description' => 'Swipeable PDF carousel', + ], 'facebook_post' => [ 'label' => 'Post', 'description' => 'Standard post on your page', diff --git a/lang/es/posts.php b/lang/es/posts.php index c52e8895..4a8d3096 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -143,6 +143,7 @@ 'variant' => [ 'post' => 'Publicación', 'carousel' => 'Carrusel', + 'document' => 'Documento', ], ], 'pinterest' => [ @@ -188,9 +189,11 @@ 'min_files_required' => 'Este tipo requiere al menos :min archivos (tienes :current).', 'no_video_allowed' => 'Este tipo no acepta videos.', 'no_image_allowed' => 'Este tipo acepta solo videos.', + 'no_document_allowed' => 'Este tipo no acepta documentos PDF.', 'gif_not_allowed' => 'Esta red no acepta GIF. Elimínalo o selecciona otra red.', 'image_too_large' => 'La imagen supera el límite de :max (la tuya es :current).', 'video_too_large' => 'El video supera el límite de :max (el tuyo es :current).', + 'document_too_large' => 'El PDF supera el límite de :max (el tuyo es :current).', 'video_too_long' => 'El video dura :current, pero este tipo permite hasta :max.', 'aspect_ratio_too_narrow' => 'La proporción :current es demasiado alta (mínimo :min).', 'aspect_ratio_too_wide' => 'La proporción :current es demasiado ancha (máximo :max).', @@ -472,6 +475,14 @@ 'label' => 'Carrusel', 'description' => 'Imágenes deslizables', ], + 'linkedin_document' => [ + 'label' => 'Documento', + 'description' => 'Carrusel PDF deslizable', + ], + 'linkedin_page_document' => [ + 'label' => 'Documento', + 'description' => 'Carrusel PDF deslizable', + ], 'facebook_post' => [ 'label' => 'Post', 'description' => 'Post estándar en tu página', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 3b580dcb..71630476 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -143,6 +143,7 @@ 'variant' => [ 'post' => 'Post', 'carousel' => 'Carrossel', + 'document' => 'Documento', ], ], 'pinterest' => [ @@ -188,9 +189,11 @@ 'min_files_required' => 'Este tipo exige pelo menos :min arquivos (você tem :current).', 'no_video_allowed' => 'Este tipo não aceita vídeos.', 'no_image_allowed' => 'Este tipo aceita apenas vídeos.', + 'no_document_allowed' => 'Este tipo não aceita documentos PDF.', 'gif_not_allowed' => 'Esta rede não aceita GIF. Remova o GIF ou escolha outra rede.', 'image_too_large' => 'A imagem passa do limite de :max (a sua tem :current).', 'video_too_large' => 'O vídeo passa do limite de :max (o seu tem :current).', + 'document_too_large' => 'O PDF passa do limite de :max (o seu tem :current).', 'video_too_long' => 'O vídeo dura :current, mas este tipo permite no máximo :max.', 'aspect_ratio_too_narrow' => 'A proporção :current está muito alta (mínimo :min).', 'aspect_ratio_too_wide' => 'A proporção :current está muito larga (máximo :max).', @@ -472,6 +475,14 @@ 'label' => 'Carrossel', 'description' => 'Imagens deslizáveis', ], + 'linkedin_document' => [ + 'label' => 'Documento', + 'description' => 'Carrossel PDF deslizável', + ], + 'linkedin_page_document' => [ + 'label' => 'Documento', + 'description' => 'Carrossel PDF deslizável', + ], 'facebook_post' => [ 'label' => 'Post', 'description' => 'Post padrão na sua página', diff --git a/resources/js/components/assets/GalleryBrowser.vue b/resources/js/components/assets/GalleryBrowser.vue index 6095ac50..3ef9f68e 100644 --- a/resources/js/components/assets/GalleryBrowser.vue +++ b/resources/js/components/assets/GalleryBrowser.vue @@ -1,6 +1,6 @@