feat(bluesky): support video upload and embedding
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 <noreply@anthropic.com>
This commit is contained in:
parent
97249f3bab
commit
43de7372a6
4 changed files with 399 additions and 0 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue