diff --git a/bsky/bluesky.go b/bsky/bluesky.go
index 4e69a78..46249d8 100644
--- a/bsky/bluesky.go
+++ b/bsky/bluesky.go
@@ -294,6 +294,6 @@ func (bluesky *Bluesky) FetchPost(did string, rkey string) FetchedPost {
}{
URIs: fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey),
}
- bluesky.sling.New().Get("/xrpc/app.bsky.feed.getPosts").QueryStruct(¶ms).Receive(resp, resp)
+ bluesky.publicSling.New().Get("/xrpc/app.bsky.feed.getPosts").QueryStruct(¶ms).Receive(resp, resp)
return resp.Posts[0]
}
diff --git a/bsky/parse.go b/bsky/parse.go
index 99bab03..8474daa 100644
--- a/bsky/parse.go
+++ b/bsky/parse.go
@@ -16,7 +16,7 @@ type Post struct {
Langs []string `json:"langs,omitempty"`
Labels *Labels `json:"labels,omitempty"`
Reply *Reply `json:"reply,omitempty"`
- Facets *[]Facets `json:"facets,omitempty"`
+ Facets []Facets `json:"facets,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -67,7 +67,7 @@ type Media struct {
Type string `json:"$type,omitempty"`
External *External `json:"external,omitempty"`
Video *Video `json:"video,omitempty"`
- Images *[]Images `json:"images,omitempty"`
+ Images []Images `json:"images,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
}
@@ -86,7 +86,7 @@ type PostRecord struct {
type Embed struct {
Type string `json:"$type,omitempty"`
Media *Media `json:"media,omitempty"`
- Images *[]Images `json:"images,omitempty"`
+ Images []Images `json:"images,omitempty"`
Video *Video `json:"video,omitempty"`
Record *PostRecord `json:"record,omitempty"`
External *External `json:"external,omitempty"`
@@ -97,8 +97,8 @@ type Values struct {
}
type Labels struct {
- Type string `json:"$type,omitempty"`
- Values *[]Values `json:"values,omitempty"`
+ Type string `json:"$type,omitempty"`
+ Values []Values `json:"values,omitempty"`
}
type Root struct {
@@ -145,9 +145,9 @@ type Features struct {
}
type Facets struct {
- Type string `json:"$type"`
- Index *Index `json:"index,omitempty"`
- Features *[]Features `json:"features,omitempty"`
+ Type string `json:"$type"`
+ Index *Index `json:"index,omitempty"`
+ Features []Features `json:"features,omitempty"`
}
type ParsedEmbeds struct {
@@ -333,33 +333,36 @@ func (post *Post) ProcessFacets(aliases []Records) string {
return html.EscapeString(post.Text)
}
- sort.Slice((*post.Facets), func(i, j int) bool {
- return (*post.Facets)[i].Index.ByteStart < (*post.Facets)[j].Index.ByteStart
+ 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
- for _, facet := range *post.Facets {
+ for _, facet := range post.Facets {
start := facet.Index.ByteStart
end := facet.Index.ByteEnd
// Escape HTML in plain text portions
result.WriteString(html.EscapeString(post.Text[lastIndex:start]))
- for _, feature := range *facet.Features {
+ for _, feature := range facet.Features {
switch feature.Type {
case "app.bsky.richtext.facet#mention":
link := fmt.Sprintf(`%s`, feature.Did, html.EscapeString(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])
+ parts := strings.SplitN(alias.Value.Target, "#", 2)
+ if len(parts) == 2 {
+ link = fmt.Sprintf(`%s`, parts[0], parts[1])
+ }
}
}
result.WriteString(link)
case "app.bsky.richtext.facet#link":
- link := fmt.Sprintf(`%s`, feature.URI, html.EscapeString(post.Text[start:end]))
+ uri := strings.Trim(feature.URI, "\"")
+ link := fmt.Sprintf(`%s`, uri, html.EscapeString(post.Text[start:end]))
result.WriteString(link)
case "app.bsky.richtext.facet#tag":
link := fmt.Sprintf(`%s`, feature.Tag, html.EscapeString(post.Text[start:end]))
@@ -375,60 +378,88 @@ func (post *Post) ProcessFacets(aliases []Records) string {
return result.String()
}
-func (p *Post) GetEmbeds() *[]ParsedEmbeds {
- var parsedEmbeds = &[]ParsedEmbeds{}
- if p.Embed != nil {
- if p.Embed.Video != nil {
- parsedEmbed := ParsedEmbeds{
+func (p *Post) GetEmbeds() []ParsedEmbeds {
+ var parsedEmbeds []ParsedEmbeds
+
+ if p.Embed == nil {
+ return parsedEmbeds
+ }
+
+ switch p.Embed.Type {
+ case "app.bsky.embed.images":
+ for _, image := range p.Embed.Images {
+ if image.Image != nil && image.Image.Ref != nil {
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
+ URI: image.Image.Ref.Link,
+ Type: "image",
+ })
+ }
+ }
+
+ case "app.bsky.embed.video":
+ if p.Embed.Video != nil && p.Embed.Video.Ref != nil {
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.Video.Ref.Link,
Type: "video",
- }
- *parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
+ })
}
+
+ case "app.bsky.embed.external":
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)
+ t := "external"
+ if strings.Contains(p.Embed.External.URI, "tenor.com") {
+ t = "gif"
}
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
+ URI: p.Embed.External.URI,
+ Type: t,
+ })
+ }
+
+ case "app.bsky.embed.record":
+ if p.Embed.Record != nil {
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
+ URI: p.Embed.Record.Record.URI,
+ Cid: p.Embed.Record.Record.Cid,
+ Type: "record",
+ })
+ }
+
+ case "app.bsky.embed.recordWithMedia":
+ // Quote post - also extract the media it contains
+ if p.Embed.Record != nil {
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
+ URI: p.Embed.Record.Record.URI,
+ Cid: p.Embed.Record.Record.Cid,
+ Type: "record",
+ })
}
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",
+ for _, image := range p.Embed.Media.Images {
+ if image.Image != nil && image.Image.Ref != nil {
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
+ URI: image.Image.Ref.Link,
+ Type: "image",
+ })
}
- *parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
}
}
- if p.Embed.Media.Video != nil {
- parsedEmbed := ParsedEmbeds{
+ if p.Embed.Media.Video != nil && p.Embed.Media.Video.Ref != nil {
+ parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.Media.Video.Ref.Link,
Type: "video",
- }
- *parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
+ })
}
if p.Embed.Media.External != nil {
- parsedEmbed := ParsedEmbeds{
+ parsedEmbeds = append(parsedEmbeds, 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
}
@@ -441,7 +472,7 @@ func (p *Post) GetMedia() *Media {
return nil
}
-func (p *Post) GetMediaImages() *[]Images {
+func (p *Post) GetMediaImages() []Images {
if p.GetMedia() != nil {
return p.GetMedia().Images
}
diff --git a/main.go b/main.go
index 34d2b0b..cab0d5e 100644
--- a/main.go
+++ b/main.go
@@ -186,6 +186,53 @@ func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error {
return nil
}
+func (h *handler) handleVideo(media bsky.ParsedEmbeds) (tgbotapi.InputMedia, error) {
+ url := buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI)
+ log.Printf("Fetching video: %s\n", url)
+
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch video: %w", err)
+ }
+ defer resp.Body.Close()
+
+ filename := media.URI + ".mp4"
+ f, err := os.Create(filename)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create video file: %w", err)
+ }
+ defer func() {
+ f.Close()
+ os.Remove(filename)
+ }()
+
+ if _, err := io.Copy(f, resp.Body); err != nil {
+ return nil, fmt.Errorf("failed to write video: %w", err)
+ }
+ if _, err := f.Seek(0, 0); err != nil {
+ return nil, fmt.Errorf("failed to seek video: %w", err)
+ }
+
+ mediaAdd := tgbotapi.NewInputMediaVideo(tgbotapi.FileReader{Name: "video.mp4", Reader: f})
+
+ metadata, err := getVideoMetadata(f.Name())
+ if err != nil {
+ return nil, fmt.Errorf("failed to read video metadata: %w", err)
+ }
+
+ mediaAdd.SupportsStreaming = true
+ mediaAdd.Height = metadata.Height()
+ mediaAdd.Width = metadata.Width()
+ mediaAdd.Duration = int(metadata.Duration())
+
+ frames, _ := metadata.ReadFrames(0)
+ var buf bytes.Buffer
+ jpeg.Encode(&buf, frames[0], &jpeg.Options{Quality: 90})
+ mediaAdd.Thumb = tgbotapi.FileBytes{Name: "thumb.jpg", Bytes: buf.Bytes()}
+
+ return &mediaAdd, nil
+}
+
func (h *handler) ProcessPost(event *models.Event) error {
ps, _ := h.bsky.ParsePost(event.Commit.Record)
po := ps.GetEmbeds()
@@ -209,42 +256,38 @@ func (h *handler) ProcessPost(event *models.Event) error {
aliases := h.bsky.Bluesky.FetchAliases()
facets := ps.ProcessFacets(aliases)
+ ownHandle, handleErr := h.bsky.GetHandleFromDID(h.bsky.Bluesky.Cfg.DID)
+ if handleErr != nil {
+ ownHandle = h.bsky.Bluesky.Cfg.Handle
+ }
+
var captionText string
if ps.IsQuotePost() {
- ownHandle, handleErr := h.bsky.GetHandleFromDID(h.bsky.Bluesky.Cfg.DID)
- if handleErr != nil {
- ownHandle = h.bsky.Bluesky.Cfg.Handle
+ var quotedURI string
+ if ps.Embed.Record != nil && ps.Embed.Record.Record != nil && ps.Embed.Record.Record.URI != "" {
+ quotedURI = ps.Embed.Record.Record.URI
+ } else if ps.Embed.Record != nil && ps.Embed.Record.URI != "" {
+ quotedURI = ps.Embed.Record.URI
}
- if ps.Embed.Record.Type == "app.bsky.embed.record" {
- handle, _ := h.bsky.GetHandleFromDID(strings.Split(ps.Embed.Record.Record.URI, "/")[2])
- captionText = fmt.Sprintf(
- quotePostFormat,
- facets,
- strings.Split(ps.Embed.Record.Record.URI, "/")[2],
- strings.Split(ps.Embed.Record.Record.URI, "/")[4],
- handle,
- event.Did,
- event.Commit.RKey,
- ownHandle)
- } else {
- handle, _ := h.bsky.GetHandleFromDID(strings.Split(ps.Embed.Record.URI, "/")[2])
- captionText = fmt.Sprintf(
- quotePostFormat,
- facets,
- strings.Split(ps.Embed.Record.URI, "/")[2],
- strings.Split(ps.Embed.Record.URI, "/")[4],
- handle,
- event.Did,
- event.Commit.RKey,
- ownHandle)
+
+ if quotedURI != "" {
+ parts := strings.Split(quotedURI, "/")
+ if len(parts) >= 5 {
+ handle, _ := h.bsky.GetHandleFromDID(parts[2])
+ captionText = fmt.Sprintf(
+ quotePostFormat,
+ facets,
+ parts[2],
+ parts[4],
+ handle,
+ event.Did,
+ event.Commit.RKey,
+ ownHandle)
+ }
}
}
if captionText == "" {
- ownHandle, handleErr := h.bsky.GetHandleFromDID(h.bsky.Bluesky.Cfg.DID)
- if handleErr != nil {
- ownHandle = h.bsky.Bluesky.Cfg.Handle
- }
if facets != "" {
captionText = fmt.Sprintf(postFormat, facets, h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, ownHandle)
} else {
@@ -252,16 +295,72 @@ func (h *handler) ProcessPost(event *models.Event) error {
}
}
- // post has media
- if len((*po)) != 0 {
+ if len(po) != 0 {
mediaGroup := []tgbotapi.InputMedia{}
- if (*po)[0].Type == "external" {
- tenorGif := tgbotapi.NewInputMediaVideo(tgbotapi.FileURL((*po)[0].URI)) // is most likely gif from Tenor
+
+ if ps.Embed.Type == "app.bsky.embed.recordWithMedia" {
+ hasExternal := false
+ for _, media := range po {
+ if media.Type == "external" {
+ hasExternal = true
+ break
+ }
+ }
+
+ if hasExternal && ps.Embed.Media != nil && ps.Embed.Media.External != nil {
+ // Send as text message with webpage preview
+ m := tgbotapi.NewMessage(cid, captionText)
+ m.ParseMode = tgbotapi.ModeHTML
+ m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
+ IsDisabled: false,
+ URL: ps.Embed.Media.External.URI,
+ PreferLargeMedia: true,
+ ShowAboveText: false,
+ }
+ resp, _ := h.tg.Send(m)
+ uri, postCid := getLink(event)
+ h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
+ ChannelID: resp.Chat.ID,
+ MessageID: []int{resp.MessageID},
+ Link: &bsky.Link{
+ Cid: postCid,
+ URI: uri,
+ },
+ }, event.Commit.RKey)
+ return nil
+ }
+
+ // recordWithMedia with images or video (not external) — fall through to normal media handling
+ for _, media := range po {
+ switch media.Type {
+ case "record":
+ continue
+ case "image":
+ mediaAdd := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI)))
+ if len(mediaGroup) == 0 {
+ mediaAdd.Caption = captionText
+ mediaAdd.ParseMode = tgbotapi.ModeHTML
+ }
+ mediaGroup = append(mediaGroup, &mediaAdd)
+ case "video":
+ mediaAdd, err := h.handleVideo(media)
+ if err != nil {
+ log.Printf("Failed to handle video: %s\n", err)
+ break
+ }
+ if len(mediaGroup) == 0 {
+ setCaption(mediaAdd, captionText)
+ }
+ mediaGroup = append(mediaGroup, mediaAdd)
+ }
+ }
+ } else if po[0].Type == "external" {
+ tenorGif := tgbotapi.NewInputMediaVideo(tgbotapi.FileURL(po[0].URI))
tenorGif.Caption = captionText
tenorGif.ParseMode = tgbotapi.ModeHTML
mediaGroup = append(mediaGroup, &tenorGif)
} else {
- for _, media := range *po {
+ for _, media := range po {
switch media.Type {
case "image":
mediaAdd := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI)))
@@ -271,37 +370,19 @@ func (h *handler) ProcessPost(event *models.Event) error {
}
mediaGroup = append(mediaGroup, &mediaAdd)
case "video":
- log.Printf("Fetching video: %s\n", buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
- resp, _ := http.Get(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
- defer resp.Body.Close()
- f, _ := os.Create(media.URI + ".mp4")
- defer f.Close()
- io.Copy(f, resp.Body)
- f.Seek(0, 0)
- mediaAdd := tgbotapi.NewInputMediaVideo(tgbotapi.FileReader{Name: "video.mp4", Reader: f})
- metadata, err := getVideoMetadata(f.Name())
+ mediaAdd, err := h.handleVideo(media)
if err != nil {
- log.Printf("Unable to read video metadata: %s - URL: %s\n", err, buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
+ log.Printf("Failed to handle video: %s\n", err)
break
}
- mediaAdd.SupportsStreaming = true
- mediaAdd.Height = metadata.Height()
- mediaAdd.Width = metadata.Width()
- mediaAdd.Duration = int(metadata.Duration())
-
- frames, _ := metadata.ReadFrames(0)
- var buf bytes.Buffer
- jpeg.Encode(&buf, frames[0], &jpeg.Options{Quality: 90})
- mediaAdd.Thumb = tgbotapi.FileBytes{Name: "thumb.jpg", Bytes: buf.Bytes()}
if len(mediaGroup) == 0 {
- mediaAdd.Caption = captionText
- mediaAdd.ParseMode = tgbotapi.ModeHTML
+ setCaption(mediaAdd, captionText)
}
- os.Remove(media.URI + ".mp4")
- mediaGroup = append(mediaGroup, &mediaAdd)
+ mediaGroup = append(mediaGroup, mediaAdd)
}
}
}
+
if len(mediaGroup) == 0 {
log.Print("No mediaGroup to send, see previous error")
} else {
@@ -310,7 +391,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
fmt.Println(resp, err)
} else {
resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup))
- uri, cid := getLink(event)
+ uri, postCid := getLink(event)
var messageIDs []int
for _, msgID := range resp {
messageIDs = append(messageIDs, msgID.MessageID)
@@ -319,7 +400,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
ChannelID: resp[0].Chat.ID,
MessageID: messageIDs,
Link: &bsky.Link{
- Cid: cid,
+ Cid: postCid,
URI: uri,
},
}, event.Commit.RKey)
@@ -334,11 +415,19 @@ func (h *handler) ProcessPost(event *models.Event) error {
}
m.ParseMode = tgbotapi.ModeHTML
if ps.IsQuotePost() {
- m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
- IsDisabled: false,
- URL: fmt.Sprintf(embedURL,
+ var previewURI string
+ if ps.Embed.Record != nil && ps.Embed.Record.Record != nil {
+ previewURI = fmt.Sprintf(embedURL,
+ strings.Split(ps.Embed.Record.Record.URI, "/")[2],
+ strings.Split(ps.Embed.Record.Record.URI, "/")[4])
+ } else if ps.Embed.Record != nil {
+ previewURI = fmt.Sprintf(embedURL,
strings.Split(ps.Embed.Record.URI, "/")[2],
- strings.Split(ps.Embed.Record.URI, "/")[4]),
+ strings.Split(ps.Embed.Record.URI, "/")[4])
+ }
+ m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
+ IsDisabled: false,
+ URL: previewURI,
PreferSmallMedia: true,
PreferLargeMedia: false,
ShowAboveText: true,
@@ -346,13 +435,14 @@ func (h *handler) ProcessPost(event *models.Event) error {
} else {
m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{IsDisabled: true}
}
- resp, _ := h.tg.Send(m)
- uri, cid := getLink(event)
+ resp, e := h.tg.Send(m)
+ fmt.Println(resp, e)
+ uri, postCid := getLink(event)
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp.Chat.ID,
MessageID: []int{resp.MessageID},
Link: &bsky.Link{
- Cid: cid,
+ Cid: postCid,
URI: uri,
},
}, event.Commit.RKey)
@@ -360,6 +450,17 @@ func (h *handler) ProcessPost(event *models.Event) error {
return nil
}
+func setCaption(media tgbotapi.InputMedia, caption string) {
+ switch m := media.(type) {
+ case *tgbotapi.InputMediaVideo:
+ m.Caption = caption
+ m.ParseMode = tgbotapi.ModeHTML
+ case *tgbotapi.InputMediaPhoto:
+ m.Caption = caption
+ m.ParseMode = tgbotapi.ModeHTML
+ }
+}
+
func buildBlobURL(server string, did string, cid string) string {
return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + cid
}