A locally focused bluesky appview
at master 263 lines 6.8 kB view raw
1package hydration 2 3import ( 4 "bytes" 5 "context" 6 "fmt" 7 "log/slog" 8 "strings" 9 "sync" 10 11 "github.com/bluesky-social/indigo/api/bsky" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "github.com/whyrusleeping/market/models" 14) 15 16// ActorInfo contains hydrated actor information 17type ActorInfo struct { 18 DID string 19 Handle string 20 Profile *bsky.ActorProfile 21} 22 23// HydrateActor hydrates full actor information 24func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) { 25 ctx, span := tracer.Start(ctx, "hydrateActor") 26 defer span.End() 27 28 // Look up handle 29 resp, err := h.dir.LookupDID(ctx, syntax.DID(did)) 30 if err != nil { 31 return nil, fmt.Errorf("failed to lookup DID: %w", err) 32 } 33 34 info := &ActorInfo{ 35 DID: did, 36 Handle: resp.Handle.String(), 37 } 38 39 // Load profile from database 40 var dbProfile struct { 41 Repo uint 42 Raw []byte 43 } 44 err = h.db.Raw("SELECT repo, raw FROM profiles WHERE repo = (SELECT id FROM repos WHERE did = ?)", did). 45 Scan(&dbProfile).Error 46 if err != nil { 47 slog.Error("failed to fetch user profile", "error", err) 48 } else { 49 if len(dbProfile.Raw) > 0 { 50 var profile bsky.ActorProfile 51 if err := profile.UnmarshalCBOR(bytes.NewReader(dbProfile.Raw)); err == nil { 52 info.Profile = &profile 53 } 54 } else { 55 h.addMissingActor(did) 56 } 57 } 58 59 return info, nil 60} 61 62type ActorInfoDetailed struct { 63 ActorInfo 64 FollowCount int64 65 FollowerCount int64 66 PostCount int64 67 ViewerState *bsky.ActorDefs_ViewerState 68} 69 70func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string, viewer string) (*ActorInfoDetailed, error) { 71 act, err := h.HydrateActor(ctx, did) 72 if err != nil { 73 return nil, err 74 } 75 76 actd := ActorInfoDetailed{ 77 ActorInfo: *act, 78 } 79 80 var wg sync.WaitGroup 81 wg.Go(func() { 82 c, err := h.getFollowCountForUser(ctx, did) 83 if err != nil { 84 slog.Error("failed to get follow count", "did", did, "error", err) 85 } 86 actd.FollowCount = c 87 }) 88 wg.Go(func() { 89 c, err := h.getFollowerCountForUser(ctx, did) 90 if err != nil { 91 slog.Error("failed to get follower count", "did", did, "error", err) 92 } 93 actd.FollowerCount = c 94 }) 95 wg.Go(func() { 96 c, err := h.getPostCountForUser(ctx, did) 97 if err != nil { 98 slog.Error("failed to get post count", "did", did, "error", err) 99 } 100 actd.PostCount = c 101 }) 102 103 if viewer != "" { 104 wg.Go(func() { 105 vs, err := h.getProfileViewerState(ctx, did, viewer) 106 if err != nil { 107 slog.Error("failed to get viewer state", "did", did, "viewer", viewer, "error", err) 108 } 109 actd.ViewerState = vs 110 }) 111 } 112 113 wg.Wait() 114 115 return &actd, nil 116} 117 118func (h *Hydrator) getProfileViewerState(ctx context.Context, did, viewer string) (*bsky.ActorDefs_ViewerState, error) { 119 vs := &bsky.ActorDefs_ViewerState{} 120 121 var wg sync.WaitGroup 122 123 // Check if viewer is blocked by the target account 124 wg.Go(func() { 125 blockedBy, err := h.getBlockPair(ctx, did, viewer) 126 if err != nil { 127 slog.Error("failed to get blockedBy relationship", "did", did, "viewer", viewer, "error", err) 128 return 129 } 130 131 if blockedBy != nil { 132 v := true 133 vs.BlockedBy = &v 134 } 135 }) 136 137 // Check if viewer is blocking the target account 138 wg.Go(func() { 139 blocking, err := h.getBlockPair(ctx, viewer, did) 140 if err != nil { 141 slog.Error("failed to get blocking relationship", "did", did, "viewer", viewer, "error", err) 142 return 143 } 144 145 if blocking != nil { 146 uri := fmt.Sprintf("at://%s/app.bsky.graph.block/%s", viewer, blocking.Rkey) 147 vs.Blocking = &uri 148 } 149 }) 150 151 // Check if viewer is following the target account 152 wg.Go(func() { 153 following, err := h.getFollowPair(ctx, viewer, did) 154 if err != nil { 155 slog.Error("failed to get following relationship", "did", did, "viewer", viewer, "error", err) 156 return 157 } 158 159 if following != nil { 160 uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", viewer, following.Rkey) 161 vs.Following = &uri 162 } 163 }) 164 165 // Check if target account is following the viewer 166 wg.Go(func() { 167 followedBy, err := h.getFollowPair(ctx, did, viewer) 168 if err != nil { 169 slog.Error("failed to get followedBy relationship", "did", did, "viewer", viewer, "error", err) 170 return 171 } 172 173 if followedBy != nil { 174 uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", did, followedBy.Rkey) 175 vs.FollowedBy = &uri 176 } 177 }) 178 179 wg.Wait() 180 181 return vs, nil 182} 183 184func (h *Hydrator) getBlockPair(ctx context.Context, a, b string) (*models.Block, error) { 185 var blk models.Block 186 if err := h.db.Raw("SELECT * FROM blocks WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&blk).Error; err != nil { 187 return nil, err 188 } 189 if blk.ID == 0 { 190 return nil, nil 191 } 192 193 return &blk, nil 194} 195 196func (h *Hydrator) getFollowPair(ctx context.Context, a, b string) (*models.Follow, error) { 197 var fol models.Follow 198 if err := h.db.Raw("SELECT * FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&fol).Error; err != nil { 199 return nil, err 200 } 201 if fol.ID == 0 { 202 return nil, nil 203 } 204 205 return &fol, nil 206} 207 208func (h *Hydrator) getFollowCountForUser(ctx context.Context, did string) (int64, error) { 209 var count int64 210 if err := h.db.Raw("SELECT count(*) FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil { 211 return 0, err 212 } 213 214 return count, nil 215} 216 217func (h *Hydrator) getFollowerCountForUser(ctx context.Context, did string) (int64, error) { 218 var count int64 219 if err := h.db.Raw("SELECT count(*) FROM follows WHERE subject = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil { 220 return 0, err 221 } 222 223 return count, nil 224} 225 226func (h *Hydrator) getPostCountForUser(ctx context.Context, did string) (int64, error) { 227 var count int64 228 if err := h.db.Raw("SELECT count(*) FROM posts WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil { 229 return 0, err 230 } 231 232 return count, nil 233} 234 235// HydrateActors hydrates multiple actors 236func (h *Hydrator) HydrateActors(ctx context.Context, dids []string) (map[string]*ActorInfo, error) { 237 result := make(map[string]*ActorInfo, len(dids)) 238 for _, did := range dids { 239 info, err := h.HydrateActor(ctx, did) 240 if err != nil { 241 // Skip actors that fail to hydrate rather than failing the whole batch 242 continue 243 } 244 result[did] = info 245 } 246 return result, nil 247} 248 249// ResolveDID resolves a handle or DID to a DID 250func (h *Hydrator) ResolveDID(ctx context.Context, actor string) (string, error) { 251 // If it's already a DID, return it 252 if strings.HasPrefix(actor, "did:") { 253 return actor, nil 254 } 255 256 // Otherwise, resolve the handle 257 resp, err := h.dir.LookupHandle(ctx, syntax.Handle(actor)) 258 if err != nil { 259 return "", fmt.Errorf("failed to resolve handle: %w", err) 260 } 261 262 return resp.DID.String(), nil 263}