add files

This commit is contained in:
Astra 2025-06-05 07:22:57 +01:00
commit 04e29ed525
7 changed files with 1337 additions and 0 deletions

227
bsky/bluesky.go Normal file
View file

@ -0,0 +1,227 @@
package bsky
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()
}
}
type TelegramRecord struct {
ChannelID int64 `json:"channel_id"`
MessageID int `json:"message_id"`
Link *Link `json:"link"`
Error string `json:"error"`
Message string `json:"message"`
}
func (bluesky *Bluesky) CommitTelegramResponse(data *TelegramRecord, rkey string) *CommitResponse {
bluesky.CheckSessionValid()
resp := new(CommitResponse)
record := struct {
Repo string `json:"repo"`
Collection string `json:"collection"`
RKey string `json:"rkey"`
Record TelegramRecord `json:"record"`
}{
Repo: bluesky.Cfg.DID,
Collection: "blue.zio.bsky2tg.post",
RKey: rkey,
Record: TelegramRecord{
ChannelID: data.ChannelID,
MessageID: data.MessageID,
Link: &Link{
Cid: data.Link.Cid,
URI: data.Link.URI,
},
},
}
bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)).
Post("/xrpc/com.atproto.repo.createRecord").BodyJSON(&record).Receive(resp, resp)
return resp
}
func (bluesky *Bluesky) GetTelegramData(rkey string) (*TelegramRecord, string) {
resp := &struct {
Value *TelegramRecord `json:"value"`
URI string `json:"uri"`
Cid string `json:"cid"`
Error string `json:"error"`
Message string `json:"message"`
}{}
params := struct {
Repo string `url:"repo"`
Collection string `url:"collection"`
RKey string `url:"rkey"`
}{
Repo: bluesky.Cfg.DID,
Collection: "blue.zio.bsky2tg.post",
RKey: rkey,
}
bluesky.sling.New().Get("/xrpc/com.atproto.repo.getRecord").QueryStruct(&params).Receive(resp, resp)
return resp.Value, resp.Message
}
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 `url:"rkey"`
Repo string `url:"repo"`
Collection string `url:"collection"`
}{
RKey: args[0],
Repo: args[1],
Collection: args[2],
}
bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)).
Get("/xrpc/com.atproto.repo.deleteRecord").QueryStruct(params).ReceiveSuccess(resp)
return resp
}

149
bsky/client.go Normal file
View file

@ -0,0 +1,149 @@
package bsky
import (
"encoding/json"
"errors"
"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) getPDS() 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: b.Bluesky.Cfg.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)
}
var didURL url.URL
if strings.HasPrefix(resp.DID, "did:web:") {
didURL.Host = "https://" + resp.DID[8:]
didURL.Path = "/.well-known/did.json"
} else if strings.HasPrefix(resp.DID, "did:plc:") {
didURL.Host = "https://plc.directory"
didURL.Path = "/" + resp.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 errors.New("unable to auth")
}
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
}

274
bsky/parse.go Normal file
View file

@ -0,0 +1,274 @@
package bsky
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
type Post struct {
Text string `json:"text,omitempty"`
Type string `json:"$type,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"`
}
type Ref struct {
Link string `json:"$link,omitempty"`
}
type Thumb struct {
Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"`
}
type External struct {
URI string `json:"uri,omitempty"`
Thumb *Thumb `json:"thumb,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
}
type Video struct {
Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"`
}
type Image struct {
Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"`
}
type AspectRatio struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type Images struct {
Alt string `json:"alt,omitempty"`
Image *Image `json:"image,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
}
type Media struct {
Type string `json:"$type,omitempty"`
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"`
}
type PostRecord struct {
Type string `json:"$type,omitempty"`
Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"`
Record *Record `json:"record,omitempty"`
}
type Embed struct {
Type string `json:"$type,omitempty"`
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,omitempty"`
}
type Labels struct {
Type string `json:"$type,omitempty"`
Values *[]Values `json:"values,omitempty"`
}
type Root struct {
Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"`
}
type Parent struct {
Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"`
}
type Reply struct {
Root *Root `json:"root,omitempty"`
Parent *Parent `json:"parent,omitempty"`
}
type Index struct {
ByteEnd int `json:"byteEnd,omitempty"`
ByteStart int `json:"byteStart,omitempty"`
}
type Features struct {
Did string `json:"did,omitempty"`
URI string `json:"uri,omitempty"`
Tag string `json:"tag,omitempty"`
Type string `json:"$type,omitempty"`
}
type Facets struct {
Type string `json:"$type"`
Index *Index `json:"index,omitempty"`
Features *[]Features `json:"features,omitempty"`
}
type ParsedEmbeds struct {
Type string
MimeType string
Ref string
URI string
Width int64
Height int64
}
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
}