Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions app/Services/Social/BlueskyPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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);

Expand All @@ -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,
],
],
];
Expand Down Expand Up @@ -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],
);
Comment on lines +285 to +303

$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(),
]);
}
}
Comment on lines +309 to +321

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));
Expand Down
68 changes: 68 additions & 0 deletions tests/Feature/Services/Social/BlueskyPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]);
Comment on lines +107 to +118

$this->publisher->publish($this->postPlatform);

Http::assertSent(function ($request) {
$record = $request->data()['record'] ?? null;

if (! $record || ! isset($record['facets'])) {
return false;
}
Comment on lines +122 to +127

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([
Expand Down