Federated via graft
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 := 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}