package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" ) type ( AtprotoCreateSession struct { Identifier string `json:"identifier"` Password string `json:"password"` } AtprotoSession struct { AccessJwt string `json:"accessJwt"` RefreshJwt string `json:"refreshJwt"` Handle string `json:"handle"` Did string `json:"did"` PdsUrl string `json:"-"` } AtprotoIdentity struct { Did string `json:"did"` Handle string `json:"handle"` DidDoc string `json:"didDoc"` } AtprotoGetRecordResponse struct { Uri string `json:"uri"` Cid string `json:"cid"` Value json.RawMessage `json:"value"` } AtprotoCreateRecord struct { Repo string `json:"repo"` Collection string `json:"collection"` Record json.RawMessage `json:"record"` } AtprotoPutRecord struct { Repo string `json:"repo"` Collection string `json:"collection"` Rkey string `json:"rkey"` Record any `json:"record"` } AtprotoUriCid struct { Uri string `json:"uri"` Cid string `json:"cid"` } AtprotoRecord struct { Type string `json:"$type"` } AtprotoRef struct { Link string `json:"$link"` } AtprotoBlob struct { LexiconTypeID string `json:"$type,const=blob"` Ref AtprotoRef `json:"ref"` MimeType string `json:"mimeType"` Size int `json:"size"` } BskyActorProfileView struct { Did string `json:"did"` Handle string `json:"handle"` DisplayName *string `json:"displayName"` Description *string `json:"description"` Avatar *string `json:"avatar"` Associated *json.RawMessage `json:"associated"` IndexedAt *string `json:"indexedAt"` CreatedAt *string `json:"createdAt"` Viewer *json.RawMessage `json:"viewer"` Labels *json.RawMessage `json:"labels"` Verification *json.RawMessage `json:"verification"` Status *json.RawMessage `json:"status"` } BskyGetFollowsResponse struct { Subject BskyActorProfileView `json:"subject"` Cursor *string `json:"cursor"` Follows []BskyActorProfileView `json:"follows"` } BskyActorProfile struct { LexiconTypeID string `json:"$type,const=app.bsky.actor.profile"` DisplayName *string `json:"displayName,omitempty"` Description *string `json:"description,omitempty"` Avatar *AtprotoBlob `json:"avatar,omitempty"` Banner *AtprotoBlob `json:"banner,omitempty"` Labels []string `json:"labels,omitempty"` PinnedPost *AtprotoUriCid `json:"pinnedPost,omitempty"` } BskyActorPreferences struct { Preferences []json.RawMessage `json:"preferences"` } BskyGraphFollow struct { LexiconTypeID string `json:"$type,const=app.bsky.graph.follow"` CreatedAt string `json:"createdAt"` Subject string `json:"subject"` } ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` } ) func CreateSession (pdsUrl string, identifier string, password string) (*AtprotoSession, error) { reqBody, reqBodyBytes := AtprotoCreateSession{ Identifier: identifier, Password: password, }, new(bytes.Buffer) json.NewEncoder(reqBodyBytes).Encode(reqBody) res, err := http.Post(pdsUrl + "/xrpc/com.atproto.server.createSession", "application/json", reqBodyBytes) if err != nil { return nil, errors.New(fmt.Sprintf("failed to log into @%s: %v\n", identifier, err)) } session := AtprotoSession{} err = json.NewDecoder(res.Body).Decode(&session) if err != nil { return nil, errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err)) } session.PdsUrl = pdsUrl return &session, nil } func ResolveAtprotoHandle(session *AtprotoSession, handle string) (string, error) { reqUrl, _ := url.Parse(session.PdsUrl + "/xrpc/com.atproto.identity.resolveHandle") reqUrl.RawQuery = url.Values{ "handle": { handle }, }.Encode() req, _ := http.NewRequest(http.MethodGet, reqUrl.String(), nil) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + session.AccessJwt) res, err := http.DefaultClient.Do(req) if err != nil { return "", err } if res.StatusCode != http.StatusOK { errBody := ErrorResponse{} json.NewDecoder(res.Body).Decode(&errBody) return "", errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } type DidResponse struct { Did string `json:"did"` } identity := DidResponse{} json.NewDecoder(res.Body).Decode(&identity) return identity.Did, nil } func CreateAtprotoRecord(session *AtprotoSession, collection string, record json.RawMessage) (*AtprotoUriCid, error) { reqBody, reqBodyBytes := AtprotoCreateRecord{ Repo: session.Did, Collection: collection, Record: record, }, new(bytes.Buffer) err := json.NewEncoder(reqBodyBytes).Encode(reqBody) if err != nil { return nil, err } req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.createRecord", reqBodyBytes) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + session.AccessJwt) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { errBody := ErrorResponse{} json.NewDecoder(res.Body).Decode(&errBody) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } createdRecord := AtprotoUriCid{} err = json.NewDecoder(res.Body).Decode(&createdRecord) return &createdRecord, err } func PutAtprotoRecord(session *AtprotoSession, collection string, record any) (*AtprotoUriCid, error) { reqBody, reqBodyBytes := AtprotoPutRecord{ Repo: session.Did, Collection: collection, Record: record, Rkey: "self", }, new(bytes.Buffer) err := json.NewEncoder(reqBodyBytes).Encode(reqBody) if err != nil { return nil, err } req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.putRecord", reqBodyBytes) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + session.AccessJwt) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { errBody := ErrorResponse{} json.NewDecoder(res.Body).Decode(&errBody) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } updatedRecord := AtprotoUriCid{} err = json.NewDecoder(res.Body).Decode(&updatedRecord) return &updatedRecord, err } func GetAtprotoBlob(pdsUrl string, did string, cid string) ([]byte, error) { reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.sync.getBlob") reqUrl.RawQuery = url.Values{ "did": { did }, "cid": { cid }, }.Encode() res, err := http.Get(reqUrl.String()) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { errBody := ErrorResponse{} json.NewDecoder(res.Body).Decode(&errBody) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } data, err := io.ReadAll(res.Body) if err != nil { return nil, errors.New(fmt.Sprintf("failed to read bytes: %v", err)) } return data, nil } func UploadAtprotoBlob(session *AtprotoSession, data []byte, mimeType string) (*AtprotoBlob, error) { req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data)) req.Header.Set("Content-Type", mimeType) req.Header.Set("Authorization", "Bearer " + session.AccessJwt) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { errBody := ErrorResponse{} json.NewDecoder(res.Body).Decode(&errBody) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } type Response struct { Blob AtprotoBlob `json:"blob"` } blob := Response{} err = json.NewDecoder(res.Body).Decode(&blob) if err != nil { return nil, errors.New(fmt.Sprintf("failed to read response body: %v", err)) } blob.Blob.LexiconTypeID = "blob" return &blob.Blob, nil }