Federated via graft
3.3 kB
122 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// Client is a session against one AT Proto PDS, authenticated as one
25// account.
26type 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.
35func 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.
47func (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.
83func (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}