Federated via graft
6.3 kB
199 lines
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}