A community based topic aggregation platform built on atproto

feat(posts): convert Bluesky URLs to post embeds with strongRef

When a user pastes a Bluesky URL (bsky.app) as an external embed,
convert it to a social.coves.embed.post with proper strongRef
containing both URI and CID. This enables rich embedded quote posts
instead of plain external links.

Key changes:
- Implement tryConvertBlueskyURLToPostEmbed in service.go
- Detect Bluesky URLs and resolve them via blueskyService
- Parse URL to AT-URI (resolves handle to DID if needed)
- Fetch CID from Bluesky API for strongRef
- Fall back to external embed on errors (graceful degradation)
- Differentiated logging for circuit breaker vs other errors
- Keep unavailable posts as external embeds (no fake CIDs)

Test coverage:
- Unit tests for all error cases and success path
- Integration test for URL to strongRef conversion

Closes: Coves-p44

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

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

+475 -7
+304
internal/core/posts/embed_conversion_test.go
··· 1 + package posts 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "Coves/internal/core/blueskypost" 9 + 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + ) 13 + 14 + // mockBlueskyService implements blueskypost.Service for testing 15 + type mockBlueskyService struct { 16 + isBlueskyURLResult bool 17 + parseURLResult string 18 + parseURLError error 19 + resolvePostResult *blueskypost.BlueskyPostResult 20 + resolvePostError error 21 + } 22 + 23 + func (m *mockBlueskyService) IsBlueskyURL(url string) bool { 24 + return m.isBlueskyURLResult 25 + } 26 + 27 + func (m *mockBlueskyService) ParseBlueskyURL(_ context.Context, _ string) (string, error) { 28 + return m.parseURLResult, m.parseURLError 29 + } 30 + 31 + func (m *mockBlueskyService) ResolvePost(_ context.Context, _ string) (*blueskypost.BlueskyPostResult, error) { 32 + return m.resolvePostResult, m.resolvePostError 33 + } 34 + 35 + func TestTryConvertBlueskyURLToPostEmbed(t *testing.T) { 36 + ctx := context.Background() 37 + 38 + t.Run("returns false when blueskyService is nil", func(t *testing.T) { 39 + svc := &postService{ 40 + blueskyService: nil, // nil service 41 + } 42 + 43 + external := map[string]interface{}{ 44 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 45 + } 46 + postRecord := &PostRecord{} 47 + 48 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 49 + 50 + assert.False(t, result, "Should return false when blueskyService is nil") 51 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 52 + }) 53 + 54 + t.Run("returns false when URL is empty", func(t *testing.T) { 55 + mockSvc := &mockBlueskyService{ 56 + isBlueskyURLResult: true, 57 + } 58 + svc := &postService{ 59 + blueskyService: mockSvc, 60 + } 61 + 62 + external := map[string]interface{}{ 63 + "uri": "", // empty URL 64 + } 65 + postRecord := &PostRecord{} 66 + 67 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 68 + 69 + assert.False(t, result, "Should return false when URL is empty") 70 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 71 + }) 72 + 73 + t.Run("returns false when URI field is missing", func(t *testing.T) { 74 + mockSvc := &mockBlueskyService{ 75 + isBlueskyURLResult: true, 76 + } 77 + svc := &postService{ 78 + blueskyService: mockSvc, 79 + } 80 + 81 + external := map[string]interface{}{ 82 + // no "uri" field 83 + } 84 + postRecord := &PostRecord{} 85 + 86 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 87 + 88 + assert.False(t, result, "Should return false when uri field is missing") 89 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 90 + }) 91 + 92 + t.Run("returns false when URL is not Bluesky", func(t *testing.T) { 93 + mockSvc := &mockBlueskyService{ 94 + isBlueskyURLResult: false, // not a Bluesky URL 95 + } 96 + svc := &postService{ 97 + blueskyService: mockSvc, 98 + } 99 + 100 + external := map[string]interface{}{ 101 + "uri": "https://twitter.com/user/status/123", 102 + } 103 + postRecord := &PostRecord{} 104 + 105 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 106 + 107 + assert.False(t, result, "Should return false when URL is not Bluesky") 108 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 109 + }) 110 + 111 + t.Run("returns false when URL parsing fails", func(t *testing.T) { 112 + mockSvc := &mockBlueskyService{ 113 + isBlueskyURLResult: true, 114 + parseURLError: errors.New("handle resolution failed"), 115 + } 116 + svc := &postService{ 117 + blueskyService: mockSvc, 118 + } 119 + 120 + external := map[string]interface{}{ 121 + "uri": "https://bsky.app/profile/nonexistent.bsky.social/post/abc123", 122 + } 123 + postRecord := &PostRecord{} 124 + 125 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 126 + 127 + assert.False(t, result, "Should return false when URL parsing fails") 128 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 129 + }) 130 + 131 + t.Run("returns false when post resolution fails with error", func(t *testing.T) { 132 + mockSvc := &mockBlueskyService{ 133 + isBlueskyURLResult: true, 134 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 135 + resolvePostError: errors.New("API timeout"), 136 + } 137 + svc := &postService{ 138 + blueskyService: mockSvc, 139 + } 140 + 141 + external := map[string]interface{}{ 142 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 143 + } 144 + postRecord := &PostRecord{} 145 + 146 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 147 + 148 + assert.False(t, result, "Should return false when post resolution fails") 149 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 150 + }) 151 + 152 + t.Run("returns false when post is unavailable", func(t *testing.T) { 153 + mockSvc := &mockBlueskyService{ 154 + isBlueskyURLResult: true, 155 + parseURLResult: "at://did:plc:deleted/app.bsky.feed.post/deleted123", 156 + resolvePostResult: &blueskypost.BlueskyPostResult{ 157 + Unavailable: true, 158 + Message: "This post has been deleted", 159 + }, 160 + } 161 + svc := &postService{ 162 + blueskyService: mockSvc, 163 + } 164 + 165 + external := map[string]interface{}{ 166 + "uri": "https://bsky.app/profile/deleted.bsky.social/post/deleted123", 167 + } 168 + postRecord := &PostRecord{} 169 + 170 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 171 + 172 + assert.False(t, result, "Should return false for unavailable posts - keep as external embed") 173 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 174 + }) 175 + 176 + t.Run("returns false when ResolvePost returns nil result", func(t *testing.T) { 177 + mockSvc := &mockBlueskyService{ 178 + isBlueskyURLResult: true, 179 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 180 + resolvePostResult: nil, // nil result 181 + resolvePostError: nil, // no error 182 + } 183 + svc := &postService{ 184 + blueskyService: mockSvc, 185 + } 186 + 187 + external := map[string]interface{}{ 188 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 189 + } 190 + postRecord := &PostRecord{} 191 + 192 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 193 + 194 + assert.False(t, result, "Should return false when result is nil") 195 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 196 + }) 197 + 198 + t.Run("returns false with circuit breaker error", func(t *testing.T) { 199 + mockSvc := &mockBlueskyService{ 200 + isBlueskyURLResult: true, 201 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 202 + resolvePostError: blueskypost.ErrCircuitOpen, 203 + } 204 + svc := &postService{ 205 + blueskyService: mockSvc, 206 + } 207 + 208 + external := map[string]interface{}{ 209 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 210 + } 211 + postRecord := &PostRecord{} 212 + 213 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 214 + 215 + assert.False(t, result, "Should return false when circuit breaker is open") 216 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 217 + }) 218 + 219 + t.Run("returns false when resolved post has empty URI", func(t *testing.T) { 220 + mockSvc := &mockBlueskyService{ 221 + isBlueskyURLResult: true, 222 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 223 + resolvePostResult: &blueskypost.BlueskyPostResult{ 224 + URI: "", // empty URI 225 + CID: "bafytest123", 226 + }, 227 + } 228 + svc := &postService{ 229 + blueskyService: mockSvc, 230 + } 231 + 232 + external := map[string]interface{}{ 233 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 234 + } 235 + postRecord := &PostRecord{} 236 + 237 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 238 + 239 + assert.False(t, result, "Should return false when URI is empty") 240 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 241 + }) 242 + 243 + t.Run("returns false when resolved post has empty CID", func(t *testing.T) { 244 + mockSvc := &mockBlueskyService{ 245 + isBlueskyURLResult: true, 246 + parseURLResult: "at://did:plc:test/app.bsky.feed.post/abc123", 247 + resolvePostResult: &blueskypost.BlueskyPostResult{ 248 + URI: "at://did:plc:test/app.bsky.feed.post/abc123", 249 + CID: "", // empty CID 250 + }, 251 + } 252 + svc := &postService{ 253 + blueskyService: mockSvc, 254 + } 255 + 256 + external := map[string]interface{}{ 257 + "uri": "https://bsky.app/profile/test.bsky.social/post/abc123", 258 + } 259 + postRecord := &PostRecord{} 260 + 261 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 262 + 263 + assert.False(t, result, "Should return false when CID is empty") 264 + assert.Nil(t, postRecord.Embed, "Should not modify embed") 265 + }) 266 + 267 + t.Run("successfully converts valid Bluesky URL to post embed", func(t *testing.T) { 268 + mockSvc := &mockBlueskyService{ 269 + isBlueskyURLResult: true, 270 + parseURLResult: "at://did:plc:abcdef/app.bsky.feed.post/xyz789", 271 + resolvePostResult: &blueskypost.BlueskyPostResult{ 272 + URI: "at://did:plc:abcdef/app.bsky.feed.post/xyz789", 273 + CID: "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm", 274 + Text: "Hello from Bluesky!", 275 + Author: &blueskypost.Author{ 276 + DID: "did:plc:abcdef", 277 + Handle: "test.bsky.social", 278 + }, 279 + }, 280 + } 281 + svc := &postService{ 282 + blueskyService: mockSvc, 283 + } 284 + 285 + external := map[string]interface{}{ 286 + "uri": "https://bsky.app/profile/test.bsky.social/post/xyz789", 287 + } 288 + postRecord := &PostRecord{} 289 + 290 + result := svc.tryConvertBlueskyURLToPostEmbed(ctx, external, postRecord) 291 + 292 + assert.True(t, result, "Should return true for successful conversion") 293 + require.NotNil(t, postRecord.Embed, "Should set embed") 294 + 295 + // Verify embed structure (Embed is already map[string]interface{}) 296 + assert.Equal(t, "social.coves.embed.post", postRecord.Embed["$type"]) 297 + 298 + postRef, ok := postRecord.Embed["post"].(map[string]interface{}) 299 + require.True(t, ok, "post should be a map") 300 + 301 + assert.Equal(t, "at://did:plc:abcdef/app.bsky.feed.post/xyz789", postRef["uri"]) 302 + assert.Equal(t, "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm", postRef["cid"]) 303 + }) 304 + }
+69 -7
internal/core/posts/service.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "io" 9 10 "log" ··· 468 469 // Returns true if the conversion was successful and the postRecord was modified. 469 470 // Returns false if the URL is not a Bluesky URL or if conversion failed (caller should continue with external embed). 470 471 // 471 - // Phase 1: Disabled - just keep URL as external/text. 472 - // The social.coves.embed.post lexicon requires a valid CID in strongRef, which we don't have 473 - // until we call ResolvePost. For Phase 1 (text-only), we skip this conversion. 474 - // Phase 2 will properly resolve the post, get the CID, and create the embed. 475 - // See issue: Coves-p44 476 - func (s *postService) tryConvertBlueskyURLToPostEmbed(_ context.Context, _ map[string]interface{}, _ *PostRecord) bool { 477 - return false 472 + // A strongRef is an AT Protocol reference containing both URI (at://did/collection/rkey) and CID 473 + // (content identifier hash). This function resolves the Bluesky URL to obtain both values, 474 + // enabling rich embedded quote posts instead of plain external links. 475 + func (s *postService) tryConvertBlueskyURLToPostEmbed(ctx context.Context, external map[string]interface{}, postRecord *PostRecord) bool { 476 + // 1. Check if blueskyService is available 477 + if s.blueskyService == nil { 478 + log.Printf("[POST-CREATE] BlueskyService unavailable, keeping as external embed") 479 + return false 480 + } 481 + 482 + // 2. Extract and validate URL 483 + url, ok := external["uri"].(string) 484 + if !ok || url == "" { 485 + return false 486 + } 487 + 488 + // 3. Check if it's a Bluesky URL 489 + if !s.blueskyService.IsBlueskyURL(url) { 490 + return false 491 + } 492 + 493 + // 4. Parse URL to AT-URI (resolves handle to DID if needed) 494 + atURI, err := s.blueskyService.ParseBlueskyURL(ctx, url) 495 + if err != nil { 496 + log.Printf("[POST-CREATE] Failed to parse Bluesky URL %s: %v", url, err) 497 + return false // Fall back to external embed 498 + } 499 + 500 + // 5. Resolve post to get CID 501 + result, err := s.blueskyService.ResolvePost(ctx, atURI) 502 + if err != nil { 503 + // Differentiate error types for better debugging 504 + if errors.Is(err, blueskypost.ErrCircuitOpen) { 505 + log.Printf("[POST-CREATE] WARN: Bluesky circuit breaker OPEN, keeping as external embed: %s", atURI) 506 + } else { 507 + log.Printf("[POST-CREATE] Failed to resolve Bluesky post %s: %v", atURI, err) 508 + } 509 + return false // Fall back to external embed 510 + } 511 + 512 + if result == nil { 513 + log.Printf("[POST-CREATE] ERROR: ResolvePost returned nil result for %s", atURI) 514 + return false 515 + } 516 + 517 + // 6. Handle unavailable posts - keep as external embed since we can't get a valid CID 518 + if result.Unavailable { 519 + log.Printf("[POST-CREATE] Bluesky post unavailable (deleted/private), keeping as external embed: %s", atURI) 520 + return false 521 + } 522 + 523 + // 7. Validate we have both URI and CID 524 + if result.URI == "" || result.CID == "" { 525 + log.Printf("[POST-CREATE] ERROR: Bluesky post missing URI or CID (internal bug): uri=%q, cid=%q", result.URI, result.CID) 526 + return false 527 + } 528 + 529 + // 8. Convert embed to social.coves.embed.post with strongRef 530 + postRecord.Embed = map[string]interface{}{ 531 + "$type": "social.coves.embed.post", 532 + "post": map[string]interface{}{ 533 + "uri": result.URI, 534 + "cid": result.CID, 535 + }, 536 + } 537 + 538 + log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID) 539 + return true 478 540 }
+102
tests/integration/bluesky_post_test.go
··· 585 585 } 586 586 }) 587 587 } 588 + 589 + // TestBlueskyPostCrossPosting_EmbedConversion tests that Bluesky URLs in external embeds 590 + // are converted to social.coves.embed.post with proper strongRef (uri + cid) 591 + func TestBlueskyPostCrossPosting_EmbedConversion(t *testing.T) { 592 + if testing.Short() { 593 + t.Skip("Skipping integration test in short mode") 594 + } 595 + 596 + db := setupTestDB(t) 597 + defer func() { _ = db.Close() }() 598 + 599 + // Cleanup cache from previous runs 600 + _, _ = db.Exec("DELETE FROM bluesky_post_cache") 601 + 602 + // Setup identity resolver for handle resolution 603 + identityConfig := identity.DefaultConfig() 604 + identityResolver := identity.NewResolver(db, identityConfig) 605 + 606 + // Setup Bluesky post service 607 + repo := blueskypost.NewRepository(db) 608 + blueskyService := blueskypost.NewService(repo, identityResolver, 609 + blueskypost.WithTimeout(30*time.Second), 610 + blueskypost.WithCacheTTL(1*time.Hour), 611 + ) 612 + 613 + ctx := context.Background() 614 + 615 + t.Run("Convert Bluesky URL to post embed with strongRef", func(t *testing.T) { 616 + // Use a real Bluesky post URL 617 + bskyURL := "https://bsky.app/profile/ianboudreau.com/post/3makab2jnwk2p" 618 + 619 + // 1. Verify URL is detected as Bluesky 620 + require.True(t, blueskyService.IsBlueskyURL(bskyURL), "Should detect as Bluesky URL") 621 + 622 + // 2. Parse URL to AT-URI 623 + atURI, err := blueskyService.ParseBlueskyURL(ctx, bskyURL) 624 + if err != nil { 625 + t.Skipf("Handle resolution failed (network issue): %v", err) 626 + } 627 + t.Logf("Parsed AT-URI: %s", atURI) 628 + 629 + // 3. Resolve the post to get CID 630 + result, err := blueskyService.ResolvePost(ctx, atURI) 631 + if err != nil { 632 + t.Skipf("Post resolution failed (network issue): %v", err) 633 + } 634 + 635 + if result.Unavailable { 636 + t.Skipf("Post unavailable: %s", result.Message) 637 + } 638 + 639 + // 4. Verify we have all fields needed for strongRef 640 + require.NotEmpty(t, result.URI, "Should have AT-URI") 641 + require.NotEmpty(t, result.CID, "Should have CID for strongRef") 642 + 643 + // 5. Verify the CID is a valid format (starts with 'baf') 644 + assert.True(t, len(result.CID) > 10, "CID should be a valid length") 645 + assert.True(t, result.CID[:3] == "baf", "CID should start with 'baf' (CIDv1)") 646 + 647 + // 6. Simulate the conversion that would happen in tryConvertBlueskyURLToPostEmbed 648 + convertedEmbed := map[string]interface{}{ 649 + "$type": "social.coves.embed.post", 650 + "post": map[string]interface{}{ 651 + "uri": result.URI, 652 + "cid": result.CID, 653 + }, 654 + } 655 + 656 + // Verify the converted embed structure 657 + embedType := convertedEmbed["$type"].(string) 658 + assert.Equal(t, "social.coves.embed.post", embedType) 659 + 660 + postRef := convertedEmbed["post"].(map[string]interface{}) 661 + assert.NotEmpty(t, postRef["uri"]) 662 + assert.NotEmpty(t, postRef["cid"]) 663 + 664 + t.Logf("✅ Embed conversion successful:") 665 + t.Logf(" $type: %s", embedType) 666 + t.Logf(" uri: %s", postRef["uri"]) 667 + t.Logf(" cid: %s", postRef["cid"]) 668 + }) 669 + 670 + t.Run("Unavailable post keeps external embed", func(t *testing.T) { 671 + // Use a fake URI that won't exist 672 + fakeURI := "at://did:plc:nonexistent123/app.bsky.feed.post/doesnotexist" 673 + 674 + result, err := blueskyService.ResolvePost(ctx, fakeURI) 675 + if err != nil { 676 + // Some errors are acceptable for non-existent posts 677 + t.Logf("Got error for non-existent post: %v", err) 678 + t.Logf("✅ Error case would fall back to external embed") 679 + return 680 + } 681 + 682 + if result != nil && result.Unavailable { 683 + // This is the expected case - post is marked unavailable 684 + // With the new behavior, we keep the external embed instead of creating a placeholder 685 + t.Logf("✅ Unavailable post detected: %s", result.Message) 686 + t.Logf(" Would keep as external embed (no placeholder CID)") 687 + } 688 + }) 689 + }