From e8b6e04239da1782cb069693a24accbf71960146 Mon Sep 17 00:00:00 2001 From: Astra Date: Thu, 26 Mar 2026 07:49:58 +0000 Subject: [PATCH] Handle recordWithMedia, refactor processPost function --- bsky/bluesky.go | 2 +- bsky/parse.go | 131 ++++++++++++++++----------- main.go | 233 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 249 insertions(+), 117 deletions(-) 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 }