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 <noreply@anthropic.com>
208 lines
5.0 KiB
Go
208 lines
5.0 KiB
Go
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/api/atproto"
|
|
"github.com/bluesky-social/indigo/api/bsky"
|
|
lexutil "github.com/bluesky-social/indigo/lex/util"
|
|
"github.com/bluesky-social/indigo/util"
|
|
"github.com/bluesky-social/indigo/xrpc"
|
|
)
|
|
|
|
const blueskyCollection = "app.bsky.feed.post"
|
|
|
|
type blueskyoutput struct {
|
|
identifier string
|
|
password string
|
|
}
|
|
|
|
func BlueskyOutput(identifier string, password string) *blueskyoutput {
|
|
blueskyoutput := &blueskyoutput{
|
|
identifier: identifier,
|
|
password: password,
|
|
}
|
|
|
|
return blueskyoutput
|
|
}
|
|
|
|
// session logs in and returns a client authenticated as the configured account.
|
|
func (bo *blueskyoutput) session() (*xrpc.Client, error) {
|
|
cli := &xrpc.Client{
|
|
Host: "https://bsky.social",
|
|
}
|
|
|
|
input := &atproto.ServerCreateSession_Input{
|
|
Identifier: bo.identifier,
|
|
Password: bo.password,
|
|
}
|
|
|
|
output, err := atproto.ServerCreateSession(context.TODO(), cli, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cli.Auth = &xrpc.AuthInfo{
|
|
AccessJwt: output.AccessJwt,
|
|
RefreshJwt: output.RefreshJwt,
|
|
Handle: output.Handle,
|
|
Did: output.Did,
|
|
}
|
|
return cli, nil
|
|
}
|
|
|
|
func (bo *blueskyoutput) Write(post Post) (Ref, error) {
|
|
cli, err := bo.session()
|
|
if err != nil {
|
|
return Ref{}, err
|
|
}
|
|
|
|
feedpost := &bsky.FeedPost{
|
|
Text: post.Text,
|
|
CreatedAt: time.Now().Format(util.ISO8601),
|
|
Langs: []string{"ja"},
|
|
}
|
|
|
|
feedpost.Facets = linkFacets(post.Links)
|
|
|
|
root := Ref{}
|
|
if post.ReplyTo != nil && post.ReplyTo.URI != "" {
|
|
parent := &atproto.RepoStrongRef{
|
|
Uri: post.ReplyTo.URI,
|
|
Cid: post.ReplyTo.CID,
|
|
}
|
|
// A post that is itself a reply carries the thread root; one that is
|
|
// not is the root of its own thread.
|
|
rootref := &atproto.RepoStrongRef{
|
|
Uri: post.ReplyTo.RootURI,
|
|
Cid: post.ReplyTo.RootCID,
|
|
}
|
|
if rootref.Uri == "" {
|
|
rootref = parent
|
|
}
|
|
feedpost.Reply = &bsky.FeedPost_ReplyRef{
|
|
Parent: parent,
|
|
Root: rootref,
|
|
}
|
|
root = Ref{RootURI: rootref.Uri, RootCID: rootref.Cid}
|
|
}
|
|
|
|
// Bluesky allows a single embed, so attached images win over a link card.
|
|
switch {
|
|
case len(post.Images) > 0:
|
|
embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images))
|
|
for _, img := range post.Images {
|
|
blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(img.Data))
|
|
if err != nil {
|
|
return Ref{}, fmt.Errorf("uploading %s: %w", img.Filename, err)
|
|
}
|
|
embedimages = append(embedimages, &bsky.EmbedImages_Image{
|
|
Alt: img.Filename,
|
|
Image: blob.Blob,
|
|
})
|
|
}
|
|
feedpost.Embed = &bsky.FeedPost_Embed{
|
|
EmbedImages: &bsky.EmbedImages{
|
|
Images: embedimages,
|
|
},
|
|
}
|
|
case post.Preview != nil:
|
|
external := &bsky.EmbedExternal_External{
|
|
Uri: post.Preview.URL,
|
|
Title: post.Preview.Title,
|
|
Description: post.Preview.Description,
|
|
}
|
|
if thumb := post.Preview.Thumb; thumb != nil {
|
|
blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(thumb.Data))
|
|
if err != nil {
|
|
return Ref{}, fmt.Errorf("uploading %s: %w", thumb.Filename, err)
|
|
}
|
|
external.Thumb = blob.Blob
|
|
}
|
|
feedpost.Embed = &bsky.FeedPost_Embed{
|
|
EmbedExternal: &bsky.EmbedExternal{
|
|
External: external,
|
|
},
|
|
}
|
|
}
|
|
|
|
Recordinput := &atproto.RepoCreateRecord_Input{
|
|
Collection: blueskyCollection,
|
|
Repo: cli.Auth.Did, // "matope.bsky.social" のDID
|
|
Record: &lexutil.LexiconTypeDecoder{Val: feedpost},
|
|
}
|
|
record, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
|
|
if recerr != nil {
|
|
return Ref{}, recerr
|
|
}
|
|
|
|
return Ref{
|
|
URI: record.Uri,
|
|
CID: record.Cid,
|
|
RootURI: root.RootURI,
|
|
RootCID: root.RootCID,
|
|
}, 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 {
|
|
return err
|
|
}
|
|
|
|
cli, err := bo.session()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = atproto.RepoDeleteRecord(context.TODO(), cli, &atproto.RepoDeleteRecord_Input{
|
|
Collection: blueskyCollection,
|
|
Repo: cli.Auth.Did,
|
|
Rkey: rkey,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// recordKey pulls the rkey out of an at://did/collection/rkey URI.
|
|
func recordKey(uri string) (string, error) {
|
|
if uri == "" {
|
|
return "", errors.New("no record URI to delete")
|
|
}
|
|
idx := strings.LastIndex(uri, "/")
|
|
if idx < 0 || idx == len(uri)-1 {
|
|
return "", fmt.Errorf("malformed record URI %q", uri)
|
|
}
|
|
return uri[idx+1:], nil
|
|
}
|
|
|
|
func (bo *blueskyoutput) GetName() string {
|
|
return "bluesky"
|
|
}
|