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) }