Fix(bluesky): resolve @mention handles to DIDs before building facets

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schrall 2026-06-11 15:17:06 +02:00
parent d6a82c03ae
commit eaef12b331
2 changed files with 124 additions and 3 deletions

View file

@ -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));

View file

@ -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([