From eaef12b331c3741ac322eaf6182e130c2483fc62 Mon Sep 17 00:00:00 2001 From: Patrick Schrall Date: Thu, 11 Jun 2026 15:17:06 +0200 Subject: [PATCH 1/2] Fix(bluesky): resolve @mention handles to DIDs before building facets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mention facet (app.bsky.richtext.facet#mention) requires the target's DID in its `did` field. The publisher put the raw handle there with the comment "Will be resolved by Bluesky", but Bluesky does not resolve handles at that point — it rejects the whole record with InvalidRequest, surfaced to users as "Invalid post data". As a result, every scheduled post that contained an @mention failed to publish, while posts without a mention published fine. Resolve each handle to a DID via com.atproto.identity.resolveHandle (account PDS first, then the public AppView / bsky.social as fallback), cache resolutions per post, and use the resolved DID in the facet. If a handle cannot be resolved, the facet is skipped so the post still publishes with the @handle as plain text instead of failing entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Services/Social/BlueskyPublisher.php | 62 +++++++++++++++++- .../Services/Social/BlueskyPublisherTest.php | 65 +++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 38094fc6..abc4a04e 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,49 @@ 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 + { + $endpoints = array_values(array_unique(array_filter([ + $service, + 'https://public.api.bsky.app', + 'https://bsky.social', + ]))); + + 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) { + Log::warning('Bluesky handle resolution failed', [ + 'handle' => $handle, + 'endpoint' => $endpoint, + 'error' => $e->getMessage(), + ]); + } + } + + 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..71610c69 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -101,6 +101,71 @@ }); }); +test('bluesky publisher resolves mentions to DIDs as facets', function () { + $this->post->update(['content' => 'Shout out to @friend.bsky.social']); + + Http::fake([ + 'https://bsky.social/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['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['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([ From b317b8dbb2243014784ae20ce1cbbc9ebbd9da36 Mon Sep 17 00:00:00 2001 From: Patrick Schrall Date: Thu, 11 Jun 2026 17:37:16 +0200 Subject: [PATCH 2/2] Address Copilot review: normalize endpoints, quieter logs, robust tests - resolveHandleToDid: rtrim trailing slash on the service URL before building the request URL and before the `=== $service` auth check (avoids double-slash URLs and mis-detected auth). - Log per-endpoint resolution attempts at debug; emit a single warning only after all endpoints are exhausted (less noise during transient outages). - Tests: fake resolveHandle with a wildcard so the case is isolated from the configured service URL; read request payload via $request->data(). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Services/Social/BlueskyPublisher.php | 19 ++++++++++++++++++- .../Services/Social/BlueskyPublisherTest.php | 9 ++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index abc4a04e..b3a7943e 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -278,12 +278,18 @@ private function parseFacets(string $text, ?string $service = null, ?SocialAccou */ 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(); @@ -301,7 +307,12 @@ private function resolveHandleToDid(string $handle, ?string $service = null, ?So return $did; } } catch (\Throwable $e) { - Log::warning('Bluesky handle resolution failed', [ + // 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(), @@ -309,6 +320,12 @@ private function resolveHandleToDid(string $handle, ?string $service = null, ?So } } + Log::warning('Bluesky handle resolution failed; mention will be sent as plain text', [ + 'handle' => $handle, + 'endpoint' => $lastEndpoint, + 'error' => $lastError, + ]); + return null; } diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 71610c69..6ce6df4a 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -105,7 +105,10 @@ $this->post->update(['content' => 'Shout out to @friend.bsky.social']); Http::fake([ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle*' => Http::response([ + // 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([ @@ -117,7 +120,7 @@ $this->publisher->publish($this->postPlatform); Http::assertSent(function ($request) { - $record = $request['record'] ?? null; + $record = $request->data()['record'] ?? null; if (! $record || ! isset($record['facets'])) { return false; @@ -152,7 +155,7 @@ expect($result['id'])->toBe('3abc123xyz'); Http::assertSent(function ($request) { - $record = $request['record'] ?? null; + $record = $request->data()['record'] ?? null; if (! $record) { return false;