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:
parent
d6a82c03ae
commit
eaef12b331
2 changed files with 124 additions and 3 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue