add files

This commit is contained in:
Astra 2025-07-13 18:09:52 +01:00
commit 86a1a1c006
5 changed files with 864 additions and 0 deletions

181
bluesky.go Normal file
View file

@ -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(&params).Receive(resp, resp)
return resp.Posts[0]
}

156
client.go Normal file
View file

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

27
go.mod Normal file
View file

@ -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
)

50
go.sum Normal file
View file

@ -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=

450
parse.go Normal file
View file

@ -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(`<a href="https://bsky.app/profile/%s">%s</a>`, feature.Did, post.Text[start:end])
result.WriteString(link)
case "app.bsky.richtext.facet#link":
link := fmt.Sprintf(`<a href="%s">%s</a>`, feature.URI, post.Text[start:end])
result.WriteString(link)
case "app.bsky.richtext.facet#tag":
link := fmt.Sprintf(`<a href="https://bsky.app/search?q=%%23%s">%s</a>`, 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
}