From d794f65673c9d62f90528458dd6f7a4a0c011609 Mon Sep 17 00:00:00 2001 From: sirrow Date: Mon, 3 Aug 2026 09:35:59 +0900 Subject: [PATCH] make links in bluesky posts clickable Bluesky linkifies nothing by itself. A URL in the text of a record is plain text unless a richtext facet says which bytes of the post are a link and where they point, so our posts carried URLs no one could press. Each link now travels on the Post as an output.Link with the offsets of the text it occupies, and the bluesky output turns those into app.bsky.richtext.facet#link. The offsets are UTF-8 byte offsets, not character counts: a facet measured in characters slides off the URL as soon as any Japanese text precedes it, and underlines the wrong words. Scanning moved from preview.go to findLinks in main.go, which now reports every link with its offsets rather than just the first one; the preview card still goes to the first, which is the one a reader meets first. X is unaffected, as it linkifies URLs itself. The facet feature only gains its lexicon type when marshalled, so the test checks the encoded record rather than the struct. Co-Authored-By: Claude Opus 5 --- main.go | 25 +++++++++++++++++++-- main_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++ output/bluesky.go | 25 +++++++++++++++++++++ output/bluesky_test.go | 47 +++++++++++++++++++++++++++++++++++++++- output/output.go | 10 +++++++++ preview.go | 11 ---------- preview_test.go | 23 -------------------- 7 files changed, 153 insertions(+), 37 deletions(-) diff --git a/main.go b/main.go index c2a6310..0a17c8c 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,24 @@ func trimURL(match string) string { return strings.TrimRight(match, ".,!?、。)]}>") } +// findLinks returns every link in content, each with the UTF-8 byte offsets +// of the text it occupies. +func findLinks(content string) []output.Link { + var links []output.Link + for _, span := range urlPattern.FindAllStringIndex(content, -1) { + raw := trimURL(content[span[0]:span[1]]) + if u, err := url.Parse(raw); err != nil || u.Host == "" { + continue + } + links = append(links, output.Link{ + URL: raw, + ByteStart: span[0], + ByteEnd: span[0] + len(raw), + }) + } + return links +} + // tweetLength counts a message the way Twitter does, charging every link a // fixed length instead of its actual one. func tweetLength(content string) int { @@ -221,9 +239,11 @@ func (dist *distributor) created(event discord.Event) { return } + // The card goes to the first link, the one a reader meets first. + links := findLinks(event.Content) var preview *output.Preview - if link := findLink(event.Content); link != "" { - preview, err = linkPreview(link) + if len(links) > 0 { + preview, err = linkPreview(links[0].URL) if err != nil { // The post is still worth making without its card. fmt.Fprintln(os.Stderr, err) @@ -241,6 +261,7 @@ func (dist *distributor) created(event discord.Event) { post := output.Post{ Text: event.Content, Images: images, + Links: links, Preview: preview, } if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() { diff --git a/main_test.go b/main_test.go index 4ae3e42..6891d4b 100644 --- a/main_test.go +++ b/main_test.go @@ -30,6 +30,55 @@ func noiseJPEG(t *testing.T, width, height int) []byte { return buf.Bytes() } +func TestFindLinks(t *testing.T) { + tests := []struct { + name string + content string + want []output.Link + }{ + { + // The offsets are byte offsets, so the multibyte prefix counts + // for more than the three characters it looks like. + "after multibyte text", + "みてね https://example.com/article", + []output.Link{{URL: "https://example.com/article", ByteStart: 10, ByteEnd: 37}}, + }, + { + "trailing punctuation is outside the link", + "これ→https://example.com/x。", + []output.Link{{URL: "https://example.com/x", ByteStart: 9, ByteEnd: 30}}, + }, + { + "several", + "https://one.example https://two.example", + []output.Link{ + {URL: "https://one.example", ByteStart: 0, ByteEnd: 19}, + {URL: "https://two.example", ByteStart: 20, ByteEnd: 39}, + }, + }, + {"no link", "ただのつぶやき", nil}, + {"scheme only", "http:// と書いただけ", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findLinks(tt.content) + if len(got) != len(tt.want) { + t.Fatalf("findLinks(%q) returned %d links, want %d", tt.content, len(got), len(tt.want)) + } + for i, link := range got { + if link != tt.want[i] { + t.Errorf("link %d = %+v, want %+v", i, link, tt.want[i]) + } + // A facet pointing at the wrong bytes marks up the wrong text. + if slice := tt.content[link.ByteStart:link.ByteEnd]; slice != link.URL { + t.Errorf("bytes [%d:%d] are %q, want %q", link.ByteStart, link.ByteEnd, slice, link.URL) + } + } + }) + } +} + func TestTweetLength(t *testing.T) { tests := []struct { name string diff --git a/output/bluesky.go b/output/bluesky.go index cefa18f..3478ded 100644 --- a/output/bluesky.go +++ b/output/bluesky.go @@ -67,6 +67,8 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) { Langs: []string{"ja"}, } + feedpost.Facets = linkFacets(post.Links) + root := Ref{} if post.ReplyTo != nil && post.ReplyTo.URI != "" { parent := &atproto.RepoStrongRef{ @@ -146,6 +148,29 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) { }, nil } +// linkFacets marks up the links in a post's text. Bluesky linkifies nothing +// by itself: a URL stays plain text until a facet says which bytes of the +// post are a link and where they point. +func linkFacets(links []Link) []*bsky.RichtextFacet { + if len(links) == 0 { + return nil + } + + facets := make([]*bsky.RichtextFacet, 0, len(links)) + for _, link := range links { + facets = append(facets, &bsky.RichtextFacet{ + Index: &bsky.RichtextFacet_ByteSlice{ + ByteStart: int64(link.ByteStart), + ByteEnd: int64(link.ByteEnd), + }, + Features: []*bsky.RichtextFacet_Features_Elem{{ + RichtextFacet_Link: &bsky.RichtextFacet_Link{Uri: link.URL}, + }}, + }) + } + return facets +} + func (bo *blueskyoutput) Delete(ref Ref) error { rkey, err := recordKey(ref.URI) if err != nil { diff --git a/output/bluesky_test.go b/output/bluesky_test.go index 625f44f..7e7212e 100644 --- a/output/bluesky_test.go +++ b/output/bluesky_test.go @@ -1,6 +1,51 @@ package output -import "testing" +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/util" +) + +func TestLinkFacets(t *testing.T) { + if facets := linkFacets(nil); facets != nil { + t.Errorf("a post with no links got %d facets, want none", len(facets)) + } + + text := "みてね https://example.com/x" + post := &bsky.FeedPost{ + Text: text, + CreatedAt: time.Now().Format(util.ISO8601), + Facets: linkFacets([]Link{{URL: "https://example.com/x", ByteStart: 10, ByteEnd: 31}}), + } + + // The feature carries its lexicon type only once marshalled, so the + // record as it goes over the wire is what has to be checked. + encoded, err := json.Marshal(post) + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + `"$type":"app.bsky.richtext.facet#link"`, + `"uri":"https://example.com/x"`, + `"byteStart":10`, + `"byteEnd":31`, + } { + if !strings.Contains(string(encoded), want) { + t.Errorf("record does not contain %s\ngot: %s", want, encoded) + } + } + + // A facet that points at the wrong bytes underlines the wrong text. + index := post.Facets[0].Index + if slice := text[index.ByteStart:index.ByteEnd]; slice != "https://example.com/x" { + t.Errorf("facet covers %q, want the URL", slice) + } +} func TestRecordKey(t *testing.T) { rkey, err := recordKey("at://did:plc:abc123/app.bsky.feed.post/3kqz7xyz") diff --git a/output/output.go b/output/output.go index b4b4c6e..dd7c5bf 100644 --- a/output/output.go +++ b/output/output.go @@ -14,6 +14,15 @@ type Preview struct { Thumb *Image } +// Link is a URL inside a post's text, located by the UTF-8 byte offsets +// bluesky needs to mark it up: it does not linkify text on its own, so a URL +// no one points at stays unclickable. +type Link struct { + URL string + ByteStart int + ByteEnd int +} + // Ref identifies a post an output has already published so it can later be // replied to or deleted. The fields are output specific; only the ones the // publishing output filled in are meaningful to it. @@ -34,6 +43,7 @@ func (r Ref) IsZero() bool { type Post struct { Text string Images []Image + Links []Link Preview *Preview // ReplyTo is the Ref this same output returned for the post being // replied to, or nil for a top level post. diff --git a/preview.go b/preview.go index 718738a..2d8703a 100644 --- a/preview.go +++ b/preview.go @@ -18,17 +18,6 @@ import ( // tags. They belong in , so reading further is wasted. const maxHTMLBytes = 1 << 20 -// findLink returns the first link in content, or "" if there is none. -func findLink(content string) string { - for _, match := range urlPattern.FindAllString(content, -1) { - raw := trimURL(match) - if u, err := url.Parse(raw); err == nil && u.Host != "" { - return raw - } - } - return "" -} - // linkPreview builds the preview card for a link. Bluesky shows no card of // its own: whatever the record does not embed is not displayed, so every card // has to be assembled here. diff --git a/preview_test.go b/preview_test.go index fd08ec3..6f49785 100644 --- a/preview_test.go +++ b/preview_test.go @@ -5,29 +5,6 @@ import ( "testing" ) -func TestFindLink(t *testing.T) { - tests := []struct { - name string - content string - want string - }{ - {"plain", "みてみて https://example.com/article おもしろい", "https://example.com/article"}, - {"query", "https://example.com/watch?v=abc&t=1", "https://example.com/watch?v=abc&t=1"}, - {"trailing punctuation", "これ→https://example.com/記事。", "https://example.com/記事"}, - {"first of several", "https://one.example https://two.example", "https://one.example"}, - {"no link", "ただのつぶやき", ""}, - {"scheme only", "http:// と書いただけ", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := findLink(tt.content); got != tt.want { - t.Errorf("findLink(%q) = %q, want %q", tt.content, got, tt.want) - } - }) - } -} - func TestIsYouTube(t *testing.T) { tests := []struct { raw string