bluesky viewer in the terminal
at main 6.9 kB view raw
1package main 2 3import ( 4 "context" 5 "fmt" 6 "regexp" 7 "strings" 8 9 "github.com/google/uuid" 10 "github.com/stormlightlabs/skypanel/cli/internal/registry" 11 "github.com/stormlightlabs/skypanel/cli/internal/setup" 12 "github.com/stormlightlabs/skypanel/cli/internal/store" 13 "github.com/stormlightlabs/skypanel/cli/internal/ui" 14 "github.com/urfave/cli/v3" 15) 16 17// ViewFeedAction views posts from a feed (fetches from API) 18func ViewFeedAction(ctx context.Context, cmd *cli.Command) error { 19 if err := setup.EnsurePersistenceReady(ctx); err != nil { 20 return fmt.Errorf("persistence layer not ready: %w", err) 21 } 22 23 reg := registry.Get() 24 25 if cmd.Args().Len() == 0 { 26 return fmt.Errorf("feed URI or local feed ID required") 27 } 28 29 feedIdentifier := cmd.Args().First() 30 limit := cmd.Int("limit") 31 cursor := cmd.String("cursor") 32 asJSON := cmd.Bool("json") 33 34 service, err := reg.GetService() 35 if err != nil { 36 return fmt.Errorf("failed to get service: %w", err) 37 } 38 39 if !service.Authenticated() { 40 return fmt.Errorf("not authenticated: run 'skycli login' first") 41 } 42 43 feedRepo, err := reg.GetFeedRepo() 44 if err != nil { 45 return fmt.Errorf("failed to get feed repository: %w", err) 46 } 47 48 var feedURI string 49 50 if _, err := uuid.Parse(feedIdentifier); err == nil { 51 feed, err := feedRepo.Get(ctx, feedIdentifier) 52 if err != nil { 53 return fmt.Errorf("failed to get local feed: %w", err) 54 } 55 if feedModel, ok := feed.(*store.FeedModel); ok { 56 feedURI = feedModel.Source 57 logger.Debug("Resolved local feed ID to URI", "id", feedIdentifier, "uri", feedURI) 58 } 59 } else { 60 feedURI = feedIdentifier 61 } 62 63 logger.Debug("Fetching feed from API", "uri", feedURI, "limit", limit, "cursor", cursor) 64 65 response, err := service.GetAuthorFeed(ctx, feedURI, limit, cursor) 66 if err != nil { 67 return fmt.Errorf("failed to fetch feed: %w", err) 68 } 69 70 if asJSON { 71 return ui.DisplayJSON(response) 72 } 73 74 ui.Titleln("Feed: %s", feedURI) 75 ui.DisplayFeed(response.Feed, response.Cursor) 76 return nil 77} 78 79// ViewPostAction views a single post by URI or URL 80func ViewPostAction(ctx context.Context, cmd *cli.Command) error { 81 if err := setup.EnsurePersistenceReady(ctx); err != nil { 82 return fmt.Errorf("persistence layer not ready: %w", err) 83 } 84 85 reg := registry.Get() 86 87 if cmd.Args().Len() == 0 { 88 return fmt.Errorf("post URI or URL required") 89 } 90 91 postIdentifier := cmd.Args().First() 92 asJSON := cmd.Bool("json") 93 94 service, err := reg.GetService() 95 if err != nil { 96 return fmt.Errorf("failed to get service: %w", err) 97 } 98 99 if !service.Authenticated() { 100 return fmt.Errorf("not authenticated: run 'skycli login' first") 101 } 102 103 postURI, err := parsePostIdentifier(postIdentifier) 104 if err != nil { 105 return fmt.Errorf("failed to parse post identifier: %w", err) 106 } 107 108 logger.Debug("Fetching post", "uri", postURI) 109 110 response, err := service.GetPosts(ctx, []string{postURI}) 111 if err != nil { 112 return fmt.Errorf("failed to fetch post: %w", err) 113 } 114 115 if len(response.Posts) == 0 { 116 return fmt.Errorf("post not found: %s", postURI) 117 } 118 119 if asJSON { 120 return ui.DisplayJSON(response.Posts[0]) 121 } 122 123 ui.Titleln("Post View") 124 ui.DisplayFeed([]store.FeedViewPost{response.Posts[0]}, "") 125 126 return nil 127} 128 129// ViewProfileAction views an actor's profile with stats 130func ViewProfileAction(ctx context.Context, cmd *cli.Command) error { 131 if err := setup.EnsurePersistenceReady(ctx); err != nil { 132 return fmt.Errorf("persistence layer not ready: %w", err) 133 } 134 135 reg := registry.Get() 136 137 if cmd.Args().Len() == 0 { 138 return fmt.Errorf("actor handle or DID required") 139 } 140 141 actor := cmd.Args().First() 142 showPosts := cmd.Bool("with-posts") 143 asJSON := cmd.Bool("json") 144 145 service, err := reg.GetService() 146 if err != nil { 147 return fmt.Errorf("failed to get service: %w", err) 148 } 149 150 if !service.Authenticated() { 151 return fmt.Errorf("not authenticated: run 'skycli login' first") 152 } 153 154 logger.Debug("Fetching profile", "actor", actor) 155 156 profile, err := service.GetProfile(ctx, actor) 157 if err != nil { 158 return fmt.Errorf("failed to fetch profile: %w", err) 159 } 160 161 if asJSON { 162 return ui.DisplayJSON(profile) 163 } 164 165 ui.DisplayProfileHeader(profile) 166 167 if showPosts { 168 logger.Debug("Fetching recent posts", "actor", actor) 169 feed, err := service.GetAuthorFeed(ctx, actor, 10, "") 170 if err != nil { 171 ui.Warningln("Failed to fetch recent posts: %v", err) 172 } else { 173 fmt.Println() 174 ui.Subtitleln("Recent Posts") 175 ui.DisplayFeed(feed.Feed, "") 176 } 177 } 178 179 return nil 180} 181 182// ViewCommand returns the view command with subcommands for feed, post, and profile 183func ViewCommand() *cli.Command { 184 return &cli.Command{ 185 Name: "view", 186 Usage: "View feeds, posts, or profiles", 187 Commands: []*cli.Command{ 188 { 189 Name: "feed", 190 Usage: "View posts from a feed by URI or local feed ID", 191 ArgsUsage: "<feed-uri-or-id>", 192 Flags: []cli.Flag{ 193 &cli.IntFlag{ 194 Name: "limit", 195 Aliases: []string{"l"}, 196 Usage: "Maximum number of posts to display", 197 Value: 25, 198 }, 199 &cli.StringFlag{ 200 Name: "cursor", 201 Aliases: []string{"c"}, 202 Usage: "Pagination cursor", 203 }, 204 &cli.BoolFlag{ 205 Name: "json", 206 Aliases: []string{"j"}, 207 Usage: "Output raw JSON response", 208 }, 209 }, 210 Action: ViewFeedAction, 211 }, 212 { 213 Name: "post", 214 Usage: "View a single post by URI or bsky.app URL", 215 ArgsUsage: "<post-uri-or-url>", 216 Flags: []cli.Flag{ 217 &cli.BoolFlag{ 218 Name: "json", 219 Aliases: []string{"j"}, 220 Usage: "Output raw JSON response", 221 }, 222 }, 223 Action: ViewPostAction, 224 }, 225 { 226 Name: "profile", 227 Usage: "View an actor's profile", 228 ArgsUsage: "<actor-handle-or-did>", 229 Flags: []cli.Flag{ 230 &cli.BoolFlag{ 231 Name: "with-posts", 232 Aliases: []string{"p"}, 233 Usage: "Also display recent posts from this profile", 234 }, 235 &cli.BoolFlag{ 236 Name: "json", 237 Aliases: []string{"j"}, 238 Usage: "Output raw JSON response", 239 }, 240 }, 241 Action: ViewProfileAction, 242 }, 243 }, 244 } 245} 246 247// parsePostIdentifier converts a bsky.app URL or AT URI to an AT URI 248// Examples: 249// - https://bsky.app/profile/alice.bsky.social/post/abc123 250// - at://did:plc:xyz/app.bsky.feed.post/abc123 251func parsePostIdentifier(identifier string) (string, error) { 252 if strings.HasPrefix(identifier, "at://") { 253 return identifier, nil 254 } 255 256 if strings.HasPrefix(identifier, "https://bsky.app/profile/") || 257 strings.HasPrefix(identifier, "http://bsky.app/profile/") { 258 re := regexp.MustCompile(`^https?://bsky\.app/profile/([^/]+)/post/([^/]+)`) 259 matches := re.FindStringSubmatch(identifier) 260 if len(matches) != 3 { 261 return "", fmt.Errorf("invalid bsky.app URL format") 262 } 263 264 handle := matches[1] 265 rkey := matches[2] 266 267 return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", handle, rkey), nil 268 } 269 270 return "", fmt.Errorf("identifier must be an AT URI (at://...) or bsky.app URL") 271}