From 2ff34369e0db350e17f3070fe9bc94e2742111c5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 20:02:54 -0300 Subject: [PATCH] Stop URL facets swallowing trailing punctuation The URL regex captured trailing sentence punctuation and an unmatched closing paren ("see https://x.com).") into the link facet, so Bluesky rendered the punctuation as part of a malformed link. Trim it like the official atproto tokenizer. Add tests for the trim and for byte-offset correctness after multibyte characters (emoji/accents). --- app/Services/Social/BlueskyPublisher.php | 20 +++++++- .../Services/Social/BlueskyPublisherTest.php | 46 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index a47115e1..b117ac62 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -180,7 +180,7 @@ private function parseFacets(string $text): array ); foreach ($urlMatches[0] as $match) { - $url = $match[0]; + $url = $this->trimTrailingUrlPunctuation($match[0]); $start = $this->getUtf8ByteOffset($text, (int) $match[1]); $end = $start + strlen($url); @@ -299,6 +299,24 @@ private function getUtf8ByteOffset(string $text, int $charOffset): int return strlen(substr($text, 0, $charOffset)); } + /** + * Trailing sentence punctuation and an unmatched closing paren are almost + * never part of a URL (e.g. "see https://x.com)."). Mirrors the official + * atproto link tokenizer so the link facet doesn't over-extend past the URL. + */ + private function trimTrailingUrlPunctuation(string $url): string + { + if (preg_match('/[.,;:!?]$/', $url)) { + $url = substr($url, 0, -1); + } + + if (str_ends_with($url, ')') && ! str_contains($url, '(')) { + $url = substr($url, 0, -1); + } + + return $url; + } + private function buildPostUrl(string $handle, string $postId): string { return "https://bsky.app/profile/{$handle}/post/{$postId}"; diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 8df42b21..400a0117 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -101,6 +101,52 @@ }); }); +test('bluesky publisher strips trailing punctuation from URL facets', function () { + $this->post->update(['content' => 'see https://example.com).']); + + Http::fake([ + config('trypost.platforms.bluesky.default_service').'/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) { + $link = collect($request['record']['facets'] ?? []) + ->first(fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link'); + + return $link + && $link['features'][0]['uri'] === 'https://example.com' + && $link['index']['byteEnd'] === $link['index']['byteStart'] + strlen('https://example.com'); + }); +}); + +test('bluesky publisher computes byte offsets after multibyte characters', function () { + $this->post->update(['content' => 'Olá 🎉 #café']); + + Http::fake([ + config('trypost.platforms.bluesky.default_service').'/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) { + $text = $request['record']['text']; + $tag = collect($request['record']['facets'] ?? []) + ->first(fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#tag'); + + // byteStart must be the UTF-8 byte position of '#café', not its character index. + return $tag + && $tag['index']['byteStart'] === strpos($text, '#café') + && $tag['index']['byteEnd'] === strpos($text, '#café') + strlen('#café'); + }); +}); + test('bluesky publisher resolves mentions to DIDs as facets', function () { $this->post->update(['content' => 'Shout out to @friend.bsky.social']);