diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index 8b5eaef..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,24 +0,0 @@ -on: - push: - branches: - - main -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Login to Docker Hub - uses: https://github.com/docker/login-action@v3 - with: - registry: git.zio.sh - username: ${{ secrets.REPO_USER }} - password: ${{ secrets.REPO_PASS }} - - name: Set up Docker Build Push Action - uses: https://github.com/docker/build-push-action@v2 - with: - tags: | - git.zio.sh/astra/bsky2tg:latest - git.zio.sh/astra/bsky2tg:${{ github.sha }} - push: true - load: false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2dadb1b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM golang:alpine AS builder - -WORKDIR /go/src/git.zio.sh/bsky2tg -COPY . . - -RUN apk update && \ - apk add --no-cache git bash && \ - go get -d -v ./... && \ - go install - -FROM alpine:latest - -COPY --from=builder /go/bin/bsky2tg /usr/local/bin/bsky2tg - -CMD ["bsky2tg"] \ No newline at end of file diff --git a/README.md b/README.md index 8ee216b..b61cf15 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,6 @@ BSKY_HANDLE= BSKY_PASSWORD= ``` -If you use a different Telegram bot endpoint, you can set it with - -```properties -TG_API_ENDPOINT=https://api.domain.com/bot%s/%s -``` - To run: ```bash diff --git a/bsky/bluesky.go b/bsky/bluesky.go index 1ca7bd5..ae8d5cf 100644 --- a/bsky/bluesky.go +++ b/bsky/bluesky.go @@ -127,7 +127,7 @@ func (bluesky *Bluesky) CheckSessionValid() { type TelegramRecord struct { ChannelID int64 `json:"channel_id"` - MessageID []int `json:"message_id"` + MessageID int `json:"message_id"` Link *Link `json:"link"` Error string `json:"error"` Message string `json:"message"` @@ -263,17 +263,3 @@ 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 c11661d..34047a4 100644 --- a/bsky/client.go +++ b/bsky/client.go @@ -28,7 +28,7 @@ func NewBSky() *BSky { } } -func (b *BSky) ResolveHandle(handle string) (string, error) { +func (b *BSky) getPDS() error { httpClient := &http.Client{Timeout: 3 * time.Second} resp := new(BSkySessionResponse) errResp := &struct { @@ -38,29 +38,23 @@ func (b *BSky) ResolveHandle(handle string) (string, error) { params := struct { Handle string `url:"handle"` }{ - Handle: 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) + 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:] + if strings.HasPrefix(resp.DID, "did:web:") { + didURL.Host = "https://" + resp.DID[8:] didURL.Path = "/.well-known/did.json" - } else if strings.HasPrefix(did, "did:plc:") { + } else if strings.HasPrefix(resp.DID, "did:plc:") { didURL.Host = "https://plc.directory" - didURL.Path = "/" + did + didURL.Path = "/" + resp.DID } else { return errors.New("DID is not supported") } @@ -110,7 +104,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 fmt.Errorf("unable to auth: %s", err) + return errors.New(fmt.Sprintf("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 a06f131..1560be2 100644 --- a/bsky/parse.go +++ b/bsky/parse.go @@ -18,50 +18,42 @@ type Post struct { 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"` @@ -69,19 +61,16 @@ type Media struct { 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"` @@ -90,59 +79,35 @@ type Embed struct { 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"` } - -func (r *Root) GetDID() string { - return strings.Split(r.URI, "/")[2] -} - -func (r *Root) GetRKey() string { - return strings.Split(r.URI, "/")[4] -} - type Parent struct { Cid string `json:"cid,omitempty"` URI string `json:"uri,omitempty"` } - -func (p *Parent) GetDID() string { - return strings.Split(p.URI, "/")[2] -} - -func (p *Parent) GetRKey() string { - return strings.Split(p.URI, "/")[4] -} - 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"` @@ -153,166 +118,11 @@ type ParsedEmbeds struct { Type string MimeType string Ref string - Cid 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"` - // 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) @@ -350,10 +160,12 @@ 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]) - 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]) + 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]) + } } } result.WriteString(link) diff --git a/main.go b/main.go index 2f4ac7e..b45363f 100644 --- a/main.go +++ b/main.go @@ -3,8 +3,6 @@ package main import ( "bytes" "context" - "encoding/json" - "flag" "fmt" "image/jpeg" "io" @@ -13,7 +11,6 @@ import ( "net/http" "net/url" "os" - "regexp" "strconv" "strings" "time" @@ -21,6 +18,7 @@ import ( "git.zio.sh/astra/bsky2tg/bsky" 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" @@ -41,14 +39,7 @@ type handler struct { bsky *bsky.BSky } -var ( - post = flag.String("post", "", "URL to a BlueSky post") - delete = flag.Bool("delete", false, "true/false to delete post") -) - func main() { - flag.Parse() - var handle = os.Getenv("BSKY_HANDLE") var password = os.Getenv("BSKY_PASSWORD") bskyClient := bsky.NewBSky() @@ -57,72 +48,6 @@ func main() { log.Fatal(err, ". please set BSKY_HANDLE and BSKY_PASSWORD env variables") } - h := &handler{ - seenSeqs: make(map[int64]struct{}), - bsky: bskyClient, - } - - endpoint := "https://api.telegram.org/bot%s/%s" - if os.Getenv("TG_API_ENDPOINT") != "" { - endpoint = os.Getenv("TG_API_ENDPOINT") - } - bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(os.Getenv("TG_TOKEN"), endpoint) - if err != nil { - panic(err) - } - h.tg = bot - - if os.Getenv("TG_CHANNEL_ID") == "" { - 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]) - } - - if handle != bskyClient.Bluesky.Cfg.DID { - log.Fatal("Unable to send posts from other accounts") - } - - tgpost, tgposterr := h.bsky.Bluesky.GetTelegramData(s[2]) - if *delete { - if tgposterr == "" { - log.Printf("Found post %s in channel %d, deleting", s[2], tgpost.ChannelID) - m := tgbotapi.NewDeleteMessages(tgpost.ChannelID, tgpost.MessageID) - h.tg.Send(m) - h.bsky.Bluesky.DeleteRecord([]string{s[2], s[1], "blue.zio.bsky2tg.post"}) - } else { - log.Printf("Unable to find post %s on PDS", s[2]) - } - return - } - - if tgpost.ChannelID != 0 { - log.Printf("Post %s already sent to channel %d, exiting", s[2], tgpost.ChannelID) - return - } - - 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(), @@ -135,6 +60,11 @@ func main() { 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) @@ -142,6 +72,57 @@ func main() { log.Fatalf("failed to create client: %v", err) } + bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(os.Getenv("TG_TOKEN"), "https://bot.astra.blue/bot%s/%s") + if err != nil { + panic(err) + } + h.tg = bot + + if os.Getenv("TG_CHANNEL_ID") == "" { + log.Fatal("TG_CHANNEL_ID is not set") + } + + // ------------------------------------------------------------------------------ + // file, err := os.Open("posts.json") + // if err != nil { + // fmt.Printf("Error opening file: %v\n", err) + // return + // } + // defer file.Close() + // byteValue, err := io.ReadAll(file) + // if err != nil { + // fmt.Printf("Error reading file: %v\n", err) + // return + // } + + // var posts = struct { + // Records []struct { + // URI string `json:"uri"` + // CID string `json:"cid"` + // Value *bsky.Post `json:"value"` + // } `json:"records"` + // }{} + + // // 4. Unmarshal (decode) the JSON data into the struct + // err = json.Unmarshal(byteValue, &posts) + // if err != nil { + // fmt.Printf("Error unmarshaling JSON: %v\n", err) + // return + // } + // for _, post := range posts.Records { + // log.Printf("post: %s\n", post.Value.ProcessFacets(h.bsky.Bluesky.FetchAliases())) + // s, _ := json.Marshal(post.Value) + // h.ProcessPost(&models.Event{Did: bskyClient.Bluesky.Cfg.DID, Commit: &models.Commit{ + // Record: s, + // RKey: strings.Split(post.URI, "/")[4], + // CID: post.CID, + // Collection: "app.bsky.feed.post", + // }}) + // time.Sleep(time.Second * 2) + // } + // return + // ------------------------------------------------------------------------------ + cursor := time.Now().UnixMicro() restartCount := 0 loop: @@ -164,19 +145,19 @@ func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error { return nil } - switch event.Commit.Operation { - case models.CommitOperationCreate, models.CommitOperationUpdate: + if event.Commit.Operation == models.CommitOperationCreate || + event.Commit.Operation == models.CommitOperationUpdate { h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post bsky.PersistAuthSession(h.bsky.Bluesky.Cfg) h.ProcessPost(event) - case models.CommitOperationDelete: + } else if event.Commit.Operation == models.CommitOperationDelete { h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post bsky.PersistAuthSession(h.bsky.Bluesky.Cfg) r, e := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey) if e == "" { - m := tgbotapi.NewDeleteMessages(r.ChannelID, r.MessageID) + m := tgbotapi.NewDeleteMessage(r.ChannelID, r.MessageID) h.tg.Send(m) - h.bsky.Bluesky.DeleteRecord([]string{event.Commit.RKey, event.Did, "blue.zio.bsky2tg.post"}) + h.bsky.Bluesky.DeleteRecord([]string{event.Commit.RKey, event.Did, event.Commit.Collection}) } } @@ -283,13 +264,9 @@ func (h *handler) ProcessPost(event *models.Event) error { } else { resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup)) uri, cid := getLink(event) - var messageIDs []int - for _, msgID := range resp { - messageIDs = append(messageIDs, msgID.MessageID) - } h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{ ChannelID: resp[0].Chat.ID, - MessageID: messageIDs, + MessageID: resp[0].MessageID, Link: &bsky.Link{ Cid: cid, URI: uri, @@ -307,11 +284,10 @@ func (h *handler) ProcessPost(event *models.Event) error { if ps.IsQuotePost() { m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{ IsDisabled: false, - URL: fmt.Sprintf("https://fxbsky.app/profile/%s/post/%s", + URL: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", strings.Split(ps.Embed.Record.URI, "/")[2], strings.Split(ps.Embed.Record.URI, "/")[4]), - PreferSmallMedia: false, - PreferLargeMedia: true, + PreferSmallMedia: true, ShowAboveText: true, } } else { @@ -321,7 +297,7 @@ func (h *handler) ProcessPost(event *models.Event) error { uri, cid := getLink(event) h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{ ChannelID: resp.Chat.ID, - MessageID: []int{resp.MessageID}, + MessageID: resp.MessageID, Link: &bsky.Link{ Cid: cid, URI: uri, @@ -332,7 +308,7 @@ func (h *handler) ProcessPost(event *models.Event) error { } func buildBlobURL(server string, did string, cid string) string { - return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + cid + return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + url.QueryEscape(cid) } func getLink(event *models.Event) (string, string) {