Federated via graft
0

Configure Feed

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

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. 4package atproto 5 6import ( 7 "bytes" 8 "encoding/json" 9 "fmt" 10 "io" 11 "net/http" 12 "strings" 13 "time" 14 "unicode/utf8" 15) 16 17const 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). 22const maxPostRunes = 300 23 24// maxResponseBody caps how much of a PDS response this client reads into 25// memory; maxLoggedBody further caps how much of an error body ends up 26// embedded in an error message (and from there, in logs) — untrusted 27// upstream content shouldn't get an unbounded write into our own logging. 28const ( 29 maxResponseBody = 1 << 20 // 1 MiB 30 maxLoggedBody = 500 31) 32 33func truncate(b []byte) string { 34 if len(b) <= maxLoggedBody { 35 return string(b) 36 } 37 return string(b[:maxLoggedBody]) + "... (truncated)" 38} 39 40// Client is a session against one AT Proto PDS, authenticated as one 41// account. 42type Client struct { 43 pdsHost string 44 http *http.Client 45 did string 46 accessJWT string 47} 48 49// New creates a client for pdsHost (empty defaults to https://bsky.social). 50// Call Login before Post. 51func New(pdsHost string) *Client { 52 if pdsHost == "" { 53 pdsHost = defaultPDSHost 54 } 55 return &Client{ 56 pdsHost: strings.TrimRight(pdsHost, "/"), 57 http: &http.Client{Timeout: 15 * time.Second}, 58 } 59} 60 61// Login authenticates with an app password (never the account's real 62// password) via com.atproto.server.createSession. 63func (c *Client) Login(handle, appPassword string) error { 64 body, err := json.Marshal(map[string]string{"identifier": handle, "password": appPassword}) 65 if err != nil { 66 return err 67 } 68 req, err := http.NewRequest(http.MethodPost, c.pdsHost+"/xrpc/com.atproto.server.createSession", bytes.NewReader(body)) 69 if err != nil { 70 return err 71 } 72 req.Header.Set("Content-Type", "application/json") 73 74 resp, err := c.http.Do(req) 75 if err != nil { 76 return fmt.Errorf("create session: %w", err) 77 } 78 defer resp.Body.Close() 79 if resp.StatusCode >= 300 { 80 b, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) 81 return fmt.Errorf("create session: HTTP %d: %s", resp.StatusCode, truncate(b)) 82 } 83 84 var out struct { 85 DID string `json:"did"` 86 AccessJwt string `json:"accessJwt"` 87 } 88 if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out); err != nil { 89 return fmt.Errorf("decode session: %w", err) 90 } 91 c.did = out.DID 92 c.accessJWT = out.AccessJwt 93 return nil 94} 95 96// Post publishes a short text-only post via com.atproto.repo.createRecord. 97// text is truncated to maxPostRunes if needed. Login must have succeeded 98// first. 99func (c *Client) Post(text string) error { 100 if c.accessJWT == "" { 101 return fmt.Errorf("not logged in") 102 } 103 if utf8.RuneCountInString(text) > maxPostRunes { 104 r := []rune(text) 105 text = string(r[:maxPostRunes-1]) + "…" 106 } 107 108 payload := map[string]any{ 109 "repo": c.did, 110 "collection": "app.bsky.feed.post", 111 "record": map[string]any{ 112 "$type": "app.bsky.feed.post", 113 "text": text, 114 "createdAt": time.Now().UTC().Format(time.RFC3339), 115 }, 116 } 117 body, err := json.Marshal(payload) 118 if err != nil { 119 return err 120 } 121 req, err := http.NewRequest(http.MethodPost, c.pdsHost+"/xrpc/com.atproto.repo.createRecord", bytes.NewReader(body)) 122 if err != nil { 123 return err 124 } 125 req.Header.Set("Content-Type", "application/json") 126 req.Header.Set("Authorization", "Bearer "+c.accessJWT) 127 128 resp, err := c.http.Do(req) 129 if err != nil { 130 return fmt.Errorf("create record: %w", err) 131 } 132 defer resp.Body.Close() 133 if resp.StatusCode >= 300 { 134 b, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) 135 return fmt.Errorf("create record: HTTP %d: %s", resp.StatusCode, truncate(b)) 136 } 137 return nil 138}