A locally focused bluesky appview
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 75d5295fde853347d619d5c9eb5c40e8afe7b9a2 984 lines 24 kB view raw
1package main 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "log/slog" 9 "sync" 10 "time" 11 12 "github.com/bluesky-social/indigo/api/bsky" 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 xrpclib "github.com/bluesky-social/indigo/xrpc" 15 "github.com/labstack/echo/v4" 16 "github.com/labstack/echo/v4/middleware" 17 "github.com/labstack/gommon/log" 18 "github.com/whyrusleeping/market/models" 19) 20 21func (s *Server) runApiServer() error { 22 23 e := echo.New() 24 e.Use(middleware.CORS()) 25 e.GET("/debug", s.handleGetDebugInfo) 26 e.GET("/reldids", s.handleGetRelevantDids) 27 28 views := e.Group("/api") 29 views.GET("/me", s.handleGetMe) 30 views.GET("/notifications", s.handleGetNotifications) 31 views.GET("/profile/:account/post/:rkey", s.handleGetPost) 32 views.GET("/profile/:account", s.handleGetProfileView) 33 views.GET("/profile/:account/posts", s.handleGetProfilePosts) 34 views.GET("/followingfeed", s.handleGetFollowingFeed) 35 views.GET("/thread/:postid", s.handleGetThread) 36 views.GET("/post/:postid/likes", s.handleGetPostLikes) 37 views.GET("/post/:postid/reposts", s.handleGetPostReposts) 38 views.GET("/post/:postid/replies", s.handleGetPostReplies) 39 views.POST("/createRecord", s.handleCreateRecord) 40 41 return e.Start(":4444") 42} 43 44func (s *Server) handleGetDebugInfo(e echo.Context) error { 45 s.seqLk.Lock() 46 seq := s.lastSeq 47 s.seqLk.Unlock() 48 49 return e.JSON(200, map[string]any{ 50 "seq": seq, 51 }) 52} 53 54func (s *Server) handleGetRelevantDids(e echo.Context) error { 55 return e.JSON(200, map[string]any{ 56 "dids": s.backend.relevantDids, 57 }) 58} 59 60func (s *Server) handleGetMe(e echo.Context) error { 61 ctx := e.Request().Context() 62 63 resp, err := s.dir.LookupDID(ctx, syntax.DID(s.mydid)) 64 if err != nil { 65 return e.JSON(500, map[string]any{ 66 "error": "failed to lookup handle", 67 }) 68 } 69 70 return e.JSON(200, map[string]any{ 71 "did": s.mydid, 72 "handle": resp.Handle.String(), 73 }) 74} 75 76func (s *Server) handleGetPost(e echo.Context) error { 77 ctx := e.Request().Context() 78 79 account := e.Param("account") 80 rkey := e.Param("rkey") 81 82 did, err := s.resolveAccountIdent(ctx, account) 83 if err != nil { 84 return err 85 } 86 87 postUri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 88 89 p, err := s.backend.getPostByUri(ctx, postUri, "*") 90 if err != nil { 91 return err 92 } 93 94 if p.Raw == nil { 95 return e.JSON(404, map[string]any{ 96 "error": "missing post", 97 }) 98 } 99 100 var fp bsky.FeedPost 101 if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err != nil { 102 return nil 103 } 104 105 return e.JSON(200, fp) 106} 107 108func (s *Server) handleGetProfileView(e echo.Context) error { 109 ctx := e.Request().Context() 110 111 account := e.Param("account") 112 113 accdid, err := s.resolveAccountIdent(ctx, account) 114 if err != nil { 115 return err 116 } 117 118 r, err := s.backend.getOrCreateRepo(ctx, accdid) 119 if err != nil { 120 return err 121 } 122 123 var profile models.Profile 124 if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 125 return err 126 } 127 128 if profile.Raw == nil || len(profile.Raw) == 0 { 129 s.addMissingProfile(ctx, accdid) 130 return e.JSON(404, map[string]any{ 131 "error": "missing profile info for user", 132 }) 133 } 134 135 var prof bsky.ActorProfile 136 if err := prof.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err != nil { 137 return err 138 } 139 140 return e.JSON(200, prof) 141} 142 143func (s *Server) handleGetProfilePosts(e echo.Context) error { 144 ctx := e.Request().Context() 145 146 account := e.Param("account") 147 148 accdid, err := s.resolveAccountIdent(ctx, account) 149 if err != nil { 150 return err 151 } 152 153 r, err := s.backend.getOrCreateRepo(ctx, accdid) 154 if err != nil { 155 return err 156 } 157 158 // Get cursor from query parameter (timestamp in RFC3339 format) 159 cursor := e.QueryParam("cursor") 160 limit := 50 161 162 tcursor := time.Now() 163 if cursor != "" { 164 t, err := time.Parse(time.RFC3339, cursor) 165 if err != nil { 166 return fmt.Errorf("invalid cursor: %w", err) 167 } 168 tcursor = t 169 } 170 171 var dbposts []models.Post 172 if err := s.backend.db.Raw("SELECT * FROM posts WHERE author = ? AND created < ? ORDER BY created DESC LIMIT ?", r.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 173 return err 174 } 175 176 posts := s.hydratePosts(ctx, dbposts) 177 178 // Generate next cursor from the last post's timestamp 179 var nextCursor string 180 if len(dbposts) > 0 { 181 nextCursor = dbposts[len(dbposts)-1].Created.Format(time.RFC3339) 182 } 183 184 return e.JSON(200, map[string]any{ 185 "posts": posts, 186 "cursor": nextCursor, 187 }) 188} 189 190type postCounts struct { 191 Likes int `json:"likes"` 192 Reposts int `json:"reposts"` 193 Replies int `json:"replies"` 194} 195 196type embedRecordView struct { 197 Type string `json:"$type"` 198 Uri string `json:"uri"` 199 Cid string `json:"cid"` 200 Author *authorInfo `json:"author,omitempty"` 201 Value *bsky.FeedPost `json:"value,omitempty"` 202} 203 204type viewerLike struct { 205 Uri string `json:"uri"` 206 Cid string `json:"cid"` 207} 208 209type postResponse struct { 210 Missing bool `json:"missing"` 211 Uri string `json:"uri"` 212 Cid string `json:"cid"` 213 Post *feedPostView `json:"post"` 214 AuthorInfo *authorInfo `json:"author"` 215 Counts *postCounts `json:"counts"` 216 ViewerLike *viewerLike `json:"viewerLike,omitempty"` 217 218 ID uint `json:"id"` 219 ReplyTo uint `json:"replyTo,omitempty"` 220 ReplyToUsr uint `json:"replyToUsr,omitempty"` 221 InThread uint `json:"inThread,omitempty"` 222} 223 224type feedPostView struct { 225 Type string `json:"$type"` 226 CreatedAt string `json:"createdAt"` 227 Langs []string `json:"langs,omitempty"` 228 Text string `json:"text"` 229 Facets interface{} `json:"facets,omitempty"` 230 Embed interface{} `json:"embed,omitempty"` 231} 232 233type authorInfo struct { 234 Handle string `json:"handle"` 235 Did string `json:"did"` 236 Profile *bsky.ActorProfile `json:"profile"` 237} 238 239func (s *Server) handleGetFollowingFeed(e echo.Context) error { 240 ctx := e.Request().Context() 241 242 myr, err := s.backend.getOrCreateRepo(ctx, s.mydid) 243 if err != nil { 244 return err 245 } 246 247 // Get cursor from query parameter (timestamp in RFC3339 format) 248 cursor := e.QueryParam("cursor") 249 limit := 20 250 251 tcursor := time.Now() 252 if cursor != "" { 253 t, err := time.Parse(time.RFC3339, cursor) 254 if err != nil { 255 return fmt.Errorf("invalid cursor: %w", err) 256 } 257 tcursor = t 258 } 259 var dbposts []models.Post 260 if err := s.backend.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) AND created < ? order by created DESC limit ?", myr.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 261 return err 262 } 263 264 posts := s.hydratePosts(ctx, dbposts) 265 266 // Generate next cursor from the last post's timestamp 267 var nextCursor string 268 if len(dbposts) > 0 { 269 nextCursor = dbposts[len(dbposts)-1].Created.Format(time.RFC3339) 270 } 271 272 return e.JSON(200, map[string]any{ 273 "posts": posts, 274 "cursor": nextCursor, 275 }) 276} 277 278func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) { 279 var profile models.Profile 280 if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 281 return nil, err 282 } 283 284 resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 285 if err != nil { 286 return nil, err 287 } 288 289 if profile.Raw == nil || len(profile.Raw) == 0 { 290 s.addMissingProfile(ctx, r.Did) 291 return &authorInfo{ 292 Handle: resp.Handle.String(), 293 Did: r.Did, 294 }, nil 295 } 296 297 var prof bsky.ActorProfile 298 if err := prof.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err != nil { 299 return nil, err 300 } 301 302 return &authorInfo{ 303 Handle: resp.Handle.String(), 304 Did: r.Did, 305 Profile: &prof, 306 }, nil 307} 308 309func (s *Server) getPostCounts(ctx context.Context, pid uint) (*postCounts, error) { 310 var pc postCounts 311 var wg sync.WaitGroup 312 313 wg.Add(3) 314 315 go func() { 316 defer wg.Done() 317 if err := s.backend.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil { 318 slog.Error("failed to get likes count", "post", pid, "error", err) 319 } 320 }() 321 322 go func() { 323 defer wg.Done() 324 if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil { 325 slog.Error("failed to get reposts count", "post", pid, "error", err) 326 } 327 }() 328 329 go func() { 330 defer wg.Done() 331 if err := s.backend.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil { 332 slog.Error("failed to get replies count", "post", pid, "error", err) 333 } 334 }() 335 336 wg.Wait() 337 338 return &pc, nil 339} 340 341func (s *Server) hydratePosts(ctx context.Context, dbposts []models.Post) []postResponse { 342 posts := make([]postResponse, len(dbposts)) 343 var wg sync.WaitGroup 344 345 for i := range dbposts { 346 wg.Add(1) 347 go func(ix int) { 348 defer wg.Done() 349 p := dbposts[ix] 350 r, err := s.backend.getRepoByID(ctx, p.Author) 351 if err != nil { 352 fmt.Println("failed to get repo: ", err) 353 posts[ix] = postResponse{ 354 Uri: "", 355 Missing: true, 356 } 357 return 358 } 359 360 uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey) 361 if len(p.Raw) == 0 || p.NotFound { 362 s.addMissingPost(ctx, uri) 363 posts[ix] = postResponse{ 364 Uri: uri, 365 Missing: true, 366 } 367 return 368 } 369 370 var fp bsky.FeedPost 371 if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err != nil { 372 log.Warn("failed to unmarshal post", "uri", uri, "error", err) 373 posts[ix] = postResponse{ 374 Uri: uri, 375 Missing: true, 376 } 377 return 378 } 379 380 author, err := s.getAuthorInfo(ctx, r) 381 if err != nil { 382 slog.Error("failed to load author info for post", "error", err) 383 } 384 385 counts, err := s.getPostCounts(ctx, p.ID) 386 if err != nil { 387 slog.Error("failed to get counts for post", "post", p.ID, "error", err) 388 } 389 390 // Build post view with hydrated embeds 391 postView := s.buildPostView(ctx, &fp) 392 393 viewerLike := s.checkViewerLike(ctx, p.ID) 394 395 posts[ix] = postResponse{ 396 Uri: uri, 397 Cid: p.Cid, 398 Post: postView, 399 AuthorInfo: author, 400 Counts: counts, 401 ID: p.ID, 402 ReplyTo: p.ReplyTo, 403 ReplyToUsr: p.ReplyToUsr, 404 InThread: p.InThread, 405 406 ViewerLike: viewerLike, 407 } 408 }(i) 409 } 410 411 wg.Wait() 412 413 return posts 414} 415 416func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike { 417 var like Like 418 if err := s.backend.db.Raw("SELECT * FROM likes WHERE subject = ? AND author = ?", pid, s.myrepo.ID).Scan(&like).Error; err != nil { 419 slog.Error("failed to lookup like", "error", err) 420 return nil 421 } 422 423 if like.ID == 0 { 424 return nil 425 } 426 427 uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", s.myrepo.Did, like.Rkey) 428 429 return &viewerLike{ 430 Uri: uri, 431 Cid: like.Cid, 432 } 433} 434 435func (s *Server) buildPostView(ctx context.Context, fp *bsky.FeedPost) *feedPostView { 436 view := &feedPostView{ 437 Type: fp.LexiconTypeID, 438 CreatedAt: fp.CreatedAt, 439 Text: fp.Text, 440 Facets: fp.Facets, 441 } 442 443 if fp.Langs != nil { 444 view.Langs = fp.Langs 445 } 446 447 if fp.Embed != nil { 448 view.Embed = s.hydrateEmbed(ctx, fp.Embed) 449 } 450 451 return view 452} 453 454func (s *Server) hydrateEmbed(ctx context.Context, embed *bsky.FeedPost_Embed) interface{} { 455 switch { 456 case embed.EmbedImages != nil: 457 return embed.EmbedImages 458 case embed.EmbedExternal != nil: 459 return embed.EmbedExternal 460 case embed.EmbedRecord != nil: 461 return s.hydrateQuotedPost(ctx, embed.EmbedRecord) 462 case embed.EmbedRecordWithMedia != nil: 463 return s.hydrateRecordWithMedia(ctx, embed.EmbedRecordWithMedia) 464 default: 465 return nil 466 } 467} 468 469func (s *Server) hydrateRecordWithMedia(ctx context.Context, rwm *bsky.EmbedRecordWithMedia) interface{} { 470 result := map[string]interface{}{ 471 "$type": "app.bsky.embed.recordWithMedia", 472 } 473 474 // Hydrate media 475 if rwm.Media != nil { 476 if rwm.Media.EmbedImages != nil { 477 result["media"] = rwm.Media.EmbedImages 478 } else if rwm.Media.EmbedExternal != nil { 479 result["media"] = rwm.Media.EmbedExternal 480 } 481 } 482 483 // Hydrate record 484 if rwm.Record != nil { 485 result["record"] = s.hydrateQuotedPost(ctx, rwm.Record) 486 } 487 488 return result 489} 490 491func (s *Server) hydrateQuotedPost(ctx context.Context, embedRecord *bsky.EmbedRecord) interface{} { 492 quotedURI := embedRecord.Record.Uri 493 quotedCid := embedRecord.Record.Cid 494 495 quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*") 496 if err != nil { 497 slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err) 498 s.addMissingPost(ctx, quotedURI) 499 return s.buildQuoteFallback(quotedURI, quotedCid) 500 } 501 502 if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound { 503 s.addMissingPost(ctx, quotedURI) 504 return s.buildQuoteFallback(quotedURI, quotedCid) 505 } 506 507 var quotedFP bsky.FeedPost 508 if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err != nil { 509 slog.Warn("failed to unmarshal quoted post", "error", err) 510 return s.buildQuoteFallback(quotedURI, quotedCid) 511 } 512 513 quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author) 514 if err != nil { 515 slog.Warn("failed to get quoted post author", "error", err) 516 return s.buildQuoteFallback(quotedURI, quotedCid) 517 } 518 519 quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo) 520 if err != nil { 521 slog.Warn("failed to get quoted post author info", "error", err) 522 return s.buildQuoteFallback(quotedURI, quotedCid) 523 } 524 525 return map[string]interface{}{ 526 "$type": "app.bsky.embed.record", 527 "record": &embedRecordView{ 528 Type: "app.bsky.embed.record#viewRecord", 529 Uri: quotedURI, 530 Cid: quotedCid, 531 Author: quotedAuthor, 532 Value: &quotedFP, 533 }, 534 } 535} 536 537func (s *Server) buildQuoteFallback(uri, cid string) map[string]interface{} { 538 return map[string]interface{}{ 539 "$type": "app.bsky.embed.record", 540 "record": map[string]interface{}{ 541 "uri": uri, 542 "cid": cid, 543 }, 544 } 545} 546 547func (s *Server) handleGetThread(e echo.Context) error { 548 ctx := e.Request().Context() 549 550 postIDStr := e.Param("postid") 551 var postID uint 552 if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 553 return e.JSON(400, map[string]any{ 554 "error": "invalid post ID", 555 }) 556 } 557 558 // Get the requested post to find the thread root 559 var requestedPost models.Post 560 if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil { 561 return err 562 } 563 564 if requestedPost.ID == 0 { 565 return e.JSON(404, map[string]any{ 566 "error": "post not found", 567 }) 568 } 569 570 // Determine the root post ID 571 rootPostID := postID 572 if requestedPost.InThread != 0 { 573 rootPostID = requestedPost.InThread 574 } 575 576 // Get all posts in this thread 577 var dbposts []models.Post 578 query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC" 579 if err := s.backend.db.Raw(query, rootPostID, rootPostID).Scan(&dbposts).Error; err != nil { 580 return err 581 } 582 583 // Build response for each post 584 posts := []postResponse{} 585 for _, p := range dbposts { 586 r, err := s.backend.getRepoByID(ctx, p.Author) 587 if err != nil { 588 return err 589 } 590 591 uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey) 592 if len(p.Raw) == 0 || p.NotFound { 593 posts = append(posts, postResponse{ 594 Uri: uri, 595 Missing: true, 596 ReplyTo: p.ReplyTo, 597 ReplyToUsr: p.ReplyToUsr, 598 InThread: p.InThread, 599 }) 600 continue 601 } 602 603 var fp bsky.FeedPost 604 if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err != nil { 605 return err 606 } 607 608 author, err := s.getAuthorInfo(ctx, r) 609 if err != nil { 610 slog.Error("failed to load author info for post", "error", err) 611 } 612 613 counts, err := s.getPostCounts(ctx, p.ID) 614 if err != nil { 615 slog.Error("failed to get counts for post", "post", p.ID, "error", err) 616 } 617 618 // Build post view with hydrated embeds 619 postView := s.buildPostView(ctx, &fp) 620 621 posts = append(posts, postResponse{ 622 Uri: uri, 623 Cid: p.Cid, 624 Post: postView, 625 AuthorInfo: author, 626 Counts: counts, 627 ID: p.ID, 628 ReplyTo: p.ReplyTo, 629 ReplyToUsr: p.ReplyToUsr, 630 InThread: p.InThread, 631 }) 632 } 633 634 return e.JSON(200, map[string]any{ 635 "posts": posts, 636 "rootPostId": rootPostID, 637 }) 638} 639 640type engagementUser struct { 641 Handle string `json:"handle"` 642 Did string `json:"did"` 643 Profile *bsky.ActorProfile `json:"profile,omitempty"` 644 Time string `json:"time"` 645} 646 647func (s *Server) handleGetPostLikes(e echo.Context) error { 648 ctx := e.Request().Context() 649 650 postIDStr := e.Param("postid") 651 var postID uint 652 if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 653 return e.JSON(400, map[string]any{ 654 "error": "invalid post ID", 655 }) 656 } 657 658 // Get all likes for this post 659 var likes []models.Like 660 if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil { 661 return err 662 } 663 664 users := []engagementUser{} 665 for _, like := range likes { 666 r, err := s.backend.getRepoByID(ctx, like.Author) 667 if err != nil { 668 slog.Error("failed to get repo for like author", "error", err) 669 continue 670 } 671 672 // Look up handle 673 resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 674 if err != nil { 675 slog.Error("failed to lookup DID", "did", r.Did, "error", err) 676 continue 677 } 678 679 // Get profile if available 680 var profile models.Profile 681 s.backend.db.Find(&profile, "repo = ?", r.ID) 682 683 var prof *bsky.ActorProfile 684 if len(profile.Raw) > 0 { 685 var p bsky.ActorProfile 686 if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil { 687 prof = &p 688 } 689 } else { 690 s.addMissingProfile(ctx, r.Did) 691 } 692 693 users = append(users, engagementUser{ 694 Handle: resp.Handle.String(), 695 Did: r.Did, 696 Profile: prof, 697 Time: like.Created.Format("2006-01-02T15:04:05Z"), 698 }) 699 } 700 701 return e.JSON(200, map[string]any{ 702 "users": users, 703 "count": len(users), 704 }) 705} 706 707func (s *Server) handleGetPostReposts(e echo.Context) error { 708 ctx := e.Request().Context() 709 710 postIDStr := e.Param("postid") 711 var postID uint 712 if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 713 return e.JSON(400, map[string]any{ 714 "error": "invalid post ID", 715 }) 716 } 717 718 // Get all reposts for this post 719 var reposts []models.Repost 720 if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil { 721 return err 722 } 723 724 users := []engagementUser{} 725 for _, repost := range reposts { 726 r, err := s.backend.getRepoByID(ctx, repost.Author) 727 if err != nil { 728 slog.Error("failed to get repo for repost author", "error", err) 729 continue 730 } 731 732 // Look up handle 733 resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 734 if err != nil { 735 slog.Error("failed to lookup DID", "did", r.Did, "error", err) 736 continue 737 } 738 739 // Get profile if available 740 var profile models.Profile 741 s.backend.db.Find(&profile, "repo = ?", r.ID) 742 743 var prof *bsky.ActorProfile 744 if len(profile.Raw) > 0 { 745 var p bsky.ActorProfile 746 if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil { 747 prof = &p 748 } 749 } else { 750 s.addMissingProfile(ctx, r.Did) 751 } 752 753 users = append(users, engagementUser{ 754 Handle: resp.Handle.String(), 755 Did: r.Did, 756 Profile: prof, 757 Time: repost.Created.Format("2006-01-02T15:04:05Z"), 758 }) 759 } 760 761 return e.JSON(200, map[string]any{ 762 "users": users, 763 "count": len(users), 764 }) 765} 766 767func (s *Server) handleGetPostReplies(e echo.Context) error { 768 ctx := e.Request().Context() 769 770 postIDStr := e.Param("postid") 771 var postID uint 772 if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 773 return e.JSON(400, map[string]any{ 774 "error": "invalid post ID", 775 }) 776 } 777 778 // Get all replies to this post 779 var replies []models.Post 780 if err := s.backend.db.Find(&replies, "reply_to = ?", postID).Error; err != nil { 781 return err 782 } 783 784 users := []engagementUser{} 785 seen := make(map[uint]bool) // Track unique authors 786 787 for _, reply := range replies { 788 // Skip if we've already added this author 789 if seen[reply.Author] { 790 continue 791 } 792 seen[reply.Author] = true 793 794 r, err := s.backend.getRepoByID(ctx, reply.Author) 795 if err != nil { 796 slog.Error("failed to get repo for reply author", "error", err) 797 continue 798 } 799 800 // Look up handle 801 resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 802 if err != nil { 803 slog.Error("failed to lookup DID", "did", r.Did, "error", err) 804 continue 805 } 806 807 // Get profile if available 808 var profile models.Profile 809 s.backend.db.Find(&profile, "repo = ?", r.ID) 810 811 var prof *bsky.ActorProfile 812 if len(profile.Raw) > 0 { 813 var p bsky.ActorProfile 814 if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil { 815 prof = &p 816 } 817 } else { 818 s.addMissingProfile(ctx, r.Did) 819 } 820 821 users = append(users, engagementUser{ 822 Handle: resp.Handle.String(), 823 Did: r.Did, 824 Profile: prof, 825 Time: reply.Created.Format("2006-01-02T15:04:05Z"), 826 }) 827 } 828 829 return e.JSON(200, map[string]any{ 830 "users": users, 831 "count": len(users), 832 }) 833} 834 835type createRecordRequest struct { 836 Collection string `json:"collection"` 837 Record map[string]any `json:"record"` 838} 839 840type createRecordResponse struct { 841 Uri string `json:"uri"` 842 Cid string `json:"cid"` 843} 844 845func (s *Server) handleCreateRecord(e echo.Context) error { 846 ctx := e.Request().Context() 847 848 var req createRecordRequest 849 if err := e.Bind(&req); err != nil { 850 return e.JSON(400, map[string]any{ 851 "error": "invalid request", 852 }) 853 } 854 855 // Marshal the record to JSON for XRPC 856 recordBytes, err := json.Marshal(req.Record) 857 if err != nil { 858 slog.Error("failed to marshal record", "error", err) 859 return e.JSON(400, map[string]any{ 860 "error": "invalid record", 861 }) 862 } 863 864 // Create the input for the repo.createRecord call 865 input := map[string]any{ 866 "repo": s.mydid, 867 "collection": req.Collection, 868 "record": json.RawMessage(recordBytes), 869 } 870 871 var resp createRecordResponse 872 if err := s.client.Do(ctx, xrpclib.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil { 873 slog.Error("failed to create record", "error", err) 874 return e.JSON(500, map[string]any{ 875 "error": "failed to create record", 876 "details": err.Error(), 877 }) 878 } 879 880 return e.JSON(200, resp) 881} 882 883type notificationResponse struct { 884 ID uint `json:"id"` 885 Kind string `json:"kind"` 886 Author *authorInfo `json:"author"` 887 Source string `json:"source"` 888 SourcePost *struct { 889 Text string `json:"text"` 890 Uri string `json:"uri"` 891 } `json:"sourcePost,omitempty"` 892 CreatedAt string `json:"createdAt"` 893} 894 895func (s *Server) handleGetNotifications(e echo.Context) error { 896 ctx := e.Request().Context() 897 898 // Get cursor from query parameter (notification ID) 899 cursor := e.QueryParam("cursor") 900 limit := 50 901 902 var cursorID uint 903 if cursor != "" { 904 if _, err := fmt.Sscanf(cursor, "%d", &cursorID); err != nil { 905 return e.JSON(400, map[string]any{ 906 "error": "invalid cursor", 907 }) 908 } 909 } 910 911 // Query notifications 912 var notifications []Notification 913 query := `SELECT * FROM notifications WHERE "for" = ?` 914 if cursorID > 0 { 915 query += ` AND id < ?` 916 if err := s.backend.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, cursorID, limit).Scan(&notifications).Error; err != nil { 917 return err 918 } 919 } else { 920 if err := s.backend.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, limit).Scan(&notifications).Error; err != nil { 921 return err 922 } 923 } 924 925 // Hydrate notifications 926 results := []notificationResponse{} 927 for _, notif := range notifications { 928 // Get author info 929 author, err := s.backend.getRepoByID(ctx, notif.Author) 930 if err != nil { 931 slog.Error("failed to get repo for notification author", "error", err) 932 continue 933 } 934 935 authorInfo, err := s.getAuthorInfo(ctx, author) 936 if err != nil { 937 slog.Error("failed to get author info", "error", err) 938 continue 939 } 940 941 resp := notificationResponse{ 942 ID: notif.ID, 943 Kind: notif.Kind, 944 Author: authorInfo, 945 Source: notif.Source, 946 CreatedAt: notif.CreatedAt.Format(time.RFC3339), 947 } 948 949 // Try to get source post preview for reply/mention notifications 950 if notif.Kind == NotifKindReply || notif.Kind == NotifKindMention { 951 // Parse URI to get post 952 p, err := s.backend.getPostByUri(ctx, notif.Source, "*") 953 if err == nil && p.Raw != nil && len(p.Raw) > 0 { 954 var fp bsky.FeedPost 955 if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err == nil { 956 preview := fp.Text 957 if len(preview) > 100 { 958 preview = preview[:100] + "..." 959 } 960 resp.SourcePost = &struct { 961 Text string `json:"text"` 962 Uri string `json:"uri"` 963 }{ 964 Text: preview, 965 Uri: notif.Source, 966 } 967 } 968 } 969 } 970 971 results = append(results, resp) 972 } 973 974 // Generate next cursor 975 var nextCursor string 976 if len(notifications) > 0 { 977 nextCursor = fmt.Sprintf("%d", notifications[len(notifications)-1].ID) 978 } 979 980 return e.JSON(200, map[string]any{ 981 "notifications": results, 982 "cursor": nextCursor, 983 }) 984}