Federated via graft
0

Configure Feed

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

graft-source / cmd / sync / bluesky_replies.go
4.2 kB 136 lines
1package main 2 3import ( 4 "fmt" 5 "log/slog" 6 "strings" 7 8 "graft/internal/atproto" 9 "graft/internal/config" 10 "graft/internal/state" 11) 12 13// pollBlueskyReplies checks every series with a Bluesky account configured 14// for new replies to posts graft itself made, and bridges each one back 15// onto the underlying Forgejo/Radicle item — the AT Proto equivalent of 16// activitypub's handleReply. 17// 18// NOT verified against a live Bluesky account: none was configured in 19// this deployment at the time this was written (see BlueskyTarget in 20// config.go — a nil block is a no-op, so this simply never runs until 21// one exists). Built directly from AT Proto's published 22// app.bsky.notification.listNotifications / app.bsky.feed.post lexicon. 23// Verify against a real account and a real reply before trusting this 24// deeply — see atproto.Notification's doc comment. 25func pollBlueskyReplies(pairs []config.RepoPair, live *liveState, st *state.Store, log *slog.Logger) { 26 seenSeries := map[string]bool{} 27 for _, pair := range pairs { 28 if pair.Bluesky == nil { 29 continue 30 } 31 series := pair.Series 32 if series == "" { 33 series = pair.Name 34 } 35 if seenSeries[series] { 36 continue 37 } 38 seenSeries[series] = true 39 40 appPassword, err := config.ReadToken(pair.Bluesky.AppPasswordFile) 41 if err != nil { 42 log.Error("bluesky reply poll: read app password", "series", series, "err", err) 43 continue 44 } 45 if err := pollSeriesReplies(series, pair.Bluesky.Handle, appPassword, pair.Bluesky.PDSHost, live, st, log); err != nil { 46 log.Error("bluesky reply poll", "series", series, "err", err) 47 } 48 } 49} 50 51func pollSeriesReplies(series, handle, appPassword, pdsHost string, live *liveState, st *state.Store, log *slog.Logger) error { 52 c := atproto.New(pdsHost) 53 if err := c.Login(handle, appPassword); err != nil { 54 return fmt.Errorf("login: %w", err) 55 } 56 notifications, err := c.ListNotifications() 57 if err != nil { 58 return fmt.Errorf("list notifications: %w", err) 59 } 60 61 watermark, err := st.ATProtoCursor(series) 62 if err != nil { 63 return fmt.Errorf("read cursor: %w", err) 64 } 65 newest := watermark 66 67 // Newest first per the API's convention; process oldest-first so the 68 // watermark advances monotonically even if this loop is interrupted 69 // partway through. 70 for i := len(notifications) - 1; i >= 0; i-- { 71 n := notifications[i] 72 if n.Reason != "reply" || n.Record.Reply == nil { 73 continue 74 } 75 if watermark != "" && n.IndexedAt <= watermark { 76 continue 77 } 78 if n.IndexedAt > newest { 79 newest = n.IndexedAt 80 } 81 82 parentURI := n.Record.Reply.Parent.URI 83 activityID, found, err := st.ATProtoPostActivity(parentURI) 84 if err != nil { 85 log.Error("bluesky reply: lookup parent", "series", series, "err", err) 86 continue 87 } 88 if !found { 89 continue // a reply to something graft didn't post about, or to a reply itself 90 } 91 e, err := st.ActivityByID(activityID) 92 if err != nil { 93 log.Error("bluesky reply: load activity", "series", series, "id", activityID, "err", err) 94 continue 95 } 96 if e == nil || (e.Kind != "issue" && e.Kind != "patch") { 97 continue 98 } 99 rs, ok := live.syncerForPair(e.RepoPair) 100 if !ok { 101 continue 102 } 103 104 body := state.MarkMirrored(fmt.Sprintf("**via Bluesky, @%s:**\n\n%s", n.Author.Handle, strings.TrimSpace(n.Record.Text))) 105 if err := rs.CommentOnItem(e.Kind, e.ForgejoID, e.RadicleID, body); err != nil { 106 log.Error("bluesky reply: post comment", "series", series, "repo_pair", e.RepoPair, "err", err) 107 continue 108 } 109 if _, err := st.LogActivity(state.Activity{ 110 RepoPair: e.RepoPair, Series: e.Series, SeriesURL: e.SeriesURL, 111 Kind: "comment", Direction: e.Direction, Origin: "atproto", 112 Summary: truncateATProtoSummary(n.Record.Text), 113 URL: e.URL, 114 ForgejoID: e.ForgejoID, RadicleID: e.RadicleID, 115 }); err != nil { 116 log.Error("bluesky reply: log activity", "series", series, "err", err) 117 } 118 } 119 120 if newest != watermark { 121 if err := st.SetATProtoCursor(series, newest); err != nil { 122 return fmt.Errorf("save cursor: %w", err) 123 } 124 } 125 return nil 126} 127 128func truncateATProtoSummary(s string) string { 129 s = strings.TrimSpace(s) 130 const max = 200 131 r := []rune(s) 132 if len(r) <= max { 133 return s 134 } 135 return string(r[:max-1]) + "…" 136}