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