A community based topic aggregation platform built on atproto

feat(bluesky): extract external link embeds from Bluesky posts

When embedding Bluesky posts that contain external links (link cards),
the external link metadata (URI, title, description, thumbnail) is now
extracted and stored in the BlueskyPostResult.

Changes:
- Add ExternalEmbed type to capture link card data
- Add Embed field to BlueskyPostResult
- Parse app.bsky.embed.external#view from Bluesky API responses
- Add unit tests for external embed extraction
- Fix productionPLCIdentityResolver to require db parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+147 -12
+24 -5
internal/core/blueskypost/fetcher.go
··· 64 64 65 65 // blueskyAPIEmbed represents resolved embed data in the API response 66 66 type blueskyAPIEmbed struct { 67 - Video json.RawMessage `json:"video,omitempty"` 68 - Record *blueskyAPIEmbedRecord `json:"record,omitempty"` 69 - Media *blueskyAPIEmbedMedia `json:"media,omitempty"` 70 - Type string `json:"$type"` 71 - Images []json.RawMessage `json:"images,omitempty"` 67 + Video json.RawMessage `json:"video,omitempty"` 68 + Record *blueskyAPIEmbedRecord `json:"record,omitempty"` 69 + Media *blueskyAPIEmbedMedia `json:"media,omitempty"` 70 + External *blueskyAPIExternal `json:"external,omitempty"` 71 + Type string `json:"$type"` 72 + Images []json.RawMessage `json:"images,omitempty"` 73 + } 74 + 75 + // blueskyAPIExternal represents an external link embed in the API response 76 + type blueskyAPIExternal struct { 77 + URI string `json:"uri"` 78 + Title string `json:"title,omitempty"` 79 + Description string `json:"description,omitempty"` 80 + Thumb string `json:"thumb,omitempty"` 72 81 } 73 82 74 83 // blueskyAPIEmbedMedia represents media in a recordWithMedia embed ··· 230 239 result.HasMedia = true 231 240 if result.MediaCount == 0 { 232 241 result.MediaCount = 1 242 + } 243 + } 244 + 245 + // Extract external link embed (app.bsky.embed.external#view) 246 + if post.Embed.External != nil && post.Embed.External.URI != "" { 247 + result.Embed = &ExternalEmbed{ 248 + URI: post.Embed.External.URI, 249 + Title: post.Embed.External.Title, 250 + Description: post.Embed.External.Description, 251 + Thumb: post.Embed.External.Thumb, 233 252 } 234 253 } 235 254
+79
internal/core/blueskypost/fetcher_test.go
··· 601 601 t.Error("Expected no quoted post with nil embeds") 602 602 } 603 603 } 604 + 605 + func TestMapAPIPostToResult_ExternalEmbed(t *testing.T) { 606 + // Test that external link embeds are correctly extracted 607 + apiPost := &blueskyAPIPost{ 608 + URI: "at://did:plc:test/app.bsky.feed.post/test", 609 + CID: "bafyreiabc123", 610 + Author: blueskyAPIAuthor{ 611 + DID: "did:plc:test", 612 + Handle: "english.lemonde.fr", 613 + DisplayName: "Le Monde", 614 + }, 615 + Record: blueskyAPIRecord{ 616 + Text: "Check out this article", 617 + CreatedAt: "2025-12-21T10:30:00Z", 618 + }, 619 + Embed: &blueskyAPIEmbed{ 620 + Type: "app.bsky.embed.external#view", 621 + External: &blueskyAPIExternal{ 622 + URI: "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html", 623 + Title: "NBA and Fiba announce search for teams", 624 + Description: "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league.", 625 + Thumb: "https://cdn.lemonde.fr/thumbnail.jpg", 626 + }, 627 + }, 628 + ReplyCount: 10, 629 + RepostCount: 5, 630 + LikeCount: 100, 631 + } 632 + 633 + result := mapAPIPostToResult(apiPost) 634 + 635 + // Verify basic fields 636 + if result.URI != "at://did:plc:test/app.bsky.feed.post/test" { 637 + t.Errorf("Expected URI 'at://did:plc:test/app.bsky.feed.post/test', got %s", result.URI) 638 + } 639 + if result.Author.Handle != "english.lemonde.fr" { 640 + t.Errorf("Expected Handle 'english.lemonde.fr', got %s", result.Author.Handle) 641 + } 642 + 643 + // Verify external embed is extracted 644 + if result.Embed == nil { 645 + t.Fatal("Expected Embed to be set for external link post") 646 + } 647 + if result.Embed.URI != "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html" { 648 + t.Errorf("Expected external URI, got %s", result.Embed.URI) 649 + } 650 + if result.Embed.Title != "NBA and Fiba announce search for teams" { 651 + t.Errorf("Expected external title, got %s", result.Embed.Title) 652 + } 653 + if result.Embed.Description != "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league." { 654 + t.Errorf("Expected external description, got %s", result.Embed.Description) 655 + } 656 + if result.Embed.Thumb != "https://cdn.lemonde.fr/thumbnail.jpg" { 657 + t.Errorf("Expected external thumb, got %s", result.Embed.Thumb) 658 + } 659 + } 660 + 661 + func TestMapAPIPostToResult_ExternalEmbedNil(t *testing.T) { 662 + // Test that posts without external embeds don't have Embed set 663 + apiPost := &blueskyAPIPost{ 664 + URI: "at://did:plc:test/app.bsky.feed.post/test", 665 + CID: "bafyreiabc123", 666 + Author: blueskyAPIAuthor{ 667 + DID: "did:plc:test", 668 + Handle: "user.bsky.social", 669 + }, 670 + Record: blueskyAPIRecord{ 671 + Text: "Just a regular post without links", 672 + CreatedAt: "2025-12-21T10:30:00Z", 673 + }, 674 + Embed: nil, 675 + } 676 + 677 + result := mapAPIPostToResult(apiPost) 678 + 679 + if result.Embed != nil { 680 + t.Errorf("Expected Embed to be nil for post without external embed, got %+v", result.Embed) 681 + } 682 + }
+20
internal/core/blueskypost/types.go
··· 54 54 55 55 // Unavailable indicates the post could not be resolved (deleted, private, blocked, etc.) 56 56 Unavailable bool `json:"unavailable"` 57 + 58 + // Embed contains the post's external link embed, if present 59 + // This captures link cards from the original Bluesky post 60 + Embed *ExternalEmbed `json:"embed,omitempty"` 57 61 } 58 62 59 63 // Author represents a Bluesky post author's identity. ··· 70 74 // Avatar is the URL to the user's avatar image (may be empty) 71 75 Avatar string `json:"avatar,omitempty"` 72 76 } 77 + 78 + // ExternalEmbed represents an external link embed from a Bluesky post. 79 + // This captures link cards (URLs with title, description, and thumbnail). 80 + type ExternalEmbed struct { 81 + // URI is the URL of the external link 82 + URI string `json:"uri"` 83 + 84 + // Title is the page title (from og:title or <title>) 85 + Title string `json:"title,omitempty"` 86 + 87 + // Description is the page description (from og:description or meta description) 88 + Description string `json:"description,omitempty"` 89 + 90 + // Thumb is the URL to the thumbnail image (from og:image) 91 + Thumb string `json:"thumb,omitempty"` 92 + }
+24 -7
tests/integration/bluesky_post_test.go
··· 4 4 "Coves/internal/atproto/identity" 5 5 "Coves/internal/core/blueskypost" 6 6 "context" 7 + "database/sql" 7 8 "fmt" 8 9 "net/http" 9 10 "testing" ··· 21 22 // 22 23 // Use this for tests that need to resolve real Bluesky handles like "ianboudreau.com". 23 24 // Do NOT use for tests involving local Coves identities (use local PLC instead). 24 - func productionPLCIdentityResolver() identity.Resolver { 25 + // 26 + // NOTE: Requires a database connection for the identity cache. Pass the test db. 27 + func productionPLCIdentityResolver(db *sql.DB) identity.Resolver { 25 28 config := identity.DefaultConfig() 26 29 config.PLCURL = "https://plc.directory" // Production PLC - READ ONLY 27 - return identity.NewResolver(nil, config) 30 + return identity.NewResolver(db, config) 28 31 } 29 32 30 33 // TestBlueskyPostCrossPosting_URLParsing tests URL detection and parsing ··· 37 40 defer func() { _ = db.Close() }() 38 41 39 42 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 40 - identityResolver := productionPLCIdentityResolver() 43 + identityResolver := productionPLCIdentityResolver(db) 41 44 42 45 // Setup Bluesky post service 43 46 repo := blueskypost.NewRepository(db) ··· 119 122 _, _ = db.Exec("DELETE FROM bluesky_post_cache") 120 123 121 124 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 122 - identityResolver := productionPLCIdentityResolver() 125 + identityResolver := productionPLCIdentityResolver(db) 123 126 124 127 repo := blueskypost.NewRepository(db) 125 128 service := blueskypost.NewService(repo, identityResolver, ··· 239 242 assert.Equal(t, "davidpfau.com", result.Author.Handle) 240 243 assert.NotEmpty(t, result.Text) 241 244 245 + // Verify external embed is extracted 246 + if result.Embed != nil { 247 + assert.NotEmpty(t, result.Embed.URI, "External embed should have URI") 248 + t.Logf(" External embed URI: %s", result.Embed.URI) 249 + if result.Embed.Title != "" { 250 + t.Logf(" External embed title: %s", result.Embed.Title) 251 + } 252 + if result.Embed.Thumb != "" { 253 + t.Logf(" External embed thumb: %s", result.Embed.Thumb) 254 + } 255 + } else { 256 + t.Log(" Note: No external embed found (post may have been modified)") 257 + } 258 + 242 259 t.Logf("✓ Successfully fetched post with link embed:") 243 260 t.Logf(" Author: @%s", result.Author.Handle) 244 261 t.Logf(" Text: %.80s...", result.Text) ··· 385 402 defer func() { _ = db.Close() }() 386 403 387 404 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 388 - identityResolver := productionPLCIdentityResolver() 405 + identityResolver := productionPLCIdentityResolver(db) 389 406 390 407 repo := blueskypost.NewRepository(db) 391 408 service := blueskypost.NewService(repo, identityResolver, ··· 542 559 _, _ = db.Exec("DELETE FROM bluesky_post_cache WHERE at_uri LIKE 'at://did:plc:%'") 543 560 544 561 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 545 - identityResolver := productionPLCIdentityResolver() 562 + identityResolver := productionPLCIdentityResolver(db) 546 563 547 564 repo := blueskypost.NewRepository(db) 548 565 service := blueskypost.NewService(repo, identityResolver, ··· 611 628 _, _ = db.Exec("DELETE FROM bluesky_post_cache") 612 629 613 630 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 614 - identityResolver := productionPLCIdentityResolver() 631 + identityResolver := productionPLCIdentityResolver(db) 615 632 616 633 // Setup Bluesky post service 617 634 repo := blueskypost.NewRepository(db)