diff --git a/cli/cmd/export.go b/cli/cmd/export.go index 62087fd..2300127 100644 --- a/cli/cmd/export.go +++ b/cli/cmd/export.go @@ -147,22 +147,13 @@ func ExportPostAction(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("post URI or URL required") } - postURI := cmd.Args().First() + postIdentifier := cmd.Args().First() format := strings.ToLower(cmd.String("format")) if format != "json" && format != "txt" { return fmt.Errorf("invalid format for post: %s (must be json or txt)", format) } - // TODO: Convert URL to URI if needed - if strings.HasPrefix(postURI, "https://bsky.app/profile/") { - // Extract URI from bsky.app URL - // Example: https://bsky.app/profile/user.bsky.social/post/abc123 - // -> at://did:plc:.../app.bsky.feed.post/abc123 - ui.Warningln("URL to URI conversion not yet implemented, please provide AT URI directly") - return fmt.Errorf("URL conversion not implemented, use AT URI format (at://...)") - } - service, err := reg.GetService() if err != nil { return fmt.Errorf("failed to get service: %w", err) @@ -172,12 +163,40 @@ func ExportPostAction(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("not authenticated: run 'skycli login' first") } + postURI, err := parsePostURI(postIdentifier) + if err != nil { + return fmt.Errorf("failed to parse post identifier: %w", err) + } + logger.Debug("Fetching post for export", "uri", postURI) - // TODO: Implement GetPost in BlueskyService - // For now, we'll fetch the author feed and find the post - ui.Warningln("Direct post fetch not yet implemented") - return fmt.Errorf("direct post export not yet implemented - use feed export") + response, err := service.GetPosts(ctx, []string{postURI}) + if err != nil { + return fmt.Errorf("failed to fetch post: %w", err) + } + + if len(response.Posts) == 0 { + return fmt.Errorf("post not found: %s", postURI) + } + + post := &response.Posts[0] + + filename := fmt.Sprintf("post_%s_%s.%s", extractRkey(postURI), time.Now().Format("2006-01-02"), format) + + switch format { + case "json": + err = export.FeedViewPostToJSON(filename, post) + case "txt": + err = export.FeedViewPostToTXT(filename, post) + } + + if err != nil { + logger.Error("Failed to export", "error", err) + return err + } + + ui.Successln("Exported post to %s", filename) + return nil } // ExportCommand returns the export command with subcommands for feed, profile, and post @@ -257,3 +276,32 @@ func ExportCommand() *cli.Command { }, } } + +// parsePostURI converts a bsky.app URL or AT URI to an AT URI +func parsePostURI(identifier string) (string, error) { + if strings.HasPrefix(identifier, "at://") { + return identifier, nil + } + + if strings.HasPrefix(identifier, "https://bsky.app/profile/") || + strings.HasPrefix(identifier, "http://bsky.app/profile/") { + parts := strings.Split(identifier, "/") + if len(parts) < 7 || parts[5] != "post" { + return "", fmt.Errorf("invalid bsky.app URL format") + } + handle := parts[4] + rkey := parts[6] + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", handle, rkey), nil + } + + return "", fmt.Errorf("identifier must be an AT URI (at://...) or bsky.app URL") +} + +// extractRkey extracts the record key from an AT URI +func extractRkey(uri string) string { + parts := strings.Split(uri, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "unknown" +} diff --git a/cli/cmd/login.go b/cli/cmd/login.go index 2d4c51d..9b66188 100644 --- a/cli/cmd/login.go +++ b/cli/cmd/login.go @@ -7,6 +7,7 @@ import ( "github.com/stormlightlabs/skypanel/cli/internal/imports" "github.com/stormlightlabs/skypanel/cli/internal/registry" "github.com/stormlightlabs/skypanel/cli/internal/setup" + "github.com/stormlightlabs/skypanel/cli/internal/store" "github.com/stormlightlabs/skypanel/cli/internal/ui" "github.com/urfave/cli/v3" ) @@ -106,10 +107,38 @@ func LoginAction(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to get session repository: %w", err) } - if err := sessionRepo.UpdateTokens(ctx, service.GetAccessToken(), service.GetRefreshToken()); err != nil { - logger.Warn("Failed to save session tokens", "error", err) + session, err := createSessionFromService(service, handle) + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + + if err := sessionRepo.Save(ctx, session); err != nil { + logger.Error("Failed to save session", "error", err) + return fmt.Errorf("authentication succeeded but failed to save session: %w", err) } + logger.Debug("Session saved successfully", "did", session.ID(), "handle", handle) ui.Successln("Successfully authenticated as %s", handle) return nil } + +// createSessionFromService creates a SessionModel from an authenticated service +func createSessionFromService(service *store.BlueskyService, handle string) (*store.SessionModel, error) { + did := service.GetDid() + if did == "" { + return nil, fmt.Errorf("no DID available from authenticated service") + } + + accessToken := service.GetAccessToken() + refreshToken := service.GetRefreshToken() + + session := &store.SessionModel{ + Handle: handle, + Token: accessToken + "|" + refreshToken, + ServiceURL: service.BaseURL(), + IsValid: true, + } + session.SetID(did) + + return session, nil +} diff --git a/cli/cmd/main.go b/cli/cmd/main.go index eddd563..13cf816 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -29,7 +29,7 @@ func main() { Version: "0.1.0", Commands: []*cli.Command{ SetupCommand(), LoginCommand(), StatusCommand(), - FetchCommand(), ListCommand(), ViewCommand(), ExportCommand(), + FetchCommand(), SearchCommand(), ListCommand(), ViewCommand(), ExportCommand(), }, } diff --git a/cli/cmd/search.go b/cli/cmd/search.go new file mode 100644 index 0000000..b9348d9 --- /dev/null +++ b/cli/cmd/search.go @@ -0,0 +1,260 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/stormlightlabs/skypanel/cli/internal/registry" + "github.com/stormlightlabs/skypanel/cli/internal/setup" + "github.com/stormlightlabs/skypanel/cli/internal/store" + "github.com/stormlightlabs/skypanel/cli/internal/ui" + "github.com/urfave/cli/v3" +) + +// SearchUsersAction searches for users (actors) by query string +func SearchUsersAction(ctx context.Context, cmd *cli.Command) error { + if err := setup.EnsurePersistenceReady(ctx); err != nil { + return fmt.Errorf("persistence layer not ready: %w", err) + } + + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("search query required") + } + + query := cmd.Args().First() + limit := cmd.Int("limit") + cursor := cmd.String("cursor") + asJSON := cmd.Bool("json") + + service, err := reg.GetService() + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + if !service.Authenticated() { + return fmt.Errorf("not authenticated: run 'skycli login' first") + } + + logger.Debug("Searching users", "query", query, "limit", limit, "cursor", cursor) + + result, err := service.SearchActors(ctx, query, limit, cursor) + if err != nil { + return fmt.Errorf("failed to search users: %w", err) + } + + if asJSON { + return ui.DisplayJSON(result) + } + + if len(result.Actors) == 0 { + ui.Infoln("No users found matching query: %s", query) + return nil + } + + ui.Titleln("Search Results: %s", query) + fmt.Println() + + for i, actor := range result.Actors { + ui.Subtitleln("[%d] @%s", i+1, actor.Handle) + if actor.DisplayName != "" { + ui.Infoln(" Name: %s", actor.DisplayName) + } + ui.Infoln(" DID: %s", actor.Did) + if actor.Description != "" { + desc := actor.Description + if len(desc) > 100 { + desc = desc[:100] + "..." + } + ui.Infoln(" Bio: %s", desc) + } + ui.Infoln(" Followers: %d | Following: %d | Posts: %d", + actor.FollowersCount, actor.FollowsCount, actor.PostsCount) + fmt.Println() + } + + ui.Successln("Found %d user(s)", len(result.Actors)) + if result.Cursor != "" { + ui.Infoln("Next cursor: %s", result.Cursor) + } + + return nil +} + +// SearchPostsAction searches for posts by query string +func SearchPostsAction(ctx context.Context, cmd *cli.Command) error { + if err := setup.EnsurePersistenceReady(ctx); err != nil { + return fmt.Errorf("persistence layer not ready: %w", err) + } + + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("search query required") + } + + query := cmd.Args().First() + limit := cmd.Int("limit") + cursor := cmd.String("cursor") + asJSON := cmd.Bool("json") + + service, err := reg.GetService() + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + if !service.Authenticated() { + return fmt.Errorf("not authenticated: run 'skycli login' first") + } + + logger.Debug("Searching posts", "query", query, "limit", limit, "cursor", cursor) + + result, err := service.SearchPosts(ctx, query, limit, cursor) + if err != nil { + return fmt.Errorf("failed to search posts: %w", err) + } + + if asJSON { + return ui.DisplayJSON(result) + } + + if len(result.Posts) == 0 { + ui.Infoln("No posts found matching query: %s", query) + return nil + } + + ui.Titleln("Search Results: %s", query) + ui.DisplayFeed(result.Posts, result.Cursor) + + return nil +} + +// SearchFeedsAction searches for feeds in the local database by name or source +func SearchFeedsAction(ctx context.Context, cmd *cli.Command) error { + if err := setup.EnsurePersistenceReady(ctx); err != nil { + return fmt.Errorf("persistence layer not ready: %w", err) + } + + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("search query required") + } + + query := cmd.Args().First() + asJSON := cmd.Bool("json") + + feedRepo, err := reg.GetFeedRepo() + if err != nil { + return fmt.Errorf("failed to get feed repository: %w", err) + } + + logger.Debug("Searching local feeds", "query", query) + + allFeeds, err := feedRepo.List(ctx) + if err != nil { + logger.Error("Failed to list feeds", "error", err) + return err + } + + var matchingFeeds []store.Model + queryLower := strings.ToLower(query) + + for _, model := range allFeeds { + if feed, ok := model.(*store.FeedModel); ok { + nameLower := strings.ToLower(feed.Name) + sourceLower := strings.ToLower(feed.Source) + if strings.Contains(nameLower, queryLower) || strings.Contains(sourceLower, queryLower) { + matchingFeeds = append(matchingFeeds, feed) + } + } + } + + if len(matchingFeeds) == 0 { + ui.Infoln("No feeds found matching query: %s", query) + return nil + } + + if asJSON { + return ui.DisplayJSON(matchingFeeds) + } + + ui.Titleln("Search Results: %s", query) + fmt.Println() + + for i, model := range matchingFeeds { + if feed, ok := model.(*store.FeedModel); ok { + ui.Subtitleln("[%d] %s", i+1, feed.Name) + ui.Infoln(" ID: %s", feed.ID()) + ui.Infoln(" Source: %s", feed.Source) + ui.Infoln(" Local: %t", feed.IsLocal) + fmt.Println() + } + } + + ui.Successln("Found %d feed(s)", len(matchingFeeds)) + return nil +} + +// SearchCommand returns the search command with subcommands for users, posts, and feeds +func SearchCommand() *cli.Command { + commonFlags := []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Aliases: []string{"l"}, + Usage: "Maximum number of results to return", + Value: 25, + }, + &cli.StringFlag{ + Name: "cursor", + Aliases: []string{"c"}, + Usage: "Pagination cursor for fetching additional results", + }, + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output raw JSON response", + }, + } + + feedFlags := []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output raw JSON response", + }, + } + + return &cli.Command{ + Name: "search", + Usage: "Search for users, posts, or feeds", + Commands: []*cli.Command{ + { + Name: "users", + Usage: "Search for users by handle or name", + ArgsUsage: "", + Flags: commonFlags, + Action: SearchUsersAction, + }, + { + Name: "posts", + Usage: "Search for posts by text content", + ArgsUsage: "", + Flags: commonFlags, + Action: SearchPostsAction, + }, + { + Name: "feeds", + Usage: "Search local feeds by name or source (local search only)", + ArgsUsage: "", + Flags: feedFlags, + Action: SearchFeedsAction, + }, + }, + } +} diff --git a/cli/cmd/view.go b/cli/cmd/view.go index 337e50b..64e7f83 100644 --- a/cli/cmd/view.go +++ b/cli/cmd/view.go @@ -3,7 +3,8 @@ package main import ( "context" "fmt" - "time" + "regexp" + "strings" "github.com/google/uuid" "github.com/stormlightlabs/skypanel/cli/internal/registry" @@ -13,7 +14,8 @@ import ( "github.com/urfave/cli/v3" ) -func ViewAction(ctx context.Context, cmd *cli.Command) error { +// ViewFeedAction views posts from a feed (fetches from API) +func ViewFeedAction(ctx context.Context, cmd *cli.Command) error { if err := setup.EnsurePersistenceReady(ctx); err != nil { return fmt.Errorf("persistence layer not ready: %w", err) } @@ -22,97 +24,251 @@ func ViewAction(ctx context.Context, cmd *cli.Command) error { reg := registry.Get() if cmd.Args().Len() == 0 { - return fmt.Errorf("feed ID or URI required") + return fmt.Errorf("feed URI or local feed ID required") } feedIdentifier := cmd.Args().First() - size := cmd.Int("size") + limit := cmd.Int("limit") + cursor := cmd.String("cursor") + asJSON := cmd.Bool("json") - feedRepo, err := reg.GetFeedRepo() + service, err := reg.GetService() if err != nil { - return fmt.Errorf("failed to get feed repository: %w", err) + return fmt.Errorf("failed to get service: %w", err) } - postRepo, err := reg.GetPostRepo() + if !service.Authenticated() { + return fmt.Errorf("not authenticated: run 'skycli login' first") + } + + feedRepo, err := reg.GetFeedRepo() if err != nil { - return fmt.Errorf("failed to get post repository: %w", err) + return fmt.Errorf("failed to get feed repository: %w", err) } - var feedID string + var feedURI string if _, err := uuid.Parse(feedIdentifier); err == nil { - feedID = feedIdentifier - } else { - feeds, err := feedRepo.List(ctx) + feed, err := feedRepo.Get(ctx, feedIdentifier) if err != nil { - logger.Error("Failed to list feeds", "error", err) - return err + return fmt.Errorf("failed to get local feed: %w", err) } - - found := false - for _, model := range feeds { - if feed, ok := model.(*store.FeedModel); ok { - if feed.Source == feedIdentifier { - feedID = feed.ID() - found = true - break - } - } + if feedModel, ok := feed.(*store.FeedModel); ok { + feedURI = feedModel.Source + logger.Debug("Resolved local feed ID to URI", "id", feedIdentifier, "uri", feedURI) } + } else { + feedURI = feedIdentifier + } - if !found { - return fmt.Errorf("feed not found with identifier: %s", feedIdentifier) - } + logger.Debug("Fetching feed from API", "uri", feedURI, "limit", limit, "cursor", cursor) + + response, err := service.GetAuthorFeed(ctx, feedURI, limit, cursor) + if err != nil { + return fmt.Errorf("failed to fetch feed: %w", err) + } + + if asJSON { + return ui.DisplayJSON(response) + } + + ui.Titleln("Feed: %s", feedURI) + ui.DisplayFeed(response.Feed, response.Cursor) + return nil +} + +// ViewPostAction views a single post by URI or URL +func ViewPostAction(ctx context.Context, cmd *cli.Command) error { + if err := setup.EnsurePersistenceReady(ctx); err != nil { + return fmt.Errorf("persistence layer not ready: %w", err) + } + + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("post URI or URL required") } - posts, err := postRepo.QueryByFeedID(ctx, feedID, size, 0) + postIdentifier := cmd.Args().First() + asJSON := cmd.Bool("json") + + service, err := reg.GetService() if err != nil { - logger.Error("Failed to query posts", "error", err) - return err + return fmt.Errorf("failed to get service: %w", err) } - if len(posts) == 0 { - ui.Infoln("No posts found for this feed.") - return nil + if !service.Authenticated() { + return fmt.Errorf("not authenticated: run 'skycli login' first") } - totalCount, err := postRepo.CountByFeedID(ctx, feedID) + postURI, err := parsePostIdentifier(postIdentifier) if err != nil { - logger.Warn("Failed to get total count", "error", err) + return fmt.Errorf("failed to parse post identifier: %w", err) } - ui.Titleln("Posts for Feed: %s", feedID) - fmt.Println() + logger.Debug("Fetching post", "uri", postURI) - for i, post := range posts { - ui.Subtitleln("[%d] %s", i+1, post.URI) - ui.Infoln(" Author: %s", post.AuthorDID) - text := post.Text - if len(text) > 100 { - text = text[:100] + "..." + response, err := service.GetPosts(ctx, []string{postURI}) + if err != nil { + return fmt.Errorf("failed to fetch post: %w", err) + } + + if len(response.Posts) == 0 { + return fmt.Errorf("post not found: %s", postURI) + } + + if asJSON { + return ui.DisplayJSON(response.Posts[0]) + } + + ui.Titleln("Post View") + ui.DisplayFeed([]store.FeedViewPost{response.Posts[0]}, "") + + return nil +} + +// ViewProfileAction views an actor's profile with stats +func ViewProfileAction(ctx context.Context, cmd *cli.Command) error { + if err := setup.EnsurePersistenceReady(ctx); err != nil { + return fmt.Errorf("persistence layer not ready: %w", err) + } + + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("actor handle or DID required") + } + + actor := cmd.Args().First() + showPosts := cmd.Bool("with-posts") + asJSON := cmd.Bool("json") + + service, err := reg.GetService() + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + if !service.Authenticated() { + return fmt.Errorf("not authenticated: run 'skycli login' first") + } + + logger.Debug("Fetching profile", "actor", actor) + + profile, err := service.GetProfile(ctx, actor) + if err != nil { + return fmt.Errorf("failed to fetch profile: %w", err) + } + + if asJSON { + return ui.DisplayJSON(profile) + } + + ui.DisplayProfileHeader(profile) + + if showPosts { + logger.Debug("Fetching recent posts", "actor", actor) + feed, err := service.GetAuthorFeed(ctx, actor, 10, "") + if err != nil { + ui.Warningln("Failed to fetch recent posts: %v", err) + } else { + fmt.Println() + ui.Subtitleln("Recent Posts") + ui.DisplayFeed(feed.Feed, "") } - ui.Infoln(" Text: %s", text) - ui.Infoln(" Indexed: %s", post.IndexedAt.Format(time.RFC3339)) - fmt.Println() } - ui.Successln("Showing %d of %d post(s)", len(posts), totalCount) return nil } +// ViewCommand returns the view command with subcommands for feed, post, and profile func ViewCommand() *cli.Command { return &cli.Command{ - Name: "view", - Usage: "View posts from a feed", - ArgsUsage: "", - Flags: []cli.Flag{ - &cli.IntFlag{ - Name: "size", - Aliases: []string{"s"}, - Usage: "Number of posts to display", - Value: 25, + Name: "view", + Usage: "View feeds, posts, or profiles", + Commands: []*cli.Command{ + { + Name: "feed", + Usage: "View posts from a feed by URI or local feed ID", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Aliases: []string{"l"}, + Usage: "Maximum number of posts to display", + Value: 25, + }, + &cli.StringFlag{ + Name: "cursor", + Aliases: []string{"c"}, + Usage: "Pagination cursor", + }, + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output raw JSON response", + }, + }, + Action: ViewFeedAction, + }, + { + Name: "post", + Usage: "View a single post by URI or bsky.app URL", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output raw JSON response", + }, + }, + Action: ViewPostAction, + }, + { + Name: "profile", + Usage: "View an actor's profile", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "with-posts", + Aliases: []string{"p"}, + Usage: "Also display recent posts from this profile", + }, + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output raw JSON response", + }, + }, + Action: ViewProfileAction, }, }, - Action: ViewAction, } } + +// parsePostIdentifier converts a bsky.app URL or AT URI to an AT URI +// Examples: +// - https://bsky.app/profile/alice.bsky.social/post/abc123 +// - at://did:plc:xyz/app.bsky.feed.post/abc123 +func parsePostIdentifier(identifier string) (string, error) { + if strings.HasPrefix(identifier, "at://") { + return identifier, nil + } + + if strings.HasPrefix(identifier, "https://bsky.app/profile/") || + strings.HasPrefix(identifier, "http://bsky.app/profile/") { + re := regexp.MustCompile(`^https?://bsky\.app/profile/([^/]+)/post/([^/]+)`) + matches := re.FindStringSubmatch(identifier) + if len(matches) != 3 { + return "", fmt.Errorf("invalid bsky.app URL format") + } + + handle := matches[1] + rkey := matches[2] + + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", handle, rkey), nil + } + + return "", fmt.Errorf("identifier must be an AT URI (at://...) or bsky.app URL") +} diff --git a/cli/internal/store/bluesky.go b/cli/internal/store/bluesky.go index 5aacd81..59e86c1 100644 --- a/cli/internal/store/bluesky.go +++ b/cli/internal/store/bluesky.go @@ -18,6 +18,10 @@ const ( defaultTimeout = 30 * time.Second ) +type jwtClaims struct { + Exp int64 `json:"exp"` +} + // BlueskyService implements the [Service] interface for AT Protocol / Bluesky API type BlueskyService struct { baseURL string @@ -26,6 +30,8 @@ type BlueskyService struct { refreshToken string tokenExpiry time.Time authenticated bool + did string + handle string } // NewBlueskyService creates a new Bluesky service client @@ -113,6 +119,8 @@ func (s *BlueskyService) Authenticate(ctx context.Context, credentials any) erro s.accessToken = session.AccessJwt s.refreshToken = session.RefreshJwt + s.did = session.Did + s.handle = session.Handle s.authenticated = true if expiry, err := parseJWTExpiry(s.accessToken); err == nil { @@ -195,6 +203,9 @@ func (s *BlueskyService) Close() error { s.authenticated = false s.accessToken = "" s.refreshToken = "" + s.did = "" + s.handle = "" + s.tokenExpiry = time.Time{} return nil } @@ -300,6 +311,93 @@ func (s *BlueskyService) GetProfile(ctx context.Context, actor string) (*ActorPr return &profile, nil } +// SearchActors searches for actors (users) matching the query string. +// Returns actor profiles with pagination support. +func (s *BlueskyService) SearchActors(ctx context.Context, query string, limit int, cursor string) (*SearchActorsResponse, error) { + urlPath := fmt.Sprintf("/xrpc/app.bsky.actor.searchActors?q=%s&limit=%d", strings.ReplaceAll(query, " ", "+"), limit) + if cursor != "" { + urlPath += "&cursor=" + cursor + } + + resp, err := s.Request(ctx, "GET", urlPath, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyText, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("searchActors failed: %s - %s", resp.Status, string(bodyText)) + } + + var result SearchActorsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// SearchPosts searches for posts matching the query string returning feed view posts with pagination support. +func (s *BlueskyService) SearchPosts(ctx context.Context, query string, limit int, cursor string) (*SearchPostsResponse, error) { + urlPath := fmt.Sprintf("/xrpc/app.bsky.feed.searchPosts?q=%s&limit=%d", strings.ReplaceAll(query, " ", "+"), limit) + if cursor != "" { + urlPath += "&cursor=" + cursor + } + + resp, err := s.Request(ctx, "GET", urlPath, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyText, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("searchPosts failed: %s - %s", resp.Status, string(bodyText)) + } + + var result SearchPostsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// GetPosts fetches specific posts by their AT URIs. +// Accepts a slice of URIs and returns the corresponding posts. +func (s *BlueskyService) GetPosts(ctx context.Context, uris []string) (*GetPostsResponse, error) { + if len(uris) == 0 { + return &GetPostsResponse{Posts: []FeedViewPost{}}, nil + } + + url := "/xrpc/app.bsky.feed.getPosts?" + for i, uri := range uris { + if i > 0 { + url += "&" + } + url += "uris=" + uri + } + + resp, err := s.Request(ctx, "GET", url, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyText, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getPosts failed: %s - %s", resp.Status, string(bodyText)) + } + + var result GetPostsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + // SetTokens allows external code to set tokens (e.g., from SessionRepository) func (s *BlueskyService) SetTokens(accessToken, refreshToken string) { s.accessToken = accessToken @@ -321,6 +419,16 @@ func (s *BlueskyService) GetRefreshToken() string { return s.refreshToken } +// GetDid returns the authenticated user's DID +func (s *BlueskyService) GetDid() string { + return s.did +} + +// GetHandle returns the authenticated user's handle +func (s *BlueskyService) GetHandle() string { + return s.handle +} + // shouldRefreshToken checks if token is 90% through its lifetime func (s *BlueskyService) shouldRefreshToken() bool { if s.tokenExpiry.IsZero() { @@ -329,15 +437,14 @@ func (s *BlueskyService) shouldRefreshToken() bool { now := time.Now() lifetime := s.tokenExpiry.Sub(now) - threshold := lifetime / 10 // 10% remaining + threshold := lifetime / 10 return now.Add(threshold).After(s.tokenExpiry) } // refreshAccessToken uses the refresh token to get a new access token func (s *BlueskyService) refreshAccessToken(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, "POST", - s.baseURL+"/xrpc/com.atproto.server.refreshSession", nil) + req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/xrpc/com.atproto.server.refreshSession", nil) if err != nil { return err } @@ -377,16 +484,12 @@ func parseJWTExpiry(token string) (time.Time, error) { return time.Time{}, errors.New("invalid JWT format") } - // Decode payload (second part) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return time.Time{}, err } - var claims struct { - Exp int64 `json:"exp"` - } - + var claims jwtClaims if err := json.Unmarshal(payload, &claims); err != nil { return time.Time{}, err } diff --git a/cli/internal/store/bluesky_test.go b/cli/internal/store/bluesky_test.go index e4938e5..01c6ddb 100644 --- a/cli/internal/store/bluesky_test.go +++ b/cli/internal/store/bluesky_test.go @@ -700,3 +700,337 @@ func TestBlueskyService_GetProfile_ServerError(t *testing.T) { t.Error("expected error for server error") } } + +func TestBlueskyService_SearchActors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "app.bsky.actor.searchActors") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + query := r.URL.Query().Get("q") + if query != "test user" { + t.Errorf("expected q='test user', got %s", query) + } + + limit := r.URL.Query().Get("limit") + if limit != "25" { + t.Errorf("expected limit=25, got %s", limit) + } + + w.WriteHeader(http.StatusOK) + response := SearchActorsResponse{ + Cursor: "next-cursor", + Actors: []ActorProfile{ + { + Did: "did:plc:search1", + Handle: "testuser.bsky.social", + DisplayName: "Test User", + }, + { + Did: "did:plc:search2", + Handle: "testuser2.bsky.social", + DisplayName: "Test User 2", + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.SearchActors(context.Background(), "test user", 25, "") + if err != nil { + t.Fatalf("SearchActors failed: %v", err) + } + + if result.Cursor != "next-cursor" { + t.Errorf("expected cursor 'next-cursor', got %s", result.Cursor) + } + if len(result.Actors) != 2 { + t.Errorf("expected 2 actors, got %d", len(result.Actors)) + } + if result.Actors[0].Handle != "testuser.bsky.social" { + t.Errorf("expected first actor handle 'testuser.bsky.social', got %s", result.Actors[0].Handle) + } +} + +func TestBlueskyService_SearchActors_WithCursor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + if cursor != "test-cursor" { + t.Errorf("expected cursor='test-cursor', got %s", cursor) + } + + response := SearchActorsResponse{ + Actors: []ActorProfile{ + {Did: "did:plc:page2", Handle: "user3.bsky.social"}, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.SearchActors(context.Background(), "test", 25, "test-cursor") + if err != nil { + t.Fatalf("SearchActors with cursor failed: %v", err) + } + + if len(result.Actors) != 1 { + t.Errorf("expected 1 actor, got %d", len(result.Actors)) + } +} + +func TestBlueskyService_SearchActors_NotAuthenticated(t *testing.T) { + svc := NewBlueskyService("") + + _, err := svc.SearchActors(context.Background(), "test", 25, "") + if err == nil { + t.Error("expected error when not authenticated") + } + if !strings.Contains(err.Error(), "not authenticated") { + t.Errorf("expected authentication error, got: %v", err) + } +} + +func TestBlueskyService_SearchActors_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"InternalError"}`)) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + _, err := svc.SearchActors(context.Background(), "test", 25, "") + if err == nil { + t.Error("expected error for server error") + } +} + +func TestBlueskyService_SearchPosts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "app.bsky.feed.searchPosts") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + query := r.URL.Query().Get("q") + if query != "test post" { + t.Errorf("expected q='test post', got %s", query) + } + + limit := r.URL.Query().Get("limit") + if limit != "30" { + t.Errorf("expected limit=30, got %s", limit) + } + + w.WriteHeader(http.StatusOK) + response := SearchPostsResponse{ + Cursor: "next-cursor", + Posts: []FeedViewPost{ + { + Post: &PostView{ + Uri: "at://test/post1", + Author: &ActorProfile{ + Did: "did:plc:author1", + Handle: "author1.bsky.social", + }, + Record: map[string]any{ + "text": "This is a test post", + }, + }, + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.SearchPosts(context.Background(), "test post", 30, "") + if err != nil { + t.Fatalf("SearchPosts failed: %v", err) + } + + if result.Cursor != "next-cursor" { + t.Errorf("expected cursor 'next-cursor', got %s", result.Cursor) + } + if len(result.Posts) != 1 { + t.Errorf("expected 1 post, got %d", len(result.Posts)) + } + if result.Posts[0].Post.Uri != "at://test/post1" { + t.Errorf("expected post URI 'at://test/post1', got %s", result.Posts[0].Post.Uri) + } +} + +func TestBlueskyService_SearchPosts_WithCursor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + if cursor != "page2-cursor" { + t.Errorf("expected cursor='page2-cursor', got %s", cursor) + } + + response := SearchPostsResponse{ + Posts: []FeedViewPost{ + {Post: &PostView{Uri: "at://test/post2"}}, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.SearchPosts(context.Background(), "test", 30, "page2-cursor") + if err != nil { + t.Fatalf("SearchPosts with cursor failed: %v", err) + } + + if len(result.Posts) != 1 { + t.Errorf("expected 1 post, got %d", len(result.Posts)) + } +} + +func TestBlueskyService_SearchPosts_NotAuthenticated(t *testing.T) { + svc := NewBlueskyService("") + + _, err := svc.SearchPosts(context.Background(), "test", 30, "") + if err == nil { + t.Error("expected error when not authenticated") + } + if !strings.Contains(err.Error(), "not authenticated") { + t.Errorf("expected authentication error, got: %v", err) + } +} + +func TestBlueskyService_GetPosts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "app.bsky.feed.getPosts") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + uris := r.URL.Query()["uris"] + if len(uris) != 2 { + t.Errorf("expected 2 URIs, got %d", len(uris)) + } + if uris[0] != "at://test/post1" { + t.Errorf("expected first URI 'at://test/post1', got %s", uris[0]) + } + if uris[1] != "at://test/post2" { + t.Errorf("expected second URI 'at://test/post2', got %s", uris[1]) + } + + response := GetPostsResponse{ + Posts: []FeedViewPost{ + {Post: &PostView{Uri: "at://test/post1"}}, + {Post: &PostView{Uri: "at://test/post2"}}, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.GetPosts(context.Background(), []string{"at://test/post1", "at://test/post2"}) + if err != nil { + t.Fatalf("GetPosts failed: %v", err) + } + + if len(result.Posts) != 2 { + t.Errorf("expected 2 posts, got %d", len(result.Posts)) + } + if result.Posts[0].Post.Uri != "at://test/post1" { + t.Errorf("expected first post URI 'at://test/post1', got %s", result.Posts[0].Post.Uri) + } +} + +func TestBlueskyService_GetPosts_EmptyURIs(t *testing.T) { + svc := NewBlueskyService("") + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.GetPosts(context.Background(), []string{}) + if err != nil { + t.Fatalf("GetPosts with empty URIs failed: %v", err) + } + + if len(result.Posts) != 0 { + t.Errorf("expected 0 posts, got %d", len(result.Posts)) + } +} + +func TestBlueskyService_GetPosts_SingleURI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uris := r.URL.Query()["uris"] + if len(uris) != 1 { + t.Errorf("expected 1 URI, got %d", len(uris)) + } + + response := GetPostsResponse{ + Posts: []FeedViewPost{ + { + Post: &PostView{ + Uri: "at://test/single", + Author: &ActorProfile{ + Handle: "author.bsky.social", + }, + }, + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + result, err := svc.GetPosts(context.Background(), []string{"at://test/single"}) + if err != nil { + t.Fatalf("GetPosts with single URI failed: %v", err) + } + + if len(result.Posts) != 1 { + t.Errorf("expected 1 post, got %d", len(result.Posts)) + } +} + +func TestBlueskyService_GetPosts_NotAuthenticated(t *testing.T) { + svc := NewBlueskyService("") + + _, err := svc.GetPosts(context.Background(), []string{"at://test/post1"}) + if err == nil { + t.Error("expected error when not authenticated") + } + if !strings.Contains(err.Error(), "not authenticated") { + t.Errorf("expected authentication error, got: %v", err) + } +} + +func TestBlueskyService_GetPosts_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"InternalError"}`)) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + _, err := svc.GetPosts(context.Background(), []string{"at://test/post1"}) + if err == nil { + t.Error("expected error for server error") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected 500 error, got: %v", err) + } +} diff --git a/cli/internal/store/service.go b/cli/internal/store/service.go index b9d3624..5b26491 100644 --- a/cli/internal/store/service.go +++ b/cli/internal/store/service.go @@ -24,7 +24,7 @@ type Service interface { // Authenticate establishes credentials with the service (token, key, etc.). Authenticate(ctx context.Context, credentials any) error // Request performs a generic API request and returns the raw response. - // Implementations may wrap or replace http.Client as needed. + // Implementations may wrap or replace [http.Client] as needed. Request(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) // HealthCheck verifies connectivity and minimal readiness of the remote API. HealthCheck(ctx context.Context) error @@ -66,7 +66,6 @@ type VerificationMethod struct { } // DidService represents a service endpoint in the DID document (e.g., PDS endpoint). -// Renamed from Service to avoid collision with the Service interface. type DidService struct { ID string `json:"id"` Type string `json:"type"` @@ -239,3 +238,20 @@ type ActorStatus struct { ExpiresAt string `json:"expiresAt,omitempty"` IsActive bool `json:"isActive"` } + +// SearchActorsResponse models response from app.bsky.actor.searchActors matching the search query with pagination support. +type SearchActorsResponse struct { + Cursor string `json:"cursor,omitempty"` + Actors []ActorProfile `json:"actors"` +} + +// SearchPostsResponse models response from app.bsky.feed.searchPosts matching the search query with pagination support. +type SearchPostsResponse struct { + Cursor string `json:"cursor,omitempty"` + Posts []FeedViewPost `json:"posts"` +} + +// GetPostsResponse models response from app.bsky.feed.getPosts. +type GetPostsResponse struct { + Posts []FeedViewPost `json:"posts"` +}