A locally focused bluesky appview
at master 15 kB view raw
1package hydration 2 3import ( 4 "bytes" 5 "context" 6 "fmt" 7 "log/slog" 8 "sync" 9 10 "github.com/bluesky-social/indigo/api/bsky" 11 "github.com/bluesky-social/indigo/lex/util" 12 "github.com/whyrusleeping/market/models" 13 "go.opentelemetry.io/otel" 14) 15 16var tracer = otel.Tracer("hydrator") 17 18// PostInfo contains hydrated post information 19type PostInfo struct { 20 ID uint 21 URI string 22 Cid string 23 Post *bsky.FeedPost 24 Author string // DID 25 ReplyTo uint 26 ReplyToUsr uint 27 InThread uint 28 LikeCount int 29 RepostCount int 30 ReplyCount int 31 ViewerLike string // URI of viewer's like, if any 32 33 EmbedInfo *bsky.FeedDefs_PostView_Embed 34} 35 36const fakeCid = "bafyreiapw4hagb5ehqgoeho4v23vf7fhlqey4b7xvjpy76krgkqx7xlolu" 37 38// HydratePost hydrates a single post by URI 39func (h *Hydrator) HydratePost(ctx context.Context, uri string, viewerDID string) (*PostInfo, error) { 40 ctx, span := tracer.Start(ctx, "hydratePost") 41 defer span.End() 42 43 p, err := h.backend.GetPostByUri(ctx, uri, "*") 44 if err != nil { 45 return nil, err 46 } 47 48 return h.HydratePostDB(ctx, uri, p, viewerDID) 49} 50 51func (h *Hydrator) HydratePostDB(ctx context.Context, uri string, dbPost *models.Post, viewerDID string) (*PostInfo, error) { 52 autoFetch, _ := ctx.Value("auto-fetch").(bool) 53 54 authorDid := extractDIDFromURI(uri) 55 r, err := h.backend.GetOrCreateRepo(ctx, authorDid) 56 if err != nil { 57 return nil, err 58 } 59 60 if dbPost.NotFound || len(dbPost.Raw) == 0 { 61 if autoFetch { 62 h.AddMissingRecord(uri, true) 63 if err := h.db.Raw(`SELECT * FROM posts WHERE author = ? AND rkey = ?`, r.ID, extractRkeyFromURI(uri)).Scan(&dbPost).Error; err != nil { 64 return nil, fmt.Errorf("failed to query post: %w", err) 65 } 66 if dbPost.NotFound || len(dbPost.Raw) == 0 { 67 return nil, fmt.Errorf("post not found") 68 } 69 } else { 70 return nil, fmt.Errorf("post not found") 71 } 72 } 73 74 // Unmarshal post record 75 var feedPost bsky.FeedPost 76 if err := feedPost.UnmarshalCBOR(bytes.NewReader(dbPost.Raw)); err != nil { 77 return nil, fmt.Errorf("failed to unmarshal post: %w", err) 78 } 79 80 var wg sync.WaitGroup 81 82 authorDID := r.Did 83 84 // Get engagement counts 85 var likes, reposts, replies int 86 wg.Go(func() { 87 _, span := tracer.Start(ctx, "likeCounts") 88 defer span.End() 89 h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes) 90 }) 91 wg.Go(func() { 92 _, span := tracer.Start(ctx, "repostCounts") 93 defer span.End() 94 h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts) 95 }) 96 wg.Go(func() { 97 _, span := tracer.Start(ctx, "replyCounts") 98 defer span.End() 99 h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies) 100 }) 101 102 // Check if viewer liked this post 103 var likeRkey string 104 if viewerDID != "" { 105 wg.Go(func() { 106 _, span := tracer.Start(ctx, "viewerLikeState") 107 defer span.End() 108 h.db.Raw(` 109 SELECT l.rkey FROM likes l 110 WHERE l.subject = ? 111 AND l.author = (SELECT id FROM repos WHERE did = ?) 112 `, dbPost.ID, viewerDID).Scan(&likeRkey) 113 }) 114 } 115 116 var ei *bsky.FeedDefs_PostView_Embed 117 if feedPost.Embed != nil { 118 wg.Go(func() { 119 ei = h.formatEmbed(ctx, feedPost.Embed, authorDID, viewerDID) 120 }) 121 } 122 123 wg.Wait() 124 125 info := &PostInfo{ 126 ID: dbPost.ID, 127 URI: uri, 128 Cid: dbPost.Cid, 129 Post: &feedPost, 130 Author: authorDID, 131 ReplyTo: dbPost.ReplyTo, 132 ReplyToUsr: dbPost.ReplyToUsr, 133 InThread: dbPost.InThread, 134 LikeCount: likes, 135 RepostCount: reposts, 136 ReplyCount: replies, 137 EmbedInfo: ei, 138 } 139 140 if likeRkey != "" { 141 info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey) 142 } 143 144 if info.Cid == "" { 145 slog.Error("MISSING CID", "uri", uri) 146 info.Cid = fakeCid 147 } 148 149 // Hydrate embed 150 151 return info, nil 152} 153 154// HydratePosts hydrates multiple posts 155func (h *Hydrator) HydratePosts(ctx context.Context, uris []string, viewerDID string) (map[string]*PostInfo, error) { 156 result := make(map[string]*PostInfo, len(uris)) 157 for _, uri := range uris { 158 info, err := h.HydratePost(ctx, uri, viewerDID) 159 if err != nil { 160 // Skip posts that fail to hydrate 161 continue 162 } 163 result[uri] = info 164 } 165 return result, nil 166} 167 168// Helper functions to extract DID and rkey from AT URI 169func extractDIDFromURI(uri string) string { 170 // URI format: at://did:plc:xxx/collection/rkey 171 if len(uri) < 5 || uri[:5] != "at://" { 172 return "" 173 } 174 parts := []rune(uri[5:]) 175 for i, r := range parts { 176 if r == '/' { 177 return string(parts[:i]) 178 } 179 } 180 return string(parts) 181} 182 183func extractRkeyFromURI(uri string) string { 184 // URI format: at://did:plc:xxx/collection/rkey 185 if len(uri) < 5 || uri[:5] != "at://" { 186 return "" 187 } 188 // Find last slash 189 for i := len(uri) - 1; i >= 5; i-- { 190 if uri[i] == '/' { 191 return uri[i+1:] 192 } 193 } 194 return "" 195} 196 197func (h *Hydrator) formatEmbed(ctx context.Context, embed *bsky.FeedPost_Embed, authorDID string, viewerDID string) *bsky.FeedDefs_PostView_Embed { 198 if embed == nil { 199 return nil 200 } 201 _, span := tracer.Start(ctx, "formatEmbed") 202 defer span.End() 203 204 result := &bsky.FeedDefs_PostView_Embed{} 205 206 // Handle images 207 if embed.EmbedImages != nil { 208 viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedImages.Images)) 209 for i, img := range embed.EmbedImages.Images { 210 // Convert blob to CDN URLs 211 fullsize := "" 212 thumb := "" 213 if img.Image != nil { 214 // CDN URL format for feed images 215 cid := img.Image.Ref.String() 216 fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid) 217 thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 218 } 219 220 viewImages[i] = &bsky.EmbedImages_ViewImage{ 221 Alt: img.Alt, 222 AspectRatio: img.AspectRatio, 223 Fullsize: fullsize, 224 Thumb: thumb, 225 } 226 } 227 result.EmbedImages_View = &bsky.EmbedImages_View{ 228 LexiconTypeID: "app.bsky.embed.images#view", 229 Images: viewImages, 230 } 231 return result 232 } 233 234 // Handle external links 235 if embed.EmbedExternal != nil && embed.EmbedExternal.External != nil { 236 // Convert blob thumb to CDN URL if present 237 var thumbURL *string 238 if embed.EmbedExternal.External.Thumb != nil { 239 // CDN URL for external link thumbnails 240 cid := embed.EmbedExternal.External.Thumb.Ref.String() 241 url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 242 thumbURL = &url 243 } 244 245 result.EmbedExternal_View = &bsky.EmbedExternal_View{ 246 LexiconTypeID: "app.bsky.embed.external#view", 247 External: &bsky.EmbedExternal_ViewExternal{ 248 Uri: embed.EmbedExternal.External.Uri, 249 Title: embed.EmbedExternal.External.Title, 250 Description: embed.EmbedExternal.External.Description, 251 Thumb: thumbURL, 252 }, 253 } 254 return result 255 } 256 257 // Handle video 258 if embed.EmbedVideo != nil && embed.EmbedVideo.Video != nil { 259 cid := embed.EmbedVideo.Video.Ref.String() 260 // URL-encode the DID (replace : with %3A) 261 encodedDID := "" 262 for _, ch := range authorDID { 263 if ch == ':' { 264 encodedDID += "%3A" 265 } else { 266 encodedDID += string(ch) 267 } 268 } 269 270 playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid) 271 thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid) 272 273 result.EmbedVideo_View = &bsky.EmbedVideo_View{ 274 LexiconTypeID: "app.bsky.embed.video#view", 275 Cid: cid, 276 Playlist: playlist, 277 Thumbnail: &thumbnail, 278 Alt: embed.EmbedVideo.Alt, 279 AspectRatio: embed.EmbedVideo.AspectRatio, 280 } 281 return result 282 } 283 284 // Handle record (quote posts, etc.) 285 if embed.EmbedRecord != nil && embed.EmbedRecord.Record != nil { 286 rec := embed.EmbedRecord.Record 287 288 result.EmbedRecord_View = &bsky.EmbedRecord_View{ 289 LexiconTypeID: "app.bsky.embed.record#view", 290 Record: h.hydrateEmbeddedRecord(ctx, rec.Uri, viewerDID), 291 } 292 return result 293 } 294 295 // Handle record with media (quote post with images/external) 296 if embed.EmbedRecordWithMedia != nil { 297 recordView := &bsky.EmbedRecordWithMedia_View{ 298 LexiconTypeID: "app.bsky.embed.recordWithMedia#view", 299 } 300 301 // Hydrate the record part 302 if embed.EmbedRecordWithMedia.Record != nil && embed.EmbedRecordWithMedia.Record.Record != nil { 303 recordView.Record = &bsky.EmbedRecord_View{ 304 LexiconTypeID: "app.bsky.embed.record#view", 305 Record: h.hydrateEmbeddedRecord(ctx, embed.EmbedRecordWithMedia.Record.Record.Uri, viewerDID), 306 } 307 } 308 309 // Hydrate the media part (images or external) 310 if embed.EmbedRecordWithMedia.Media != nil { 311 if embed.EmbedRecordWithMedia.Media.EmbedImages != nil { 312 viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedRecordWithMedia.Media.EmbedImages.Images)) 313 for i, img := range embed.EmbedRecordWithMedia.Media.EmbedImages.Images { 314 fullsize := "" 315 thumb := "" 316 if img.Image != nil { 317 cid := img.Image.Ref.String() 318 fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid) 319 thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 320 } 321 322 viewImages[i] = &bsky.EmbedImages_ViewImage{ 323 Alt: img.Alt, 324 AspectRatio: img.AspectRatio, 325 Fullsize: fullsize, 326 Thumb: thumb, 327 } 328 } 329 recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{ 330 EmbedImages_View: &bsky.EmbedImages_View{ 331 LexiconTypeID: "app.bsky.embed.images#view", 332 Images: viewImages, 333 }, 334 } 335 } else if embed.EmbedRecordWithMedia.Media.EmbedExternal != nil && embed.EmbedRecordWithMedia.Media.EmbedExternal.External != nil { 336 var thumbURL *string 337 if embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb != nil { 338 cid := embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb.Ref.String() 339 url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 340 thumbURL = &url 341 } 342 343 recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{ 344 EmbedExternal_View: &bsky.EmbedExternal_View{ 345 LexiconTypeID: "app.bsky.embed.external#view", 346 External: &bsky.EmbedExternal_ViewExternal{ 347 Uri: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Uri, 348 Title: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Title, 349 Description: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Description, 350 Thumb: thumbURL, 351 }, 352 }, 353 } 354 } else if embed.EmbedRecordWithMedia.Media.EmbedVideo != nil && embed.EmbedRecordWithMedia.Media.EmbedVideo.Video != nil { 355 cid := embed.EmbedRecordWithMedia.Media.EmbedVideo.Video.Ref.String() 356 // URL-encode the DID (replace : with %3A) 357 encodedDID := "" 358 for _, ch := range authorDID { 359 if ch == ':' { 360 encodedDID += "%3A" 361 } else { 362 encodedDID += string(ch) 363 } 364 } 365 366 playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid) 367 thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid) 368 369 recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{ 370 EmbedVideo_View: &bsky.EmbedVideo_View{ 371 LexiconTypeID: "app.bsky.embed.video#view", 372 Cid: cid, 373 Playlist: playlist, 374 Thumbnail: &thumbnail, 375 Alt: embed.EmbedRecordWithMedia.Media.EmbedVideo.Alt, 376 AspectRatio: embed.EmbedRecordWithMedia.Media.EmbedVideo.AspectRatio, 377 }, 378 } 379 } 380 } 381 382 result.EmbedRecordWithMedia_View = recordView 383 return result 384 } 385 386 return nil 387} 388 389// hydrateEmbeddedRecord hydrates an embedded record (for quote posts, etc.) 390func (h *Hydrator) hydrateEmbeddedRecord(ctx context.Context, uri string, viewerDID string) *bsky.EmbedRecord_View_Record { 391 ctx, span := tracer.Start(ctx, "hydrateEmbeddedRecord") 392 defer span.End() 393 394 // Check if it's a post URI 395 if !isPostURI(uri) { 396 // Could be a feed generator, list, labeler, or starter pack 397 // For now, return not found for non-post embeds 398 return &bsky.EmbedRecord_View_Record{ 399 EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{ 400 LexiconTypeID: "app.bsky.embed.record#viewNotFound", 401 Uri: uri, 402 }, 403 } 404 } 405 406 // Try to hydrate the post 407 quotedPost, err := h.HydratePost(ctx, uri, viewerDID) 408 if err != nil { 409 // Post not found 410 return &bsky.EmbedRecord_View_Record{ 411 EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{ 412 LexiconTypeID: "app.bsky.embed.record#viewNotFound", 413 Uri: uri, 414 NotFound: true, 415 }, 416 } 417 } 418 419 // Hydrate the author 420 authorInfo, err := h.HydrateActor(ctx, quotedPost.Author) 421 if err != nil { 422 // Author not found, treat as not found 423 return &bsky.EmbedRecord_View_Record{ 424 EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{ 425 LexiconTypeID: "app.bsky.embed.record#viewNotFound", 426 Uri: uri, 427 NotFound: true, 428 }, 429 } 430 } 431 432 // TODO: Check if viewer has blocked or is blocked by the author 433 // For now, just return the record view 434 435 // Build the author profile view 436 authorView := &bsky.ActorDefs_ProfileViewBasic{ 437 Did: authorInfo.DID, 438 Handle: authorInfo.Handle, 439 } 440 if authorInfo.Profile != nil { 441 if authorInfo.Profile.DisplayName != nil && *authorInfo.Profile.DisplayName != "" { 442 authorView.DisplayName = authorInfo.Profile.DisplayName 443 } 444 if authorInfo.Profile.Avatar != nil { 445 avatarURL := fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", authorInfo.DID, authorInfo.Profile.Avatar.Ref.String()) 446 authorView.Avatar = &avatarURL 447 } 448 } 449 450 // Build the embedded post view 451 embedView := &bsky.EmbedRecord_ViewRecord{ 452 LexiconTypeID: "app.bsky.embed.record#viewRecord", 453 Uri: quotedPost.URI, 454 Cid: quotedPost.Cid, 455 Author: authorView, 456 Value: &util.LexiconTypeDecoder{ 457 Val: quotedPost.Post, 458 }, 459 IndexedAt: quotedPost.Post.CreatedAt, 460 } 461 462 // Add engagement counts 463 if quotedPost.LikeCount > 0 { 464 lc := int64(quotedPost.LikeCount) 465 embedView.LikeCount = &lc 466 } 467 if quotedPost.RepostCount > 0 { 468 rc := int64(quotedPost.RepostCount) 469 embedView.RepostCount = &rc 470 } 471 if quotedPost.ReplyCount > 0 { 472 rpc := int64(quotedPost.ReplyCount) 473 embedView.ReplyCount = &rpc 474 } 475 476 // Note: We don't recursively hydrate embeds for quoted posts to avoid deep nesting 477 // The official app also doesn't show embeds within quoted posts 478 479 return &bsky.EmbedRecord_View_Record{ 480 EmbedRecord_ViewRecord: embedView, 481 } 482} 483 484// isPostURI checks if a URI is a post URI 485func isPostURI(uri string) bool { 486 return len(uri) > 5 && uri[:5] == "at://" && ( 487 // Check if it contains /app.bsky.feed.post/ 488 len(uri) > 25 && uri[len(uri)-25:len(uri)-12] == "/app.bsky.feed.post/" || 489 // More flexible check 490 contains(uri, "/app.bsky.feed.post/")) 491} 492 493// contains checks if a string contains a substring 494func contains(s, substr string) bool { 495 for i := 0; i <= len(s)-len(substr); i++ { 496 if s[i:i+len(substr)] == substr { 497 return true 498 } 499 } 500 return false 501}