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
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: "edFP,
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(¬ifications).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(¬ifications).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}