trypost/app/Services/Social/AbstractLinkedInPublisher.php
Paulo Castellano cf045f2cdb Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 13:55:33 -03:00

565 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
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;
/**
* Shared LinkedIn publishing pipeline. The publish format follows the attached
* media — a PDF becomes a document post, 2+ images a multi-image post, and
* anything else (a single image/video or text only) a regular post. Subclasses
* provide the author identity (member vs. company page) and its public URL.
*/
abstract class AbstractLinkedInPublisher
{
use HasSocialHttpClient;
private string $apiVersion = '202601';
private string $accessToken;
protected SocialAccount $account;
private bool $hasRetried = false;
/**
* The LinkedIn account kind this publisher targets (member or company page).
*/
abstract protected function platform(): Platform;
/**
* The URN that authors the post and owns its uploaded media.
*/
abstract protected function authorUrn(): string;
/**
* Public URL of the created post. Defaults to the member feed update;
* company pages override it.
*/
protected function postUrl(?string $postId): ?string
{
return $postId ? "https://www.linkedin.com/feed/update/{$postId}" : null;
}
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->post->content
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
: null;
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
if ($this->account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($this->account);
}
$this->accessToken = $this->account->access_token;
try {
return $this->dispatchByMedia($content, $postPlatform);
} catch (TokenExpiredException $e) {
return $this->retryWithRefresh($postPlatform, $content, $e);
}
}
private function dispatchByMedia(?string $content, PostPlatform $postPlatform): array
{
$media = $postPlatform->post->mediaItems;
if ($media->contains(fn ($item) => $item->isDocument())) {
return $this->publishDocument($content, $media, $this->resolveDocumentTitle($postPlatform));
}
if ($media->filter(fn ($item) => $item->isImage())->count() >= 2) {
return $this->publishCarousel($content, $media);
}
return $this->publishPost($content, $media);
}
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
{
if ($this->hasRetried) {
throw $originalException;
}
$this->hasRetried = true;
try {
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->accessToken = $this->account->access_token;
return $this->dispatchByMedia($content, $postPlatform);
} catch (\Throwable $e) {
Log::error("{$this->label()} refresh failed during retry", [
'account_id' => $this->account->id,
'error' => $e->getMessage(),
]);
throw $originalException;
}
}
private function publishPost(?string $content, $media): array
{
$payload = $this->basePayload($content);
if ($media->isNotEmpty()) {
$item = $media->first();
$mediaUrn = $this->uploadMedia($item);
if ($mediaUrn) {
$payload['content'] = ['media' => array_filter([
'id' => $mediaUrn,
'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null,
], fn ($v) => $v !== null)];
}
}
return $this->createPost($payload, 'post creation');
}
private function publishCarousel(?string $content, $media): array
{
$images = [];
$imageItems = collect($media)
->filter(fn ($item) => $item->isImage())
->take($this->platform()->maxImages());
foreach ($imageItems as $item) {
$imageUrn = $this->uploadImage($item);
if ($imageUrn) {
$images[] = array_filter([
'id' => $imageUrn,
'altText' => $item->altTextFor($this->platform()),
], fn ($v) => $v !== null);
}
}
$payload = $this->basePayload($content);
$payload['content'] = ['multiImage' => ['images' => $images]];
return $this->createPost($payload, 'carousel post creation');
}
private function publishDocument(?string $content, $media, string $title): array
{
$document = $media->first(fn ($item) => $item->isDocument());
if (! $document) {
throw new LinkedInPublishException(
userMessage: "No PDF document was found for this {$this->label()} document post.",
category: ErrorCategory::MediaFormat,
);
}
$documentUrn = $this->uploadDocument($document);
if (! $documentUrn) {
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the document. Please try again.",
category: ErrorCategory::ServerError,
);
}
$payload = $this->basePayload($content);
$payload['content'] = ['media' => ['id' => $documentUrn, 'title' => $title]];
return $this->createPost($payload, 'document post creation');
}
/**
* The shared post envelope shared by every content shape.
*
* @return array<string, mixed>
*/
private function basePayload(?string $content): array
{
return [
'author' => $this->authorUrn(),
'commentary' => $content ?? '',
'visibility' => 'PUBLIC',
'distribution' => [
'feedDistribution' => 'MAIN_FEED',
'targetEntities' => [],
'thirdPartyDistributionChannels' => [],
],
'lifecycleState' => 'PUBLISHED',
];
}
/**
* POST the assembled payload to the Posts API and return the created post.
*
* @param array<string, mixed> $payload
* @return array{id: string, url: ?string}
*/
private function createPost(array $payload, string $context): array
{
$response = $this->getHttpClient()->post("{$this->baseUrl()}/rest/posts", $payload);
if ($response->failed()) {
Log::error("{$this->label()} {$context} failed", [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$postId = $response->header('x-restli-id');
return [
'id' => $postId ?? 'unknown',
'url' => $this->postUrl($postId ?: null),
];
}
/**
* 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(fn ($media) => $media->isDocument())?->original_filename ?? 'Document';
}
private function uploadMedia($mediaItem): ?string
{
return match (true) {
$mediaItem->isVideo() => $this->uploadVideo($mediaItem),
$mediaItem->isImage() => $this->uploadImage($mediaItem),
default => null,
};
}
private function uploadImage($mediaItem): ?string
{
$initResponse = $this->getHttpClient()
->post("{$this->baseUrl()}/rest/images?action=initializeUpload", [
'initializeUploadRequest' => ['owner' => $this->authorUrn()],
]);
if ($initResponse->failed()) {
Log::error("{$this->label()} image init failed", ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
$initData = $initResponse->json();
$uploadUrl = data_get($initData, 'value.uploadUrl');
$imageUrn = data_get($initData, 'value.image');
if (! $uploadUrl || ! $imageUrn) {
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the image upload. Please try again.",
category: ErrorCategory::ServerError,
);
}
$tempFile = tempnam(sys_get_temp_dir(), 'li_image_');
try {
$this->downloadToTempFile($mediaItem->url, $tempFile);
$detectedMime = mime_content_type($tempFile) ?: '';
if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) {
$optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, $this->platform());
@unlink($tempFile);
$tempFile = $optimizedPath;
}
$stream = fopen($tempFile, 'r');
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders(['Content-Type' => 'application/octet-stream'])
->withBody($stream, 'application/octet-stream')
->put($uploadUrl);
if (is_resource($stream)) {
fclose($stream);
}
if ($uploadResponse->failed()) {
Log::error("{$this->label()} image upload failed", ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
return $imageUrn;
} finally {
@unlink($tempFile);
}
}
private function uploadVideo($mediaItem): ?string
{
$tempFile = tempnam(sys_get_temp_dir(), 'li_video_');
try {
return $this->doUploadVideo($tempFile, $mediaItem);
} finally {
@unlink($tempFile);
}
}
private function doUploadVideo(string $tempFile, $mediaItem): ?string
{
$this->downloadToTempFile($mediaItem->url, $tempFile);
$fileSize = (int) filesize($tempFile);
$initResponse = $this->getHttpClient()
->post("{$this->baseUrl()}/rest/videos?action=initializeUpload", [
'initializeUploadRequest' => [
'owner' => $this->authorUrn(),
'fileSizeBytes' => $fileSize,
'uploadCaptions' => false,
'uploadThumbnail' => false,
],
]);
if ($initResponse->failed()) {
Log::error("{$this->label()} video init failed", ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
$initData = $initResponse->json();
$videoUrn = data_get($initData, 'value.video');
$uploadInstructions = data_get($initData, 'value.uploadInstructions', []);
$uploadToken = data_get($initData, 'value.uploadToken', '');
if (! $videoUrn || empty($uploadInstructions)) {
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the video upload. Please try again.",
category: ErrorCategory::ServerError,
);
}
$uploadedPartIds = [];
$handle = fopen($tempFile, 'r');
try {
foreach ($uploadInstructions as $index => $instruction) {
$firstByte = data_get($instruction, 'firstByte');
$lastByte = data_get($instruction, 'lastByte');
$chunkLength = $lastByte - $firstByte + 1;
fseek($handle, $firstByte);
$chunkData = fread($handle, $chunkLength);
$chunkResponse = Http::withToken($this->accessToken)
->withHeaders(['Content-Type' => 'application/octet-stream'])
->timeout(600)
->withBody($chunkData, 'application/octet-stream')
->put(data_get($instruction, 'uploadUrl'));
if ($chunkResponse->failed()) {
Log::error("{$this->label()} video chunk upload failed", [
'index' => $index,
'body' => $this->redactResponseBody($chunkResponse->body()),
]);
$this->handleApiError($chunkResponse);
}
$etag = $chunkResponse->header('etag');
if ($etag) {
$uploadedPartIds[] = $etag;
}
unset($chunkData, $chunkResponse);
$this->freeChunkMemory();
}
} finally {
fclose($handle);
}
$finalizeResponse = $this->getHttpClient()
->post("{$this->baseUrl()}/rest/videos?action=finalizeUpload", [
'finalizeUploadRequest' => [
'video' => $videoUrn,
'uploadToken' => $uploadToken,
'uploadedPartIds' => $uploadedPartIds,
],
]);
if ($finalizeResponse->failed()) {
Log::error("{$this->label()} video finalize failed", ['body' => $this->redactResponseBody($finalizeResponse->body())]);
$this->handleApiError($finalizeResponse);
}
$this->waitForProcessing('videos', $videoUrn, 'video');
return $videoUrn;
}
private function uploadDocument($mediaItem): ?string
{
$initResponse = $this->getHttpClient()
->post("{$this->baseUrl()}/rest/documents?action=initializeUpload", [
'initializeUploadRequest' => ['owner' => $this->authorUrn()],
]);
if ($initResponse->failed()) {
Log::error("{$this->label()} document init failed", ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
$initData = $initResponse->json();
$uploadUrl = data_get($initData, 'value.uploadUrl');
$documentUrn = data_get($initData, 'value.document');
if (! $uploadUrl || ! $documentUrn) {
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the document upload. Please try again.",
category: ErrorCategory::ServerError,
);
}
// The Documents API is not chunked — upload the PDF in a single request.
$tempFile = tempnam(sys_get_temp_dir(), 'li_document_');
try {
$this->downloadToTempFile($mediaItem->url, $tempFile);
$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("{$this->label()} document upload failed", ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
} finally {
@unlink($tempFile);
}
$this->waitForProcessing('documents', $documentUrn, 'document');
return $documentUrn;
}
/**
* Poll an asset until it finishes processing. LinkedIn rejects a post that
* references an asset still being processed, so we wait for AVAILABLE and
* fail loudly if it never gets there — publishing against an unprocessed
* asset would error out at the API anyway, with a far less obvious message.
*/
private function waitForProcessing(string $resource, string $assetUrn, string $label): void
{
$encodedUrn = urlencode($assetUrn);
for ($i = 0; $i < $this->processingMaxAttempts(); $i++) {
$response = $this->getHttpClient()->get("{$this->baseUrl()}/rest/{$resource}/{$encodedUrn}");
if ($response->failed()) {
Log::warning("{$this->label()} {$label} status check failed", ['attempt' => $i]);
sleep($this->processingPollSeconds());
continue;
}
$status = data_get($response->json(), 'status', 'UNKNOWN');
if ($status === 'AVAILABLE') {
return;
}
if ($status === 'PROCESSING_FAILED') {
throw new LinkedInPublishException(
userMessage: "{$this->label()} {$label} processing failed.",
category: ErrorCategory::ServerError,
);
}
sleep($this->processingPollSeconds());
}
throw new LinkedInPublishException(
userMessage: "{$this->label()} {$label} processing did not complete in time.",
category: ErrorCategory::ServerError,
);
}
/**
* How many times to poll an uploaded asset for AVAILABLE before giving up.
*/
protected function processingMaxAttempts(): int
{
return 30;
}
/**
* Seconds to wait between asset processing status checks.
*/
protected function processingPollSeconds(): int
{
return 5;
}
private function downloadToTempFile(string $url, string $tempFile): void
{
$response = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
if ($response->failed()) {
throw new LinkedInPublishException(
userMessage: "Could not fetch the media to upload to {$this->label()}. Please try again.",
category: ErrorCategory::ServerError,
);
}
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken)
->withHeaders([
'X-Restli-Protocol-Version' => '2.0.0',
'LinkedIn-Version' => $this->apiVersion,
'Content-Type' => 'application/json',
]);
}
private function baseUrl(): string
{
return config("trypost.platforms.{$this->platform()->value}.api");
}
private function label(): string
{
return $this->platform()->label();
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);
}
}