Replying to a message on the watched channel now posts the reply as a reply to the posts that message produced, instead of as a standalone post. Discord tells us the parent through MessageReference, and the store maps it back to the Ref each output returned; that Ref rides along on the new Post.ReplyTo. X takes it as in_reply_to_tweet_id. Bluesky needs the thread root as well as the parent, so a Ref now carries the root it was posted under and a post that is not itself a reply acts as its own root. Replying to something we never distributed, an error notice from the bot for instance, still posts normally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
3.7 KiB
Go
163 lines
3.7 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"},
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
if 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|