From 43de7372a65c923afca6bfc60377c7030d5cea4d Mon Sep 17 00:00:00 2001 From: Patrick Schrall Date: Tue, 23 Jun 2026 08:00:33 +0200 Subject: [PATCH] feat(bluesky): support video upload and embedding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlueskyPublisher only attached images; posts whose media was a video published as text-only with no embed. Bluesky videos do not use the PDS uploadBlob path — they go through the separate video service (video.bsky.app), which transcodes the file and writes the resulting blob back to the account's PDS. Add a video path that runs when no image embed was built (a post carries either images or one video, never both): 1. Resolve the account's real PDS host from its DID document (entryway accounts store bsky.social as `service`, but the video service-auth audience must be the actual *.host.bsky.network PDS). 2. Mint a com.atproto.server.getServiceAuth token scoped to uploadBlob. 3. POST the bytes to app.bsky.video.uploadVideo. 4. Poll app.bsky.video.getJobStatus until the blob is ready, then embed it as app.bsky.embed.video. Failures (download, oversized >100MB, service-auth, transcode) log and return null so the post still publishes as text rather than crashing the job — mirroring uploadBlob(). New NSID constants live in BlueskyLexicon and the video service / PLC directory hosts are configurable. Co-Authored-By: Claude Opus 4.8 --- app/Services/Social/BlueskyLexicon.php | 10 + app/Services/Social/BlueskyPublisher.php | 256 ++++++++++++++++++ config/trypost.php | 6 + .../Services/Social/BlueskyPublisherTest.php | 127 +++++++++ 4 files changed, 399 insertions(+) diff --git a/app/Services/Social/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index 048f87ae..0bc90175 100644 --- a/app/Services/Social/BlueskyLexicon.php +++ b/app/Services/Social/BlueskyLexicon.php @@ -21,6 +21,14 @@ final class BlueskyLexicon public const REFRESH_SESSION = 'com.atproto.server.refreshSession'; + public const GET_SERVICE_AUTH = 'com.atproto.server.getServiceAuth'; + + public const VIDEO_UPLOAD = 'app.bsky.video.uploadVideo'; + + public const VIDEO_GET_JOB_STATUS = 'app.bsky.video.getJobStatus'; + + public const VIDEO_GET_UPLOAD_LIMITS = 'app.bsky.video.getUploadLimits'; + public const FEED_POST = 'app.bsky.feed.post'; public const GET_POSTS = 'app.bsky.feed.getPosts'; @@ -29,6 +37,8 @@ final class BlueskyLexicon public const EMBED_IMAGES = 'app.bsky.embed.images'; + public const EMBED_VIDEO = 'app.bsky.embed.video'; + public const FACET_LINK = 'app.bsky.richtext.facet#link'; public const FACET_MENTION = 'app.bsky.richtext.facet#mention'; diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index cc998718..fc6664fe 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -60,6 +60,23 @@ public function publish(PostPlatform $postPlatform): array } } + // A post carries either images or a single video, never both. Only look + // for a video when no image embed was built (mirrors the official client). + if ($embed === null) { + $video = $medias->first(fn ($media) => $media->isVideo()); + + if ($video) { + $videoBlob = $this->uploadVideo($account, $service, $video->url); + + if ($videoBlob) { + $embed = [ + '$type' => BlueskyLexicon::EMBED_VIDEO, + 'video' => $videoBlob, + ]; + } + } + } + // Parse facets (links, mentions, hashtags) from text $text = $content ?? ''; $facets = $this->parseFacets($text); @@ -168,6 +185,245 @@ private function uploadBlob(SocialAccount $account, string $service, string $url } } + /** + * Upload a video to Bluesky and return the processed blob for embedding. + * + * Unlike images, video does not go to the PDS via uploadBlob. It is sent to + * the separate video service (video.bsky.app), which transcodes it and + * stores the resulting blob on the account's PDS. The flow is: + * 1. resolve the account's real PDS host (for the service-auth audience), + * 2. mint a service-auth token scoped to uploadBlob, + * 3. POST the bytes to app.bsky.video.uploadVideo, + * 4. poll app.bsky.video.getJobStatus until the blob is ready. + * + * Returns null on any failure so the post still publishes as text rather + * than crashing the whole job (mirrors uploadBlob()). + */ + private function uploadVideo(SocialAccount $account, string $service, string $url): ?array + { + $videoService = (string) config('trypost.platforms.bluesky.video_service'); + $did = (string) $account->platform_user_id; + + $tempFile = tempnam(sys_get_temp_dir(), 'bsky_video_'); + + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($downloadResponse->failed()) { + throw new Exception('Failed to download video: HTTP '.$downloadResponse->status()); + } + + $fileSize = filesize($tempFile); + + if ($fileSize === false || $fileSize === 0) { + Log::error('Bluesky failed to download video', ['url' => $url]); + + return null; + } + + // Bluesky caps videos at 100MB; skip oversized files rather than + // burning an upload that the service will reject. + if ($fileSize > 100 * 1024 * 1024) { + Log::error('Bluesky video exceeds 100MB limit', ['url' => $url, 'size' => $fileSize]); + + return null; + } + + $pds = $this->resolvePdsEndpoint($account, $service); + $pdsHost = parse_url($pds, PHP_URL_HOST); + + if (! is_string($pdsHost) || $pdsHost === '') { + Log::error('Bluesky could not resolve PDS host for video upload', ['did' => $did]); + + return null; + } + + // The upload token is consumed by the video service to write the + // blob back to the user's PDS, so its audience is the PDS itself. + $uploadToken = $this->getServiceAuth($account, $pds, "did:web:{$pdsHost}", BlueskyLexicon::UPLOAD_BLOB); + + if ($uploadToken === null) { + return null; + } + + $name = bin2hex(random_bytes(8)).'.mp4'; + $uploadUrl = "{$videoService}/xrpc/".BlueskyLexicon::VIDEO_UPLOAD + .'?did='.rawurlencode($did).'&name='.rawurlencode($name); + + $stream = fopen($tempFile, 'r'); + + $response = $this->socialHttp()->withToken($uploadToken) + ->withHeaders(['Content-Type' => 'video/mp4']) + ->withBody($stream, 'video/mp4') + ->post($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + $jobStatus = data_get($response->json(), 'jobStatus'); + + // A re-upload of identical bytes returns 409 with the already + // finished job, whose blob we can embed directly. + if ($response->failed() && $response->status() !== 409) { + Log::error('Bluesky video upload failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + if (data_get($jobStatus, 'blob')) { + return data_get($jobStatus, 'blob'); + } + + $jobId = data_get($jobStatus, 'jobId'); + + if (! is_string($jobId) || $jobId === '') { + Log::error('Bluesky video upload returned no jobId', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + return $this->pollVideoJob($account, $pds, $videoService, $jobId); + } catch (Exception $e) { + Log::error('Bluesky video upload exception', [ + 'error' => $e->getMessage(), + 'url' => $url, + ]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * Poll the video service until the transcode job finishes, then return its + * blob. Returns null if the job fails or never completes in time. + */ + private function pollVideoJob(SocialAccount $account, string $pds, string $videoService, string $jobId): ?array + { + $videoServiceDid = (string) config('trypost.platforms.bluesky.video_service_did'); + $jobToken = $this->getServiceAuth($account, $pds, $videoServiceDid, BlueskyLexicon::VIDEO_GET_JOB_STATUS); + + if ($jobToken === null) { + return null; + } + + $statusUrl = "{$videoService}/xrpc/".BlueskyLexicon::VIDEO_GET_JOB_STATUS; + + // Up to ~5 minutes; processing usually finishes within seconds. State is + // checked before sleeping so an already-complete job returns at once. + for ($attempt = 0; $attempt < 150; $attempt++) { + $response = $this->socialHttp()->withToken($jobToken) + ->get($statusUrl, ['jobId' => $jobId]); + + $jobStatus = data_get($response->json(), 'jobStatus'); + $state = data_get($jobStatus, 'state'); + + if ($state === 'JOB_STATE_COMPLETED' && data_get($jobStatus, 'blob')) { + return data_get($jobStatus, 'blob'); + } + + if ($state === 'JOB_STATE_FAILED') { + Log::error('Bluesky video processing failed', [ + 'jobId' => $jobId, + 'message' => data_get($jobStatus, 'message'), + ]); + + return null; + } + + sleep(2); + } + + Log::error('Bluesky video processing timed out', ['jobId' => $jobId]); + + return null; + } + + /** + * Mint a short-lived service-auth token (com.atproto.server.getServiceAuth) + * scoped to a single audience + method, used to authorize the video service. + */ + private function getServiceAuth(SocialAccount $account, string $pds, string $aud, string $lxm): ?string + { + try { + $response = $this->socialHttp()->withToken($account->access_token) + ->get("{$pds}/xrpc/".BlueskyLexicon::GET_SERVICE_AUTH, [ + 'aud' => $aud, + 'lxm' => $lxm, + 'exp' => now()->addMinutes(30)->timestamp, + ]); + + if ($response->failed()) { + Log::error('Bluesky getServiceAuth failed', [ + 'status' => $response->status(), + 'lxm' => $lxm, + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + $token = data_get($response->json(), 'token'); + + return is_string($token) && $token !== '' ? $token : null; + } catch (Throwable $e) { + Log::error('Bluesky getServiceAuth exception', ['error' => $e->getMessage(), 'lxm' => $lxm]); + + return null; + } + } + + /** + * Resolve the account's real PDS service endpoint from its DID document. + * Bluesky entryway accounts store the entryway URL as `service`, but video + * service-auth must be scoped to the actual PDS host (e.g. *.host.bsky.network). + * Falls back to the stored service URL when the DID document is unavailable. + */ + private function resolvePdsEndpoint(SocialAccount $account, string $service): string + { + $did = (string) $account->platform_user_id; + + try { + $docUrl = null; + if (str_starts_with($did, 'did:plc:')) { + $directory = (string) config('trypost.platforms.bluesky.plc_directory'); + $docUrl = "{$directory}/".rawurlencode($did); + } elseif (str_starts_with($did, 'did:web:')) { + $host = substr($did, strlen('did:web:')); + $docUrl = "https://{$host}/.well-known/did.json"; + } + + if ($docUrl !== null) { + $response = $this->socialHttp()->get($docUrl); + + if ($response->successful()) { + $services = data_get($response->json(), 'service', []); + foreach ($services as $entry) { + $type = data_get($entry, 'type'); + $id = data_get($entry, 'id'); + if ($type === 'AtprotoPersonalDataServer' || $id === '#atproto_pds') { + $endpoint = data_get($entry, 'serviceEndpoint'); + if (is_string($endpoint) && $endpoint !== '') { + return $endpoint; + } + } + } + } + } + } catch (Throwable $e) { + Log::warning('Bluesky PDS resolution failed', ['did' => $did, 'error' => $e->getMessage()]); + } + + return $service; + } + private function parseFacets(string $text): array { $facets = []; diff --git a/config/trypost.php b/config/trypost.php index 16bac3f5..7f80bfe7 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -147,6 +147,12 @@ 'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'), // Web client where published posts are viewed (profile/post URLs). 'web_app' => env('BLUESKY_WEB_APP', 'https://bsky.app'), + // Video upload service (separate from the PDS). Videos are processed + // here, then the resulting blob is embedded in the post record. + 'video_service' => env('BLUESKY_VIDEO_SERVICE', 'https://video.bsky.app'), + 'video_service_did' => env('BLUESKY_VIDEO_SERVICE_DID', 'did:web:video.bsky.app'), + // PLC directory, used to resolve an account's real PDS host from its DID. + 'plc_directory' => env('BLUESKY_PLC_DIRECTORY', 'https://plc.directory'), ], 'mastodon' => [ 'enabled' => env('MASTODON_ENABLED', true), diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 76c648ad..1db84237 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -594,3 +594,130 @@ && count($request['record']['embed']['images'] ?? []) === 4; }); }); + +/** + * Fake the Bluesky video pipeline. Order matters: getServiceAuth must be + * matched before uploadBlob because its query carries lxm=...uploadBlob. + */ +function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool $blobOnComplete = true): void +{ + Http::fake(function ($request) use ($jobState, $blobOnComplete) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response([ + 'service' => [[ + 'id' => '#atproto_pds', + 'type' => 'AtprotoPersonalDataServer', + 'serviceEndpoint' => 'https://pds.example.host', + ]], + ], 200); + } + + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobStatus' => ['jobId' => 'job-123', 'state' => 'JOB_STATE_CREATED']], 200); + } + + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + $status = ['jobId' => 'job-123', 'state' => $jobState]; + if ($jobState === 'JOB_STATE_COMPLETED' && $blobOnComplete) { + $status['blob'] = ['$type' => 'blob', 'ref' => ['$link' => 'bafvideo123'], 'mimeType' => 'video/mp4', 'size' => 2048]; + } + if ($jobState === 'JOB_STATE_FAILED') { + $status['message'] = 'processing error'; + } + + return Http::response(['jobStatus' => $status], 200); + } + + if (str_contains($url, 'createRecord')) { + return Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3vid123xyz', + 'cid' => 'bafyreivid123', + ], 200); + } + + return Http::response(str_repeat('v', 2048), 200); // video download + }); +} + +test('bluesky publisher uploads a video and embeds it', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-video-id', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test.mp4', + ]], + ]); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + + $embed = $request['record']['embed'] ?? null; + + return $embed + && $embed['$type'] === 'app.bsky.embed.video' + && data_get($embed, 'video.ref.$link') === 'bafvideo123'; + }); + + // The video goes to the video service, never to the PDS uploadBlob endpoint. + Http::assertSent(fn ($request) => str_contains($request->url(), 'app.bsky.video.uploadVideo')); +}); + +test('bluesky publisher scopes the upload service-auth to the resolved PDS host', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-video-id', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test.mp4', + ]], + ]); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + // Audience for the upload token must be the real PDS host from the DID doc, + // not the bsky.social entryway the account was connected through. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'getServiceAuth') + && str_contains($request->url(), 'aud=did%3Aweb%3Apds.example.host') + && str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob'); + }); +}); + +test('bluesky publisher publishes text-only when video processing fails', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-video-id', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test.mp4', + ]], + ]); + + fakeBlueskyVideoPipeline('JOB_STATE_FAILED'); + + $this->publisher->publish($this->postPlatform); + + // A failed transcode must not crash the job; the post still goes out as text. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'createRecord') + && ! isset($request['record']['embed']); + }); +});