A community based topic aggregation platform built on atproto

feat(bluesky): add Bluesky post cross-posting support (Phase 1: text-only)

Enable users to embed Bluesky posts by pasting bsky.app URLs. Posts are
resolved at response time with text, author info, and engagement stats.

## New Package: internal/core/blueskypost/

Core service following the unfurl package pattern:
- types.go: BlueskyPostResult, Author structs with ErrCircuitOpen sentinel
- interfaces.go: Service and Repository interfaces
- repository.go: PostgreSQL cache with TTL (1 hour) and AT-URI validation
- url_parser.go: bsky.app URL → AT-URI conversion with rkey validation
- fetcher.go: Bluesky public API client using SSRF-safe HTTP client
- circuit_breaker.go: Failure protection (3 failures, 5min open)
- service.go: Cache-first resolution with circuit breaker integration

## Features

- Detect bsky.app URLs in post creation, convert to social.coves.embed.post
- Resolve Bluesky posts at feed response time via TransformPostEmbeds()
- Support for quoted posts (1 level deep)
- Media indicators (hasMedia, mediaCount) without rendering (Phase 2)
- Typed error handling with retryable flag for transient failures
- Debug logging for embed processing traceability

## Integration

- Updated discover, timeline, communityFeed handlers
- Wired blueskypost service in cmd/server/main.go
- Database migration for bluesky_post_cache table

## Test Coverage: 73.1%

- url_parser_test.go: URL parsing, validation, edge cases
- circuit_breaker_test.go: State transitions, thread safety
- service_test.go: Cache hit/miss, circuit breaker integration
- fetcher_test.go: Post mapping, media detection, quotes
- repository_test.go: AT-URI validation

## Out of Scope (Phase 2)

- Rendering embedded images/videos
- Moderation labels (NSFW handling)
- Deep quote chains (>1 level nesting)

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

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

+2940 -143
+31 -19
cmd/server/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 5 + "context" 6 + "crypto/rand" 7 + "database/sql" 8 + "encoding/base64" 9 + "encoding/json" 10 + "fmt" 11 + "io" 12 + "log" 13 + "net/http" 14 + "os" 15 + "os/signal" 16 + "strings" 17 + "syscall" 18 + "time" 19 + 4 20 "Coves/internal/api/middleware" 5 21 "Coves/internal/api/routes" 6 22 "Coves/internal/atproto/identity" ··· 8 24 "Coves/internal/atproto/oauth" 9 25 "Coves/internal/core/aggregators" 10 26 "Coves/internal/core/blobs" 27 + "Coves/internal/core/blueskypost" 11 28 "Coves/internal/core/comments" 12 29 "Coves/internal/core/communities" 13 30 "Coves/internal/core/communityFeeds" ··· 17 34 "Coves/internal/core/unfurl" 18 35 "Coves/internal/core/users" 19 36 "Coves/internal/core/votes" 20 - "bytes" 21 - "context" 22 - "crypto/rand" 23 - "database/sql" 24 - "encoding/base64" 25 - "encoding/json" 26 - "fmt" 27 - "io" 28 - "log" 29 - "net/http" 30 - "os" 31 - "os/signal" 32 - "strings" 33 - "syscall" 34 - "time" 35 37 36 38 "github.com/go-chi/chi/v5" 37 39 chiMiddleware "github.com/go-chi/chi/v5/middleware" ··· 390 392 ) 391 393 log.Println("✅ Unfurl and blob services initialized") 392 394 395 + // Initialize Bluesky post cache repository and service 396 + blueskyRepo := blueskypost.NewRepository(db) 397 + blueskyService := blueskypost.NewService( 398 + blueskyRepo, 399 + identityResolver, 400 + blueskypost.WithTimeout(10*time.Second), 401 + blueskypost.WithCacheTTL(1*time.Hour), // 1 hour cache (shorter than unfurl) 402 + ) 403 + log.Println("✅ Bluesky post service initialized") 404 + 393 405 // Initialize post service (with aggregator support) 394 406 postRepo := postgresRepo.NewPostRepository(db) 395 - postService := posts.NewPostService(postRepo, communityService, aggregatorService, blobService, unfurlService, defaultPDS) 407 + postService := posts.NewPostService(postRepo, communityService, aggregatorService, blobService, unfurlService, blueskyService, defaultPDS) 396 408 397 409 // Initialize vote repository (used by Jetstream consumer for indexing) 398 410 voteRepo := postgresRepo.NewVoteRepository(db) ··· 543 555 log.Println(" - POST /xrpc/social.coves.community.comment.update") 544 556 log.Println(" - POST /xrpc/social.coves.community.comment.delete") 545 557 546 - routes.RegisterCommunityFeedRoutes(r, feedService, voteService, authMiddleware) 558 + routes.RegisterCommunityFeedRoutes(r, feedService, voteService, blueskyService, authMiddleware) 547 559 log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)") 548 560 549 - routes.RegisterTimelineRoutes(r, timelineService, voteService, authMiddleware) 561 + routes.RegisterTimelineRoutes(r, timelineService, voteService, blueskyService, authMiddleware) 550 562 log.Println("Timeline XRPC endpoints registered (requires authentication, includes viewer vote state)") 551 563 552 - routes.RegisterDiscoverRoutes(r, discoverService, voteService, authMiddleware) 564 + routes.RegisterDiscoverRoutes(r, discoverService, voteService, blueskyService, authMiddleware) 553 565 log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)") 554 566 555 567 routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver)
+18 -10
internal/api/handlers/communityFeed/get_community.go
··· 1 1 package communityFeed 2 2 3 3 import ( 4 - "Coves/internal/api/handlers/common" 5 - "Coves/internal/core/communityFeeds" 6 - "Coves/internal/core/posts" 7 - "Coves/internal/core/votes" 8 4 "encoding/json" 9 5 "log" 10 6 "net/http" 11 7 "strconv" 8 + 9 + "Coves/internal/api/handlers/common" 10 + "Coves/internal/core/blueskypost" 11 + "Coves/internal/core/communityFeeds" 12 + "Coves/internal/core/posts" 13 + "Coves/internal/core/votes" 12 14 ) 13 15 14 16 // GetCommunityHandler handles community feed retrieval 15 17 type GetCommunityHandler struct { 16 - service communityFeeds.Service 17 - voteService votes.Service 18 + service communityFeeds.Service 19 + voteService votes.Service 20 + blueskyService blueskypost.Service 18 21 } 19 22 20 23 // NewGetCommunityHandler creates a new community feed handler 21 - func NewGetCommunityHandler(service communityFeeds.Service, voteService votes.Service) *GetCommunityHandler { 24 + func NewGetCommunityHandler(service communityFeeds.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetCommunityHandler { 25 + if blueskyService == nil { 26 + log.Printf("[COMMUNITY-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") 27 + } 22 28 return &GetCommunityHandler{ 23 - service: service, 24 - voteService: voteService, 29 + service: service, 30 + voteService: voteService, 31 + blueskyService: blueskyService, 25 32 } 26 33 } 27 34 ··· 50 57 // Populate viewer vote state if authenticated 51 58 common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) 52 59 53 - // Transform blob refs to URLs for all posts 60 + // Transform blob refs to URLs and resolve post embeds for all posts 54 61 for _, feedPost := range response.Feed { 55 62 if feedPost.Post != nil { 56 63 posts.TransformBlobRefsToURLs(feedPost.Post) 64 + posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) 57 65 } 58 66 } 59 67
+18 -10
internal/api/handlers/discover/get_discover.go
··· 1 1 package discover 2 2 3 3 import ( 4 - "Coves/internal/api/handlers/common" 5 - "Coves/internal/core/discover" 6 - "Coves/internal/core/posts" 7 - "Coves/internal/core/votes" 8 4 "encoding/json" 9 5 "log" 10 6 "net/http" 11 7 "strconv" 8 + 9 + "Coves/internal/api/handlers/common" 10 + "Coves/internal/core/blueskypost" 11 + "Coves/internal/core/discover" 12 + "Coves/internal/core/posts" 13 + "Coves/internal/core/votes" 12 14 ) 13 15 14 16 // GetDiscoverHandler handles discover feed retrieval 15 17 type GetDiscoverHandler struct { 16 - service discover.Service 17 - voteService votes.Service 18 + service discover.Service 19 + voteService votes.Service 20 + blueskyService blueskypost.Service 18 21 } 19 22 20 23 // NewGetDiscoverHandler creates a new discover handler 21 - func NewGetDiscoverHandler(service discover.Service, voteService votes.Service) *GetDiscoverHandler { 24 + func NewGetDiscoverHandler(service discover.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetDiscoverHandler { 25 + if blueskyService == nil { 26 + log.Printf("[DISCOVER-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") 27 + } 22 28 return &GetDiscoverHandler{ 23 - service: service, 24 - voteService: voteService, 29 + service: service, 30 + voteService: voteService, 31 + blueskyService: blueskyService, 25 32 } 26 33 } 27 34 ··· 47 54 // Populate viewer vote state if authenticated 48 55 common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) 49 56 50 - // Transform blob refs to URLs for all posts 57 + // Transform blob refs to URLs and resolve post embeds for all posts 51 58 for _, feedPost := range response.Feed { 52 59 if feedPost.Post != nil { 53 60 posts.TransformBlobRefsToURLs(feedPost.Post) 61 + posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) 54 62 } 55 63 } 56 64
+19 -11
internal/api/handlers/timeline/get_timeline.go
··· 1 1 package timeline 2 2 3 3 import ( 4 - "Coves/internal/api/handlers/common" 5 - "Coves/internal/api/middleware" 6 - "Coves/internal/core/posts" 7 - "Coves/internal/core/timeline" 8 - "Coves/internal/core/votes" 9 4 "encoding/json" 10 5 "log" 11 6 "net/http" 12 7 "strconv" 13 8 "strings" 9 + 10 + "Coves/internal/api/handlers/common" 11 + "Coves/internal/api/middleware" 12 + "Coves/internal/core/blueskypost" 13 + "Coves/internal/core/posts" 14 + "Coves/internal/core/timeline" 15 + "Coves/internal/core/votes" 14 16 ) 15 17 16 18 // GetTimelineHandler handles timeline feed retrieval 17 19 type GetTimelineHandler struct { 18 - service timeline.Service 19 - voteService votes.Service 20 + service timeline.Service 21 + voteService votes.Service 22 + blueskyService blueskypost.Service 20 23 } 21 24 22 25 // NewGetTimelineHandler creates a new timeline handler 23 - func NewGetTimelineHandler(service timeline.Service, voteService votes.Service) *GetTimelineHandler { 26 + func NewGetTimelineHandler(service timeline.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetTimelineHandler { 27 + if blueskyService == nil { 28 + log.Printf("[TIMELINE-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") 29 + } 24 30 return &GetTimelineHandler{ 25 - service: service, 26 - voteService: voteService, 31 + service: service, 32 + voteService: voteService, 33 + blueskyService: blueskyService, 27 34 } 28 35 } 29 36 ··· 60 67 // Populate viewer vote state if authenticated 61 68 common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) 62 69 63 - // Transform blob refs to URLs for all posts 70 + // Transform blob refs to URLs and resolve post embeds for all posts 64 71 for _, feedPost := range response.Feed { 65 72 if feedPost.Post != nil { 66 73 posts.TransformBlobRefsToURLs(feedPost.Post) 74 + posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) 67 75 } 68 76 } 69 77
+3 -1
internal/api/routes/communityFeed.go
··· 3 3 import ( 4 4 "Coves/internal/api/handlers/communityFeed" 5 5 "Coves/internal/api/middleware" 6 + "Coves/internal/core/blueskypost" 6 7 "Coves/internal/core/communityFeeds" 7 8 "Coves/internal/core/votes" 8 9 ··· 14 15 r chi.Router, 15 16 feedService communityFeeds.Service, 16 17 voteService votes.Service, 18 + blueskyService blueskypost.Service, 17 19 authMiddleware *middleware.OAuthAuthMiddleware, 18 20 ) { 19 21 // Create handlers 20 - getCommunityHandler := communityFeed.NewGetCommunityHandler(feedService, voteService) 22 + getCommunityHandler := communityFeed.NewGetCommunityHandler(feedService, voteService, blueskyService) 21 23 22 24 // GET /xrpc/social.coves.communityFeed.getCommunity 23 25 // Public endpoint with optional auth for viewer-specific state (vote state)
+3 -1
internal/api/routes/discover.go
··· 3 3 import ( 4 4 "Coves/internal/api/handlers/discover" 5 5 "Coves/internal/api/middleware" 6 + "Coves/internal/core/blueskypost" 6 7 discoverCore "Coves/internal/core/discover" 7 8 "Coves/internal/core/votes" 8 9 ··· 22 23 r chi.Router, 23 24 discoverService discoverCore.Service, 24 25 voteService votes.Service, 26 + blueskyService blueskypost.Service, 25 27 authMiddleware *middleware.OAuthAuthMiddleware, 26 28 ) { 27 29 // Create handlers 28 - getDiscoverHandler := discover.NewGetDiscoverHandler(discoverService, voteService) 30 + getDiscoverHandler := discover.NewGetDiscoverHandler(discoverService, voteService, blueskyService) 29 31 30 32 // GET /xrpc/social.coves.feed.getDiscover 31 33 // Public endpoint with optional auth for viewer-specific state (vote state)
+3 -1
internal/api/routes/timeline.go
··· 3 3 import ( 4 4 "Coves/internal/api/handlers/timeline" 5 5 "Coves/internal/api/middleware" 6 + "Coves/internal/core/blueskypost" 6 7 timelineCore "Coves/internal/core/timeline" 7 8 "Coves/internal/core/votes" 8 9 ··· 14 15 r chi.Router, 15 16 timelineService timelineCore.Service, 16 17 voteService votes.Service, 18 + blueskyService blueskypost.Service, 17 19 authMiddleware *middleware.OAuthAuthMiddleware, 18 20 ) { 19 21 // Create handlers 20 - getTimelineHandler := timeline.NewGetTimelineHandler(timelineService, voteService) 22 + getTimelineHandler := timeline.NewGetTimelineHandler(timelineService, voteService, blueskyService) 21 23 22 24 // GET /xrpc/social.coves.feed.getTimeline 23 25 // Requires authentication - user must be logged in to see their timeline
+178
internal/core/blueskypost/circuit_breaker.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "sync" 7 + "time" 8 + ) 9 + 10 + // circuitState represents the state of a circuit breaker 11 + type circuitState int 12 + 13 + const ( 14 + stateClosed circuitState = iota // Normal operation 15 + stateOpen // Circuit is open (provider failing) 16 + stateHalfOpen // Testing if provider recovered 17 + ) 18 + 19 + // circuitBreaker tracks failures per provider and stops trying failing providers 20 + type circuitBreaker struct { 21 + failures map[string]int 22 + lastFailure map[string]time.Time 23 + state map[string]circuitState 24 + lastStateLog map[string]time.Time 25 + failureThreshold int 26 + openDuration time.Duration 27 + mu sync.RWMutex 28 + } 29 + 30 + // newCircuitBreaker creates a circuit breaker with default settings 31 + func newCircuitBreaker() *circuitBreaker { 32 + return &circuitBreaker{ 33 + failureThreshold: 3, // Open after 3 consecutive failures 34 + openDuration: 5 * time.Minute, // Keep open for 5 minutes 35 + failures: make(map[string]int), 36 + lastFailure: make(map[string]time.Time), 37 + state: make(map[string]circuitState), 38 + lastStateLog: make(map[string]time.Time), 39 + } 40 + } 41 + 42 + // canAttempt checks if we should attempt to call this provider 43 + // Returns true if circuit is closed or half-open (ready to retry) 44 + func (cb *circuitBreaker) canAttempt(provider string) (bool, error) { 45 + // First check under read lock if we need to transition 46 + cb.mu.RLock() 47 + state := cb.getState(provider) 48 + lastFail := cb.lastFailure[provider] 49 + needsTransition := state == stateOpen && time.Since(lastFail) > cb.openDuration 50 + cb.mu.RUnlock() 51 + 52 + // If we need to transition, acquire write lock and re-check 53 + if needsTransition { 54 + cb.mu.Lock() 55 + // Re-check state in case another goroutine already transitioned 56 + state = cb.getState(provider) 57 + lastFail = cb.lastFailure[provider] 58 + if state == stateOpen && time.Since(lastFail) > cb.openDuration { 59 + cb.state[provider] = stateHalfOpen 60 + cb.logStateChange(provider, stateHalfOpen) 61 + } 62 + state = cb.state[provider] 63 + cb.mu.Unlock() 64 + // Return based on new state 65 + if state == stateHalfOpen { 66 + return true, nil 67 + } 68 + } 69 + 70 + // Now check state under read lock 71 + cb.mu.RLock() 72 + defer cb.mu.RUnlock() 73 + 74 + state = cb.getState(provider) 75 + 76 + switch state { 77 + case stateClosed: 78 + return true, nil 79 + case stateOpen: 80 + // Still in open period 81 + failCount := cb.failures[provider] 82 + nextRetry := cb.lastFailure[provider].Add(cb.openDuration) 83 + return false, fmt.Errorf( 84 + "%w for provider '%s' (failures: %d, next retry: %s)", 85 + ErrCircuitOpen, 86 + provider, 87 + failCount, 88 + nextRetry.Format("15:04:05"), 89 + ) 90 + case stateHalfOpen: 91 + return true, nil 92 + default: 93 + return true, nil 94 + } 95 + } 96 + 97 + // recordSuccess records a successful fetch, resetting failure count 98 + func (cb *circuitBreaker) recordSuccess(provider string) { 99 + cb.mu.Lock() 100 + defer cb.mu.Unlock() 101 + 102 + oldState := cb.getState(provider) 103 + 104 + // Reset failure tracking 105 + delete(cb.failures, provider) 106 + delete(cb.lastFailure, provider) 107 + cb.state[provider] = stateClosed 108 + 109 + // Log recovery if we were in a failure state 110 + if oldState != stateClosed { 111 + cb.logStateChange(provider, stateClosed) 112 + } 113 + } 114 + 115 + // recordFailure records a failed fetch attempt 116 + func (cb *circuitBreaker) recordFailure(provider string, err error) { 117 + cb.mu.Lock() 118 + defer cb.mu.Unlock() 119 + 120 + // Increment failure count 121 + cb.failures[provider]++ 122 + cb.lastFailure[provider] = time.Now() 123 + 124 + failCount := cb.failures[provider] 125 + 126 + // Check if we should open the circuit 127 + if failCount >= cb.failureThreshold { 128 + oldState := cb.getState(provider) 129 + cb.state[provider] = stateOpen 130 + if oldState != stateOpen { 131 + log.Printf( 132 + "[BLUESKY-CIRCUIT] Opening circuit for provider '%s' after %d consecutive failures. Last error: %v", 133 + provider, 134 + failCount, 135 + err, 136 + ) 137 + cb.lastStateLog[provider] = time.Now() 138 + } 139 + } else { 140 + log.Printf( 141 + "[BLUESKY-CIRCUIT] Failure %d/%d for provider '%s': %v", 142 + failCount, 143 + cb.failureThreshold, 144 + provider, 145 + err, 146 + ) 147 + } 148 + } 149 + 150 + // getState returns the current state (must be called with lock held) 151 + func (cb *circuitBreaker) getState(provider string) circuitState { 152 + if state, exists := cb.state[provider]; exists { 153 + return state 154 + } 155 + return stateClosed 156 + } 157 + 158 + // logStateChange logs state transitions (must be called with lock held) 159 + // Debounced to avoid log spam (max once per minute per provider) 160 + func (cb *circuitBreaker) logStateChange(provider string, newState circuitState) { 161 + lastLog, exists := cb.lastStateLog[provider] 162 + if exists && time.Since(lastLog) < time.Minute { 163 + return // Don't spam logs 164 + } 165 + 166 + var stateStr string 167 + switch newState { 168 + case stateClosed: 169 + stateStr = "CLOSED (recovered)" 170 + case stateOpen: 171 + stateStr = "OPEN (failing)" 172 + case stateHalfOpen: 173 + stateStr = "HALF-OPEN (testing)" 174 + } 175 + 176 + log.Printf("[BLUESKY-CIRCUIT] Circuit for provider '%s' is now %s", provider, stateStr) 177 + cb.lastStateLog[provider] = time.Now() 178 + }
+410
internal/core/blueskypost/circuit_breaker_test.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "errors" 5 + "sync" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestCircuitBreaker_InitialState(t *testing.T) { 11 + cb := newCircuitBreaker() 12 + 13 + // Circuit should start in closed state 14 + canAttempt, err := cb.canAttempt("test-provider") 15 + if !canAttempt { 16 + t.Error("Circuit breaker should start in closed state") 17 + } 18 + if err != nil { 19 + t.Errorf("canAttempt() should not return error for closed circuit, got: %v", err) 20 + } 21 + } 22 + 23 + func TestCircuitBreaker_OpensAfterThresholdFailures(t *testing.T) { 24 + cb := newCircuitBreaker() 25 + provider := "test-provider" 26 + testErr := errors.New("test error") 27 + 28 + // Record failures up to threshold (default is 3) 29 + for i := 0; i < cb.failureThreshold; i++ { 30 + cb.recordFailure(provider, testErr) 31 + } 32 + 33 + // Circuit should now be open 34 + canAttempt, err := cb.canAttempt(provider) 35 + if canAttempt { 36 + t.Error("Circuit breaker should be open after threshold failures") 37 + } 38 + if err == nil { 39 + t.Error("canAttempt() should return error when circuit is open") 40 + } 41 + } 42 + 43 + func TestCircuitBreaker_StaysClosedBelowThreshold(t *testing.T) { 44 + cb := newCircuitBreaker() 45 + provider := "test-provider" 46 + testErr := errors.New("test error") 47 + 48 + // Record failures below threshold 49 + for i := 0; i < cb.failureThreshold-1; i++ { 50 + cb.recordFailure(provider, testErr) 51 + } 52 + 53 + // Circuit should still be closed 54 + canAttempt, err := cb.canAttempt(provider) 55 + if !canAttempt { 56 + t.Error("Circuit breaker should remain closed below threshold") 57 + } 58 + if err != nil { 59 + t.Errorf("canAttempt() should not return error below threshold, got: %v", err) 60 + } 61 + } 62 + 63 + func TestCircuitBreaker_TransitionsToHalfOpenAfterTimeout(t *testing.T) { 64 + cb := newCircuitBreaker() 65 + // Set a very short open duration for testing 66 + cb.openDuration = 10 * time.Millisecond 67 + provider := "test-provider" 68 + testErr := errors.New("test error") 69 + 70 + // Open the circuit 71 + for i := 0; i < cb.failureThreshold; i++ { 72 + cb.recordFailure(provider, testErr) 73 + } 74 + 75 + // Verify circuit is open 76 + canAttempt, err := cb.canAttempt(provider) 77 + if canAttempt || err == nil { 78 + t.Fatal("Circuit should be open after threshold failures") 79 + } 80 + 81 + // Wait for the open duration to pass 82 + time.Sleep(cb.openDuration + 5*time.Millisecond) 83 + 84 + // Circuit should transition to half-open and allow attempt 85 + canAttempt, err = cb.canAttempt(provider) 86 + if !canAttempt { 87 + t.Error("Circuit breaker should transition to half-open after timeout") 88 + } 89 + if err != nil { 90 + t.Errorf("canAttempt() should not return error in half-open state, got: %v", err) 91 + } 92 + } 93 + 94 + func TestCircuitBreaker_ClosesOnSuccessAfterHalfOpen(t *testing.T) { 95 + cb := newCircuitBreaker() 96 + cb.openDuration = 10 * time.Millisecond 97 + provider := "test-provider" 98 + testErr := errors.New("test error") 99 + 100 + // Open the circuit 101 + for i := 0; i < cb.failureThreshold; i++ { 102 + cb.recordFailure(provider, testErr) 103 + } 104 + 105 + // Wait for half-open 106 + time.Sleep(cb.openDuration + 5*time.Millisecond) 107 + 108 + // Verify we can attempt 109 + canAttempt, _ := cb.canAttempt(provider) 110 + if !canAttempt { 111 + t.Fatal("Circuit should be half-open") 112 + } 113 + 114 + // Record a success 115 + cb.recordSuccess(provider) 116 + 117 + // Circuit should now be closed 118 + cb.mu.RLock() 119 + state := cb.getState(provider) 120 + cb.mu.RUnlock() 121 + 122 + if state != stateClosed { 123 + t.Errorf("Circuit should be closed after success in half-open state, got state: %v", state) 124 + } 125 + 126 + // Should allow attempts without error 127 + canAttempt, err := cb.canAttempt(provider) 128 + if !canAttempt { 129 + t.Error("Circuit should be closed and allow attempts") 130 + } 131 + if err != nil { 132 + t.Errorf("canAttempt() should not return error when closed, got: %v", err) 133 + } 134 + } 135 + 136 + func TestCircuitBreaker_SuccessResetsFailureCount(t *testing.T) { 137 + cb := newCircuitBreaker() 138 + provider := "test-provider" 139 + testErr := errors.New("test error") 140 + 141 + // Record some failures (but below threshold) 142 + for i := 0; i < cb.failureThreshold-1; i++ { 143 + cb.recordFailure(provider, testErr) 144 + } 145 + 146 + // Verify failure count 147 + cb.mu.RLock() 148 + failCount := cb.failures[provider] 149 + cb.mu.RUnlock() 150 + if failCount != cb.failureThreshold-1 { 151 + t.Errorf("Expected %d failures, got %d", cb.failureThreshold-1, failCount) 152 + } 153 + 154 + // Record a success 155 + cb.recordSuccess(provider) 156 + 157 + // Failure count should be reset 158 + cb.mu.RLock() 159 + failCount = cb.failures[provider] 160 + cb.mu.RUnlock() 161 + if failCount != 0 { 162 + t.Errorf("Expected 0 failures after success, got %d", failCount) 163 + } 164 + } 165 + 166 + func TestCircuitBreaker_IndependentProviders(t *testing.T) { 167 + cb := newCircuitBreaker() 168 + provider1 := "provider-1" 169 + provider2 := "provider-2" 170 + testErr := errors.New("test error") 171 + 172 + // Open circuit for provider1 173 + for i := 0; i < cb.failureThreshold; i++ { 174 + cb.recordFailure(provider1, testErr) 175 + } 176 + 177 + // Provider1 should be open 178 + canAttempt1, err1 := cb.canAttempt(provider1) 179 + if canAttempt1 || err1 == nil { 180 + t.Error("Provider1 circuit should be open") 181 + } 182 + 183 + // Provider2 should still be closed 184 + canAttempt2, err2 := cb.canAttempt(provider2) 185 + if !canAttempt2 { 186 + t.Error("Provider2 circuit should be closed") 187 + } 188 + if err2 != nil { 189 + t.Errorf("Provider2 canAttempt() should not return error, got: %v", err2) 190 + } 191 + } 192 + 193 + func TestCircuitBreaker_ConcurrentAccess(t *testing.T) { 194 + cb := newCircuitBreaker() 195 + provider := "test-provider" 196 + testErr := errors.New("test error") 197 + 198 + // Number of concurrent goroutines 199 + numGoroutines := 100 200 + var wg sync.WaitGroup 201 + wg.Add(numGoroutines) 202 + 203 + // Concurrently record failures and check state 204 + for i := 0; i < numGoroutines; i++ { 205 + go func(idx int) { 206 + defer wg.Done() 207 + 208 + // Mix of operations 209 + if idx%3 == 0 { 210 + cb.recordFailure(provider, testErr) 211 + } else if idx%3 == 1 { 212 + cb.recordSuccess(provider) 213 + } else { 214 + _, _ = cb.canAttempt(provider) 215 + } 216 + }(i) 217 + } 218 + 219 + wg.Wait() 220 + 221 + // No panic or race conditions should occur 222 + // Final state check - just ensure we can call canAttempt 223 + _, _ = cb.canAttempt(provider) 224 + } 225 + 226 + func TestCircuitBreaker_MultipleProvidersThreadSafety(t *testing.T) { 227 + cb := newCircuitBreaker() 228 + testErr := errors.New("test error") 229 + numProviders := 10 230 + numOpsPerProvider := 100 231 + 232 + var wg sync.WaitGroup 233 + wg.Add(numProviders) 234 + 235 + // Concurrent operations on different providers 236 + for i := 0; i < numProviders; i++ { 237 + go func(providerID int) { 238 + defer wg.Done() 239 + provider := "provider-" + string(rune('0'+providerID)) 240 + 241 + for j := 0; j < numOpsPerProvider; j++ { 242 + switch j % 4 { 243 + case 0: 244 + cb.recordFailure(provider, testErr) 245 + case 1: 246 + cb.recordSuccess(provider) 247 + case 2: 248 + _, _ = cb.canAttempt(provider) 249 + case 3: 250 + // Read state 251 + cb.mu.RLock() 252 + _ = cb.getState(provider) 253 + cb.mu.RUnlock() 254 + } 255 + } 256 + }(i) 257 + } 258 + 259 + wg.Wait() 260 + 261 + // Verify all providers are accessible without panic 262 + for i := 0; i < numProviders; i++ { 263 + provider := "provider-" + string(rune('0'+i)) 264 + _, _ = cb.canAttempt(provider) 265 + } 266 + } 267 + 268 + func TestCircuitBreaker_StateTransitions(t *testing.T) { 269 + cb := newCircuitBreaker() 270 + cb.openDuration = 10 * time.Millisecond 271 + provider := "test-provider" 272 + testErr := errors.New("test error") 273 + 274 + // Initial state: closed 275 + cb.mu.RLock() 276 + if cb.getState(provider) != stateClosed { 277 + t.Error("Initial state should be closed") 278 + } 279 + cb.mu.RUnlock() 280 + 281 + // Transition to open 282 + for i := 0; i < cb.failureThreshold; i++ { 283 + cb.recordFailure(provider, testErr) 284 + } 285 + 286 + cb.mu.RLock() 287 + if cb.getState(provider) != stateOpen { 288 + t.Error("State should be open after threshold failures") 289 + } 290 + cb.mu.RUnlock() 291 + 292 + // Wait for half-open transition 293 + time.Sleep(cb.openDuration + 5*time.Millisecond) 294 + _, _ = cb.canAttempt(provider) // Trigger state check 295 + 296 + cb.mu.RLock() 297 + state := cb.getState(provider) 298 + cb.mu.RUnlock() 299 + if state != stateHalfOpen { 300 + t.Errorf("State should be half-open after timeout, got: %v", state) 301 + } 302 + 303 + // Transition back to closed 304 + cb.recordSuccess(provider) 305 + 306 + cb.mu.RLock() 307 + if cb.getState(provider) != stateClosed { 308 + t.Error("State should be closed after success in half-open") 309 + } 310 + cb.mu.RUnlock() 311 + } 312 + 313 + func TestCircuitBreaker_ErrorMessage(t *testing.T) { 314 + cb := newCircuitBreaker() 315 + provider := "test-provider" 316 + testErr := errors.New("test error") 317 + 318 + // Open the circuit 319 + for i := 0; i < cb.failureThreshold; i++ { 320 + cb.recordFailure(provider, testErr) 321 + } 322 + 323 + // Check error message contains useful information 324 + _, err := cb.canAttempt(provider) 325 + if err == nil { 326 + t.Fatal("Expected error when circuit is open") 327 + } 328 + 329 + errMsg := err.Error() 330 + if !contains(errMsg, "circuit breaker open") { 331 + t.Errorf("Error message should mention circuit breaker, got: %s", errMsg) 332 + } 333 + if !contains(errMsg, provider) { 334 + t.Errorf("Error message should contain provider name, got: %s", errMsg) 335 + } 336 + } 337 + 338 + func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) { 339 + cb := newCircuitBreaker() 340 + cb.openDuration = 10 * time.Millisecond 341 + provider := "test-provider" 342 + testErr := errors.New("test error") 343 + 344 + // Open the circuit 345 + for i := 0; i < cb.failureThreshold; i++ { 346 + cb.recordFailure(provider, testErr) 347 + } 348 + 349 + // Wait for half-open 350 + time.Sleep(cb.openDuration + 5*time.Millisecond) 351 + _, _ = cb.canAttempt(provider) 352 + 353 + // Record another failure in half-open state 354 + cb.recordFailure(provider, testErr) 355 + 356 + // Circuit should be open again (failure count incremented) 357 + cb.mu.RLock() 358 + failCount := cb.failures[provider] 359 + cb.mu.RUnlock() 360 + 361 + if failCount < cb.failureThreshold { 362 + t.Errorf("Expected failure count >= %d after half-open failure, got: %d", cb.failureThreshold, failCount) 363 + } 364 + } 365 + 366 + func TestCircuitBreaker_CustomThresholdAndDuration(t *testing.T) { 367 + cb := &circuitBreaker{ 368 + failureThreshold: 5, 369 + openDuration: 20 * time.Millisecond, 370 + failures: make(map[string]int), 371 + lastFailure: make(map[string]time.Time), 372 + state: make(map[string]circuitState), 373 + lastStateLog: make(map[string]time.Time), 374 + } 375 + 376 + provider := "test-provider" 377 + testErr := errors.New("test error") 378 + 379 + // Should not open until 5 failures 380 + for i := 0; i < 4; i++ { 381 + cb.recordFailure(provider, testErr) 382 + } 383 + 384 + canAttempt, err := cb.canAttempt(provider) 385 + if !canAttempt || err != nil { 386 + t.Error("Circuit should remain closed before threshold") 387 + } 388 + 389 + // 5th failure should open it 390 + cb.recordFailure(provider, testErr) 391 + 392 + canAttempt, err = cb.canAttempt(provider) 393 + if canAttempt || err == nil { 394 + t.Error("Circuit should be open after 5 failures") 395 + } 396 + 397 + // Should not transition to half-open before 20ms 398 + time.Sleep(10 * time.Millisecond) 399 + canAttempt, _ = cb.canAttempt(provider) 400 + if canAttempt { 401 + t.Error("Circuit should still be open before timeout") 402 + } 403 + 404 + // Should transition after 20ms 405 + time.Sleep(15 * time.Millisecond) 406 + canAttempt, _ = cb.canAttempt(provider) 407 + if !canAttempt { 408 + t.Error("Circuit should be half-open after timeout") 409 + } 410 + }
+208
internal/core/blueskypost/fetcher.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "Coves/internal/atproto/oauth" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net/http" 11 + "net/url" 12 + "time" 13 + ) 14 + 15 + // blueskyAPIBaseURL is the public Bluesky API endpoint 16 + const blueskyAPIBaseURL = "https://public.api.bsky.app" 17 + 18 + // blueskyAPIResponse represents the response from app.bsky.feed.getPosts 19 + type blueskyAPIResponse struct { 20 + Posts []blueskyAPIPost `json:"posts"` 21 + } 22 + 23 + // blueskyAPIPost represents a post in the Bluesky API response 24 + type blueskyAPIPost struct { 25 + Author blueskyAPIAuthor `json:"author"` 26 + Record blueskyAPIRecord `json:"record"` 27 + Embed *blueskyAPIEmbed `json:"embed,omitempty"` 28 + URI string `json:"uri"` 29 + CID string `json:"cid"` 30 + IndexedAt string `json:"indexedAt"` 31 + ReplyCount int `json:"replyCount"` 32 + RepostCount int `json:"repostCount"` 33 + LikeCount int `json:"likeCount"` 34 + } 35 + 36 + // blueskyAPIAuthor represents the author in the Bluesky API response 37 + type blueskyAPIAuthor struct { 38 + DID string `json:"did"` 39 + Handle string `json:"handle"` 40 + DisplayName string `json:"displayName,omitempty"` 41 + Avatar string `json:"avatar,omitempty"` 42 + } 43 + 44 + // blueskyAPIRecord represents the post record in the Bluesky API response 45 + type blueskyAPIRecord struct { 46 + Embed *recordEmbed `json:"embed,omitempty"` 47 + Text string `json:"text"` 48 + CreatedAt string `json:"createdAt"` 49 + } 50 + 51 + // recordEmbed represents embedded content in the post record 52 + type recordEmbed struct { 53 + Video json.RawMessage `json:"video,omitempty"` 54 + Record *recordEmbedRecord `json:"record,omitempty"` 55 + Type string `json:"$type"` 56 + Images []json.RawMessage `json:"images,omitempty"` 57 + } 58 + 59 + // recordEmbedRecord represents a quoted post in the embed 60 + type recordEmbedRecord struct { 61 + URI string `json:"uri"` 62 + CID string `json:"cid"` 63 + } 64 + 65 + // blueskyAPIEmbed represents resolved embed data in the API response 66 + type blueskyAPIEmbed struct { 67 + Video json.RawMessage `json:"video,omitempty"` 68 + Record *blueskyAPIEmbedRecord `json:"record,omitempty"` 69 + Type string `json:"$type"` 70 + Images []json.RawMessage `json:"images,omitempty"` 71 + } 72 + 73 + // blueskyAPIEmbedRecord represents a quoted post embed in the API response 74 + type blueskyAPIEmbedRecord struct { 75 + Record blueskyAPIPost `json:"record"` 76 + } 77 + 78 + // fetchBlueskyPost fetches a Bluesky post from the public API 79 + func fetchBlueskyPost(ctx context.Context, atURI string, timeout time.Duration) (*BlueskyPostResult, error) { 80 + // Create SSRF-safe HTTP client 81 + client := oauth.NewSSRFSafeHTTPClient(false) // Don't allow private IPs 82 + client.Timeout = timeout 83 + 84 + // Construct API URL 85 + apiURL := fmt.Sprintf("%s/xrpc/app.bsky.feed.getPosts?uris=%s", blueskyAPIBaseURL, url.QueryEscape(atURI)) 86 + 87 + // Create HTTP request 88 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 89 + if err != nil { 90 + return nil, fmt.Errorf("failed to create request: %w", err) 91 + } 92 + 93 + // Set User-Agent header 94 + req.Header.Set("User-Agent", "CovesBot/1.0 (+https://coves.social)") 95 + 96 + // Execute request 97 + resp, err := client.Do(req) 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to fetch post: %w", err) 100 + } 101 + defer func() { _ = resp.Body.Close() }() 102 + 103 + // Handle 404 - post is deleted or doesn't exist 104 + if resp.StatusCode == http.StatusNotFound { 105 + return &BlueskyPostResult{ 106 + URI: atURI, 107 + Unavailable: true, 108 + Message: "This Bluesky post is unavailable", 109 + }, nil 110 + } 111 + 112 + // Handle other non-200 responses 113 + if resp.StatusCode != http.StatusOK { 114 + // Limit error body to 1KB to prevent unbounded reads 115 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) 116 + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) 117 + } 118 + 119 + // Parse response 120 + var apiResp blueskyAPIResponse 121 + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { 122 + return nil, fmt.Errorf("failed to decode response: %w", err) 123 + } 124 + 125 + // Validate we got a post 126 + if len(apiResp.Posts) == 0 { 127 + return &BlueskyPostResult{ 128 + URI: atURI, 129 + Unavailable: true, 130 + Message: "This Bluesky post is unavailable", 131 + }, nil 132 + } 133 + 134 + // Convert API response to BlueskyPostResult 135 + post := apiResp.Posts[0] 136 + result := mapAPIPostToResult(&post) 137 + 138 + return result, nil 139 + } 140 + 141 + // mapAPIPostToResult converts a Bluesky API post to BlueskyPostResult 142 + func mapAPIPostToResult(post *blueskyAPIPost) *BlueskyPostResult { 143 + result := &BlueskyPostResult{ 144 + URI: post.URI, 145 + CID: post.CID, 146 + Text: post.Record.Text, 147 + ReplyCount: post.ReplyCount, 148 + RepostCount: post.RepostCount, 149 + LikeCount: post.LikeCount, 150 + Author: &Author{ 151 + DID: post.Author.DID, 152 + Handle: post.Author.Handle, 153 + DisplayName: post.Author.DisplayName, 154 + Avatar: post.Author.Avatar, 155 + }, 156 + } 157 + 158 + // Parse CreatedAt timestamp 159 + if post.Record.CreatedAt != "" { 160 + createdAt, err := time.Parse(time.RFC3339, post.Record.CreatedAt) 161 + if err == nil { 162 + result.CreatedAt = createdAt 163 + } else { 164 + log.Printf("[BLUESKY] Warning: Failed to parse CreatedAt timestamp %q for post %s: %v", post.Record.CreatedAt, post.URI, err) 165 + } 166 + } 167 + 168 + // Check for media in the record embed (Phase 1: indicator only) 169 + if post.Record.Embed != nil { 170 + if len(post.Record.Embed.Images) > 0 { 171 + result.HasMedia = true 172 + result.MediaCount = len(post.Record.Embed.Images) 173 + } 174 + if len(post.Record.Embed.Video) > 0 { 175 + result.HasMedia = true 176 + result.MediaCount = 1 177 + } 178 + } 179 + 180 + // Check for media in the resolved embed (may have additional info) 181 + if post.Embed != nil { 182 + if len(post.Embed.Images) > 0 { 183 + result.HasMedia = true 184 + if result.MediaCount == 0 { 185 + result.MediaCount = len(post.Embed.Images) 186 + } 187 + } 188 + if len(post.Embed.Video) > 0 { 189 + result.HasMedia = true 190 + if result.MediaCount == 0 { 191 + result.MediaCount = 1 192 + } 193 + } 194 + 195 + // Handle quoted post (1 level deep only) 196 + // Support both pure record embeds and recordWithMedia embeds 197 + if post.Embed.Record != nil && 198 + (post.Embed.Type == "app.bsky.embed.record#view" || 199 + post.Embed.Type == "app.bsky.embed.recordWithMedia#view") { 200 + quotedPost := mapAPIPostToResult(&post.Embed.Record.Record) 201 + // Don't recurse deeper than 1 level 202 + quotedPost.QuotedPost = nil 203 + result.QuotedPost = quotedPost 204 + } 205 + } 206 + 207 + return result 208 + }
+507
internal/core/blueskypost/fetcher_test.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestMapAPIPostToResult_BasicPost(t *testing.T) { 10 + apiPost := &blueskyAPIPost{ 11 + URI: "at://did:plc:alice123/app.bsky.feed.post/abc123", 12 + CID: "cid123", 13 + Author: blueskyAPIAuthor{ 14 + DID: "did:plc:alice123", 15 + Handle: "alice.bsky.social", 16 + DisplayName: "Alice", 17 + Avatar: "https://example.com/avatar.jpg", 18 + }, 19 + Record: blueskyAPIRecord{ 20 + Text: "Hello world!", 21 + CreatedAt: "2025-12-21T10:30:00Z", 22 + }, 23 + ReplyCount: 5, 24 + RepostCount: 10, 25 + LikeCount: 20, 26 + } 27 + 28 + result := mapAPIPostToResult(apiPost) 29 + 30 + // Verify basic fields 31 + if result.URI != apiPost.URI { 32 + t.Errorf("Expected URI %q, got %q", apiPost.URI, result.URI) 33 + } 34 + if result.CID != apiPost.CID { 35 + t.Errorf("Expected CID %q, got %q", apiPost.CID, result.CID) 36 + } 37 + if result.Text != "Hello world!" { 38 + t.Errorf("Expected text %q, got %q", "Hello world!", result.Text) 39 + } 40 + if result.ReplyCount != 5 { 41 + t.Errorf("Expected reply count 5, got %d", result.ReplyCount) 42 + } 43 + if result.RepostCount != 10 { 44 + t.Errorf("Expected repost count 10, got %d", result.RepostCount) 45 + } 46 + if result.LikeCount != 20 { 47 + t.Errorf("Expected like count 20, got %d", result.LikeCount) 48 + } 49 + 50 + // Verify author 51 + if result.Author == nil { 52 + t.Fatal("Author should not be nil") 53 + } 54 + if result.Author.DID != "did:plc:alice123" { 55 + t.Errorf("Expected DID %q, got %q", "did:plc:alice123", result.Author.DID) 56 + } 57 + if result.Author.Handle != "alice.bsky.social" { 58 + t.Errorf("Expected handle %q, got %q", "alice.bsky.social", result.Author.Handle) 59 + } 60 + if result.Author.DisplayName != "Alice" { 61 + t.Errorf("Expected display name %q, got %q", "Alice", result.Author.DisplayName) 62 + } 63 + if result.Author.Avatar != "https://example.com/avatar.jpg" { 64 + t.Errorf("Expected avatar %q, got %q", "https://example.com/avatar.jpg", result.Author.Avatar) 65 + } 66 + } 67 + 68 + func TestMapAPIPostToResult_TimestampParsing(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + createdAt string 72 + expectError bool 73 + }{ 74 + { 75 + name: "valid RFC3339 timestamp", 76 + createdAt: "2025-12-21T10:30:00Z", 77 + expectError: false, 78 + }, 79 + { 80 + name: "valid RFC3339 with timezone", 81 + createdAt: "2025-12-21T10:30:00-05:00", 82 + expectError: false, 83 + }, 84 + { 85 + name: "valid RFC3339 with milliseconds", 86 + createdAt: "2025-12-21T10:30:00.123Z", 87 + expectError: false, 88 + }, 89 + { 90 + name: "invalid timestamp", 91 + createdAt: "not-a-timestamp", 92 + expectError: true, 93 + }, 94 + { 95 + name: "empty timestamp", 96 + createdAt: "", 97 + expectError: true, 98 + }, 99 + } 100 + 101 + for _, tt := range tests { 102 + t.Run(tt.name, func(t *testing.T) { 103 + apiPost := &blueskyAPIPost{ 104 + URI: "at://did:plc:test/app.bsky.feed.post/test", 105 + CID: "cid", 106 + Author: blueskyAPIAuthor{ 107 + DID: "did:plc:test", 108 + Handle: "test.bsky.social", 109 + }, 110 + Record: blueskyAPIRecord{ 111 + Text: "Test", 112 + CreatedAt: tt.createdAt, 113 + }, 114 + } 115 + 116 + result := mapAPIPostToResult(apiPost) 117 + 118 + if tt.expectError { 119 + if !result.CreatedAt.IsZero() { 120 + t.Errorf("Expected zero time for invalid timestamp, got %v", result.CreatedAt) 121 + } 122 + } else { 123 + if result.CreatedAt.IsZero() { 124 + t.Error("Expected valid timestamp, got zero time") 125 + } 126 + 127 + // Verify the timestamp is reasonable 128 + now := time.Now() 129 + if result.CreatedAt.After(now.Add(24 * time.Hour)) { 130 + t.Error("Timestamp is unreasonably far in the future") 131 + } 132 + } 133 + }) 134 + } 135 + } 136 + 137 + func TestMapAPIPostToResult_MediaInRecordEmbed(t *testing.T) { 138 + tests := []struct { 139 + recordEmbed *recordEmbed 140 + name string 141 + expectedCount int 142 + expectedMedia bool 143 + }{ 144 + { 145 + name: "no embed", 146 + recordEmbed: nil, 147 + expectedMedia: false, 148 + expectedCount: 0, 149 + }, 150 + { 151 + name: "single image", 152 + recordEmbed: &recordEmbed{ 153 + Type: "app.bsky.embed.images", 154 + Images: []json.RawMessage{json.RawMessage(`{"alt":"test"}`)}, 155 + }, 156 + expectedMedia: true, 157 + expectedCount: 1, 158 + }, 159 + { 160 + name: "multiple images", 161 + recordEmbed: &recordEmbed{ 162 + Type: "app.bsky.embed.images", 163 + Images: []json.RawMessage{ 164 + json.RawMessage(`{"alt":"test1"}`), 165 + json.RawMessage(`{"alt":"test2"}`), 166 + json.RawMessage(`{"alt":"test3"}`), 167 + }, 168 + }, 169 + expectedMedia: true, 170 + expectedCount: 3, 171 + }, 172 + { 173 + name: "video", 174 + recordEmbed: &recordEmbed{ 175 + Type: "app.bsky.embed.video", 176 + Video: json.RawMessage(`{"cid":"video123"}`), 177 + }, 178 + expectedMedia: true, 179 + expectedCount: 1, 180 + }, 181 + } 182 + 183 + for _, tt := range tests { 184 + t.Run(tt.name, func(t *testing.T) { 185 + apiPost := &blueskyAPIPost{ 186 + URI: "at://did:plc:test/app.bsky.feed.post/test", 187 + CID: "cid", 188 + Author: blueskyAPIAuthor{ 189 + DID: "did:plc:test", 190 + Handle: "test.bsky.social", 191 + }, 192 + Record: blueskyAPIRecord{ 193 + Text: "Test", 194 + CreatedAt: "2025-12-21T10:30:00Z", 195 + Embed: tt.recordEmbed, 196 + }, 197 + } 198 + 199 + result := mapAPIPostToResult(apiPost) 200 + 201 + if result.HasMedia != tt.expectedMedia { 202 + t.Errorf("Expected HasMedia %v, got %v", tt.expectedMedia, result.HasMedia) 203 + } 204 + if result.MediaCount != tt.expectedCount { 205 + t.Errorf("Expected MediaCount %d, got %d", tt.expectedCount, result.MediaCount) 206 + } 207 + }) 208 + } 209 + } 210 + 211 + func TestMapAPIPostToResult_MediaInAPIEmbed(t *testing.T) { 212 + tests := []struct { 213 + apiEmbed *blueskyAPIEmbed 214 + name string 215 + expectedCount int 216 + expectedMedia bool 217 + }{ 218 + { 219 + name: "no embed", 220 + apiEmbed: nil, 221 + expectedMedia: false, 222 + expectedCount: 0, 223 + }, 224 + { 225 + name: "images in API embed", 226 + apiEmbed: &blueskyAPIEmbed{ 227 + Type: "app.bsky.embed.images#view", 228 + Images: []json.RawMessage{ 229 + json.RawMessage(`{"thumb":"url1"}`), 230 + json.RawMessage(`{"thumb":"url2"}`), 231 + }, 232 + }, 233 + expectedMedia: true, 234 + expectedCount: 2, 235 + }, 236 + { 237 + name: "video in API embed", 238 + apiEmbed: &blueskyAPIEmbed{ 239 + Type: "app.bsky.embed.video#view", 240 + Video: json.RawMessage(`{"playlist":"url"}`), 241 + }, 242 + expectedMedia: true, 243 + expectedCount: 1, 244 + }, 245 + } 246 + 247 + for _, tt := range tests { 248 + t.Run(tt.name, func(t *testing.T) { 249 + apiPost := &blueskyAPIPost{ 250 + URI: "at://did:plc:test/app.bsky.feed.post/test", 251 + CID: "cid", 252 + Author: blueskyAPIAuthor{ 253 + DID: "did:plc:test", 254 + Handle: "test.bsky.social", 255 + }, 256 + Record: blueskyAPIRecord{ 257 + Text: "Test", 258 + CreatedAt: "2025-12-21T10:30:00Z", 259 + }, 260 + Embed: tt.apiEmbed, 261 + } 262 + 263 + result := mapAPIPostToResult(apiPost) 264 + 265 + if result.HasMedia != tt.expectedMedia { 266 + t.Errorf("Expected HasMedia %v, got %v", tt.expectedMedia, result.HasMedia) 267 + } 268 + if result.MediaCount != tt.expectedCount { 269 + t.Errorf("Expected MediaCount %d, got %d", tt.expectedCount, result.MediaCount) 270 + } 271 + }) 272 + } 273 + } 274 + 275 + func TestMapAPIPostToResult_QuotedPost(t *testing.T) { 276 + // Create a quoted post structure 277 + apiPost := &blueskyAPIPost{ 278 + URI: "at://did:plc:alice123/app.bsky.feed.post/abc123", 279 + CID: "cid123", 280 + Author: blueskyAPIAuthor{ 281 + DID: "did:plc:alice123", 282 + Handle: "alice.bsky.social", 283 + }, 284 + Record: blueskyAPIRecord{ 285 + Text: "Check out this post!", 286 + CreatedAt: "2025-12-21T10:30:00Z", 287 + }, 288 + Embed: &blueskyAPIEmbed{ 289 + Type: "app.bsky.embed.record#view", 290 + Record: &blueskyAPIEmbedRecord{ 291 + Record: blueskyAPIPost{ 292 + URI: "at://did:plc:bob456/app.bsky.feed.post/xyz789", 293 + CID: "cid789", 294 + Author: blueskyAPIAuthor{ 295 + DID: "did:plc:bob456", 296 + Handle: "bob.bsky.social", 297 + }, 298 + Record: blueskyAPIRecord{ 299 + Text: "Original post", 300 + CreatedAt: "2025-12-20T08:00:00Z", 301 + }, 302 + LikeCount: 100, 303 + }, 304 + }, 305 + }, 306 + } 307 + 308 + result := mapAPIPostToResult(apiPost) 309 + 310 + // Verify main post 311 + if result.Text != "Check out this post!" { 312 + t.Errorf("Expected main post text %q, got %q", "Check out this post!", result.Text) 313 + } 314 + 315 + // Verify quoted post exists 316 + if result.QuotedPost == nil { 317 + t.Fatal("Expected quoted post, got nil") 318 + } 319 + 320 + // Verify quoted post content 321 + if result.QuotedPost.Text != "Original post" { 322 + t.Errorf("Expected quoted post text %q, got %q", "Original post", result.QuotedPost.Text) 323 + } 324 + if result.QuotedPost.Author.Handle != "bob.bsky.social" { 325 + t.Errorf("Expected quoted post handle %q, got %q", "bob.bsky.social", result.QuotedPost.Author.Handle) 326 + } 327 + if result.QuotedPost.LikeCount != 100 { 328 + t.Errorf("Expected quoted post like count 100, got %d", result.QuotedPost.LikeCount) 329 + } 330 + 331 + // Verify no nested quoted posts (1 level deep only) 332 + if result.QuotedPost.QuotedPost != nil { 333 + t.Error("Quoted posts should not be nested more than 1 level deep") 334 + } 335 + } 336 + 337 + func TestMapAPIPostToResult_QuotedPostNonRecordEmbed(t *testing.T) { 338 + // Test that non-record embeds don't create quoted posts 339 + apiPost := &blueskyAPIPost{ 340 + URI: "at://did:plc:test/app.bsky.feed.post/test", 341 + CID: "cid", 342 + Author: blueskyAPIAuthor{ 343 + DID: "did:plc:test", 344 + Handle: "test.bsky.social", 345 + }, 346 + Record: blueskyAPIRecord{ 347 + Text: "Test", 348 + CreatedAt: "2025-12-21T10:30:00Z", 349 + }, 350 + Embed: &blueskyAPIEmbed{ 351 + Type: "app.bsky.embed.images#view", 352 + Images: []json.RawMessage{ 353 + json.RawMessage(`{"thumb":"url"}`), 354 + }, 355 + }, 356 + } 357 + 358 + result := mapAPIPostToResult(apiPost) 359 + 360 + if result.QuotedPost != nil { 361 + t.Error("Non-record embeds should not create quoted posts") 362 + } 363 + } 364 + 365 + func TestMapAPIPostToResult_EmptyOptionalFields(t *testing.T) { 366 + apiPost := &blueskyAPIPost{ 367 + URI: "at://did:plc:test/app.bsky.feed.post/test", 368 + CID: "cid", 369 + Author: blueskyAPIAuthor{ 370 + DID: "did:plc:test", 371 + Handle: "test.bsky.social", 372 + // DisplayName and Avatar omitted 373 + }, 374 + Record: blueskyAPIRecord{ 375 + Text: "Test", 376 + // CreatedAt omitted 377 + }, 378 + // Counts default to 0 379 + } 380 + 381 + result := mapAPIPostToResult(apiPost) 382 + 383 + if result.Author.DisplayName != "" { 384 + t.Errorf("Expected empty display name, got %q", result.Author.DisplayName) 385 + } 386 + if result.Author.Avatar != "" { 387 + t.Errorf("Expected empty avatar, got %q", result.Author.Avatar) 388 + } 389 + if !result.CreatedAt.IsZero() { 390 + t.Error("Expected zero time for missing CreatedAt") 391 + } 392 + if result.ReplyCount != 0 || result.RepostCount != 0 || result.LikeCount != 0 { 393 + t.Error("Expected zero counts for missing engagement metrics") 394 + } 395 + } 396 + 397 + func TestMapAPIPostToResult_MediaFromBothEmbeds(t *testing.T) { 398 + // Test that media is detected from either record embed or API embed 399 + apiPost := &blueskyAPIPost{ 400 + URI: "at://did:plc:test/app.bsky.feed.post/test", 401 + CID: "cid", 402 + Author: blueskyAPIAuthor{ 403 + DID: "did:plc:test", 404 + Handle: "test.bsky.social", 405 + }, 406 + Record: blueskyAPIRecord{ 407 + Text: "Test", 408 + CreatedAt: "2025-12-21T10:30:00Z", 409 + Embed: &recordEmbed{ 410 + Type: "app.bsky.embed.images", 411 + Images: []json.RawMessage{json.RawMessage(`"img1"`), json.RawMessage(`"img2"`)}, 412 + }, 413 + }, 414 + Embed: &blueskyAPIEmbed{ 415 + Type: "app.bsky.embed.images#view", 416 + Images: []json.RawMessage{json.RawMessage(`"img1"`), json.RawMessage(`"img2"`)}, 417 + }, 418 + } 419 + 420 + result := mapAPIPostToResult(apiPost) 421 + 422 + if !result.HasMedia { 423 + t.Error("Expected HasMedia to be true") 424 + } 425 + // MediaCount should be set from the first source (record embed) 426 + if result.MediaCount != 2 { 427 + t.Errorf("Expected MediaCount 2, got %d", result.MediaCount) 428 + } 429 + } 430 + 431 + func TestMapAPIPostToResult_ComplexPost(t *testing.T) { 432 + // Test a post with media (images) 433 + apiPost := &blueskyAPIPost{ 434 + URI: "at://did:plc:alice123/app.bsky.feed.post/complex", 435 + CID: "cid123", 436 + Author: blueskyAPIAuthor{ 437 + DID: "did:plc:alice123", 438 + Handle: "alice.bsky.social", 439 + DisplayName: "Alice", 440 + Avatar: "https://example.com/alice.jpg", 441 + }, 442 + Record: blueskyAPIRecord{ 443 + Text: "Complex post with media", 444 + CreatedAt: "2025-12-21T10:30:00Z", 445 + Embed: &recordEmbed{ 446 + Type: "app.bsky.embed.images", 447 + Images: []json.RawMessage{json.RawMessage(`"img1"`)}, 448 + }, 449 + }, 450 + Embed: &blueskyAPIEmbed{ 451 + Type: "app.bsky.embed.images#view", 452 + Images: []json.RawMessage{json.RawMessage(`"img1"`)}, 453 + }, 454 + ReplyCount: 10, 455 + RepostCount: 20, 456 + LikeCount: 30, 457 + } 458 + 459 + result := mapAPIPostToResult(apiPost) 460 + 461 + // Verify main post 462 + if result.Text != "Complex post with media" { 463 + t.Errorf("Unexpected text: %q", result.Text) 464 + } 465 + if !result.HasMedia { 466 + t.Error("Expected HasMedia to be true") 467 + } 468 + if result.MediaCount != 1 { 469 + t.Errorf("Expected MediaCount 1, got %d", result.MediaCount) 470 + } 471 + 472 + // Verify engagement 473 + if result.ReplyCount != 10 || result.RepostCount != 20 || result.LikeCount != 30 { 474 + t.Error("Engagement counts don't match") 475 + } 476 + } 477 + 478 + func TestMapAPIPostToResult_NilSafety(t *testing.T) { 479 + // Ensure the function handles nil embeds gracefully 480 + apiPost := &blueskyAPIPost{ 481 + URI: "at://did:plc:test/app.bsky.feed.post/test", 482 + CID: "cid", 483 + Author: blueskyAPIAuthor{ 484 + DID: "did:plc:test", 485 + Handle: "test.bsky.social", 486 + }, 487 + Record: blueskyAPIRecord{ 488 + Text: "Test", 489 + CreatedAt: "2025-12-21T10:30:00Z", 490 + Embed: nil, 491 + }, 492 + Embed: nil, 493 + } 494 + 495 + // Should not panic 496 + result := mapAPIPostToResult(apiPost) 497 + 498 + if result.HasMedia { 499 + t.Error("Expected HasMedia to be false with nil embeds") 500 + } 501 + if result.MediaCount != 0 { 502 + t.Errorf("Expected MediaCount 0 with nil embeds, got %d", result.MediaCount) 503 + } 504 + if result.QuotedPost != nil { 505 + t.Error("Expected no quoted post with nil embeds") 506 + } 507 + }
+39
internal/core/blueskypost/interfaces.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + // Service defines the interface for Bluesky post resolution and caching. 9 + // It orchestrates URL parsing, cache lookups, API fetching, and circuit breaking. 10 + type Service interface { 11 + // ResolvePost fetches and resolves a Bluesky post by AT-URI. 12 + // It checks the cache first, then fetches from public.api.bsky.app if needed. 13 + // Returns BlueskyPostResult with Unavailable=true if the post cannot be resolved. 14 + ResolvePost(ctx context.Context, atURI string) (*BlueskyPostResult, error) 15 + 16 + // ParseBlueskyURL converts a bsky.app URL to an AT-URI. 17 + // Example: https://bsky.app/profile/user.bsky.social/post/abc123 18 + // -> at://did:plc:xxx/app.bsky.feed.post/abc123 19 + // Returns error if the URL is invalid or handle resolution fails. 20 + ParseBlueskyURL(ctx context.Context, url string) (string, error) 21 + 22 + // IsBlueskyURL checks if a URL is a valid bsky.app post URL. 23 + // Returns true for URLs matching https://bsky.app/profile/{handle}/post/{rkey} 24 + IsBlueskyURL(url string) bool 25 + } 26 + 27 + // Repository defines the interface for Bluesky post cache persistence. 28 + // This follows the same pattern as the unfurl cache repository. 29 + type Repository interface { 30 + // Get retrieves a cached Bluesky post result for the given AT-URI. 31 + // Returns ErrCacheMiss if not found or expired (not an error condition). 32 + // Returns error only on database failures. 33 + Get(ctx context.Context, atURI string) (*BlueskyPostResult, error) 34 + 35 + // Set stores a Bluesky post result in the cache with the specified TTL. 36 + // If an entry already exists for the AT-URI, it will be updated. 37 + // The expires_at is calculated as NOW() + ttl. 38 + Set(ctx context.Context, atURI string, result *BlueskyPostResult, ttl time.Duration) error 39 + }
+131
internal/core/blueskypost/repository.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + // ErrCacheMiss is returned when a cache entry is not found or has expired 14 + var ErrCacheMiss = errors.New("cache miss") 15 + 16 + type postgresBlueskyPostRepo struct { 17 + db *sql.DB 18 + } 19 + 20 + // NewRepository creates a new PostgreSQL Bluesky post cache repository 21 + func NewRepository(db *sql.DB) Repository { 22 + if db == nil { 23 + panic("blueskypost: db cannot be nil") 24 + } 25 + return &postgresBlueskyPostRepo{db: db} 26 + } 27 + 28 + // Get retrieves a cached Bluesky post result for the given AT-URI. 29 + // Returns ErrCacheMiss if not found or expired. 30 + // Returns error only on database failures. 31 + func (r *postgresBlueskyPostRepo) Get(ctx context.Context, atURI string) (*BlueskyPostResult, error) { 32 + query := ` 33 + SELECT metadata 34 + FROM bluesky_post_cache 35 + WHERE at_uri = $1 AND expires_at > NOW() 36 + ` 37 + 38 + var metadataJSON []byte 39 + 40 + err := r.db.QueryRowContext(ctx, query, atURI).Scan(&metadataJSON) 41 + if err == sql.ErrNoRows { 42 + // Not found or expired is a cache miss 43 + return nil, ErrCacheMiss 44 + } 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to get bluesky post cache entry: %w", err) 47 + } 48 + 49 + // Unmarshal metadata JSONB to BlueskyPostResult 50 + var result BlueskyPostResult 51 + if err := json.Unmarshal(metadataJSON, &result); err != nil { 52 + return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) 53 + } 54 + 55 + return &result, nil 56 + } 57 + 58 + // Set stores a Bluesky post result in the cache with the specified TTL. 59 + // If an entry already exists for the AT-URI, it will be updated. 60 + // The expires_at is calculated as NOW() + ttl. 61 + func (r *postgresBlueskyPostRepo) Set(ctx context.Context, atURI string, result *BlueskyPostResult, ttl time.Duration) error { 62 + // Validate AT-URI format to prevent cache pollution 63 + if err := validateATURI(atURI); err != nil { 64 + return err 65 + } 66 + 67 + // Marshal BlueskyPostResult to JSON for metadata column 68 + metadataJSON, err := json.Marshal(result) 69 + if err != nil { 70 + return fmt.Errorf("failed to marshal metadata: %w", err) 71 + } 72 + 73 + // Convert Go duration to PostgreSQL interval string 74 + // e.g., "1 hour", "24 hours", "7 days" 75 + intervalStr := formatInterval(ttl) 76 + 77 + query := ` 78 + INSERT INTO bluesky_post_cache (at_uri, metadata, expires_at) 79 + VALUES ($1, $2, NOW() + $3::interval) 80 + ON CONFLICT (at_uri) DO UPDATE 81 + SET metadata = EXCLUDED.metadata, 82 + expires_at = EXCLUDED.expires_at, 83 + fetched_at = NOW() 84 + ` 85 + 86 + _, err = r.db.ExecContext(ctx, query, atURI, metadataJSON, intervalStr) 87 + if err != nil { 88 + return fmt.Errorf("failed to insert/update bluesky post cache entry: %w", err) 89 + } 90 + 91 + return nil 92 + } 93 + 94 + // formatInterval converts a Go duration to a PostgreSQL interval string 95 + // PostgreSQL accepts intervals like "1 hour", "24 hours", "7 days" 96 + func formatInterval(d time.Duration) string { 97 + seconds := int64(d.Seconds()) 98 + 99 + // Convert to appropriate unit for readability 100 + switch { 101 + case seconds >= 86400: // >= 1 day 102 + days := seconds / 86400 103 + return fmt.Sprintf("%d days", days) 104 + case seconds >= 3600: // >= 1 hour 105 + hours := seconds / 3600 106 + return fmt.Sprintf("%d hours", hours) 107 + case seconds >= 60: // >= 1 minute 108 + minutes := seconds / 60 109 + return fmt.Sprintf("%d minutes", minutes) 110 + default: 111 + return fmt.Sprintf("%d seconds", seconds) 112 + } 113 + } 114 + 115 + // validateATURI validates that a string is a properly formatted AT-URI for a Bluesky post. 116 + // AT-URIs for Bluesky posts must: 117 + // - Start with "at://" 118 + // - Contain "/app.bsky.feed.post/" 119 + // 120 + // Example valid URI: at://did:plc:abc123/app.bsky.feed.post/xyz789 121 + func validateATURI(atURI string) error { 122 + if !strings.HasPrefix(atURI, "at://") { 123 + return fmt.Errorf("invalid AT-URI: must start with 'at://'") 124 + } 125 + 126 + if !strings.Contains(atURI, "/app.bsky.feed.post/") { 127 + return fmt.Errorf("invalid AT-URI: must contain '/app.bsky.feed.post/'") 128 + } 129 + 130 + return nil 131 + }
+48
internal/core/blueskypost/repository_test.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestValidateATURI(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + atURI string 11 + wantErr bool 12 + }{ 13 + { 14 + name: "valid AT-URI", 15 + atURI: "at://did:plc:abc123/app.bsky.feed.post/xyz789", 16 + wantErr: false, 17 + }, 18 + { 19 + name: "missing at:// prefix", 20 + atURI: "did:plc:abc123/app.bsky.feed.post/xyz789", 21 + wantErr: true, 22 + }, 23 + { 24 + name: "missing /app.bsky.feed.post/", 25 + atURI: "at://did:plc:abc123/some.other.collection/xyz789", 26 + wantErr: true, 27 + }, 28 + { 29 + name: "empty string", 30 + atURI: "", 31 + wantErr: true, 32 + }, 33 + { 34 + name: "http URL instead of AT-URI", 35 + atURI: "https://bsky.app/profile/user.bsky.social/post/abc123", 36 + wantErr: true, 37 + }, 38 + } 39 + 40 + for _, tt := range tests { 41 + t.Run(tt.name, func(t *testing.T) { 42 + err := validateATURI(tt.atURI) 43 + if (err != nil) != tt.wantErr { 44 + t.Errorf("validateATURI() error = %v, wantErr %v", err, tt.wantErr) 45 + } 46 + }) 47 + } 48 + }
+119
internal/core/blueskypost/service.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "context" 6 + "errors" 7 + "fmt" 8 + "log" 9 + "time" 10 + ) 11 + 12 + // service implements the Service interface 13 + type service struct { 14 + repo Repository 15 + identityResolver identity.Resolver 16 + circuitBreaker *circuitBreaker 17 + timeout time.Duration 18 + cacheTTL time.Duration 19 + } 20 + 21 + // NewService creates a new Bluesky post service 22 + func NewService(repo Repository, identityResolver identity.Resolver, opts ...ServiceOption) Service { 23 + if repo == nil { 24 + panic("blueskypost: repo cannot be nil") 25 + } 26 + if identityResolver == nil { 27 + panic("blueskypost: identityResolver cannot be nil") 28 + } 29 + 30 + s := &service{ 31 + repo: repo, 32 + identityResolver: identityResolver, 33 + timeout: 10 * time.Second, 34 + cacheTTL: 1 * time.Hour, // 1 hour cache (shorter than unfurl since posts can be deleted) 35 + circuitBreaker: newCircuitBreaker(), 36 + } 37 + 38 + for _, opt := range opts { 39 + opt(s) 40 + } 41 + 42 + return s 43 + } 44 + 45 + // ServiceOption configures the service 46 + type ServiceOption func(*service) 47 + 48 + // WithTimeout sets the HTTP timeout for Bluesky API requests 49 + func WithTimeout(timeout time.Duration) ServiceOption { 50 + return func(s *service) { 51 + s.timeout = timeout 52 + } 53 + } 54 + 55 + // WithCacheTTL sets the cache TTL 56 + func WithCacheTTL(ttl time.Duration) ServiceOption { 57 + return func(s *service) { 58 + s.cacheTTL = ttl 59 + } 60 + } 61 + 62 + // IsBlueskyURL checks if a URL is a valid bsky.app post URL. 63 + // Returns true for URLs matching https://bsky.app/profile/{handle}/post/{rkey} 64 + func (s *service) IsBlueskyURL(url string) bool { 65 + return IsBlueskyURL(url) 66 + } 67 + 68 + // ParseBlueskyURL converts a bsky.app URL to an AT-URI. 69 + // Example: https://bsky.app/profile/user.bsky.social/post/abc123 70 + // 71 + // -> at://did:plc:xxx/app.bsky.feed.post/abc123 72 + // 73 + // Returns error if the URL is invalid or handle resolution fails. 74 + func (s *service) ParseBlueskyURL(ctx context.Context, url string) (string, error) { 75 + return ParseBlueskyURL(ctx, url, s.identityResolver) 76 + } 77 + 78 + // ResolvePost fetches and resolves a Bluesky post by AT-URI. 79 + // It checks the cache first, then fetches from public.api.bsky.app if needed. 80 + // Returns BlueskyPostResult with Unavailable=true if the post cannot be resolved. 81 + func (s *service) ResolvePost(ctx context.Context, atURI string) (*BlueskyPostResult, error) { 82 + // 1. Check cache first 83 + cached, err := s.repo.Get(ctx, atURI) 84 + if err != nil && !errors.Is(err, ErrCacheMiss) { 85 + // Log cache errors (but not cache misses) at WARNING level for operator visibility 86 + log.Printf("[BLUESKY] Warning: Cache read error for %s: %v", atURI, err) 87 + } else if err == nil && cached != nil { 88 + log.Printf("[BLUESKY] Cache hit for %s", atURI) 89 + return cached, nil 90 + } 91 + 92 + // 2. Check circuit breaker 93 + provider := "bluesky" 94 + canAttempt, err := s.circuitBreaker.canAttempt(provider) 95 + if !canAttempt { 96 + log.Printf("[BLUESKY] Skipping %s due to circuit breaker: %v", atURI, err) 97 + return nil, err 98 + } 99 + 100 + // 3. Fetch from Bluesky API 101 + log.Printf("[BLUESKY] Cache miss for %s, fetching from API...", atURI) 102 + result, err := fetchBlueskyPost(ctx, atURI, s.timeout) 103 + if err != nil { 104 + s.circuitBreaker.recordFailure(provider, err) 105 + return nil, fmt.Errorf("failed to fetch Bluesky post: %w", err) 106 + } 107 + 108 + s.circuitBreaker.recordSuccess(provider) 109 + 110 + // 4. Cache the result (even if unavailable, to prevent repeated fetches) 111 + if cacheErr := s.repo.Set(ctx, atURI, result, s.cacheTTL); cacheErr != nil { 112 + // Log but don't fail - cache is best-effort 113 + log.Printf("[BLUESKY] Warning: Failed to cache result for %s: %v", atURI, cacheErr) 114 + } 115 + 116 + log.Printf("[BLUESKY] Successfully resolved %s (unavailable: %v)", atURI, result.Unavailable) 117 + 118 + return result, nil 119 + }
+448
internal/core/blueskypost/service_test.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + // mockRepository implements Repository for testing 11 + type mockRepository struct { 12 + storage map[string]*BlueskyPostResult 13 + getErr error 14 + setErr error 15 + } 16 + 17 + func newMockRepository() *mockRepository { 18 + return &mockRepository{ 19 + storage: make(map[string]*BlueskyPostResult), 20 + } 21 + } 22 + 23 + func (m *mockRepository) Get(ctx context.Context, atURI string) (*BlueskyPostResult, error) { 24 + if m.getErr != nil { 25 + return nil, m.getErr 26 + } 27 + result, ok := m.storage[atURI] 28 + if !ok { 29 + return nil, ErrCacheMiss 30 + } 31 + return result, nil 32 + } 33 + 34 + func (m *mockRepository) Set(ctx context.Context, atURI string, result *BlueskyPostResult, ttl time.Duration) error { 35 + if m.setErr != nil { 36 + return m.setErr 37 + } 38 + m.storage[atURI] = result 39 + return nil 40 + } 41 + 42 + func TestService_IsBlueskyURL(t *testing.T) { 43 + repo := newMockRepository() 44 + resolver := &mockIdentityResolver{} 45 + svc := NewService(repo, resolver) 46 + 47 + tests := []struct { 48 + name string 49 + url string 50 + expected bool 51 + }{ 52 + { 53 + name: "valid bsky.app URL", 54 + url: "https://bsky.app/profile/alice.bsky.social/post/abc123", 55 + expected: true, 56 + }, 57 + { 58 + name: "invalid URL", 59 + url: "https://twitter.com/alice/status/123", 60 + expected: false, 61 + }, 62 + } 63 + 64 + for _, tt := range tests { 65 + t.Run(tt.name, func(t *testing.T) { 66 + result := svc.IsBlueskyURL(tt.url) 67 + if result != tt.expected { 68 + t.Errorf("IsBlueskyURL(%q) = %v, want %v", tt.url, result, tt.expected) 69 + } 70 + }) 71 + } 72 + } 73 + 74 + func TestService_ParseBlueskyURL(t *testing.T) { 75 + repo := newMockRepository() 76 + resolver := &mockIdentityResolver{ 77 + handleToDID: map[string]string{ 78 + "alice.bsky.social": "did:plc:alice123", 79 + }, 80 + } 81 + svc := NewService(repo, resolver) 82 + ctx := context.Background() 83 + 84 + tests := []struct { 85 + name string 86 + url string 87 + expectedURI string 88 + wantErr bool 89 + }{ 90 + { 91 + name: "valid URL", 92 + url: "https://bsky.app/profile/alice.bsky.social/post/abc123", 93 + expectedURI: "at://did:plc:alice123/app.bsky.feed.post/abc123", 94 + wantErr: false, 95 + }, 96 + { 97 + name: "invalid URL", 98 + url: "https://twitter.com/alice/status/123", 99 + wantErr: true, 100 + }, 101 + } 102 + 103 + for _, tt := range tests { 104 + t.Run(tt.name, func(t *testing.T) { 105 + result, err := svc.ParseBlueskyURL(ctx, tt.url) 106 + 107 + if tt.wantErr { 108 + if err == nil { 109 + t.Error("ParseBlueskyURL() expected error, got nil") 110 + } 111 + return 112 + } 113 + 114 + if err != nil { 115 + t.Errorf("ParseBlueskyURL() unexpected error: %v", err) 116 + return 117 + } 118 + 119 + if result != tt.expectedURI { 120 + t.Errorf("ParseBlueskyURL() = %q, want %q", result, tt.expectedURI) 121 + } 122 + }) 123 + } 124 + } 125 + 126 + func TestService_ResolvePost_CacheHit(t *testing.T) { 127 + repo := newMockRepository() 128 + resolver := &mockIdentityResolver{} 129 + svc := NewService(repo, resolver) 130 + ctx := context.Background() 131 + 132 + atURI := "at://did:plc:alice123/app.bsky.feed.post/abc123" 133 + expectedResult := &BlueskyPostResult{ 134 + URI: atURI, 135 + CID: "cid123", 136 + Text: "Hello from cache", 137 + Author: &Author{ 138 + DID: "did:plc:alice123", 139 + Handle: "alice.bsky.social", 140 + }, 141 + } 142 + 143 + // Pre-populate cache 144 + err := repo.Set(ctx, atURI, expectedResult, 1*time.Hour) 145 + if err != nil { 146 + t.Fatalf("Failed to set up cache: %v", err) 147 + } 148 + 149 + // Resolve should return cached result 150 + result, err := svc.ResolvePost(ctx, atURI) 151 + if err != nil { 152 + t.Fatalf("ResolvePost() unexpected error: %v", err) 153 + } 154 + 155 + if result.URI != expectedResult.URI { 156 + t.Errorf("Expected URI %q, got %q", expectedResult.URI, result.URI) 157 + } 158 + if result.Text != expectedResult.Text { 159 + t.Errorf("Expected text %q, got %q", expectedResult.Text, result.Text) 160 + } 161 + } 162 + 163 + func TestService_ResolvePost_CacheMiss(t *testing.T) { 164 + // This test would require mocking the HTTP client or using a test server 165 + // For now, we'll test that cache miss is handled properly by testing 166 + // the flow up to the point where fetching would occur 167 + 168 + repo := newMockRepository() 169 + resolver := &mockIdentityResolver{} 170 + svc := NewService(repo, resolver) 171 + ctx := context.Background() 172 + 173 + atURI := "at://did:plc:notincache/app.bsky.feed.post/xyz789" 174 + 175 + // Cache miss should trigger a fetch from the API 176 + // Since this is a fake DID, the API will return 404 which maps to unavailable 177 + result, err := svc.ResolvePost(ctx, atURI) 178 + // The request should succeed (404 is not an error, it's unavailable) 179 + if err != nil { 180 + t.Errorf("Expected no error, got: %v", err) 181 + } 182 + if result == nil { 183 + t.Fatal("Expected result, got nil") 184 + } 185 + if !result.Unavailable { 186 + t.Error("Expected result to be unavailable for fake DID") 187 + } 188 + } 189 + 190 + func TestService_ResolvePost_CacheError(t *testing.T) { 191 + repo := newMockRepository() 192 + repo.getErr = errors.New("database connection failed") 193 + resolver := &mockIdentityResolver{} 194 + svc := NewService(repo, resolver) 195 + ctx := context.Background() 196 + 197 + atURI := "at://did:plc:alice123/app.bsky.feed.post/abc123" 198 + 199 + // Cache error should be logged but not fail the request 200 + // It should proceed to fetch from the API 201 + result, err := svc.ResolvePost(ctx, atURI) 202 + // The request should succeed (cache errors are logged but not fatal) 203 + // The fetch will likely return an unavailable result for this fake DID 204 + if err != nil { 205 + t.Errorf("Expected no error despite cache failure, got: %v", err) 206 + } 207 + if result == nil { 208 + t.Error("Expected result despite cache failure, got nil") 209 + } 210 + } 211 + 212 + func TestService_ResolvePost_CircuitBreakerOpen(t *testing.T) { 213 + repo := newMockRepository() 214 + resolver := &mockIdentityResolver{} 215 + svc := NewService(repo, resolver).(*service) 216 + ctx := context.Background() 217 + 218 + atURI := "at://did:plc:alice123/app.bsky.feed.post/abc123" 219 + provider := "bluesky" 220 + 221 + // Manually open the circuit breaker 222 + testErr := errors.New("test error") 223 + for i := 0; i < svc.circuitBreaker.failureThreshold; i++ { 224 + svc.circuitBreaker.recordFailure(provider, testErr) 225 + } 226 + 227 + // Attempt to resolve should be blocked by circuit breaker 228 + _, err := svc.ResolvePost(ctx, atURI) 229 + if err == nil { 230 + t.Error("ResolvePost() should fail when circuit breaker is open") 231 + } 232 + 233 + if !contains(err.Error(), "circuit breaker open") { 234 + t.Errorf("Expected circuit breaker error, got: %v", err) 235 + } 236 + } 237 + 238 + func TestService_ResolvePost_SetCacheError(t *testing.T) { 239 + // Test that cache set errors don't fail the request 240 + repo := newMockRepository() 241 + repo.setErr = errors.New("cache write failed") 242 + resolver := &mockIdentityResolver{} 243 + svc := NewService(repo, resolver) 244 + ctx := context.Background() 245 + 246 + atURI := "at://did:plc:alice123/app.bsky.feed.post/abc123" 247 + 248 + // This will fail at fetch, but we're testing that cache set errors 249 + // are handled gracefully 250 + _, err := svc.ResolvePost(ctx, atURI) 251 + 252 + // Error should be from fetch, not from cache set 253 + // In a real test with mocked HTTP, we'd verify the cache set error 254 + // was logged but didn't fail the request 255 + if err != nil && contains(err.Error(), "cache write failed") { 256 + t.Error("Cache set errors should not fail the request") 257 + } 258 + } 259 + 260 + func TestService_WithOptions(t *testing.T) { 261 + repo := newMockRepository() 262 + resolver := &mockIdentityResolver{} 263 + 264 + customTimeout := 30 * time.Second 265 + customCacheTTL := 2 * time.Hour 266 + 267 + svc := NewService( 268 + repo, 269 + resolver, 270 + WithTimeout(customTimeout), 271 + WithCacheTTL(customCacheTTL), 272 + ).(*service) 273 + 274 + if svc.timeout != customTimeout { 275 + t.Errorf("Expected timeout %v, got %v", customTimeout, svc.timeout) 276 + } 277 + 278 + if svc.cacheTTL != customCacheTTL { 279 + t.Errorf("Expected cache TTL %v, got %v", customCacheTTL, svc.cacheTTL) 280 + } 281 + } 282 + 283 + func TestService_DefaultOptions(t *testing.T) { 284 + repo := newMockRepository() 285 + resolver := &mockIdentityResolver{} 286 + svc := NewService(repo, resolver).(*service) 287 + 288 + expectedTimeout := 10 * time.Second 289 + expectedCacheTTL := 1 * time.Hour 290 + 291 + if svc.timeout != expectedTimeout { 292 + t.Errorf("Expected default timeout %v, got %v", expectedTimeout, svc.timeout) 293 + } 294 + 295 + if svc.cacheTTL != expectedCacheTTL { 296 + t.Errorf("Expected default cache TTL %v, got %v", expectedCacheTTL, svc.cacheTTL) 297 + } 298 + 299 + if svc.circuitBreaker == nil { 300 + t.Error("Circuit breaker should be initialized") 301 + } 302 + } 303 + 304 + func TestService_ResolvePost_ContextCancellation(t *testing.T) { 305 + repo := newMockRepository() 306 + resolver := &mockIdentityResolver{} 307 + svc := NewService(repo, resolver) 308 + 309 + // Create a context that's already cancelled 310 + ctx, cancel := context.WithCancel(context.Background()) 311 + cancel() 312 + 313 + atURI := "at://did:plc:alice123/app.bsky.feed.post/abc123" 314 + 315 + _, err := svc.ResolvePost(ctx, atURI) 316 + if err == nil { 317 + t.Error("ResolvePost() should fail with cancelled context") 318 + } 319 + 320 + if !errors.Is(err, context.Canceled) && !contains(err.Error(), "context canceled") { 321 + t.Errorf("Expected context cancelled error, got: %v", err) 322 + } 323 + } 324 + 325 + func TestService_ResolvePost_MultipleProviders(t *testing.T) { 326 + // Test that circuit breaker tracks providers independently 327 + repo := newMockRepository() 328 + resolver := &mockIdentityResolver{} 329 + svc := NewService(repo, resolver).(*service) 330 + 331 + // The blueskypost service only uses one provider ("bluesky") 332 + // but we can verify the circuit breaker works independently 333 + // by testing with different URIs 334 + 335 + ctx := context.Background() 336 + atURI1 := "at://did:plc:alice123/app.bsky.feed.post/abc123" 337 + atURI2 := "at://did:plc:bob456/app.bsky.feed.post/xyz789" 338 + 339 + // Both should use the same circuit breaker 340 + // Open the circuit 341 + provider := "bluesky" 342 + testErr := errors.New("test error") 343 + for i := 0; i < svc.circuitBreaker.failureThreshold; i++ { 344 + svc.circuitBreaker.recordFailure(provider, testErr) 345 + } 346 + 347 + // Both URIs should be blocked 348 + _, err1 := svc.ResolvePost(ctx, atURI1) 349 + if err1 == nil || !contains(err1.Error(), "circuit breaker open") { 350 + t.Error("First URI should be blocked by circuit breaker") 351 + } 352 + 353 + _, err2 := svc.ResolvePost(ctx, atURI2) 354 + if err2 == nil || !contains(err2.Error(), "circuit breaker open") { 355 + t.Error("Second URI should be blocked by circuit breaker") 356 + } 357 + } 358 + 359 + func TestService_ResolvePost_CacheBypass(t *testing.T) { 360 + // Test that even if cache returns a result, it's the correct one 361 + repo := newMockRepository() 362 + resolver := &mockIdentityResolver{} 363 + svc := NewService(repo, resolver) 364 + ctx := context.Background() 365 + 366 + atURI1 := "at://did:plc:alice123/app.bsky.feed.post/abc123" 367 + atURI2 := "at://did:plc:bob456/app.bsky.feed.post/xyz789" 368 + 369 + result1 := &BlueskyPostResult{ 370 + URI: atURI1, 371 + Text: "Post 1", 372 + } 373 + result2 := &BlueskyPostResult{ 374 + URI: atURI2, 375 + Text: "Post 2", 376 + } 377 + 378 + // Cache both 379 + _ = repo.Set(ctx, atURI1, result1, 1*time.Hour) 380 + _ = repo.Set(ctx, atURI2, result2, 1*time.Hour) 381 + 382 + // Retrieve each and verify correct result 383 + got1, err := svc.ResolvePost(ctx, atURI1) 384 + if err != nil { 385 + t.Fatalf("ResolvePost(%q) error: %v", atURI1, err) 386 + } 387 + if got1.Text != "Post 1" { 388 + t.Errorf("Expected Post 1, got %q", got1.Text) 389 + } 390 + 391 + got2, err := svc.ResolvePost(ctx, atURI2) 392 + if err != nil { 393 + t.Fatalf("ResolvePost(%q) error: %v", atURI2, err) 394 + } 395 + if got2.Text != "Post 2" { 396 + t.Errorf("Expected Post 2, got %q", got2.Text) 397 + } 398 + } 399 + 400 + func TestService_IntegrationFlow(t *testing.T) { 401 + // Integration test simulating the full flow 402 + repo := newMockRepository() 403 + resolver := &mockIdentityResolver{ 404 + handleToDID: map[string]string{ 405 + "alice.bsky.social": "did:plc:alice123", 406 + }, 407 + } 408 + svc := NewService(repo, resolver) 409 + ctx := context.Background() 410 + 411 + // Step 1: Check URL 412 + url := "https://bsky.app/profile/alice.bsky.social/post/abc123" 413 + if !svc.IsBlueskyURL(url) { 414 + t.Fatalf("IsBlueskyURL(%q) should return true", url) 415 + } 416 + 417 + // Step 2: Parse URL 418 + atURI, err := svc.ParseBlueskyURL(ctx, url) 419 + if err != nil { 420 + t.Fatalf("ParseBlueskyURL() error: %v", err) 421 + } 422 + 423 + expectedURI := "at://did:plc:alice123/app.bsky.feed.post/abc123" 424 + if atURI != expectedURI { 425 + t.Errorf("Expected URI %q, got %q", expectedURI, atURI) 426 + } 427 + 428 + // Step 3: Pre-populate cache with result 429 + cachedResult := &BlueskyPostResult{ 430 + URI: atURI, 431 + Text: "Integration test post", 432 + Author: &Author{ 433 + DID: "did:plc:alice123", 434 + Handle: "alice.bsky.social", 435 + }, 436 + } 437 + _ = repo.Set(ctx, atURI, cachedResult, 1*time.Hour) 438 + 439 + // Step 4: Resolve post (should hit cache) 440 + result, err := svc.ResolvePost(ctx, atURI) 441 + if err != nil { 442 + t.Fatalf("ResolvePost() error: %v", err) 443 + } 444 + 445 + if result.Text != "Integration test post" { 446 + t.Errorf("Expected cached text, got %q", result.Text) 447 + } 448 + }
+72
internal/core/blueskypost/types.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "errors" 5 + "time" 6 + ) 7 + 8 + // Sentinel errors for typed error checking 9 + var ( 10 + // ErrCircuitOpen indicates the circuit breaker is open for a provider 11 + ErrCircuitOpen = errors.New("circuit breaker open") 12 + ) 13 + 14 + // BlueskyPostResult represents the resolved data from a Bluesky post. 15 + // This includes the post text, author information, engagement metrics, 16 + // and media indicators (Phase 1 does not render media, only indicates presence). 17 + type BlueskyPostResult struct { 18 + // CreatedAt is when the post was created 19 + CreatedAt time.Time `json:"createdAt"` 20 + 21 + // Author contains the post author's identity information 22 + Author *Author `json:"author"` 23 + 24 + // QuotedPost is a nested Bluesky post if this post quotes another post 25 + // Limited to 1 level of nesting in Phase 1 26 + QuotedPost *BlueskyPostResult `json:"quotedPost,omitempty"` 27 + 28 + // URI is the AT-URI of the post (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123) 29 + URI string `json:"uri"` 30 + 31 + // CID is the content identifier for this version of the post 32 + CID string `json:"cid"` 33 + 34 + // Text is the post content (plain text) 35 + Text string `json:"text"` 36 + 37 + // Message provides a human-readable error message if Unavailable is true 38 + Message string `json:"message,omitempty"` 39 + 40 + // ReplyCount is the number of replies to this post 41 + ReplyCount int `json:"replyCount"` 42 + 43 + // RepostCount is the number of reposts of this post 44 + RepostCount int `json:"repostCount"` 45 + 46 + // LikeCount is the number of likes on this post 47 + LikeCount int `json:"likeCount"` 48 + 49 + // MediaCount is the number of images/videos in the post (Phase 1: count only, no rendering) 50 + MediaCount int `json:"mediaCount"` 51 + 52 + // HasMedia indicates if the post contains images or videos (Phase 1: indicator only) 53 + HasMedia bool `json:"hasMedia"` 54 + 55 + // Unavailable indicates the post could not be resolved (deleted, private, blocked, etc.) 56 + Unavailable bool `json:"unavailable"` 57 + } 58 + 59 + // Author represents a Bluesky post author's identity. 60 + type Author struct { 61 + // DID is the decentralized identifier of the author 62 + DID string `json:"did"` 63 + 64 + // Handle is the user's handle (e.g., user.bsky.social) 65 + Handle string `json:"handle"` 66 + 67 + // DisplayName is the user's chosen display name (may be empty) 68 + DisplayName string `json:"displayName,omitempty"` 69 + 70 + // Avatar is the URL to the user's avatar image (may be empty) 71 + Avatar string `json:"avatar,omitempty"` 72 + }
+101
internal/core/blueskypost/url_parser.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "context" 6 + "fmt" 7 + "net/url" 8 + "regexp" 9 + "strings" 10 + ) 11 + 12 + // blueskyPostURLPattern matches https://bsky.app/profile/{handle}/post/{rkey} 13 + var blueskyPostURLPattern = regexp.MustCompile(`^https://bsky\.app/profile/([^/]+)/post/([^/]+)$`) 14 + 15 + // IsBlueskyURL checks if a URL is a valid bsky.app post URL. 16 + // Returns true for URLs matching https://bsky.app/profile/{handle}/post/{rkey} 17 + func IsBlueskyURL(urlStr string) bool { 18 + return blueskyPostURLPattern.MatchString(urlStr) 19 + } 20 + 21 + // ParseBlueskyURL converts a bsky.app URL to an AT-URI. 22 + // Example: https://bsky.app/profile/user.bsky.social/post/abc123 23 + // 24 + // -> at://did:plc:xxx/app.bsky.feed.post/abc123 25 + // 26 + // Returns error if the URL is invalid or handle resolution fails. 27 + func ParseBlueskyURL(ctx context.Context, urlStr string, resolver identity.Resolver) (string, error) { 28 + // Parse and validate the URL 29 + parsedURL, err := url.Parse(urlStr) 30 + if err != nil { 31 + return "", fmt.Errorf("invalid URL: %w", err) 32 + } 33 + 34 + // Validate URL scheme and host 35 + if parsedURL.Scheme != "https" { 36 + return "", fmt.Errorf("URL must use HTTPS scheme") 37 + } 38 + if parsedURL.Host != "bsky.app" { 39 + return "", fmt.Errorf("URL must be from bsky.app") 40 + } 41 + 42 + // Extract handle and rkey using regex 43 + matches := blueskyPostURLPattern.FindStringSubmatch(urlStr) 44 + if matches == nil || len(matches) != 3 { 45 + return "", fmt.Errorf("invalid bsky.app URL format, expected: https://bsky.app/profile/{handle}/post/{rkey}") 46 + } 47 + 48 + handle := matches[1] 49 + rkey := matches[2] 50 + 51 + // Validate handle and rkey are not empty 52 + if handle == "" || rkey == "" { 53 + return "", fmt.Errorf("handle and rkey cannot be empty") 54 + } 55 + 56 + // Validate rkey format 57 + // TID format: base32-sortable timestamp IDs are typically 13 characters 58 + // Allow alphanumeric characters, reasonable length (3-20 chars to be permissive) 59 + if err := validateRkey(rkey); err != nil { 60 + return "", fmt.Errorf("invalid rkey: %w", err) 61 + } 62 + 63 + // Resolve handle to DID 64 + // If the handle is already a DID (starts with "did:"), use it directly 65 + var did string 66 + if strings.HasPrefix(handle, "did:") { 67 + did = handle 68 + } else { 69 + // Resolve handle to DID using identity resolver 70 + resolvedDID, _, err := resolver.ResolveHandle(ctx, handle) 71 + if err != nil { 72 + return "", fmt.Errorf("failed to resolve handle %s: %w", handle, err) 73 + } 74 + did = resolvedDID 75 + } 76 + 77 + // Construct AT-URI 78 + // Format: at://{did}/app.bsky.feed.post/{rkey} 79 + atURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 80 + 81 + return atURI, nil 82 + } 83 + 84 + // rkeyPattern matches valid rkey formats (alphanumeric, typically base32 TID format) 85 + var rkeyPattern = regexp.MustCompile(`^[a-zA-Z0-9]+$`) 86 + 87 + // validateRkey validates the rkey (record key) format. 88 + // TIDs (Timestamp Identifiers) are typically 13 characters in base32-sortable format. 89 + // We allow 3-20 characters to be permissive while preventing abuse. 90 + func validateRkey(rkey string) error { 91 + if len(rkey) < 3 { 92 + return fmt.Errorf("rkey too short (minimum 3 characters)") 93 + } 94 + if len(rkey) > 20 { 95 + return fmt.Errorf("rkey too long (maximum 20 characters)") 96 + } 97 + if !rkeyPattern.MatchString(rkey) { 98 + return fmt.Errorf("rkey contains invalid characters (must be alphanumeric)") 99 + } 100 + return nil 101 + }
+327
internal/core/blueskypost/url_parser_test.go
··· 1 + package blueskypost 2 + 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "context" 6 + "errors" 7 + "testing" 8 + ) 9 + 10 + // mockIdentityResolver implements identity.Resolver for testing 11 + type mockIdentityResolver struct { 12 + handleToDID map[string]string 13 + err error 14 + } 15 + 16 + func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) { 17 + if m.err != nil { 18 + return "", "", m.err 19 + } 20 + did, ok := m.handleToDID[handle] 21 + if !ok { 22 + return "", "", errors.New("handle not found") 23 + } 24 + return did, "", nil 25 + } 26 + 27 + func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 28 + if m.err != nil { 29 + return nil, m.err 30 + } 31 + did, _, err := m.ResolveHandle(ctx, identifier) 32 + if err != nil { 33 + return nil, err 34 + } 35 + return &identity.Identity{ 36 + DID: did, 37 + Handle: identifier, 38 + }, nil 39 + } 40 + 41 + func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 42 + return nil, errors.New("not implemented") 43 + } 44 + 45 + func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error { 46 + return nil 47 + } 48 + 49 + func TestIsBlueskyURL(t *testing.T) { 50 + tests := []struct { 51 + name string 52 + url string 53 + expected bool 54 + }{ 55 + { 56 + name: "valid bsky.app URL with handle", 57 + url: "https://bsky.app/profile/user.bsky.social/post/abc123xyz", 58 + expected: true, 59 + }, 60 + { 61 + name: "valid bsky.app URL with DID", 62 + url: "https://bsky.app/profile/did:plc:abc123/post/xyz789", 63 + expected: true, 64 + }, 65 + { 66 + name: "valid bsky.app URL with alphanumeric rkey", 67 + url: "https://bsky.app/profile/alice.example/post/3k2j4h5g6f7d8s9a", 68 + expected: true, 69 + }, 70 + { 71 + name: "wrong domain", 72 + url: "https://twitter.com/profile/user/post/abc123", 73 + expected: false, 74 + }, 75 + { 76 + name: "wrong path format - missing post", 77 + url: "https://bsky.app/profile/user.bsky.social/abc123", 78 + expected: false, 79 + }, 80 + { 81 + name: "wrong path format - extra segments", 82 + url: "https://bsky.app/profile/user.bsky.social/post/abc123/extra", 83 + expected: false, 84 + }, 85 + { 86 + name: "missing handle", 87 + url: "https://bsky.app/profile//post/abc123", 88 + expected: false, 89 + }, 90 + { 91 + name: "missing rkey", 92 + url: "https://bsky.app/profile/user.bsky.social/post/", 93 + expected: false, 94 + }, 95 + { 96 + name: "empty string", 97 + url: "", 98 + expected: false, 99 + }, 100 + { 101 + name: "malformed URL", 102 + url: "not-a-url", 103 + expected: false, 104 + }, 105 + { 106 + name: "http instead of https", 107 + url: "http://bsky.app/profile/user.bsky.social/post/abc123", 108 + expected: false, 109 + }, 110 + { 111 + name: "AT-URI instead of bsky.app URL", 112 + url: "at://did:plc:abc123/app.bsky.feed.post/xyz789", 113 + expected: false, 114 + }, 115 + } 116 + 117 + for _, tt := range tests { 118 + t.Run(tt.name, func(t *testing.T) { 119 + result := IsBlueskyURL(tt.url) 120 + if result != tt.expected { 121 + t.Errorf("IsBlueskyURL(%q) = %v, want %v", tt.url, result, tt.expected) 122 + } 123 + }) 124 + } 125 + } 126 + 127 + func TestParseBlueskyURL(t *testing.T) { 128 + ctx := context.Background() 129 + 130 + tests := []struct { 131 + resolver *mockIdentityResolver 132 + name string 133 + url string 134 + expectedURI string 135 + errContains string 136 + wantErr bool 137 + }{ 138 + { 139 + name: "valid URL with handle", 140 + url: "https://bsky.app/profile/alice.bsky.social/post/abc123xyz", 141 + resolver: &mockIdentityResolver{ 142 + handleToDID: map[string]string{ 143 + "alice.bsky.social": "did:plc:alice123", 144 + }, 145 + }, 146 + expectedURI: "at://did:plc:alice123/app.bsky.feed.post/abc123xyz", 147 + wantErr: false, 148 + }, 149 + { 150 + name: "valid URL with DID (no resolution needed)", 151 + url: "https://bsky.app/profile/did:plc:bob456/post/xyz789", 152 + resolver: &mockIdentityResolver{ 153 + handleToDID: map[string]string{}, 154 + }, 155 + expectedURI: "at://did:plc:bob456/app.bsky.feed.post/xyz789", 156 + wantErr: false, 157 + }, 158 + { 159 + name: "handle resolution fails", 160 + url: "https://bsky.app/profile/unknown.bsky.social/post/abc123", 161 + resolver: &mockIdentityResolver{ 162 + handleToDID: map[string]string{}, 163 + }, 164 + wantErr: true, 165 + errContains: "failed to resolve handle", 166 + }, 167 + { 168 + name: "resolver returns error", 169 + url: "https://bsky.app/profile/error.bsky.social/post/abc123", 170 + resolver: &mockIdentityResolver{ 171 + err: errors.New("network error"), 172 + }, 173 + wantErr: true, 174 + errContains: "failed to resolve handle", 175 + }, 176 + { 177 + name: "invalid URL - wrong scheme", 178 + url: "http://bsky.app/profile/alice.bsky.social/post/abc123", 179 + resolver: &mockIdentityResolver{}, 180 + wantErr: true, 181 + errContains: "must use HTTPS scheme", 182 + }, 183 + { 184 + name: "invalid URL - wrong host", 185 + url: "https://twitter.com/profile/alice/post/abc123", 186 + resolver: &mockIdentityResolver{}, 187 + wantErr: true, 188 + errContains: "must be from bsky.app", 189 + }, 190 + { 191 + name: "invalid URL - wrong path format", 192 + url: "https://bsky.app/feed/alice.bsky.social/post/abc123", 193 + resolver: &mockIdentityResolver{}, 194 + wantErr: true, 195 + errContains: "invalid bsky.app URL format", 196 + }, 197 + { 198 + name: "invalid URL - missing rkey", 199 + url: "https://bsky.app/profile/alice.bsky.social/post/", 200 + resolver: &mockIdentityResolver{}, 201 + wantErr: true, 202 + errContains: "invalid bsky.app URL format", 203 + }, 204 + { 205 + name: "invalid URL - empty string", 206 + url: "", 207 + resolver: &mockIdentityResolver{}, 208 + wantErr: true, 209 + errContains: "HTTPS scheme", 210 + }, 211 + { 212 + name: "malformed URL", 213 + url: "not-a-valid-url", 214 + resolver: &mockIdentityResolver{}, 215 + wantErr: true, 216 + errContains: "HTTPS scheme", 217 + }, 218 + { 219 + name: "valid URL with complex handle", 220 + url: "https://bsky.app/profile/user.subdomain.example.com/post/3k2j4h5g", 221 + resolver: &mockIdentityResolver{ 222 + handleToDID: map[string]string{ 223 + "user.subdomain.example.com": "did:plc:complex789", 224 + }, 225 + }, 226 + expectedURI: "at://did:plc:complex789/app.bsky.feed.post/3k2j4h5g", 227 + wantErr: false, 228 + }, 229 + } 230 + 231 + for _, tt := range tests { 232 + t.Run(tt.name, func(t *testing.T) { 233 + result, err := ParseBlueskyURL(ctx, tt.url, tt.resolver) 234 + 235 + if tt.wantErr { 236 + if err == nil { 237 + t.Errorf("ParseBlueskyURL() expected error containing %q, got nil", tt.errContains) 238 + return 239 + } 240 + if tt.errContains != "" && !contains(err.Error(), tt.errContains) { 241 + t.Errorf("ParseBlueskyURL() error = %q, expected to contain %q", err.Error(), tt.errContains) 242 + } 243 + return 244 + } 245 + 246 + if err != nil { 247 + t.Errorf("ParseBlueskyURL() unexpected error: %v", err) 248 + return 249 + } 250 + 251 + if result != tt.expectedURI { 252 + t.Errorf("ParseBlueskyURL() = %q, want %q", result, tt.expectedURI) 253 + } 254 + }) 255 + } 256 + } 257 + 258 + func TestParseBlueskyURL_EdgeCases(t *testing.T) { 259 + ctx := context.Background() 260 + 261 + t.Run("rkey with special characters should still match pattern", func(t *testing.T) { 262 + // Bluesky rkeys are base32-like and should be alphanumeric 263 + // but let's test that our regex handles them 264 + resolver := &mockIdentityResolver{ 265 + handleToDID: map[string]string{ 266 + "alice.bsky.social": "did:plc:alice123", 267 + }, 268 + } 269 + 270 + url := "https://bsky.app/profile/alice.bsky.social/post/3km3l4n5m6k7j8h9" 271 + result, err := ParseBlueskyURL(ctx, url, resolver) 272 + if err != nil { 273 + t.Errorf("ParseBlueskyURL() with alphanumeric rkey failed: %v", err) 274 + } 275 + 276 + expected := "at://did:plc:alice123/app.bsky.feed.post/3km3l4n5m6k7j8h9" 277 + if result != expected { 278 + t.Errorf("ParseBlueskyURL() = %q, want %q", result, expected) 279 + } 280 + }) 281 + 282 + t.Run("handle that looks like DID should not be resolved", func(t *testing.T) { 283 + // If handle starts with "did:", treat it as DID 284 + resolver := &mockIdentityResolver{ 285 + handleToDID: map[string]string{ 286 + // Empty map - should not be called 287 + }, 288 + } 289 + 290 + url := "https://bsky.app/profile/did:plc:direct123/post/abc123" 291 + result, err := ParseBlueskyURL(ctx, url, resolver) 292 + if err != nil { 293 + t.Errorf("ParseBlueskyURL() with DID should not need resolution: %v", err) 294 + } 295 + 296 + expected := "at://did:plc:direct123/app.bsky.feed.post/abc123" 297 + if result != expected { 298 + t.Errorf("ParseBlueskyURL() = %q, want %q", result, expected) 299 + } 300 + }) 301 + 302 + t.Run("empty handle after split should fail", func(t *testing.T) { 303 + // This is caught by the regex, but testing defensive validation 304 + resolver := &mockIdentityResolver{} 305 + url := "https://bsky.app/profile//post/abc123" 306 + 307 + _, err := ParseBlueskyURL(ctx, url, resolver) 308 + if err == nil { 309 + t.Error("ParseBlueskyURL() with empty handle should fail") 310 + } 311 + }) 312 + } 313 + 314 + // Helper function to check if a string contains a substring 315 + func contains(s, substr string) bool { 316 + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || 317 + (len(s) > 0 && len(substr) > 0 && containsHelper(s, substr))) 318 + } 319 + 320 + func containsHelper(s, substr string) bool { 321 + for i := 0; i <= len(s)-len(substr); i++ { 322 + if s[i:i+len(substr)] == substr { 323 + return true 324 + } 325 + } 326 + return false 327 + }
+93
internal/core/posts/blob_transform.go
··· 1 1 package posts 2 2 3 3 import ( 4 + "context" 5 + "errors" 4 6 "fmt" 7 + "log" 8 + "strings" 9 + 10 + "Coves/internal/core/blueskypost" 5 11 ) 6 12 7 13 // TransformBlobRefsToURLs transforms all blob references in a PostView to PDS URLs ··· 79 85 // Replace blob ref with URL string 80 86 external["thumb"] = blobURL 81 87 } 88 + 89 + // TransformPostEmbeds enriches post embeds with resolved Bluesky post data 90 + // This modifies the Embed field in-place, adding a "resolved" field with BlueskyPostResult 91 + // Only processes social.coves.embed.post embeds with app.bsky.feed.post URIs 92 + func TransformPostEmbeds(ctx context.Context, postView *PostView, blueskyService blueskypost.Service) { 93 + if postView == nil || postView.Embed == nil || blueskyService == nil { 94 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: postView nil=%v, embed nil=%v, blueskyService nil=%v", 95 + postView == nil, postView == nil || postView.Embed == nil, blueskyService == nil) 96 + return 97 + } 98 + 99 + // Check if embed is a map (should be for post embeds) 100 + embedMap, ok := postView.Embed.(map[string]interface{}) 101 + if !ok { 102 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: embed is not a map (type: %T)", postView.Embed) 103 + return 104 + } 105 + 106 + // Check embed type 107 + embedType, ok := embedMap["$type"].(string) 108 + if !ok || embedType != "social.coves.embed.post" { 109 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: embed type is not social.coves.embed.post (type: %v)", embedType) 110 + return 111 + } 112 + 113 + // Extract the post reference 114 + postRef, ok := embedMap["post"].(map[string]interface{}) 115 + if !ok { 116 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: post reference is not a map") 117 + return 118 + } 119 + 120 + // Get the AT-URI from the post reference 121 + atURI, ok := postRef["uri"].(string) 122 + if !ok || atURI == "" { 123 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: AT-URI is missing or not a string") 124 + return 125 + } 126 + 127 + // Only process app.bsky.feed.post URIs (Bluesky posts) 128 + // Format: at://did:plc:xxx/app.bsky.feed.post/abc123 129 + if len(atURI) < 20 || atURI[:5] != "at://" { 130 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: invalid AT-URI format: %s", atURI) 131 + return 132 + } 133 + 134 + // Simple check for app.bsky.feed.post collection 135 + // We don't want to process other types of embeds (e.g., Coves posts) 136 + if !strings.Contains(atURI, "/app.bsky.feed.post/") { 137 + log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: not a Bluesky post (URI: %s)", atURI) 138 + return 139 + } 140 + 141 + // Resolve the Bluesky post 142 + result, err := blueskyService.ResolvePost(ctx, atURI) 143 + if err != nil { 144 + // Log the error but don't fail - set unavailable instead 145 + log.Printf("[TRANSFORM-EMBED] Failed to resolve Bluesky post %s: %v", atURI, err) 146 + 147 + // Differentiate between temporary and permanent failures using typed errors 148 + errorMessage := "This Bluesky post is unavailable" 149 + retryable := false 150 + 151 + // Check if it's a circuit breaker error (temporary/retryable) 152 + if errors.Is(err, blueskypost.ErrCircuitOpen) { 153 + errorMessage = "Bluesky is temporarily unavailable, please try again later" 154 + retryable = true 155 + } else if errors.Is(err, context.DeadlineExceeded) { 156 + errorMessage = "Failed to load Bluesky post, please try again" 157 + retryable = true 158 + } else if strings.Contains(err.Error(), "timeout") || 159 + strings.Contains(err.Error(), "temporary failure") { 160 + errorMessage = "Failed to load Bluesky post, please try again" 161 + retryable = true 162 + } 163 + 164 + embedMap["resolved"] = map[string]interface{}{ 165 + "unavailable": true, 166 + "message": errorMessage, 167 + "retryable": retryable, 168 + } 169 + return 170 + } 171 + 172 + // Add resolved data to embed 173 + embedMap["resolved"] = result 174 + }
+146 -90
internal/core/posts/service.go
··· 1 1 package posts 2 2 3 3 import ( 4 - "Coves/internal/api/middleware" 5 - "Coves/internal/core/aggregators" 6 - "Coves/internal/core/blobs" 7 - "Coves/internal/core/communities" 8 - "Coves/internal/core/unfurl" 9 4 "bytes" 10 5 "context" 11 6 "encoding/json" ··· 15 10 "net/http" 16 11 "os" 17 12 "time" 13 + 14 + "Coves/internal/api/middleware" 15 + "Coves/internal/core/aggregators" 16 + "Coves/internal/core/blobs" 17 + "Coves/internal/core/blueskypost" 18 + "Coves/internal/core/communities" 19 + "Coves/internal/core/unfurl" 18 20 ) 19 21 20 22 type postService struct { ··· 23 25 aggregatorService aggregators.Service 24 26 blobService blobs.Service 25 27 unfurlService unfurl.Service 28 + blueskyService blueskypost.Service 26 29 pdsURL string 27 30 } 28 31 29 32 // NewPostService creates a new post service 30 - // aggregatorService, blobService, and unfurlService can be nil if not needed (e.g., in tests or minimal setups) 33 + // aggregatorService, blobService, unfurlService, and blueskyService can be nil if not needed (e.g., in tests or minimal setups) 31 34 func NewPostService( 32 35 repo Repository, 33 36 communityService communities.Service, 34 37 aggregatorService aggregators.Service, // Optional: can be nil 35 38 blobService blobs.Service, // Optional: can be nil 36 39 unfurlService unfurl.Service, // Optional: can be nil 40 + blueskyService blueskypost.Service, // Optional: can be nil 37 41 pdsURL string, 38 42 ) Service { 39 43 return &postService{ ··· 42 46 aggregatorService: aggregatorService, 43 47 blobService: blobService, 44 48 unfurlService: unfurlService, 49 + blueskyService: blueskyService, 45 50 pdsURL: pdsURL, 46 51 } 47 52 } ··· 58 63 // 8. Return URI/CID (AppView indexes asynchronously via Jetstream) 59 64 func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) { 60 65 // 1. Validate basic input (before DID checks to give clear validation errors) 61 - if err := s.validateCreateRequest(req); err != nil { 66 + if err := s.validateCreateRequest(&req); err != nil { 62 67 return nil, err 63 68 } 64 69 ··· 178 183 if postRecord.Embed != nil { 179 184 if embedType, ok := postRecord.Embed["$type"].(string); ok && embedType == "social.coves.embed.external" { 180 185 if external, ok := postRecord.Embed["external"].(map[string]interface{}); ok { 181 - // SECURITY: Validate thumb field (must be blob, not URL string) 182 - // This validation happens BEFORE unfurl to catch client errors early 183 - if existingThumb := external["thumb"]; existingThumb != nil { 184 - if thumbStr, isString := existingThumb.(string); isString { 185 - return nil, NewValidationError("thumb", 186 - fmt.Sprintf("thumb must be a blob reference (with $type, ref, mimeType, size), not URL string: %s", thumbStr)) 187 - } 188 - 189 - // Validate blob structure if provided 190 - if thumbMap, isMap := existingThumb.(map[string]interface{}); isMap { 191 - // Check for $type field 192 - if thumbType, ok := thumbMap["$type"].(string); !ok || thumbType != "blob" { 186 + // Check if this is a Bluesky post URL and convert to post embed 187 + if !s.tryConvertBlueskyURLToPostEmbed(ctx, external, &postRecord) { 188 + // Not a Bluesky URL or conversion failed - continue with normal external embed processing 189 + // SECURITY: Validate thumb field (must be blob, not URL string) 190 + // This validation happens BEFORE unfurl to catch client errors early 191 + if existingThumb := external["thumb"]; existingThumb != nil { 192 + if thumbStr, isString := existingThumb.(string); isString { 193 193 return nil, NewValidationError("thumb", 194 - fmt.Sprintf("thumb must have $type: blob (got: %v)", thumbType)) 195 - } 196 - // Check for required blob fields 197 - if _, hasRef := thumbMap["ref"]; !hasRef { 198 - return nil, NewValidationError("thumb", "thumb blob missing required 'ref' field") 194 + fmt.Sprintf("thumb must be a blob reference (with $type, ref, mimeType, size), not URL string: %s", thumbStr)) 199 195 } 200 - if _, hasMimeType := thumbMap["mimeType"]; !hasMimeType { 201 - return nil, NewValidationError("thumb", "thumb blob missing required 'mimeType' field") 196 + 197 + // Validate blob structure if provided 198 + if thumbMap, isMap := existingThumb.(map[string]interface{}); isMap { 199 + // Check for $type field 200 + if thumbType, ok := thumbMap["$type"].(string); !ok || thumbType != "blob" { 201 + return nil, NewValidationError("thumb", 202 + fmt.Sprintf("thumb must have $type: blob (got: %v)", thumbType)) 203 + } 204 + // Check for required blob fields 205 + if _, hasRef := thumbMap["ref"]; !hasRef { 206 + return nil, NewValidationError("thumb", "thumb blob missing required 'ref' field") 207 + } 208 + if _, hasMimeType := thumbMap["mimeType"]; !hasMimeType { 209 + return nil, NewValidationError("thumb", "thumb blob missing required 'mimeType' field") 210 + } 211 + log.Printf("[POST-CREATE] Client provided valid thumbnail blob") 212 + } else { 213 + return nil, NewValidationError("thumb", 214 + fmt.Sprintf("thumb must be a blob object, got: %T", existingThumb)) 202 215 } 203 - log.Printf("[POST-CREATE] Client provided valid thumbnail blob") 204 - } else { 205 - return nil, NewValidationError("thumb", 206 - fmt.Sprintf("thumb must be a blob object, got: %T", existingThumb)) 207 216 } 208 - } 209 217 210 - // TRUSTED AGGREGATOR: Allow Kagi aggregator to provide thumbnail URLs directly 211 - // This bypasses unfurl for more accurate RSS-sourced thumbnails 212 - if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedKagi { 213 - log.Printf("[AGGREGATOR-THUMB] Trusted aggregator provided thumbnail: %s", *req.ThumbnailURL) 218 + // TRUSTED AGGREGATOR: Allow Kagi aggregator to provide thumbnail URLs directly 219 + // This bypasses unfurl for more accurate RSS-sourced thumbnails 220 + if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedKagi { 221 + log.Printf("[AGGREGATOR-THUMB] Trusted aggregator provided thumbnail: %s", *req.ThumbnailURL) 214 222 215 - if s.blobService != nil { 216 - blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second) 217 - defer blobCancel() 223 + if s.blobService != nil { 224 + blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second) 225 + defer blobCancel() 218 226 219 - blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, *req.ThumbnailURL) 220 - if blobErr != nil { 221 - log.Printf("[AGGREGATOR-THUMB] Failed to upload thumbnail: %v", blobErr) 222 - // No fallback - aggregators only use RSS feed thumbnails 223 - } else { 224 - external["thumb"] = blob 225 - log.Printf("[AGGREGATOR-THUMB] Successfully uploaded thumbnail from trusted aggregator") 227 + blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, *req.ThumbnailURL) 228 + if blobErr != nil { 229 + log.Printf("[AGGREGATOR-THUMB] Failed to upload thumbnail: %v", blobErr) 230 + // No fallback - aggregators only use RSS feed thumbnails 231 + } else { 232 + external["thumb"] = blob 233 + log.Printf("[AGGREGATOR-THUMB] Successfully uploaded thumbnail from trusted aggregator") 234 + } 226 235 } 227 236 } 228 - } 229 237 230 - // Unfurl enhancement (optional, only if URL is supported) 231 - // Skip unfurl for trusted aggregators - they provide their own metadata 232 - if !isTrustedKagi { 233 - if uri, ok := external["uri"].(string); ok && uri != "" { 234 - // Check if we support unfurling this URL 235 - if s.unfurlService != nil && s.unfurlService.IsSupported(uri) { 236 - log.Printf("[POST-CREATE] Unfurling URL: %s", uri) 238 + // Unfurl enhancement (optional, only if URL is supported) 239 + // Skip unfurl for trusted aggregators - they provide their own metadata 240 + if !isTrustedKagi { 241 + if uri, ok := external["uri"].(string); ok && uri != "" { 242 + // Check if we support unfurling this URL 243 + if s.unfurlService != nil && s.unfurlService.IsSupported(uri) { 244 + log.Printf("[POST-CREATE] Unfurling URL: %s", uri) 237 245 238 - // Unfurl with timeout (non-fatal if it fails) 239 - unfurlCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 240 - defer cancel() 246 + // Unfurl with timeout (non-fatal if it fails) 247 + unfurlCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 248 + defer cancel() 241 249 242 - result, err := s.unfurlService.UnfurlURL(unfurlCtx, uri) 243 - if err != nil { 244 - // Log but don't fail - user can still post with manual metadata 245 - log.Printf("[POST-CREATE] Warning: Failed to unfurl URL %s: %v", uri, err) 246 - } else { 247 - // Enhance embed with fetched metadata (only if client didn't provide) 248 - // Note: We respect client-provided values, even empty strings 249 - // If client sends title="", we assume they want no title 250 - if external["title"] == nil { 251 - external["title"] = result.Title 252 - } 253 - if external["description"] == nil { 254 - external["description"] = result.Description 255 - } 256 - // Always set metadata fields (provider, domain, type) 257 - external["embedType"] = result.Type 258 - external["provider"] = result.Provider 259 - external["domain"] = result.Domain 250 + result, err := s.unfurlService.UnfurlURL(unfurlCtx, uri) 251 + if err != nil { 252 + // Log but don't fail - user can still post with manual metadata 253 + log.Printf("[POST-CREATE] Warning: Failed to unfurl URL %s: %v", uri, err) 254 + } else { 255 + // Enhance embed with fetched metadata (only if client didn't provide) 256 + // Note: We respect client-provided values, even empty strings 257 + // If client sends title="", we assume they want no title 258 + if external["title"] == nil { 259 + external["title"] = result.Title 260 + } 261 + if external["description"] == nil { 262 + external["description"] = result.Description 263 + } 264 + // Always set metadata fields (provider, domain, type) 265 + external["embedType"] = result.Type 266 + external["provider"] = result.Provider 267 + external["domain"] = result.Domain 260 268 261 - // Upload thumbnail from unfurl if client didn't provide one 262 - // (Thumb validation already happened above) 263 - if external["thumb"] == nil { 264 - if result.ThumbnailURL != "" && s.blobService != nil { 265 - blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second) 266 - defer blobCancel() 269 + // Upload thumbnail from unfurl if client didn't provide one 270 + // (Thumb validation already happened above) 271 + if external["thumb"] == nil { 272 + if result.ThumbnailURL != "" && s.blobService != nil { 273 + blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second) 274 + defer blobCancel() 267 275 268 - blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, result.ThumbnailURL) 269 - if blobErr != nil { 270 - log.Printf("[POST-CREATE] Warning: Failed to upload thumbnail for %s: %v", uri, blobErr) 271 - } else { 272 - external["thumb"] = blob 273 - log.Printf("[POST-CREATE] Uploaded thumbnail blob for %s", uri) 276 + blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, result.ThumbnailURL) 277 + if blobErr != nil { 278 + log.Printf("[POST-CREATE] Warning: Failed to upload thumbnail for %s: %v", uri, blobErr) 279 + } else { 280 + external["thumb"] = blob 281 + log.Printf("[POST-CREATE] Uploaded thumbnail blob for %s", uri) 282 + } 274 283 } 275 284 } 276 - } 277 285 278 - log.Printf("[POST-CREATE] Successfully enhanced embed with unfurl data (provider: %s, type: %s)", 279 - result.Provider, result.Type) 286 + log.Printf("[POST-CREATE] Successfully enhanced embed with unfurl data (provider: %s, type: %s)", 287 + result.Provider, result.Type) 288 + } 280 289 } 281 290 } 282 291 } ··· 311 320 } 312 321 313 322 // validateCreateRequest validates basic input requirements 314 - func (s *postService) validateCreateRequest(req CreatePostRequest) error { 323 + func (s *postService) validateCreateRequest(req *CreatePostRequest) error { 315 324 // Global content limits (from lexicon) 316 325 const ( 317 326 maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon ··· 454 463 455 464 return result.URI, result.CID, nil 456 465 } 466 + 467 + // tryConvertBlueskyURLToPostEmbed attempts to convert a Bluesky URL in an external embed to a post embed. 468 + // Returns true if the conversion was successful and the postRecord was modified. 469 + // Returns false if the URL is not a Bluesky URL or if conversion failed (caller should continue with external embed). 470 + func (s *postService) tryConvertBlueskyURLToPostEmbed(ctx context.Context, external map[string]interface{}, postRecord *PostRecord) bool { 471 + // Check if we have a Bluesky service 472 + if s.blueskyService == nil { 473 + return false 474 + } 475 + 476 + // Extract URI from external embed 477 + uri, ok := external["uri"].(string) 478 + if !ok || uri == "" { 479 + return false 480 + } 481 + 482 + // Check if this is a Bluesky URL 483 + if !s.blueskyService.IsBlueskyURL(uri) { 484 + return false 485 + } 486 + 487 + log.Printf("[POST-CREATE] Detected Bluesky URL: %s", uri) 488 + 489 + // Convert bsky.app URL to AT-URI 490 + parseCtx, parseCancel := context.WithTimeout(ctx, 5*time.Second) 491 + defer parseCancel() 492 + 493 + atURI, err := s.blueskyService.ParseBlueskyURL(parseCtx, uri) 494 + if err != nil { 495 + log.Printf("[POST-CREATE] WARNING: Bluesky URL parsing failed for %s - falling back to external embed: %v", uri, err) 496 + // Return false to continue with external embed - don't fail the post creation 497 + return false 498 + } 499 + 500 + // Replace external embed with post embed 501 + postRecord.Embed = map[string]interface{}{ 502 + "$type": "social.coves.embed.post", 503 + "post": map[string]interface{}{ 504 + "uri": atURI, 505 + "cid": "", // Will be populated at resolution time 506 + }, 507 + } 508 + log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s", atURI) 509 + 510 + // Return true to signal that we successfully converted to post embed 511 + return true 512 + }
+18
internal/db/migrations/023_create_bluesky_post_cache.sql
··· 1 + -- +goose Up 2 + CREATE TABLE bluesky_post_cache ( 3 + at_uri TEXT PRIMARY KEY, 4 + metadata JSONB NOT NULL, 5 + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 6 + expires_at TIMESTAMPTZ NOT NULL 7 + ); 8 + 9 + CREATE INDEX idx_bluesky_post_cache_expires ON bluesky_post_cache(expires_at); 10 + 11 + COMMENT ON TABLE bluesky_post_cache IS 'Cache for Bluesky post data fetched from public.api.bsky.app'; 12 + COMMENT ON COLUMN bluesky_post_cache.at_uri IS 'AT-URI of the Bluesky post (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123)'; 13 + COMMENT ON COLUMN bluesky_post_cache.metadata IS 'Full BlueskyPostResult as JSON (text, author, stats, etc.)'; 14 + COMMENT ON COLUMN bluesky_post_cache.expires_at IS 'When this cache entry should be refetched (shorter TTL than unfurl since posts can be edited/deleted)'; 15 + 16 + -- +goose Down 17 + DROP INDEX IF EXISTS idx_bluesky_post_cache_expires; 18 + DROP TABLE IF EXISTS bluesky_post_cache;