naco-api/internal/bluesky/client.go
2026-07-18 22:42:44 +00:00

178 lines
4.8 KiB
Go

package bluesky
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/xrpc"
log "github.com/nacorid/logger"
)
type BlueskyClient struct {
client *xrpc.Client
}
func NewBlueskyClient(ctx context.Context, handle, password, host string) (*BlueskyClient, error) {
client := &xrpc.Client{
Host: host,
}
session, err := atproto.ServerCreateSession(ctx, client, &atproto.ServerCreateSession_Input{
Identifier: handle,
Password: password,
})
if err != nil {
return nil, fmt.Errorf("failed to create bluesky session: %w", err)
}
client.Auth = &xrpc.AuthInfo{
AccessJwt: session.AccessJwt,
RefreshJwt: session.RefreshJwt,
Handle: session.Handle,
Did: session.Did,
}
return &BlueskyClient{client: client}, nil
}
func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return nil, err
}
var uri string
if collection != "" {
uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey)
} else {
uri = fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey)
}
log.DebugWithContext(ctx, "Fetching post", "URI", uri)
resp, err := bsky.FeedGetPosts(ctx, c.client, []string{uri})
if err != nil {
return nil, fmt.Errorf("failed to get post: %w", err)
}
if len(resp.Posts) == 0 {
return nil, fmt.Errorf("post not found")
}
return resp.Posts[0], nil
}
func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return nil, err
}
resp, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), "", "posts", false, int64(limit))
if err != nil {
return nil, fmt.Errorf("failed to get latest posts: %w", err)
}
return resp.Feed, nil
}
func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return nil, err
}
var posts []*bsky.FeedDefs_FeedViewPost
var cursor string
for len(posts) < limit {
feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50)
if err != nil {
return nil, err
}
for _, post := range feed.Feed {
if post.Post.Record != nil {
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
if !ok {
log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri)
continue
}
createdAt, err := time.Parse(time.RFC3339, rec.CreatedAt)
if err != nil {
log.WarnWithContext(ctx, "Failed to parse CreatedAt timestamp", "createdAt", rec.CreatedAt, "post", post.Post.Uri)
continue
}
if createdAt.After(start) && createdAt.Before(end) {
posts = append(posts, post)
if len(posts) >= limit {
return posts, nil
}
}
}
}
if feed.Cursor == nil || *feed.Cursor == "" {
break
}
cursor = *feed.Cursor
}
return posts, nil
}
func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) {
uri, err := c.sanitizeFeedURI(ctx, feedURI)
if err != nil {
return nil, err
}
return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50))
}
func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) {
var did syntax.DID
if d, err := syntax.ParseDID(user); err == nil {
did = d
} else {
resp, err := atproto.IdentityResolveHandle(ctx, c.client, user)
if err != nil {
return "", fmt.Errorf("failed to resolve handle to DID: %w", err)
}
did, err = syntax.ParseDID(resp.Did)
if err != nil {
return "", fmt.Errorf("failed to parse resolved DID: %w", err)
}
}
return did, nil
}
func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) {
input = strings.TrimSpace(input)
if at, err := syntax.ParseATURI(input); err == nil {
return at.Normalize().String(), nil
}
u, err := url.Parse(input)
if err != nil {
return "", fmt.Errorf("invalid URI: %w", err)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) >= 4 && parts[0] == "profile" && parts[2] == "feed" {
didRaw := parts[1]
feedName := parts[3]
did, err := c.resolveUser(ctx, didRaw)
if err != nil {
return "", fmt.Errorf("failed to resolve actor in feed URI: %w", err)
}
aturi := fmt.Sprintf("at://%s/app.bsky.feed.generator/%s", did.String(), feedName)
atParsed, err := syntax.ParseATURI(aturi)
if err != nil {
return "", fmt.Errorf("assembled URI invalid: %w", err)
}
return atParsed.Normalize().String(), nil
}
return "", fmt.Errorf("unrecognized URI form: %s", input)
}