Compare commits

..

20 commits

Author SHA1 Message Date
c4d4e91548 check if post is already in channel
All checks were successful
/ build (push) Successful in 59s
2025-09-23 17:51:12 +01:00
acbdd41680 Fix video embed
All checks were successful
/ build (push) Successful in 1m11s
2025-09-14 09:55:27 +01:00
5ff08f5acc Change quote post to use deer.social for embeds
All checks were successful
/ build (push) Successful in 1m58s
2025-09-13 17:46:14 +01:00
dc7382f162 Update parse.go 2025-09-13 17:45:28 +01:00
aff13c04dd use deleteMessages instead
All checks were successful
/ build (push) Successful in 52s
2025-07-11 14:32:00 +01:00
1690279d5c add support for multiple message IDs
All checks were successful
/ build (push) Successful in 1m25s
2025-07-11 14:15:09 +01:00
bd8a437f43 Change link media to be large
All checks were successful
/ build (push) Successful in 1m3s
2025-07-10 19:19:49 +01:00
dbc89e5b95 remove test stuff 2025-07-03 18:10:35 +01:00
be8b787c52 add error message
All checks were successful
/ build (push) Successful in 1m13s
2025-07-03 16:10:36 +01:00
ce0709f72d Update .forgejo/workflows/build.yml
All checks were successful
/ build (push) Successful in 53s
2025-07-03 15:45:10 +02:00
4f94ea647c Update .forgejo/workflows/build.yml 2025-07-03 15:43:05 +02:00
21722264d1 update readme for TG_API_ENDPOINT
All checks were successful
/ build (push) Successful in 56s
2025-07-03 10:24:07 +01:00
f572ee3958 add delete flag, fix deleteRecord
All checks were successful
/ build (push) Successful in 56s
2025-07-02 16:40:20 +01:00
bf2b621171 Merge pull request 'add support for links as posts' (#2) from post into main
All checks were successful
/ build (push) Successful in 55s
Reviewed-on: #2
2025-07-01 08:09:08 +02:00
bfa829d8c7 add support for links as posts 2025-07-01 07:08:46 +01:00
2bb3946237 add env var for endpoint
All checks were successful
/ build (push) Successful in 55s
2025-06-29 18:35:01 +01:00
e0a63bd7d5 add Dockerfile
All checks were successful
/ build (push) Successful in 1m52s
2025-06-19 11:41:32 +01:00
798f8134f4 add CI build
Some checks failed
/ build (push) Failing after 29s
2025-06-19 10:54:01 +01:00
f67964b5fd change to switch statement 2025-06-18 08:49:02 +01:00
0298c21668 Merge pull request 'add alias support for mentions' (#1) from linkalias into main
Reviewed-on: #1
2025-06-16 18:07:47 +02:00
7 changed files with 359 additions and 82 deletions

View file

@ -0,0 +1,24 @@
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

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
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"]

View file

@ -16,6 +16,12 @@ BSKY_HANDLE=
BSKY_PASSWORD= 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: To run:
```bash ```bash

View file

@ -127,7 +127,7 @@ func (bluesky *Bluesky) CheckSessionValid() {
type TelegramRecord struct { type TelegramRecord struct {
ChannelID int64 `json:"channel_id"` ChannelID int64 `json:"channel_id"`
MessageID int `json:"message_id"` MessageID []int `json:"message_id"`
Link *Link `json:"link"` Link *Link `json:"link"`
Error string `json:"error"` Error string `json:"error"`
Message string `json:"message"` Message string `json:"message"`
@ -263,3 +263,17 @@ type Records struct {
Cid string `json:"cid"` Cid string `json:"cid"`
Value Value `json:"value"` 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(&params).Receive(resp, resp)
return resp.Posts[0]
}

View file

@ -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} httpClient := &http.Client{Timeout: 3 * time.Second}
resp := new(BSkySessionResponse) resp := new(BSkySessionResponse)
errResp := &struct { errResp := &struct {
@ -38,23 +38,29 @@ func (b *BSky) getPDS() error {
params := struct { params := struct {
Handle string `url:"handle"` Handle string `url:"handle"`
}{ }{
Handle: b.Bluesky.Cfg.Handle, Handle: handle,
} }
sling.New().Base("https://public.api.bsky.app/").Client(httpClient). sling.New().Base("https://public.api.bsky.app/").Client(httpClient).
Get("/xrpc/com.atproto.identity.resolveHandle").QueryStruct(params). Get("/xrpc/com.atproto.identity.resolveHandle").QueryStruct(params).
Receive(resp, errResp) Receive(resp, errResp)
if errResp.Error != "" { 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 var didURL url.URL
if strings.HasPrefix(resp.DID, "did:web:") { if strings.HasPrefix(did, "did:web:") {
didURL.Host = "https://" + resp.DID[8:] didURL.Host = "https://" + did[8:]
didURL.Path = "/.well-known/did.json" 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.Host = "https://plc.directory"
didURL.Path = "/" + resp.DID didURL.Path = "/" + did
} else { } else {
return errors.New("DID is not supported") return errors.New("DID is not supported")
} }
@ -104,7 +110,7 @@ func (b *BSky) Auth(authData []string) error {
b.Bluesky.Cfg.AppPassword = authData[1] b.Bluesky.Cfg.AppPassword = authData[1]
err = b.Bluesky.CreateSession(b.Bluesky.Cfg) err = b.Bluesky.CreateSession(b.Bluesky.Cfg)
if err != nil { 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 b.Bluesky.Cfg.AppPassword = "" // we don't need to save this
PersistAuthSession(b.Bluesky.Cfg) PersistAuthSession(b.Bluesky.Cfg)

View file

@ -18,42 +18,50 @@ type Post struct {
Facets *[]Facets `json:"facets,omitempty"` Facets *[]Facets `json:"facets,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type Ref struct { type Ref struct {
Link string `json:"$link,omitempty"` Link string `json:"$link,omitempty"`
} }
type Thumb struct { type Thumb struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"` Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"` MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"`
} }
type External struct { type External struct {
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Thumb *Thumb `json:"thumb,omitempty"` Thumb *Thumb `json:"thumb,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
type Video struct { type Video struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"` Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"` MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"`
} }
type Image struct { type Image struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"` Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"` MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"`
} }
type AspectRatio struct { type AspectRatio struct {
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"` Height int `json:"height,omitempty"`
} }
type Images struct { type Images struct {
Alt string `json:"alt,omitempty"` Alt string `json:"alt,omitempty"`
Image *Image `json:"image,omitempty"` Image *Image `json:"image,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"` AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
} }
type Media struct { type Media struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
External *External `json:"external,omitempty"` External *External `json:"external,omitempty"`
@ -61,16 +69,19 @@ type Media struct {
Images *[]Images `json:"images,omitempty"` Images *[]Images `json:"images,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"` AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
} }
type Record struct { type Record struct {
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
} }
type PostRecord struct { type PostRecord struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Record *Record `json:"record,omitempty"` Record *Record `json:"record,omitempty"`
} }
type Embed struct { type Embed struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Media *Media `json:"media,omitempty"` Media *Media `json:"media,omitempty"`
@ -79,35 +90,59 @@ type Embed struct {
Record *PostRecord `json:"record,omitempty"` Record *PostRecord `json:"record,omitempty"`
External *External `json:"external,omitempty"` External *External `json:"external,omitempty"`
} }
type Values struct { type Values struct {
Val string `json:"val,omitempty"` Val string `json:"val,omitempty"`
} }
type Labels struct { type Labels struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Values *[]Values `json:"values,omitempty"` Values *[]Values `json:"values,omitempty"`
} }
type Root struct { type Root struct {
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,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 { type Parent struct {
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,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 { type Reply struct {
Root *Root `json:"root,omitempty"` Root *Root `json:"root,omitempty"`
Parent *Parent `json:"parent,omitempty"` Parent *Parent `json:"parent,omitempty"`
} }
type Index struct { type Index struct {
ByteEnd int `json:"byteEnd,omitempty"` ByteEnd int `json:"byteEnd,omitempty"`
ByteStart int `json:"byteStart,omitempty"` ByteStart int `json:"byteStart,omitempty"`
} }
type Features struct { type Features struct {
Did string `json:"did,omitempty"` Did string `json:"did,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Tag string `json:"tag,omitempty"` Tag string `json:"tag,omitempty"`
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
} }
type Facets struct { type Facets struct {
Type string `json:"$type"` Type string `json:"$type"`
Index *Index `json:"index,omitempty"` Index *Index `json:"index,omitempty"`
@ -118,11 +153,166 @@ type ParsedEmbeds struct {
Type string Type string
MimeType string MimeType string
Ref string Ref string
Cid string
URI string URI string
Width int64 Width int64
Height 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) { func (b *BSky) ParsePost(post []byte) (*Post, error) {
var p = &Post{} var p = &Post{}
err := json.Unmarshal(post, &p) err := json.Unmarshal(post, &p)
@ -160,14 +350,12 @@ func (post *Post) ProcessFacets(aliases []Records) string {
switch feature.Type { switch feature.Type {
case "app.bsky.richtext.facet#mention": case "app.bsky.richtext.facet#mention":
link := fmt.Sprintf(`<a href="https://bsky.app/profile/%s">%s</a>`, feature.Did, post.Text[start:end]) link := fmt.Sprintf(`<a href="https://bsky.app/profile/%s">%s</a>`, feature.Did, post.Text[start:end])
if aliases != nil {
for _, alias := range aliases { for _, alias := range aliases {
if alias.Value.Subject == feature.Did { if alias.Value.Subject == feature.Did {
link = fmt.Sprintf(`<a href="%s">%s</a>`, link = fmt.Sprintf(`<a href="%s">%s</a>`,
strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1]) strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1])
} }
} }
}
result.WriteString(link) result.WriteString(link)
case "app.bsky.richtext.facet#link": case "app.bsky.richtext.facet#link":
link := fmt.Sprintf(`<a href="%s">%s</a>`, feature.URI, post.Text[start:end]) link := fmt.Sprintf(`<a href="%s">%s</a>`, feature.URI, post.Text[start:end])

158
main.go
View file

@ -3,6 +3,8 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"flag"
"fmt" "fmt"
"image/jpeg" "image/jpeg"
"io" "io"
@ -11,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -18,7 +21,6 @@ import (
"git.zio.sh/astra/bsky2tg/bsky" "git.zio.sh/astra/bsky2tg/bsky"
tgbotapi "github.com/OvyFlash/telegram-bot-api" 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"
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
"github.com/bluesky-social/jetstream/pkg/models" "github.com/bluesky-social/jetstream/pkg/models"
@ -39,7 +41,14 @@ type handler struct {
bsky *bsky.BSky 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() { func main() {
flag.Parse()
var handle = os.Getenv("BSKY_HANDLE") var handle = os.Getenv("BSKY_HANDLE")
var password = os.Getenv("BSKY_PASSWORD") var password = os.Getenv("BSKY_PASSWORD")
bskyClient := bsky.NewBSky() bskyClient := bsky.NewBSky()
@ -48,6 +57,72 @@ func main() {
log.Fatal(err, ". please set BSKY_HANDLE and BSKY_PASSWORD env variables") 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() ctx := context.Background()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug.Level(), Level: slog.LevelDebug.Level(),
@ -60,11 +135,6 @@ func main() {
config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID} config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID}
config.Compress = true config.Compress = true
h := &handler{
seenSeqs: make(map[int64]struct{}),
bsky: bskyClient,
}
scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent) scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent)
c, err := client.NewClient(config, logger, scheduler) c, err := client.NewClient(config, logger, scheduler)
@ -72,57 +142,6 @@ func main() {
log.Fatalf("failed to create client: %v", err) 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() cursor := time.Now().UnixMicro()
restartCount := 0 restartCount := 0
loop: loop:
@ -145,19 +164,19 @@ func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error {
return nil return nil
} }
if event.Commit.Operation == models.CommitOperationCreate || switch event.Commit.Operation {
event.Commit.Operation == models.CommitOperationUpdate { case models.CommitOperationCreate, models.CommitOperationUpdate:
h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post
bsky.PersistAuthSession(h.bsky.Bluesky.Cfg) bsky.PersistAuthSession(h.bsky.Bluesky.Cfg)
h.ProcessPost(event) h.ProcessPost(event)
} else if event.Commit.Operation == models.CommitOperationDelete { case models.CommitOperationDelete:
h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post
bsky.PersistAuthSession(h.bsky.Bluesky.Cfg) bsky.PersistAuthSession(h.bsky.Bluesky.Cfg)
r, e := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey) r, e := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey)
if e == "" { if e == "" {
m := tgbotapi.NewDeleteMessage(r.ChannelID, r.MessageID) m := tgbotapi.NewDeleteMessages(r.ChannelID, r.MessageID)
h.tg.Send(m) h.tg.Send(m)
h.bsky.Bluesky.DeleteRecord([]string{event.Commit.RKey, event.Did, event.Commit.Collection}) h.bsky.Bluesky.DeleteRecord([]string{event.Commit.RKey, event.Did, "blue.zio.bsky2tg.post"})
} }
} }
@ -264,9 +283,13 @@ func (h *handler) ProcessPost(event *models.Event) error {
} else { } else {
resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup)) resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup))
uri, cid := getLink(event) uri, cid := getLink(event)
var messageIDs []int
for _, msgID := range resp {
messageIDs = append(messageIDs, msgID.MessageID)
}
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{ h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp[0].Chat.ID, ChannelID: resp[0].Chat.ID,
MessageID: resp[0].MessageID, MessageID: messageIDs,
Link: &bsky.Link{ Link: &bsky.Link{
Cid: cid, Cid: cid,
URI: uri, URI: uri,
@ -284,10 +307,11 @@ func (h *handler) ProcessPost(event *models.Event) error {
if ps.IsQuotePost() { if ps.IsQuotePost() {
m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{ m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
IsDisabled: false, IsDisabled: false,
URL: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", URL: fmt.Sprintf("https://fxbsky.app/profile/%s/post/%s",
strings.Split(ps.Embed.Record.URI, "/")[2], strings.Split(ps.Embed.Record.URI, "/")[2],
strings.Split(ps.Embed.Record.URI, "/")[4]), strings.Split(ps.Embed.Record.URI, "/")[4]),
PreferSmallMedia: true, PreferSmallMedia: false,
PreferLargeMedia: true,
ShowAboveText: true, ShowAboveText: true,
} }
} else { } else {
@ -297,7 +321,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
uri, cid := getLink(event) uri, cid := getLink(event)
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{ h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp.Chat.ID, ChannelID: resp.Chat.ID,
MessageID: resp.MessageID, MessageID: []int{resp.MessageID},
Link: &bsky.Link{ Link: &bsky.Link{
Cid: cid, Cid: cid,
URI: uri, URI: uri,
@ -308,7 +332,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
} }
func buildBlobURL(server string, did string, cid string) string { func buildBlobURL(server string, did string, cid string) string {
return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + url.QueryEscape(cid) return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + cid
} }
func getLink(event *models.Event) (string, string) { func getLink(event *models.Event) (string, string) {