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