A locally focused bluesky appview
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: "edFP,
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(¬ifications).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(¬ifications).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}