A message containing a YouTube link now carries a preview card. Discord and X build their own card from the URL in the text, so the work is on the bluesky side: the new preview.go asks YouTube's oEmbed endpoint for the title, channel and thumbnail and posts them as an app.bsky.embed.external. Links are found by pulling candidates out of the text and parsing them with net/url rather than by matching a URL shaped regexp, so a host like youtube.com.example.invalid is not mistaken for the real thing. Bluesky allows one embed per post, so a message with both attachments and a link keeps the attachments. A failed oEmbed lookup only costs the card, not the post. Downloading the thumbnail wants the attachment download path, so it is split into fetch and downloadImage, and both now go through a client with a timeout: the event loop is single threaded and a hung request would stall every later message. The Dockerfile built main.go by name, which stops working now that package main spans two files; it builds the package instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
4.3 KiB
Go
183 lines
4.3 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}
|
|
}
|
|
|
|
// 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"
|
|
}
|