178 lines
4.8 KiB
Go
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)
|
|
}
|