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// and returns its AT-URI (at://did/app.bsky.feed.post/rkey) — needed to 98// later recognize a reply whose parent points back at it. text is 99// truncated to maxPostRunes if needed. Login must have succeeded first. 100func (c *Client) Post(text string) (uri string, err error) { 101 if c.accessJWT == "" { 102 return "", fmt.Errorf("not logged in") 103 } 104 if utf8.RuneCountInString(text) > maxPostRunes { 105 r := []rune(text) 106 text = string(r[:maxPostRunes-1]) + "…" 107 } 108 109 payload := map[string]any{ 110 "repo": c.did, 111 "collection": "app.bsky.feed.post", 112 "record": map[string]any{ 113 "$type": "app.bsky.feed.post", 114 "text": text, 115 "createdAt": time.Now().UTC().Format(time.RFC3339), 116 }, 117 } 118 body, err := json.Marshal(payload) 119 if err != nil { 120 return "", err 121 } 122 req, err := http.NewRequest(http.MethodPost, c.pdsHost+"/xrpc/com.atproto.repo.createRecord", bytes.NewReader(body)) 123 if err != nil { 124 return "", err 125 } 126 req.Header.Set("Content-Type", "application/json") 127 req.Header.Set("Authorization", "Bearer "+c.accessJWT) 128 129 resp, err := c.http.Do(req) 130 if err != nil { 131 return "", fmt.Errorf("create record: %w", err) 132 } 133 defer resp.Body.Close() 134 if resp.StatusCode >= 300 { 135 b, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) 136 return "", fmt.Errorf("create record: HTTP %d: %s", resp.StatusCode, truncate(b)) 137 } 138 var out struct { 139 URI string `json:"uri"` 140 } 141 if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out); err != nil { 142 return "", fmt.Errorf("decode create record response: %w", err) 143 } 144 return out.URI, nil 145} 146 147// Notification is the subset of app.bsky.notification.listNotifications' 148// response this client reads. NOT verified against a live PDS — no 149// Bluesky account was available to test against when this was written; 150// built directly from AT Proto's published lexicon 151// (app.bsky.notification.listNotifications / app.bsky.feed.post), which 152// is a stable, documented public API, but that is not the same as having 153// exercised it. Verify against a real reply before trusting this deeply. 154type Notification struct { 155 URI string `json:"uri"` 156 Reason string `json:"reason"` // "reply", "like", "repost", "mention", "quote", "follow" 157 Author struct { 158 Handle string `json:"handle"` 159 } `json:"author"` 160 IndexedAt string `json:"indexedAt"` 161 Record struct { 162 Text string `json:"text"` 163 Reply *struct { 164 Parent struct { 165 URI string `json:"uri"` 166 } `json:"parent"` 167 } `json:"reply"` 168 } `json:"record"` 169} 170 171// ListNotifications fetches one page of notifications, newest first. 172// Login must have succeeded first. 173func (c *Client) ListNotifications() ([]Notification, error) { 174 if c.accessJWT == "" { 175 return nil, fmt.Errorf("not logged in") 176 } 177 req, err := http.NewRequest(http.MethodGet, c.pdsHost+"/xrpc/app.bsky.notification.listNotifications", nil) 178 if err != nil { 179 return nil, err 180 } 181 req.Header.Set("Authorization", "Bearer "+c.accessJWT) 182 183 resp, err := c.http.Do(req) 184 if err != nil { 185 return nil, fmt.Errorf("list notifications: %w", err) 186 } 187 defer resp.Body.Close() 188 if resp.StatusCode >= 300 { 189 b, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) 190 return nil, fmt.Errorf("list notifications: HTTP %d: %s", resp.StatusCode, truncate(b)) 191 } 192 var out struct { 193 Notifications []Notification `json:"notifications"` 194 } 195 if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out); err != nil { 196 return nil, fmt.Errorf("decode notifications: %w", err) 197 } 198 return out.Notifications, nil 199}