A locally focused bluesky appview
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}