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"}, } 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 } 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" }