From 86a1a1c0066279167903cd7c3c789a19cc7a07c4 Mon Sep 17 00:00:00 2001 From: astravexton Date: Sun, 13 Jul 2025 18:09:52 +0100 Subject: [PATCH] add files --- bluesky.go | 181 +++++++++++++++++++++ client.go | 156 +++++++++++++++++++ go.mod | 27 ++++ go.sum | 50 ++++++ parse.go | 450 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 864 insertions(+) create mode 100644 bluesky.go create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 parse.go diff --git a/bluesky.go b/bluesky.go new file mode 100644 index 0000000..afcc5ee --- /dev/null +++ b/bluesky.go @@ -0,0 +1,181 @@ +package blueskyclient + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/charmbracelet/log" + "github.com/dghubble/sling" +) + +type BlueskyConfig struct { + PDSURL string `json:"pds-url"` + Repo string `json:"repo"` + Handle string `json:"handle"` + DID string `json:"did"` + AppPassword string `json:"app-password"` + AccessJWT string `json:"access-jwt"` + RefreshJWT string `json:"refresh-jwt"` + Cursor int64 `json:"cursor"` +} + +type DIDResponse struct { + Context []string `json:"@context"` + ID string `json:"id"` + AlsoKnownAs []string `json:"alsoKnownAs"` + VerificationMethod []struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyMultibase string `json:"publicKeyMultibase"` + } `json:"verificationMethod"` + Service []struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` + } `json:"service"` +} + +type BSkySessionResponse struct { + AccessJWT string `json:"accessJwt,omitempty"` + RefreshJWT string `json:"refreshJwt,omitempty"` + Handle string `json:"handle"` + DID string `json:"did"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +type CommitResponse struct { + URI string `json:"uri"` + Cid string `json:"cid"` + Commit struct { + Cid string `json:"cid"` + Rev string `json:"rev"` + } `json:"commit"` + Error string `json:"error"` + Message string `json:"message"` +} + +func (c *CommitResponse) GetRKey() string { + s := strings.SplitN(c.URI, "/", 5) + if len(s) == 5 { + return s[4] + } + return "" +} + +type Link struct { + Cid string `json:"cid"` + URI string `json:"uri"` +} + +type Bluesky struct { + Cfg *BlueskyConfig + HttpClient *http.Client + Logger *log.Logger + sling *sling.Sling +} + +func (bluesky *Bluesky) CreateSession(cfg *BlueskyConfig) error { + body := struct { + Identifier string `json:"identifier"` + Password string `json:"password"` + }{ + Identifier: cfg.Handle, + Password: cfg.AppPassword, + } + resp := new(BSkySessionResponse) + + bluesky.sling.New().Post("/xrpc/com.atproto.server.createSession").BodyJSON(body).ReceiveSuccess(resp) + if resp.AccessJWT != "" { + cfg.AccessJWT = resp.AccessJWT + cfg.RefreshJWT = resp.RefreshJWT + return nil + } + + bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)) + return errors.New("unable to authenticate, check handle/password") +} + +func (bluesky *Bluesky) RefreshSession() error { + resp := new(BSkySessionResponse) + + bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.RefreshJWT)). + Post("/xrpc/com.atproto.server.refreshSession").Receive(resp, resp) + if resp.AccessJWT != "" { + bluesky.Cfg.AccessJWT = resp.AccessJWT + bluesky.Cfg.RefreshJWT = resp.RefreshJWT + PersistAuthSession(bluesky.Cfg) + bluesky.sling.Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)) + return nil + } + + return bluesky.CreateSession(bluesky.Cfg) +} + +func (bluesky *Bluesky) CheckSessionValid() { + resp := new(BSkySessionResponse) + + bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)). + Get("/xrpc/app.bsky.actor.getProfile").Receive(resp, resp) + if resp.Error == "ExpiredToken" { + bluesky.RefreshSession() + } +} + +func (bluesky *Bluesky) GetPost(uri string) *Post { + bluesky.CheckSessionValid() + var post = struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value *Post `json:"value"` + }{} + + args := strings.SplitN(uri, "/", 5) + params := struct { + RKey string `url:"rkey"` + Repo string `url:"repo"` + Collection string `url:"collection"` + }{ + RKey: args[4], + Repo: args[2], + Collection: args[3], + } + bluesky.sling.New().Get("/xrpc/com.atproto.repo.getRecord").QueryStruct(params).ReceiveSuccess(&post) + + return post.Value +} + +func (bluesky *Bluesky) DeleteRecord(args []string) *CommitResponse { + bluesky.CheckSessionValid() + + resp := new(CommitResponse) + params := struct { + RKey string `json:"rkey"` + Repo string `json:"repo"` + Collection string `json:"collection"` + }{ + RKey: args[0], + Repo: args[1], + Collection: args[2], + } + bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)). + Post("/xrpc/com.atproto.repo.deleteRecord").BodyJSON(params).Receive(resp, resp) + return resp +} + +func (bluesky *Bluesky) FetchPost(did string, rkey string) Post { + resp := &struct { + Posts []Post `json:"posts"` + }{} + params := struct { + URIs string `url:"uris"` + }{ + URIs: fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey), + } + bluesky.sling.New().Base("https://public.api.bsky.app"). + Get("/xrpc/app.bsky.feed.getPosts").QueryStruct(¶ms).Receive(resp, resp) + return resp.Posts[0] +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..a24b5b0 --- /dev/null +++ b/client.go @@ -0,0 +1,156 @@ +package blueskyclient + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/dghubble/sling" +) + +type BSky struct { + Bluesky *Bluesky + DID string +} + +func NewBSky() *BSky { + return &BSky{ + Bluesky: &Bluesky{ + Cfg: &BlueskyConfig{}, + HttpClient: &http.Client{}, + sling: sling.New().Client(&http.Client{Timeout: time.Second * 3}), + }, + } +} + +func (b *BSky) ResolveHandle(handle string) (string, error) { + httpClient := &http.Client{Timeout: 3 * time.Second} + resp := new(BSkySessionResponse) + errResp := &struct { + Message string `json:"message"` + Error string `json:"error"` + }{} + params := struct { + Handle string `url:"handle"` + }{ + Handle: handle, + } + sling.New().Base("https://public.api.bsky.app/").Client(httpClient). + Get("/xrpc/com.atproto.identity.resolveHandle").QueryStruct(params). + Receive(resp, errResp) + + if errResp.Error != "" { + return "", errors.New(errResp.Message) + } + + return resp.DID, nil +} + +func (b *BSky) getPDS() error { + did, _ := b.ResolveHandle(b.Bluesky.Cfg.Handle) + + var didURL url.URL + if strings.HasPrefix(did, "did:web:") { + didURL.Host = "https://" + did[8:] + didURL.Path = "/.well-known/did.json" + } else if strings.HasPrefix(did, "did:plc:") { + didURL.Host = "https://plc.directory" + didURL.Path = "/" + did + } else { + return errors.New("DID is not supported") + } + + didResp := new(DIDResponse) + sling.New().Base(didURL.Host).Get(didURL.Path).ReceiveSuccess(didResp) + if didResp.ID == "" { + return errors.New("unable to resolve DID") + } + + b.Bluesky.Cfg.DID = didResp.ID + b.Bluesky.Cfg.PDSURL = didResp.Service[0].ServiceEndpoint + b.Bluesky.sling.Base(didResp.Service[0].ServiceEndpoint) + return nil +} + +func (b *BSky) GetHandleFromDID(did string) (handle string, err error) { + var didURL url.URL + if strings.HasPrefix(did, "did:web:") { + didURL.Host = "https://" + did[8:] + didURL.Path = "/.well-known/did.json" + } else if strings.HasPrefix(did, "did:plc:") { + didURL.Host = "https://plc.directory" + didURL.Path = "/" + did + } else { + return "", errors.New("DID is not supported") + } + + didResp := new(DIDResponse) + sling.New().Base(didURL.Host).Get(didURL.Path).ReceiveSuccess(didResp) + if didResp.ID == "" { + return "", errors.New("unable to resolve DID") + } + + return didResp.AlsoKnownAs[0][5:], nil +} + +func (b *BSky) GetPDS(handle string) string { + return b.Bluesky.Cfg.PDSURL +} + +func (b *BSky) Auth(authData []string) error { + b.Bluesky.Cfg.Handle = authData[0] + b.getPDS() + auth, err := loadAuth() + if err != nil { // no auth session found + b.Bluesky.Cfg.AppPassword = authData[1] + err = b.Bluesky.CreateSession(b.Bluesky.Cfg) + if err != nil { + return fmt.Errorf("unable to auth: %s", err) + } + b.Bluesky.Cfg.AppPassword = "" // we don't need to save this + PersistAuthSession(b.Bluesky.Cfg) + } else { + b.Bluesky.Cfg.Cursor = auth.Cursor + b.Bluesky.Cfg.AccessJWT = auth.AccessJWT + b.Bluesky.Cfg.RefreshJWT = auth.RefreshJWT + // b.RefreshSession() + b.Bluesky.CheckSessionValid() + } + + return nil +} + +func PersistAuthSession(sess *BlueskyConfig) error { + f, err := os.OpenFile("auth-session.json", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + + authBytes, err := json.MarshalIndent(sess, "", " ") + if err != nil { + return err + } + _, err = f.Write(authBytes) + return err +} + +func loadAuth() (*BlueskyConfig, error) { + fBytes, err := os.ReadFile("auth-session.json") + if err != nil { + return nil, err + } + + if len(fBytes) == 0 { + return nil, errors.New("no auth file found") + } + + var auth *BlueskyConfig + json.Unmarshal(fBytes, &auth) + return auth, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c98bab1 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module git.zio.sh/astra/go-bluesky-client/v1 + +go 1.24.3 + +require ( + github.com/charmbracelet/log v0.4.2 + github.com/dghubble/sling v1.4.2 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c0077fc --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dghubble/sling v1.4.2 h1:vs1HIGBbSl2SEALyU+irpYFLZMfc49Fp+jYryFebQjM= +github.com/dghubble/sling v1.4.2/go.mod h1:o0arCOz0HwfqYQJLrRtqunaWOn4X6jxE/6ORKRpVTD4= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..cc88d88 --- /dev/null +++ b/parse.go @@ -0,0 +1,450 @@ +package blueskyclient + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +type Post struct { + Type string `json:"$type"` + Text string `json:"text,omitempty"` + Embed *Embed `json:"embed,omitempty"` + Langs []string `json:"langs,omitempty"` + Labels *Labels `json:"labels,omitempty"` + Reply *Reply `json:"reply,omitempty"` + Facets *[]Facets `json:"facets,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Subject *Record `json:"subject,omitempty"` + Avatar *Avatar `json:"avatar,omitempty"` + Banner *Banner `json:"banner,omitempty"` + Description string `json:"description"` + DisplayName string `json:"displayName"` + PinnedPost *Record `json:"pinnedPost,omitempty"` + Allow []struct { + Type string `json:"$type"` + } `json:"allow,omitempty"` + HiddenReplies []any `json:"hiddenReplies,omitempty"` + Post string `json:"post,omitempty"` +} + +type Banner struct { + Type string `json:"$type"` + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` +} + +type Avatar struct { + Type string `json:"$type"` + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` +} + +type Ref struct { + Link string `json:"$link"` +} + +type Thumb struct { + Type string `json:"$type"` + Ref *Ref `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` +} + +type External struct { + URI string `json:"uri"` + Thumb *Thumb `json:"thumb"` + Title string `json:"title"` + Description string `json:"description"` +} + +type Video struct { + Type string `json:"$type"` + Ref *Ref `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` +} + +type Image struct { + Type string `json:"$type"` + Ref *Ref `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` +} + +type AspectRatio struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type Images struct { + Alt string `json:"alt,omitempty"` + Image *Image `json:"image"` + AspectRatio *AspectRatio `json:"aspectRatio"` +} + +type Media struct { + Type string `json:"$type"` + External *External `json:"external,omitempty"` + Video *Video `json:"video,omitempty"` + Images *[]Images `json:"images,omitempty"` + AspectRatio *AspectRatio `json:"aspectRatio,omitempty"` +} + +type Record struct { + Cid string `json:"cid,omitempty"` + URI string `json:"uri,omitempty"` +} + +func (r *Record) GetDID() string { + return strings.Split(r.URI, "/")[2] +} + +func (r *Record) GetCollection() string { + return strings.Split(r.URI, "/")[3] +} + +func (r *Record) GetRKey() string { + return strings.Split(r.URI, "/")[4] +} + +type PostRecord struct { + Type string `json:"$type"` + Cid string `json:"cid"` + URI string `json:"uri"` + Record *Record `json:"record"` +} + +func (r *PostRecord) GetDID() string { + return strings.Split(r.URI, "/")[2] +} + +func (r *PostRecord) GetCollection() string { + return strings.Split(r.URI, "/")[3] +} + +func (r *PostRecord) GetRKey() string { + return strings.Split(r.URI, "/")[4] +} + +type Embed struct { + Type string `json:"$type"` + Media *Media `json:"media,omitempty"` + Images *[]Images `json:"images,omitempty"` + Video *Video `json:"video,omitempty"` + Record *PostRecord `json:"record,omitempty"` + External *External `json:"external,omitempty"` +} + +type Values struct { + Val string `json:"val"` +} + +type Labels struct { + Type string `json:"$type"` + Values *[]Values `json:"values"` +} + +type Reply struct { + Root *Record `json:"root,omitempty"` + Parent *Record `json:"parent,omitempty"` +} + +type Index struct { + ByteEnd int `json:"byteEnd"` + ByteStart int `json:"byteStart"` +} + +type Features struct { + Did string `json:"did"` + URI string `json:"uri"` + Tag string `json:"tag"` + Type string `json:"$type"` +} + +type Facets struct { + Type string `json:"$type"` + Index *Index `json:"index"` + Features *[]Features `json:"features"` +} + +type ParsedEmbeds struct { + Type string + MimeType string + Ref string + URI string + Width int64 + Height int64 +} + +type FetchedPost struct { + URI string `json:"uri"` + Cid string `json:"cid"` + Author struct { + Did string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Associated struct { + Chat struct { + AllowIncoming string `json:"allowIncoming"` + } `json:"chat"` + } `json:"associated"` + Labels []interface{} `json:"labels"` + CreatedAt time.Time `json:"createdAt"` + } `json:"author"` + Record *Post `json:"record"` + Embed struct { + Type string `json:"$type"` + Media struct { + Type string `json:"$type"` + Images []struct { + Thumb string `json:"thumb"` + Fullsize string `json:"fullsize"` + Alt string `json:"alt"` + AspectRatio struct { + Height int `json:"height"` + Width int `json:"width"` + } `json:"aspectRatio"` + } `json:"images"` + } `json:"media"` + Record struct { + Record struct { + Type string `json:"$type"` + URI string `json:"uri"` + Cid string `json:"cid"` + Author struct { + Did string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Associated struct { + Chat struct { + AllowIncoming string `json:"allowIncoming"` + } `json:"chat"` + } `json:"associated"` + Labels []interface{} `json:"labels"` + CreatedAt time.Time `json:"createdAt"` + } `json:"author"` + Value struct { + Type string `json:"$type"` + CreatedAt time.Time `json:"createdAt"` + Embed struct { + Type string `json:"$type"` + AspectRatio struct { + Height int `json:"height"` + Width int `json:"width"` + } `json:"aspectRatio"` + Video struct { + Type string `json:"$type"` + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` + } `json:"video"` + } `json:"embed"` + Facets []struct { + Type string `json:"$type"` + Features []struct { + Type string `json:"$type"` + Did string `json:"did"` + } `json:"features"` + Index struct { + ByteEnd int `json:"byteEnd"` + ByteStart int `json:"byteStart"` + } `json:"index"` + } `json:"facets"` + Langs []string `json:"langs"` + Text string `json:"text"` + } `json:"value"` + Labels []interface{} `json:"labels"` + LikeCount int `json:"likeCount"` + ReplyCount int `json:"replyCount"` + RepostCount int `json:"repostCount"` + QuoteCount int `json:"quoteCount"` + IndexedAt time.Time `json:"indexedAt"` + Embeds []struct { + Type string `json:"$type"` + Cid string `json:"cid"` + Playlist string `json:"playlist"` + Thumbnail string `json:"thumbnail"` + AspectRatio struct { + Height int `json:"height"` + Width int `json:"width"` + } `json:"aspectRatio"` + } `json:"embeds"` + } `json:"record"` + } `json:"record"` + } `json:"embed"` + ReplyCount int `json:"replyCount"` + RepostCount int `json:"repostCount"` + LikeCount int `json:"likeCount"` + QuoteCount int `json:"quoteCount"` + IndexedAt time.Time `json:"indexedAt"` + Labels []struct { + Src string `json:"src"` + URI string `json:"uri"` + Cid string `json:"cid"` + Val string `json:"val"` + Cts time.Time `json:"cts"` + } `json:"labels"` +} + +func (b *BSky) ParsePost(post []byte) (*Post, error) { + var p = &Post{} + err := json.Unmarshal(post, &p) + if err != nil { + return nil, err + } + + return p, nil +} + +func (post *Post) ProcessFacets() string { + if post == nil { + return "" + } + + if post.Facets == nil { + return post.Text + } + + sort.Slice((*post.Facets), func(i, j int) bool { + return (*post.Facets)[i].Index.ByteStart < (*post.Facets)[j].Index.ByteStart + }) + + var result strings.Builder + lastIndex := 0 + // post.Text = html.EscapeString(post.Text) + + for _, facet := range *post.Facets { + start := facet.Index.ByteStart + end := facet.Index.ByteEnd + + result.WriteString(post.Text[lastIndex:start]) + + for _, feature := range *facet.Features { + switch feature.Type { + case "app.bsky.richtext.facet#mention": + link := fmt.Sprintf(`%s`, feature.Did, post.Text[start:end]) + result.WriteString(link) + case "app.bsky.richtext.facet#link": + link := fmt.Sprintf(`%s`, feature.URI, post.Text[start:end]) + result.WriteString(link) + case "app.bsky.richtext.facet#tag": + link := fmt.Sprintf(`%s`, feature.Tag, post.Text[start:end]) + result.WriteString(link) + default: + result.WriteString(post.Text[start:end]) + } + } + lastIndex = end + } + result.WriteString(post.Text[lastIndex:]) + return result.String() +} + +func (p *Post) GetEmbeds() *[]ParsedEmbeds { + var parsedEmbeds = &[]ParsedEmbeds{} + if p.Embed != nil { + if p.Embed.Video != nil { + parsedEmbed := ParsedEmbeds{ + URI: p.Embed.Video.Ref.Link, + Type: "video", + } + *parsedEmbeds = append(*parsedEmbeds, parsedEmbed) + } + if p.Embed.External != nil { + if strings.Contains(p.Embed.External.URI, "media.tenor.com") { + parsedEmbed := ParsedEmbeds{ + URI: p.Embed.External.URI, + Type: "external", + } + *parsedEmbeds = append(*parsedEmbeds, parsedEmbed) + } + } + if p.Embed.Media != nil { + if p.Embed.Media.Images != nil { + for _, image := range *p.Embed.Media.Images { + parsedEmbed := ParsedEmbeds{ + URI: image.Image.Ref.Link, + Type: "image", + } + *parsedEmbeds = append(*parsedEmbeds, parsedEmbed) + } + } + if p.Embed.Media.Video != nil { + parsedEmbed := ParsedEmbeds{ + URI: p.Embed.Media.Video.Ref.Link, + Type: "video", + } + *parsedEmbeds = append(*parsedEmbeds, parsedEmbed) + } + if p.Embed.Media.External != nil { + parsedEmbed := ParsedEmbeds{ + URI: p.Embed.Media.External.URI, + Type: "external", + } + *parsedEmbeds = append(*parsedEmbeds, parsedEmbed) + } + } + if p.Embed.Images != nil { + for _, image := range *p.Embed.Images { + parsedEmbed := ParsedEmbeds{ + URI: image.Image.Ref.Link, + Type: "image", + } + *parsedEmbeds = append(*parsedEmbeds, parsedEmbed) + } + } + } + return parsedEmbeds +} + +func (p *Post) GetMedia() *Media { + if p.GetEmbeds() != nil { + if p.Embed.Media != nil { + return p.Embed.Media + } + } + return nil +} + +func (p *Post) GetMediaImages() *[]Images { + if p.GetMedia() != nil { + return p.GetMedia().Images + } + return nil +} + +func (p *Post) GetExternal() *External { + if p.GetMedia() != nil { + if p.GetMedia().External != nil { + return p.GetMedia().External + } + } + return nil +} + +func (p *Post) IsReply() bool { + return p.Reply != nil +} + +func (p *Post) IsQuotePost() bool { + if p.Embed != nil { + if p.Embed.Record != nil { + return true + } + } + + return false +}