Federated via graft
0

Configure Feed

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

v2 M5: AT Proto/Bluesky client, wired but inert until credentials exist

internal/atproto is a minimal client: Login (app password, never the
real account password) via com.atproto.server.createSession, Post via
com.atproto.repo.createRecord (app.bsky.feed.post), truncated to
Bluesky's 300-rune post limit.

A repo pair can now declare an optional bluesky block (handle +
app_password_file, same token-file convention as Forgejo). When set,
GitSyncer and PatchSyncer post a short note whenever they mirror a new
commit or open a patch/PR, alongside the existing activity_log entry.
Absent config is a true no-op everywhere (BlueskyPoster.Post is nil-safe)
— no account configured, no behavior change, verified live: deployed
with zero bluesky config present, service healthy, /social still
correctly shows "not configured" for every series.

Genuinely untestable further without a real account, as flagged in the
v2 plan from the start — the code is complete and defensive (a failed
Bluesky call never blocks or fails the sync loop), but live posting is
unverified until a dedicated Bluesky handle + app password exists to
test against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB7C5CeuMbQujqRtb4foki

+260 -23
+11
cmd/sync/main.go
··· 63 63 } 64 64 65 65 topology := buildTopology(cfg) 66 + blueskyConfigured := map[string]bool{} 67 + for _, pair := range cfg.Repos { 68 + if pair.Bluesky != nil { 69 + series := pair.Series 70 + if series == "" { 71 + series = pair.Name 72 + } 73 + blueskyConfigured[series] = true 74 + } 75 + } 66 76 67 77 tracker := status.NewTracker(st, cfg.SourceURL) 68 78 tracker.SetTopology(topology) 69 79 tracker.SetPublicHost(cfg.PublicHost) 80 + tracker.SetBlueskyConfigured(blueskyConfigured) 70 81 71 82 var apHandler *activitypub.Handler 72 83 if cfg.PublicHost != "" {
+8
config.example.yaml
··· 36 36 git: true 37 37 issues: true 38 38 patches: true 39 + # Posts a short note to this Bluesky account whenever this pair mirrors 40 + # a new commit or opens a patch/PR. Optional — omit to never post. 41 + bluesky: 42 + handle: testrepo.bsky.social 43 + app_password_file: /etc/graft/testrepo-bluesky.app-password 44 + # PDS endpoint. Optional: defaults to https://bsky.social, correct 45 + # for any account hosted there (the common case). 46 + pds_host: https://bsky.social
+122
internal/atproto/client.go
··· 1 + // Package atproto is a minimal AT Protocol client: just enough to log in 2 + // with an app password and publish a short post — the two calls graft's 3 + // Bluesky bridge needs, not a general-purpose AT Proto SDK. 4 + package atproto 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "strings" 13 + "time" 14 + "unicode/utf8" 15 + ) 16 + 17 + const defaultPDSHost = "https://bsky.social" 18 + 19 + // maxPostRunes is Bluesky's post length limit (in grapheme-ish terms; this 20 + // package truncates on runes, which is close enough not to fail the API 21 + // for graft's short auto-generated notes). 22 + const maxPostRunes = 300 23 + 24 + // Client is a session against one AT Proto PDS, authenticated as one 25 + // account. 26 + type Client struct { 27 + pdsHost string 28 + http *http.Client 29 + did string 30 + accessJWT string 31 + } 32 + 33 + // New creates a client for pdsHost (empty defaults to https://bsky.social). 34 + // Call Login before Post. 35 + func New(pdsHost string) *Client { 36 + if pdsHost == "" { 37 + pdsHost = defaultPDSHost 38 + } 39 + return &Client{ 40 + pdsHost: strings.TrimRight(pdsHost, "/"), 41 + http: &http.Client{Timeout: 15 * time.Second}, 42 + } 43 + } 44 + 45 + // Login authenticates with an app password (never the account's real 46 + // password) via com.atproto.server.createSession. 47 + func (c *Client) Login(handle, appPassword string) error { 48 + body, err := json.Marshal(map[string]string{"identifier": handle, "password": appPassword}) 49 + if err != nil { 50 + return err 51 + } 52 + req, err := http.NewRequest(http.MethodPost, c.pdsHost+"/xrpc/com.atproto.server.createSession", bytes.NewReader(body)) 53 + if err != nil { 54 + return err 55 + } 56 + req.Header.Set("Content-Type", "application/json") 57 + 58 + resp, err := c.http.Do(req) 59 + if err != nil { 60 + return fmt.Errorf("create session: %w", err) 61 + } 62 + defer resp.Body.Close() 63 + if resp.StatusCode >= 300 { 64 + b, _ := io.ReadAll(resp.Body) 65 + return fmt.Errorf("create session: HTTP %d: %s", resp.StatusCode, b) 66 + } 67 + 68 + var out struct { 69 + DID string `json:"did"` 70 + AccessJwt string `json:"accessJwt"` 71 + } 72 + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 73 + return fmt.Errorf("decode session: %w", err) 74 + } 75 + c.did = out.DID 76 + c.accessJWT = out.AccessJwt 77 + return nil 78 + } 79 + 80 + // Post publishes a short text-only post via com.atproto.repo.createRecord. 81 + // text is truncated to maxPostRunes if needed. Login must have succeeded 82 + // first. 83 + func (c *Client) Post(text string) error { 84 + if c.accessJWT == "" { 85 + return fmt.Errorf("not logged in") 86 + } 87 + if utf8.RuneCountInString(text) > maxPostRunes { 88 + r := []rune(text) 89 + text = string(r[:maxPostRunes-1]) + "…" 90 + } 91 + 92 + payload := map[string]any{ 93 + "repo": c.did, 94 + "collection": "app.bsky.feed.post", 95 + "record": map[string]any{ 96 + "$type": "app.bsky.feed.post", 97 + "text": text, 98 + "createdAt": time.Now().UTC().Format(time.RFC3339), 99 + }, 100 + } 101 + body, err := json.Marshal(payload) 102 + if err != nil { 103 + return err 104 + } 105 + req, err := http.NewRequest(http.MethodPost, c.pdsHost+"/xrpc/com.atproto.repo.createRecord", bytes.NewReader(body)) 106 + if err != nil { 107 + return err 108 + } 109 + req.Header.Set("Content-Type", "application/json") 110 + req.Header.Set("Authorization", "Bearer "+c.accessJWT) 111 + 112 + resp, err := c.http.Do(req) 113 + if err != nil { 114 + return fmt.Errorf("create record: %w", err) 115 + } 116 + defer resp.Body.Close() 117 + if resp.StatusCode >= 300 { 118 + b, _ := io.ReadAll(resp.Body) 119 + return fmt.Errorf("create record: HTTP %d: %s", resp.StatusCode, b) 120 + } 121 + return nil 122 + }
+22
internal/config/config.go
··· 34 34 // repository (one per forge/Radicle side) under one row in the status 35 35 // dashboard. Defaults to Name — i.e. its own row — if unset. 36 36 Series string `yaml:"series"` 37 + // Bluesky, if set, posts a short note to this account whenever this 38 + // pair mirrors a new commit or opens a patch/PR. Optional: nil means 39 + // this pair never posts to Bluesky. 40 + Bluesky *BlueskyTarget `yaml:"bluesky"` 41 + } 42 + 43 + // BlueskyTarget identifies the Bluesky account to post mirror activity to. 44 + // AppPasswordFile follows the same convention as ForgejoTarget.TokenFile — 45 + // an app password (never the account's real password), read from a 46 + // separate chmod-600 file so the config itself holds no secrets. 47 + type BlueskyTarget struct { 48 + Handle string `yaml:"handle"` 49 + AppPasswordFile string `yaml:"app_password_file"` 50 + // PDSHost is the XRPC endpoint to talk to. Optional: defaults to 51 + // https://bsky.social, correct for any account hosted there (the 52 + // common case). 53 + PDSHost string `yaml:"pds_host"` 37 54 } 38 55 39 56 // ForgejoTarget identifies a repository on a Forgejo instance and where to ··· 120 137 } 121 138 if !r.Sync.Git && !r.Sync.Issues && !r.Sync.Patches { 122 139 return fmt.Errorf("at least one of sync.git, sync.issues, sync.patches must be true") 140 + } 141 + if r.Bluesky != nil { 142 + if r.Bluesky.Handle == "" || r.Bluesky.AppPasswordFile == "" { 143 + return fmt.Errorf("bluesky.handle and app_password_file are required when bluesky is set") 144 + } 123 145 } 124 146 return nil 125 147 }
+29 -17
internal/status/status.go
··· 47 47 store *state.Store 48 48 sourceURL string 49 49 50 - mu sync.Mutex 51 - pairs map[string]PairResult 52 - topology map[string][]ServerRef 53 - publicHost string 50 + mu sync.Mutex 51 + pairs map[string]PairResult 52 + topology map[string][]ServerRef 53 + publicHost string 54 + blueskyConfigured map[string]bool 54 55 } 55 56 56 57 func NewTracker(store *state.Store, sourceURL string) *Tracker { ··· 74 75 t.mu.Lock() 75 76 defer t.mu.Unlock() 76 77 t.publicHost = host 78 + } 79 + 80 + // SetBlueskyConfigured declares which series have a Bluesky account 81 + // configured, for the /social page's status badge. 82 + func (t *Tracker) SetBlueskyConfigured(configured map[string]bool) { 83 + t.mu.Lock() 84 + defer t.mu.Unlock() 85 + t.blueskyConfigured = configured 77 86 } 78 87 79 88 // Record stores the outcome of a pass for one pair. Pass nil for a scope ··· 187 196 } 188 197 189 198 type socialRow struct { 190 - Name string 191 - Handle string // "series@host" 192 - WebfingerURL string 193 - ActorURL string 194 - OutboxURL string 195 - FollowerCount int 199 + Name string 200 + Handle string // "series@host" 201 + WebfingerURL string 202 + ActorURL string 203 + OutboxURL string 204 + FollowerCount int 205 + BlueskyConfigured bool 196 206 } 197 207 198 208 func (t *Tracker) serveSocial(w http.ResponseWriter, r *http.Request) { 199 209 t.mu.Lock() 200 210 topology := t.topology 201 211 publicHost := t.publicHost 212 + blueskyConfigured := t.blueskyConfigured 202 213 t.mu.Unlock() 203 214 204 215 if publicHost == "" { ··· 220 231 followerCount = 0 221 232 } 222 233 rows = append(rows, socialRow{ 223 - Name: name, 224 - Handle: name + "@" + publicHost, 225 - WebfingerURL: "https://" + publicHost + "/.well-known/webfinger?resource=acct:" + name + "@" + publicHost, 226 - ActorURL: actorURL, 227 - OutboxURL: actorURL + "/outbox", 228 - FollowerCount: followerCount, 234 + Name: name, 235 + Handle: name + "@" + publicHost, 236 + WebfingerURL: "https://" + publicHost + "/.well-known/webfinger?resource=acct:" + name + "@" + publicHost, 237 + ActorURL: actorURL, 238 + OutboxURL: actorURL + "/outbox", 239 + FollowerCount: followerCount, 240 + BlueskyConfigured: blueskyConfigured[name], 229 241 }) 230 242 } 231 243 ··· 839 851 <a href="{{.OutboxURL}}" target="_blank" rel="noopener">Outbox</a> 840 852 </div> 841 853 <h2>AT Proto / Bluesky</h2> 842 - <span class="badge">not configured</span> 854 + <span class="badge">{{if .BlueskyConfigured}}posts on new activity{{else}}not configured{{end}}</span> 843 855 </div> 844 856 {{else}} 845 857 <span class="empty">no series configured</span>
+35
internal/sync/bluesky.go
··· 1 + package sync 2 + 3 + import ( 4 + "fmt" 5 + 6 + "graft/internal/atproto" 7 + ) 8 + 9 + // BlueskyPoster posts short notes to one Bluesky account on behalf of a 10 + // repo pair. Nil-safe: a nil *BlueskyPoster's Post is a silent no-op, so 11 + // every call site can post unconditionally instead of nil-checking — 12 + // matching the "absent config = feature off, never an error" rule for 13 + // this integration (see config.RepoPair.Bluesky). 14 + type BlueskyPoster struct { 15 + Handle string 16 + AppPassword string 17 + PDSHost string 18 + } 19 + 20 + // Post logs in fresh and publishes text. A new session per post is 21 + // wasteful but simple and avoids session-expiry bookkeeping — acceptable 22 + // given how infrequently a single pair mirrors new content. 23 + func (b *BlueskyPoster) Post(text string) error { 24 + if b == nil { 25 + return nil 26 + } 27 + c := atproto.New(b.PDSHost) 28 + if err := c.Login(b.Handle, b.AppPassword); err != nil { 29 + return fmt.Errorf("bluesky login: %w", err) 30 + } 31 + if err := c.Post(text); err != nil { 32 + return fmt.Errorf("bluesky post: %w", err) 33 + } 34 + return nil 35 + }
+11 -2
internal/sync/git.go
··· 32 32 RID string 33 33 RadHome string 34 34 State *state.Store 35 - Series string // dashboard row this pair's activity groups under 36 - SeriesURL string // where clicking that row's name goes 35 + Series string // dashboard row this pair's activity groups under 36 + SeriesURL string // where clicking that row's name goes 37 + Bluesky *BlueskyPoster // nil unless this pair has bluesky configured 37 38 } 38 39 39 40 func (g *GitSyncer) run(args ...string) (string, error) { ··· 249 250 RepoPair: g.RepoPair, Series: g.Series, SeriesURL: g.SeriesURL, 250 251 Kind: "git", Direction: direction, Summary: summary, URL: url, 251 252 }) 253 + // Best-effort, same as LogActivity above: a Bluesky hiccup never 254 + // fails the sync, and nil Bluesky (the common case, no account 255 + // configured) is a silent no-op. 256 + text := g.Series + ": " + summary 257 + if url != "" { 258 + text += "\n" + url 259 + } 260 + g.Bluesky.Post(text) 252 261 } 253 262 }
+7 -4
internal/sync/patches.go
··· 24 24 Forgejo *forgejo.Client 25 25 Radicle *radicle.Client 26 26 State *state.Store 27 - ForgejoWebURL string // https://host/owner/repo, for links in the UI 28 - RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 29 - Series string // dashboard row this pair's activity groups under 30 - SeriesURL string // where clicking that row's name goes 27 + ForgejoWebURL string // https://host/owner/repo, for links in the UI 28 + RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI 29 + Series string // dashboard row this pair's activity groups under 30 + SeriesURL string // where clicking that row's name goes 31 + Bluesky *BlueskyPoster // nil unless this pair has bluesky configured 31 32 } 32 33 33 34 func (s *PatchSyncer) git(args ...string) (string, error) { ··· 124 125 URL: s.RadicleWebURL + "/patches/" + m[1], 125 126 ForgejoID: pr.Index, RadicleID: m[1], 126 127 }) 128 + s.Bluesky.Post(s.Series + ": patch opened — " + pr.Title + "\n" + s.RadicleWebURL + "/patches/" + m[1]) 127 129 return nil 128 130 } 129 131 ··· 170 172 URL: fmt.Sprintf("%s/pulls/%d", s.ForgejoWebURL, pr.Index), 171 173 ForgejoID: pr.Index, RadicleID: p.ID, 172 174 }) 175 + s.Bluesky.Post(fmt.Sprintf("%s: patch opened — %s\n%s/pulls/%d", s.Series, p.Title, s.ForgejoWebURL, pr.Index)) 173 176 return nil 174 177 } 175 178
+15
internal/sync/syncer.go
··· 54 54 seriesURL = forgejoWebURL 55 55 } 56 56 57 + var bluesky *BlueskyPoster 58 + if pair.Bluesky != nil { 59 + appPassword, err := config.ReadToken(pair.Bluesky.AppPasswordFile) 60 + if err != nil { 61 + return nil, fmt.Errorf("read bluesky app password: %w", err) 62 + } 63 + bluesky = &BlueskyPoster{ 64 + Handle: pair.Bluesky.Handle, 65 + AppPassword: appPassword, 66 + PDSHost: pair.Bluesky.PDSHost, 67 + } 68 + } 69 + 57 70 if pair.Sync.Git || pair.Sync.Patches { 58 71 rs.git = &GitSyncer{ 59 72 RepoPair: pair.Name, ··· 67 80 State: st, 68 81 Series: series, 69 82 SeriesURL: seriesURL, 83 + Bluesky: bluesky, 70 84 } 71 85 } 72 86 if pair.Sync.Issues { ··· 89 103 RadicleWebURL: radicleWebURL, 90 104 Series: series, 91 105 SeriesURL: seriesURL, 106 + Bluesky: bluesky, 92 107 } 93 108 } 94 109 return rs, nil