diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 38094fc6..b3a7943e 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -60,7 +60,7 @@ public function publish(PostPlatform $postPlatform): array // Parse facets (links, mentions, hashtags) from text $text = $content ?? ''; - $facets = $this->parseFacets($text); + $facets = $this->parseFacets($text, $service, $account); // Create post record $record = [ @@ -166,7 +166,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url } } - private function parseFacets(string $text): array + private function parseFacets(string $text, ?string $service = null, ?SocialAccount $account = null): array { $facets = []; @@ -205,9 +205,22 @@ private function parseFacets(string $text): array PREG_OFFSET_CAPTURE ); + $didCache = []; foreach ($mentionMatches[0] as $match) { $mention = $match[0]; $handle = substr($mention, 1); // Remove @ + + // A mention facet requires the target's DID, not the handle. Bluesky + // does NOT resolve a handle placed in the `did` field — sending one + // makes the whole record invalid (InvalidRequest / "Invalid post + // data"), so every post containing a mention fails to publish. + // Resolve the handle to a DID here; if it can't be resolved, skip the + // facet so the post still publishes with the @handle as plain text. + $did = $didCache[$handle] ?? ($didCache[$handle] = $this->resolveHandleToDid($handle, $service, $account)); + if ($did === null) { + continue; + } + $start = $this->getUtf8ByteOffset($text, $match[1]); $end = $start + strlen($mention); @@ -219,7 +232,7 @@ private function parseFacets(string $text): array 'features' => [ [ '$type' => 'app.bsky.richtext.facet#mention', - 'did' => $handle, // Will be resolved by Bluesky + 'did' => $did, ], ], ]; @@ -256,6 +269,66 @@ private function parseFacets(string $text): array return $facets; } + /** + * Resolve a Bluesky handle to its DID via com.atproto.identity.resolveHandle. + * + * Tries the account's own PDS first (authenticated), then falls back to the + * public AppView and the bsky.social entryway. Returns null on failure so the + * caller can skip the mention facet instead of sending an invalid record. + */ + private function resolveHandleToDid(string $handle, ?string $service = null, ?SocialAccount $account = null): ?string + { + // Normalize so a trailing slash neither produces a double-slash URL nor + // breaks the `=== $service` check below (which decides authentication). + $service = $service !== null ? rtrim($service, '/') : null; + + $endpoints = array_values(array_unique(array_filter([ + $service, + 'https://public.api.bsky.app', + 'https://bsky.social', + ]))); + + $lastError = null; + $lastEndpoint = null; + foreach ($endpoints as $endpoint) { + try { + $request = $this->socialHttp(); + if ($account && $endpoint === $service) { + $request = $request->withToken($account->access_token); + } + + $response = $request->get( + "{$endpoint}/xrpc/com.atproto.identity.resolveHandle", + ['handle' => $handle], + ); + + $did = $response->successful() ? data_get($response->json(), 'did') : null; + if (is_string($did) && str_starts_with($did, 'did:')) { + return $did; + } + } catch (\Throwable $e) { + // Per-endpoint failures are expected during transient outages; + // keep them at debug to avoid noisy logs and surface a single + // warning below only when every endpoint has been exhausted. + $lastError = $e->getMessage(); + $lastEndpoint = $endpoint; + Log::debug('Bluesky handle resolution attempt failed', [ + 'handle' => $handle, + 'endpoint' => $endpoint, + 'error' => $e->getMessage(), + ]); + } + } + + Log::warning('Bluesky handle resolution failed; mention will be sent as plain text', [ + 'handle' => $handle, + 'endpoint' => $lastEndpoint, + 'error' => $lastError, + ]); + + return null; + } + private function getUtf8ByteOffset(string $text, int $charOffset): int { return strlen(substr($text, 0, $charOffset)); diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 0d017b88..6ce6df4a 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -101,6 +101,74 @@ }); }); +test('bluesky publisher resolves mentions to DIDs as facets', function () { + $this->post->update(['content' => 'Shout out to @friend.bsky.social']); + + Http::fake([ + // Wildcard so the fake matches whichever endpoint resolveHandleToDid() + // tries first (the account PDS, public AppView, or bsky.social) and the + // test stays isolated from the configured service URL. + '*/xrpc/com.atproto.identity.resolveHandle*' => Http::response([ + 'did' => 'did:plc:friend456', + ], 200), + 'https://bsky.social/xrpc/com.atproto.repo.createRecord' => Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', + 'cid' => 'bafyreiabc123', + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + $record = $request->data()['record'] ?? null; + + if (! $record || ! isset($record['facets'])) { + return false; + } + + foreach ($record['facets'] as $facet) { + $feature = $facet['features'][0]; + if ($feature['$type'] === 'app.bsky.richtext.facet#mention') { + // The facet must carry the resolved DID, not the raw handle. + return $feature['did'] === 'did:plc:friend456'; + } + } + + return false; + }); +}); + +test('bluesky publisher skips mention facet when handle cannot be resolved', function () { + $this->post->update(['content' => 'Shout out to @ghost.bsky.social']); + + Http::fake([ + '*/xrpc/com.atproto.identity.resolveHandle*' => Http::response(['error' => 'InvalidRequest'], 400), + 'https://bsky.social/xrpc/com.atproto.repo.createRecord' => Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', + 'cid' => 'bafyreiabc123', + ], 200), + ]); + + // Post still publishes; the unresolved @handle stays as plain text. + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('3abc123xyz'); + + Http::assertSent(function ($request) { + $record = $request->data()['record'] ?? null; + + if (! $record) { + return false; + } + + $hasMentionFacet = collect($record['facets'] ?? [])->contains( + fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#mention' + ); + + return str_contains($record['text'], '@ghost.bsky.social') && ! $hasMentionFacet; + }); +}); + test('bluesky publisher uploads images', function () { // Create a media item through the PostPlatform's media() relation $this->post->update([