Merge pull request #110 from Schrall/feat/bluesky-video-upload
feat(bluesky): support video upload and embedding
This commit is contained in:
commit
0ae4b03e23
13 changed files with 1257 additions and 21 deletions
|
|
@ -216,6 +216,20 @@ public function supportsImage(): bool
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a single post may carry images and a video together. Most
|
||||
* targets accept only one or the other; this is conservative and lists
|
||||
* only the types we've confirmed reject mixed media (e.g. a Bluesky post
|
||||
* embed is `images` XOR `video`, never both).
|
||||
*/
|
||||
public function supportsMixedMedia(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BlueskyPost => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this content type carries a text caption visible to viewers.
|
||||
* Stories are image-overlay only — viewers don't see a separate caption.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
|||
if ($hasVideo && ! $contentType->supportsVideo()) {
|
||||
$fail("{$contentType->label()} does not support videos.");
|
||||
}
|
||||
|
||||
if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) {
|
||||
$fail("{$contentType->label()} can't combine an image and a video in the same post.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ 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 FEED_POST = 'app.bsky.feed.post';
|
||||
|
||||
public const GET_POSTS = 'app.bsky.feed.getPosts';
|
||||
|
|
@ -29,6 +35,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';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Models\SocialAccount;
|
||||
use App\Services\Media\MediaOptimizer;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use Carbon\CarbonInterface;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -20,6 +21,22 @@ class BlueskyPublisher
|
|||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
/** Seconds allowed for a remote media download (large videos need time). */
|
||||
private const DOWNLOAD_TIMEOUT = 600;
|
||||
|
||||
/** Re-upload a transiently-failing transcode this many times before giving up. */
|
||||
private const VIDEO_UPLOAD_ATTEMPTS = 3;
|
||||
|
||||
/** Poll getJobStatus up to this many times (× the configured interval) before timing out. */
|
||||
private const VIDEO_POLL_MAX_ATTEMPTS = 150;
|
||||
|
||||
/** Wall-clock budget (seconds) for the whole upload+poll+retry flow, kept under the 600s job timeout so a stuck transcode degrades to text instead of being killed mid-flight. */
|
||||
private const VIDEO_PROCESSING_BUDGET = 420;
|
||||
|
||||
private const JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED';
|
||||
|
||||
private const JOB_STATE_FAILED = 'JOB_STATE_FAILED';
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
|
@ -60,6 +77,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, $video->mime_type);
|
||||
|
||||
if ($videoBlob) {
|
||||
$embed = [
|
||||
'$type' => BlueskyLexicon::EMBED_VIDEO,
|
||||
'video' => $videoBlob,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse facets (links, mentions, hashtags) from text
|
||||
$text = $content ?? '';
|
||||
$facets = $this->parseFacets($text);
|
||||
|
|
@ -109,27 +143,16 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'bsky_blob_');
|
||||
$tempFile = $this->downloadToTempFile($url, 'bsky_blob_');
|
||||
|
||||
if ($tempFile === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
|
||||
|
||||
if ($downloadResponse->failed()) {
|
||||
throw new Exception('Failed to download media: HTTP '.$downloadResponse->status());
|
||||
}
|
||||
|
||||
$fileSize = filesize($tempFile);
|
||||
|
||||
if ($fileSize === false || $fileSize === 0) {
|
||||
Log::error('Bluesky failed to download media', ['url' => $url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optimize images for Bluesky's 1MB limit
|
||||
// Optimize images for Bluesky's 1MB limit (GIFs are passed through untouched).
|
||||
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
|
||||
$optimizer = app(MediaOptimizer::class);
|
||||
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky);
|
||||
$optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Bluesky);
|
||||
@unlink($tempFile);
|
||||
$tempFile = $optimizedPath;
|
||||
$mimeType = 'image/jpeg';
|
||||
|
|
@ -137,6 +160,12 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
|
|||
|
||||
$stream = fopen($tempFile, 'r');
|
||||
|
||||
if ($stream === false) {
|
||||
Log::error('Bluesky could not open media file for upload', ['file' => $tempFile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->socialHttp()->withToken($account->access_token)
|
||||
->withHeaders(['Content-Type' => $mimeType])
|
||||
->withBody($stream, $mimeType)
|
||||
|
|
@ -156,7 +185,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
|
|||
}
|
||||
|
||||
return data_get($response->json(), 'blob');
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Bluesky blob upload exception', [
|
||||
'error' => $e->getMessage(),
|
||||
'url' => $url,
|
||||
|
|
@ -168,6 +197,357 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a remote media file to a temp file. Returns the temp path, or
|
||||
* null (after cleaning up) if the temp file can't be created, the download
|
||||
* fails, or the downloaded file is empty.
|
||||
*/
|
||||
private function downloadToTempFile(string $url, string $prefix): ?string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), $prefix);
|
||||
|
||||
if ($tempFile === false) {
|
||||
Log::error('Bluesky could not create temp file for download', ['url' => $url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withOptions(['sink' => $tempFile])->timeout(self::DOWNLOAD_TIMEOUT)->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new Exception('HTTP '.$response->status());
|
||||
}
|
||||
|
||||
$size = filesize($tempFile);
|
||||
|
||||
if ($size === false || $size === 0) {
|
||||
throw new Exception('downloaded file is empty');
|
||||
}
|
||||
|
||||
return $tempFile;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Bluesky media download failed', ['url' => $url, 'error' => $e->getMessage()]);
|
||||
@unlink($tempFile);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 upload-token audience),
|
||||
* 2. mint two service-auth tokens — one for the video service to write
|
||||
* the blob back to the PDS (uploadBlob), one to poll job status,
|
||||
* 3. POST the bytes to app.bsky.video.uploadVideo,
|
||||
* 4. poll app.bsky.video.getJobStatus until the blob is ready,
|
||||
* retrying the whole upload a few times on a transient transcode failure.
|
||||
*
|
||||
* 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, ?string $mimeType): ?array
|
||||
{
|
||||
$tempFile = $this->downloadToTempFile($url, 'bsky_video_');
|
||||
|
||||
if ($tempFile === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$fileSize = filesize($tempFile);
|
||||
|
||||
// Bluesky caps videos at 100MB; skip oversized files rather than
|
||||
// burning an upload that the service will reject.
|
||||
if ($fileSize > (int) config('trypost.platforms.bluesky.video_max_bytes')) {
|
||||
Log::error('Bluesky video exceeds size 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' => $account->platform_user_id]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Two service-auth tokens, minted once (valid 30 min) and reused
|
||||
// across retries:
|
||||
// - upload: lets the video service write the blob back to the
|
||||
// user's PDS, so its audience is the PDS itself;
|
||||
// - status: lets us poll the video service for the transcode job.
|
||||
$uploadToken = $this->getServiceAuth($account, $pds, "did:web:{$pdsHost}", BlueskyLexicon::UPLOAD_BLOB);
|
||||
$statusToken = $this->getServiceAuth($account, $pds, (string) config('trypost.platforms.bluesky.video_service_did'), BlueskyLexicon::VIDEO_GET_JOB_STATUS);
|
||||
|
||||
if ($uploadToken === null || $statusToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bound the whole upload+poll+retry flow to a wall-clock budget that
|
||||
// stays under the queue job timeout: a stuck transcode must give up
|
||||
// and let the post publish as text, not run the worker to its
|
||||
// timeout (which would drop the post entirely on a $tries=1 job).
|
||||
$deadline = now()->addSeconds(self::VIDEO_PROCESSING_BUDGET);
|
||||
|
||||
// The transcoder occasionally fails a job transiently
|
||||
// (JOB_STATE_FAILED "Failed to process video") even for valid input.
|
||||
// Re-uploading starts a fresh job, so retry until the budget runs out.
|
||||
for ($attempt = 1; $attempt <= self::VIDEO_UPLOAD_ATTEMPTS && now()->lessThan($deadline); $attempt++) {
|
||||
$blob = $this->attemptVideoUpload($account, $uploadToken, $statusToken, $tempFile, $mimeType, $deadline);
|
||||
|
||||
if ($blob !== null) {
|
||||
return $blob;
|
||||
}
|
||||
|
||||
if ($attempt < self::VIDEO_UPLOAD_ATTEMPTS) {
|
||||
Log::warning('Bluesky video upload attempt failed, retrying', [
|
||||
'attempt' => $attempt,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Bluesky video upload exception', [
|
||||
'error' => $e->getMessage(),
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single upload-and-poll attempt against the video service. Returns the
|
||||
* processed blob, or null if this attempt failed (so the caller can retry).
|
||||
*/
|
||||
private function attemptVideoUpload(SocialAccount $account, string $uploadToken, string $statusToken, string $tempFile, ?string $mimeType, CarbonInterface $deadline): ?array
|
||||
{
|
||||
$stream = fopen($tempFile, 'r');
|
||||
|
||||
if ($stream === false) {
|
||||
Log::error('Bluesky could not open video file for upload', ['file' => $tempFile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[$contentType, $extension] = $this->videoUploadFormat($mimeType);
|
||||
$videoService = (string) config('trypost.platforms.bluesky.video_service');
|
||||
$name = bin2hex(random_bytes(8)).'.'.$extension;
|
||||
$uploadUrl = "{$videoService}/xrpc/".BlueskyLexicon::VIDEO_UPLOAD
|
||||
.'?did='.rawurlencode($account->platform_user_id).'&name='.rawurlencode($name);
|
||||
|
||||
$response = $this->socialHttp()->withToken($uploadToken)
|
||||
->withHeaders(['Content-Type' => $contentType])
|
||||
->withBody($stream, $contentType)
|
||||
->post($uploadUrl);
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
$jobStatus = $this->unwrapJobStatus($response->json());
|
||||
|
||||
if ($blob = data_get($jobStatus, 'blob')) {
|
||||
return $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($statusToken, $jobId, $deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the video service until the transcode job finishes, then return its
|
||||
* blob. Returns null if the job fails or never completes within the shared
|
||||
* deadline. The status token is minted by the caller and reused per poll.
|
||||
*/
|
||||
private function pollVideoJob(string $statusToken, string $jobId, CarbonInterface $deadline): ?array
|
||||
{
|
||||
$statusUrl = (string) config('trypost.platforms.bluesky.video_service').'/xrpc/'.BlueskyLexicon::VIDEO_GET_JOB_STATUS;
|
||||
$intervalSeconds = (int) config('trypost.platforms.bluesky.video_poll_seconds');
|
||||
|
||||
// Processing usually finishes within seconds. State is checked before
|
||||
// sleeping so an already-complete job returns at once. The attempt cap
|
||||
// and the wall-clock deadline both bound the loop.
|
||||
for ($attempt = 0; $attempt < self::VIDEO_POLL_MAX_ATTEMPTS && now()->lessThan($deadline); $attempt++) {
|
||||
$response = $this->socialHttp()->withToken($statusToken)
|
||||
->get($statusUrl, ['jobId' => $jobId]);
|
||||
|
||||
// Bail on a hard error (e.g. expired/invalid token) instead of
|
||||
// sleeping to the timeout and masking the real failure.
|
||||
if ($response->failed()) {
|
||||
Log::error('Bluesky getJobStatus failed', [
|
||||
'jobId' => $jobId,
|
||||
'status' => $response->status(),
|
||||
'body' => $this->redactResponseBody($response->body()),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$jobStatus = $this->unwrapJobStatus($response->json());
|
||||
$state = data_get($jobStatus, 'state');
|
||||
|
||||
if ($state === self::JOB_STATE_COMPLETED && ($blob = data_get($jobStatus, 'blob'))) {
|
||||
return $blob;
|
||||
}
|
||||
|
||||
if ($state === self::JOB_STATE_FAILED) {
|
||||
Log::error('Bluesky video processing failed', [
|
||||
'jobId' => $jobId,
|
||||
'message' => data_get($jobStatus, 'message'),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
sleep($intervalSeconds);
|
||||
}
|
||||
|
||||
Log::error('Bluesky video processing timed out', ['jobId' => $jobId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* uploadVideo returns the job status at the top level, while getJobStatus
|
||||
* wraps it under a `jobStatus` key. Fall back to the top level only when the
|
||||
* key is absent (null), not when it's present-but-empty.
|
||||
*/
|
||||
private function unwrapJobStatus(mixed $body): mixed
|
||||
{
|
||||
return data_get($body, 'jobStatus') ?? $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a video mime type to the [Content-Type, file extension] the upload
|
||||
* should carry. Bluesky accepts mp4, mpeg, webm and mov; anything else
|
||||
* (or an unknown mime) is sent as mp4 and left to the transcoder.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function videoUploadFormat(?string $mimeType): array
|
||||
{
|
||||
return match ($mimeType) {
|
||||
'video/mpeg' => ['video/mpeg', 'mpeg'],
|
||||
'video/webm' => ['video/webm', 'webm'],
|
||||
'video/quicktime' => ['video/quicktime', 'mov'],
|
||||
default => ['video/mp4', 'mp4'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:')) {
|
||||
// Per the did:web spec, colon-separated segments map to a host
|
||||
// plus optional path; a bare host uses /.well-known/did.json.
|
||||
$segments = array_map('rawurldecode', explode(':', substr($did, strlen('did:web:'))));
|
||||
$host = array_shift($segments);
|
||||
$path = $segments === [] ? '/.well-known/did.json' : '/'.implode('/', $segments).'/did.json';
|
||||
$docUrl = "https://{$host}{$path}";
|
||||
}
|
||||
|
||||
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,16 @@
|
|||
'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'),
|
||||
// Seconds between transcode job-status polls.
|
||||
'video_poll_seconds' => env('BLUESKY_VIDEO_POLL_SECONDS', 2),
|
||||
// Bluesky rejects videos larger than 100 MB; skip oversized files early.
|
||||
'video_max_bytes' => env('BLUESKY_VIDEO_MAX_BYTES', 100 * 1024 * 1024),
|
||||
// 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),
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@
|
|||
'too_few_files' => 'Add at least :min files for this format.',
|
||||
'no_videos' => 'Only images are allowed for this format.',
|
||||
'no_images' => 'Only videos are allowed for this format.',
|
||||
'no_mixed_media' => "Images and videos can't be combined in the same post.",
|
||||
'no_gifs' => 'GIFs are not supported here.',
|
||||
'video_too_large' => 'Video exceeds the size limit for this platform.',
|
||||
'video_too_long' => 'Video must be under :seconds seconds for this format.',
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@
|
|||
'too_few_files' => 'Agrega al menos :min archivos para este formato.',
|
||||
'no_videos' => 'Solo se permiten imágenes en este formato.',
|
||||
'no_images' => 'Solo se permiten videos en este formato.',
|
||||
'no_mixed_media' => 'No se pueden combinar imágenes y videos en la misma publicación.',
|
||||
'no_gifs' => 'Los GIFs no son compatibles aquí.',
|
||||
'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.',
|
||||
'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.',
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@
|
|||
'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.',
|
||||
'no_videos' => 'Apenas imagens são permitidas neste formato.',
|
||||
'no_images' => 'Apenas vídeos são permitidos neste formato.',
|
||||
'no_mixed_media' => 'Não é possível combinar imagem e vídeo na mesma publicação.',
|
||||
'no_gifs' => 'GIFs não são suportados aqui.',
|
||||
'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.',
|
||||
'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface MediaRules {
|
|||
acceptVideos: boolean;
|
||||
requiresMedia: boolean;
|
||||
acceptsGif: boolean;
|
||||
forbidsMixedMedia?: boolean;
|
||||
maxImageBytes?: number;
|
||||
maxVideoBytes?: number;
|
||||
maxVideoDurationSec?: number;
|
||||
|
|
@ -131,10 +132,11 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
|
|||
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60,
|
||||
},
|
||||
|
||||
// Bluesky — accepts GIF; tight image size (auto-resized by backend)
|
||||
// Bluesky — accepts GIF; tight image size (auto-resized by backend).
|
||||
// The embed is images XOR video, so the two can't be combined in one post.
|
||||
bluesky_post: {
|
||||
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
|
||||
acceptsGif: true,
|
||||
acceptsGif: true, forbidsMixedMedia: true,
|
||||
maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const getMediaIncompatibilityReason = (
|
|||
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
|
||||
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
|
||||
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
|
||||
if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) return trans('posts.edit.compliance.no_mixed_media');
|
||||
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
|
||||
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
|
||||
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
|
||||
|
|
|
|||
|
|
@ -594,3 +594,767 @@
|
|||
&& count($request['record']['embed']['images'] ?? []) === 4;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach a single video to the post under test (mp4 by default).
|
||||
*/
|
||||
function attachBlueskyVideo(Post $post, string $mimeType = 'video/mp4'): void
|
||||
{
|
||||
$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' => $mimeType,
|
||||
'original_filename' => 'test.mp4',
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// Poll without sleeping so multi-poll paths stay instant under test.
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
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')) {
|
||||
// uploadVideo returns the jobStatus object directly (not wrapped).
|
||||
return Http::response(['jobId' => 'job-123', 'did' => 'did:plc:testuser123', '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 () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
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 () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher retries a transient video transcode failure', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
$jobCalls = 0;
|
||||
Http::fake(function ($request) use (&$jobCalls) {
|
||||
$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(['jobId' => 'job-123', 'state' => 'JOB_STATE_CREATED'], 200);
|
||||
}
|
||||
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
|
||||
$jobCalls++;
|
||||
// First attempt's job fails transiently; the retry succeeds.
|
||||
if ($jobCalls === 1) {
|
||||
return Http::response(['jobStatus' => ['jobId' => 'job-123', 'state' => 'JOB_STATE_FAILED', 'message' => 'transient']], 200);
|
||||
}
|
||||
|
||||
return Http::response(['jobStatus' => [
|
||||
'jobId' => 'job-123', 'state' => 'JOB_STATE_COMPLETED',
|
||||
'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafretry456'], 'mimeType' => 'video/mp4', 'size' => 2048],
|
||||
]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3retry', 'cid' => 'bafretry'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
expect($jobCalls)->toBeGreaterThanOrEqual(2);
|
||||
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') === 'bafretry456';
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher skips an oversized video and publishes text-only', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_max_bytes' => 1024]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
if (str_contains($request->url(), 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3big', 'cid' => 'bafbig'], 200);
|
||||
}
|
||||
|
||||
// Video download is 2 KB — over the 1 KB cap set above.
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Oversized video is dropped before any upload; the post still goes out as text.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'app.bsky.video.uploadVideo'));
|
||||
});
|
||||
|
||||
test('bluesky publisher publishes text-only when the video download fails', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
if (str_contains($request->url(), 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3dl', 'cid' => 'bafdl'], 200);
|
||||
}
|
||||
|
||||
// The CDN download 404s — no video, no service-auth, just text.
|
||||
return Http::response('Not Found', 404);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'getServiceAuth'));
|
||||
});
|
||||
|
||||
test('bluesky publisher publishes text-only when service-auth minting fails', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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(['error' => 'AuthRequired'], 400);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3auth', 'cid' => 'bafauth'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Without a service-auth token the upload can't proceed; degrade to text.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'app.bsky.video.uploadVideo'));
|
||||
});
|
||||
|
||||
test('bluesky publisher embeds the existing blob when upload returns 409 already-exists', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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')) {
|
||||
// Re-upload of identical bytes: 409 carrying the already-finished job.
|
||||
return Http::response([
|
||||
'jobId' => 'job-dup', 'state' => 'JOB_STATE_COMPLETED',
|
||||
'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafdup789'], 'mimeType' => 'video/mp4', 'size' => 2048],
|
||||
], 409);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3dup', 'cid' => 'bafdup'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$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') === 'bafdup789';
|
||||
});
|
||||
|
||||
// The blob came straight from the 409 body; no job polling needed. Match the
|
||||
// endpoint path, not the bare NSID — the status-token getServiceAuth request
|
||||
// also carries `lxm=app.bsky.video.getJobStatus` in its query string.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.getJobStatus'));
|
||||
});
|
||||
|
||||
test('bluesky publisher publishes text-only when upload returns no job id', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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([], 200); // neither blob nor jobId
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3nojob', 'cid' => 'bafnojob'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
});
|
||||
|
||||
test('bluesky publisher publishes text-only when getJobStatus errors', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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(['jobId' => 'job-err', 'state' => 'JOB_STATE_CREATED'], 200);
|
||||
}
|
||||
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
|
||||
return Http::response(['error' => 'InternalServerError'], 500);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3joberr', 'cid' => 'bafjoberr'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// A failing getJobStatus bails immediately rather than sleeping to timeout.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.getJobStatus'));
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
});
|
||||
|
||||
test('bluesky publisher retries the upload up to three times before giving up', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
$uploads = 0;
|
||||
Http::fake(function ($request) use (&$uploads) {
|
||||
$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')) {
|
||||
$uploads++;
|
||||
|
||||
return Http::response(['jobId' => "job-{$uploads}", 'state' => 'JOB_STATE_CREATED'], 200);
|
||||
}
|
||||
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
|
||||
return Http::response(['jobStatus' => ['jobId' => 'job', 'state' => 'JOB_STATE_FAILED', 'message' => 'permanent']], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3exhaust', 'cid' => 'bafex'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Every attempt fails permanently → exactly VIDEO_UPLOAD_ATTEMPTS uploads, then text-only.
|
||||
expect($uploads)->toBe(3);
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
});
|
||||
|
||||
test('bluesky publisher times out and publishes text-only when the job never completes', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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(['jobId' => 'job-stuck', 'state' => 'JOB_STATE_CREATED'], 200);
|
||||
}
|
||||
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
|
||||
// Never reaches a terminal state — exercises the poll timeout.
|
||||
return Http::response(['jobStatus' => ['jobId' => 'job-stuck', 'state' => 'JOB_STATE_RUNNING']], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3stuck', 'cid' => 'bafstuck'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
});
|
||||
|
||||
test('bluesky publisher falls back to the entryway when the DID document is unavailable', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (str_contains($url, 'plc.directory')) {
|
||||
return Http::response(['error' => 'NotFound'], 500); // DID doc unavailable
|
||||
}
|
||||
if (str_contains($url, 'getServiceAuth')) {
|
||||
return Http::response(['token' => 'service-auth-token'], 200);
|
||||
}
|
||||
if (str_contains($url, 'app.bsky.video.uploadVideo')) {
|
||||
return Http::response(['jobId' => 'job-fb', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'baffb'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3fb', 'cid' => 'baffb'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// With no DID doc, the upload-token audience falls back to the entryway host (bsky.social).
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'getServiceAuth')
|
||||
&& str_contains($request->url(), 'aud=did%3Aweb%3Absky.social')
|
||||
&& str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher resolves the PDS from a did:web document', function () {
|
||||
$this->socialAccount->update(['platform_user_id' => 'did:web:example.com']);
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (str_contains($url, 'example.com/.well-known/did.json')) {
|
||||
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(['jobId' => 'job-web', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafweb'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:web:example.com/app.bsky.feed.post/3web', 'cid' => 'bafweb'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'example.com/.well-known/did.json'));
|
||||
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 builds the did:web document url from colon path segments', function () {
|
||||
$this->socialAccount->update(['platform_user_id' => 'did:web:example.com:user:alice']);
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (str_contains($url, 'example.com/user/alice/did.json')) {
|
||||
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(['jobId' => 'job-seg', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafseg'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://x/app.bsky.feed.post/3seg', 'cid' => 'bafseg'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// did:web:example.com:user:alice → https://example.com/user/alice/did.json
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'example.com/user/alice/did.json'));
|
||||
});
|
||||
|
||||
test('bluesky publisher scopes the status service-auth to the video service', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
fakeBlueskyVideoPipeline();
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// The getJobStatus token is minted for the video service DID, not the PDS.
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'getServiceAuth')
|
||||
&& str_contains($request->url(), 'aud=did%3Aweb%3Avideo.bsky.app')
|
||||
&& str_contains($request->url(), 'lxm=app.bsky.video.getJobStatus');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher picks the PDS entry even when other services come first', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (str_contains($url, 'plc.directory')) {
|
||||
// A labeler service is listed before the PDS; resolution must skip it.
|
||||
return Http::response(['service' => [
|
||||
['id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labeler.example'],
|
||||
['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://real-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(['jobId' => 'job-pds', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafpds'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3pds', 'cid' => 'bafpds'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// The upload-token audience is the PDS host, not the labeler listed first.
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'getServiceAuth')
|
||||
&& str_contains($request->url(), 'aud=did%3Aweb%3Areal-pds.example.host')
|
||||
&& str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher uploads a webm video with the correct content type', function () {
|
||||
attachBlueskyVideo($this->post, 'video/webm');
|
||||
|
||||
fakeBlueskyVideoPipeline();
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Bluesky accepts webm natively; send it as webm, not mislabeled mp4.
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo')
|
||||
&& str_contains($request->url(), '.webm')
|
||||
&& $request->hasHeader('Content-Type', 'video/webm');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher uploads a mov video with the quicktime content type', function () {
|
||||
attachBlueskyVideo($this->post, 'video/quicktime');
|
||||
|
||||
fakeBlueskyVideoPipeline();
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo')
|
||||
&& str_contains($request->url(), '.mov')
|
||||
&& $request->hasHeader('Content-Type', 'video/quicktime');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher sends an unsupported video format as mp4', function () {
|
||||
attachBlueskyVideo($this->post, 'video/x-matroska'); // .mkv is not one of Bluesky's four formats
|
||||
|
||||
fakeBlueskyVideoPipeline();
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Unknown formats fall back to mp4 and let the transcoder decide.
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo')
|
||||
&& str_contains($request->url(), '.mp4')
|
||||
&& $request->hasHeader('Content-Type', 'video/mp4');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher polls when a 409 carries an in-flight job without a blob', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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')) {
|
||||
// 409 already-exists, but the job is still processing (no blob yet) — must poll.
|
||||
return Http::response(['jobId' => 'job-409', 'state' => 'JOB_STATE_CREATED'], 409);
|
||||
}
|
||||
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
|
||||
return Http::response(['jobStatus' => ['jobId' => 'job-409', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'baf409poll'], 'mimeType' => 'video/mp4', 'size' => 2048]]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3p409', 'cid' => 'bafp409'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// The 409 had no blob, so the job is polled and the completed blob is embedded.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.getJobStatus'));
|
||||
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') === 'baf409poll';
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher embeds images and skips the video when a post carries both', function () {
|
||||
$this->post->update([
|
||||
'media' => [
|
||||
['id' => 'img', 'path' => 'media/2026-01/test.jpg', 'url' => 'https://example.com/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'],
|
||||
['id' => 'vid', 'path' => 'media/2026-01/test.mp4', 'url' => 'https://example.com/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(MediaOptimizer::class)
|
||||
->shouldReceive('optimizeImage')
|
||||
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (str_contains($url, 'uploadBlob')) {
|
||||
return Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafimg'], 'mimeType' => 'image/jpeg', 'size' => 1024]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3both', 'cid' => 'bafboth'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('x', 1024), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Images win; the embed is images and the video service is never touched.
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), 'createRecord')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($request['record']['embed']['$type'] ?? null) === 'app.bsky.embed.images';
|
||||
});
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo'));
|
||||
});
|
||||
|
||||
test('bluesky publisher publishes text-only when only the status token fails', function () {
|
||||
attachBlueskyVideo($this->post);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$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')) {
|
||||
// Upload token mints fine; only the getJobStatus token fails.
|
||||
return str_contains($url, 'lxm=app.bsky.video.getJobStatus')
|
||||
? Http::response(['error' => 'AuthRequired'], 400)
|
||||
: Http::response(['token' => 'upload-token'], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3stok', 'cid' => 'bafstok'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('v', 2048), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Without the status token the upload can't be polled, so we never upload — degrade to text.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo'));
|
||||
});
|
||||
|
||||
test('bluesky publisher uploads an mpeg video with the correct content type', function () {
|
||||
attachBlueskyVideo($this->post, 'video/mpeg');
|
||||
|
||||
fakeBlueskyVideoPipeline();
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo')
|
||||
&& str_contains($request->url(), '.mpeg')
|
||||
&& $request->hasHeader('Content-Type', 'video/mpeg');
|
||||
});
|
||||
});
|
||||
|
||||
test('bluesky publisher uploads a gif without optimizing it', function () {
|
||||
$this->post->update([
|
||||
'media' => [['id' => 'gif', 'path' => 'media/2026-01/test.gif', 'url' => 'https://example.com/test.gif', 'mime_type' => 'image/gif', 'original_filename' => 'test.gif']],
|
||||
]);
|
||||
|
||||
// GIFs are passed through untouched — the optimizer must not run.
|
||||
$this->mock(MediaOptimizer::class)->shouldReceive('optimizeImage')->never();
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (str_contains($url, 'uploadBlob')) {
|
||||
return Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafgif'], 'mimeType' => 'image/gif', 'size' => 1024]], 200);
|
||||
}
|
||||
if (str_contains($url, 'createRecord')) {
|
||||
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3gif', 'cid' => 'bafgif'], 200);
|
||||
}
|
||||
|
||||
return Http::response(str_repeat('g', 1024), 200);
|
||||
});
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// The GIF is uploaded as-is with its original content type.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'uploadBlob')
|
||||
&& $request->hasHeader('Content-Type', 'image/gif'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@
|
|||
expect(ContentType::YouTubeShort->supportsImage())->toBeFalse();
|
||||
});
|
||||
|
||||
test('content type supports mixed media correctly', function () {
|
||||
// A Bluesky embed is images XOR video, so it can't carry both.
|
||||
expect(ContentType::BlueskyPost->supportsMixedMedia())->toBeFalse();
|
||||
// Multi-attachment messengers can mix an image and a video.
|
||||
expect(ContentType::DiscordMessage->supportsMixedMedia())->toBeTrue();
|
||||
expect(ContentType::TelegramPost->supportsMixedMedia())->toBeTrue();
|
||||
});
|
||||
|
||||
test('content type requires media correctly', function () {
|
||||
expect(ContentType::InstagramReel->requiresMedia())->toBeTrue();
|
||||
expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue();
|
||||
|
|
|
|||
|
|
@ -94,6 +94,48 @@ function runMediaRule(string $contentType, array $media): array
|
|||
expect($errors)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('bluesky rejects an image and a video in the same post', function () {
|
||||
$media = [
|
||||
['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'],
|
||||
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
|
||||
];
|
||||
|
||||
$errors = runMediaRule(ContentType::BlueskyPost->value, $media);
|
||||
|
||||
expect($errors)->toHaveCount(1);
|
||||
expect($errors[0])->toContain("can't combine an image and a video");
|
||||
});
|
||||
|
||||
test('bluesky still accepts an image-only or video-only post', function () {
|
||||
$image = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']];
|
||||
$video = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']];
|
||||
|
||||
expect(runMediaRule(ContentType::BlueskyPost->value, $image))->toBe([]);
|
||||
expect(runMediaRule(ContentType::BlueskyPost->value, $video))->toBe([]);
|
||||
});
|
||||
|
||||
test('bluesky rejects an animated gif combined with a video', function () {
|
||||
// A GIF counts as an image, so gif + video is still mixed media.
|
||||
$media = [
|
||||
['type' => MediaType::Image->value, 'mime_type' => 'image/gif'],
|
||||
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
|
||||
];
|
||||
|
||||
$errors = runMediaRule(ContentType::BlueskyPost->value, $media);
|
||||
|
||||
expect($errors)->toHaveCount(1);
|
||||
expect($errors[0])->toContain("can't combine an image and a video");
|
||||
});
|
||||
|
||||
test('a mixed-media content type accepts an image and a video together', function () {
|
||||
$media = [
|
||||
['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'],
|
||||
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
|
||||
];
|
||||
|
||||
expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]);
|
||||
});
|
||||
|
||||
test('does nothing for invalid content type values', function () {
|
||||
expect(runMediaRule('not_a_real_content_type', []))->toBe([]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue