Federated via graft
4.0 kB
138 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// 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}