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// 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}