diff --git a/bsky/bluesky.go b/bsky/bluesky.go index ae8d5cf..38422f7 100644 --- a/bsky/bluesky.go +++ b/bsky/bluesky.go @@ -263,3 +263,17 @@ type Records struct { Cid string `json:"cid"` Value Value `json:"value"` } + +func (bluesky *Bluesky) FetchPost(did string, rkey string) FetchedPost { + resp := &struct { + Posts []FetchedPost `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/bsky/client.go b/bsky/client.go index 34047a4..c11661d 100644 --- a/bsky/client.go +++ b/bsky/client.go @@ -28,7 +28,7 @@ func NewBSky() *BSky { } } -func (b *BSky) getPDS() error { +func (b *BSky) ResolveHandle(handle string) (string, error) { httpClient := &http.Client{Timeout: 3 * time.Second} resp := new(BSkySessionResponse) errResp := &struct { @@ -38,23 +38,29 @@ func (b *BSky) getPDS() error { params := struct { Handle string `url:"handle"` }{ - Handle: b.Bluesky.Cfg.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 "", 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(resp.DID, "did:web:") { - didURL.Host = "https://" + resp.DID[8:] + if strings.HasPrefix(did, "did:web:") { + didURL.Host = "https://" + did[8:] didURL.Path = "/.well-known/did.json" - } else if strings.HasPrefix(resp.DID, "did:plc:") { + } else if strings.HasPrefix(did, "did:plc:") { didURL.Host = "https://plc.directory" - didURL.Path = "/" + resp.DID + didURL.Path = "/" + did } else { return errors.New("DID is not supported") } @@ -104,7 +110,7 @@ func (b *BSky) Auth(authData []string) error { b.Bluesky.Cfg.AppPassword = authData[1] err = b.Bluesky.CreateSession(b.Bluesky.Cfg) if err != nil { - return errors.New(fmt.Sprintf("unable to auth: %s", err)) + return fmt.Errorf("unable to auth: %s", err) } b.Bluesky.Cfg.AppPassword = "" // we don't need to save this PersistAuthSession(b.Bluesky.Cfg) diff --git a/bsky/parse.go b/bsky/parse.go index 1560be2..c600af9 100644 --- a/bsky/parse.go +++ b/bsky/parse.go @@ -123,6 +123,160 @@ type ParsedEmbeds struct { 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"` + // Record struct { + // Type string `json:"$type"` + // CreatedAt time.Time `json:"createdAt"` + // Embed struct { + // Type string `json:"$type"` + // Media struct { + // Type string `json:"$type"` + // Images []struct { + // Alt string `json:"alt"` + // AspectRatio struct { + // Height int `json:"height"` + // Width int `json:"width"` + // } `json:"aspectRatio"` + // Image struct { + // Type string `json:"$type"` + // Ref struct { + // Link string `json:"$link"` + // } `json:"ref"` + // MimeType string `json:"mimeType"` + // Size int `json:"size"` + // } `json:"image"` + // } `json:"images"` + // } `json:"media"` + // Record struct { + // Type string `json:"$type"` + // Record struct { + // Cid string `json:"cid"` + // URI string `json:"uri"` + // } `json:"record"` + // } `json:"record"` + // } `json:"embed"` + // Labels struct { + // Type string `json:"$type"` + // Values []struct { + // Val string `json:"val"` + // } `json:"values"` + // } `json:"labels"` + // Langs []string `json:"langs"` + // Text string `json:"text"` + // } `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,omitempty"` + 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) @@ -160,12 +314,10 @@ func (post *Post) ProcessFacets(aliases []Records) string { switch feature.Type { case "app.bsky.richtext.facet#mention": link := fmt.Sprintf(`%s`, feature.Did, post.Text[start:end]) - if aliases != nil { - for _, alias := range aliases { - if alias.Value.Subject == feature.Did { - link = fmt.Sprintf(`%s`, - strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1]) - } + for _, alias := range aliases { + if alias.Value.Subject == feature.Did { + link = fmt.Sprintf(`%s`, + strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1]) } } result.WriteString(link) diff --git a/main.go b/main.go index 8f8e462..29c3282 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "encoding/json" + "flag" "fmt" "image/jpeg" "io" @@ -11,6 +13,7 @@ import ( "net/http" "net/url" "os" + "regexp" "strconv" "strings" "time" @@ -19,6 +22,7 @@ import ( tgbotapi "github.com/OvyFlash/telegram-bot-api" // apibsky "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/jetstream/pkg/client" "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" "github.com/bluesky-social/jetstream/pkg/models" @@ -39,7 +43,13 @@ type handler struct { bsky *bsky.BSky } +var ( + post = flag.String("post", "", "URL to a BlueSky post") +) + func main() { + flag.Parse() + var handle = os.Getenv("BSKY_HANDLE") var password = os.Getenv("BSKY_PASSWORD") bskyClient := bsky.NewBSky() @@ -48,30 +58,11 @@ func main() { log.Fatal(err, ". please set BSKY_HANDLE and BSKY_PASSWORD env variables") } - ctx := context.Background() - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug.Level(), - }))) - - logger := slog.Default() - config := client.DefaultClientConfig() - config.WebsocketURL = serverAddr - config.WantedCollections = []string{"app.bsky.feed.post"} - config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID} - config.Compress = true - h := &handler{ seenSeqs: make(map[int64]struct{}), bsky: bskyClient, } - scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent) - - c, err := client.NewClient(config, logger, scheduler) - if err != nil { - log.Fatalf("failed to create client: %v", err) - } - endpoint := "https://api.telegram.org/bot%s/%s" if os.Getenv("TG_API_ENDPOINT") != "" { endpoint = os.Getenv("TG_API_ENDPOINT") @@ -86,6 +77,50 @@ func main() { log.Fatal("TG_CHANNEL_ID is not set") } + if *post != "" { + r := regexp.MustCompile(`^https:\/\/.*?\/profile\/(.*?)\/post\/(.*?)$`) + s := r.FindStringSubmatch(*post) + handle := s[1] + if s[1][0:4] != "did:" { + handle, _ = bskyClient.ResolveHandle(s[1]) + } + + postJSON := bskyClient.Bluesky.FetchPost(handle, s[2]) + p, _ := json.Marshal(postJSON.Record) + h.ProcessPost(&models.Event{ + Did: postJSON.Author.Did, + TimeUS: postJSON.Record.CreatedAt.Unix(), + Kind: "", + Commit: &models.Commit{ + CID: postJSON.Cid, + Operation: "create", + RKey: strings.Split(postJSON.URI, "/")[4], + Collection: "app.bsky.feed.post", + Record: p, + }, + }) + return + } + + ctx := context.Background() + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug.Level(), + }))) + + logger := slog.Default() + config := client.DefaultClientConfig() + config.WebsocketURL = serverAddr + config.WantedCollections = []string{"app.bsky.feed.post"} + config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID} + config.Compress = true + + scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent) + + c, err := client.NewClient(config, logger, scheduler) + if err != nil { + log.Fatalf("failed to create client: %v", err) + } + // ------------------------------------------------------------------------------ // file, err := os.Open("posts.json") // if err != nil {