From 3926db776282c94021c84a51986ec60ef6ec51bc Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:56:37 +0800 Subject: [PATCH] fix(rich-message): sync preserve referenced media for web clients --- internal/rpc/convert_rich_message.go | 234 ++++++++++++++++-- .../rpc/messages_rich_message_rpc_test.go | 192 +++++++++++++- internal/rpc/rich_message_limits.go | 13 + 3 files changed, 410 insertions(+), 29 deletions(-) diff --git a/internal/rpc/convert_rich_message.go b/internal/rpc/convert_rich_message.go index 5bb39a82..7026b6e2 100644 --- a/internal/rpc/convert_rich_message.go +++ b/internal/rpc/convert_rich_message.go @@ -50,6 +50,192 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) { return out, nil } +// richMessageMediaRefs is the media closure referenced by one PageBlock graph. +// IDs retain first-reference order so every projection is deterministic. +type richMessageMediaRefs struct { + photoIDs []int64 + documentIDs []int64 + photos map[int64]struct{} + documents map[int64]struct{} +} + +func collectRichMessageMediaRefs(blocks []tg.PageBlockClass) (richMessageMediaRefs, error) { + refs := richMessageMediaRefs{ + photos: make(map[int64]struct{}), + documents: make(map[int64]struct{}), + } + if err := refs.collectBlocks(blocks); err != nil { + return richMessageMediaRefs{}, err + } + return refs, nil +} + +func (r *richMessageMediaRefs) addPhoto(id int64, required bool) error { + if id == 0 { + if required { + return photoInvalidErr() + } + return nil + } + if _, ok := r.photos[id]; ok { + return nil + } + r.photos[id] = struct{}{} + r.photoIDs = append(r.photoIDs, id) + return nil +} + +func (r *richMessageMediaRefs) addDocument(id int64) error { + if id == 0 { + return mediaInvalidErr() + } + if _, ok := r.documents[id]; ok { + return nil + } + r.documents[id] = struct{}{} + r.documentIDs = append(r.documentIDs, id) + return nil +} + +func (r *richMessageMediaRefs) collectBlocks(blocks []tg.PageBlockClass) error { + for _, block := range blocks { + switch value := block.(type) { + case *tg.PageBlockPhoto: + if err := r.addPhoto(value.PhotoID, true); err != nil { + return err + } + case *tg.PageBlockVideo: + if err := r.addDocument(value.VideoID); err != nil { + return err + } + case *tg.PageBlockAudio: + if err := r.addDocument(value.AudioID); err != nil { + return err + } + case *tg.PageBlockEmbed: + if id, ok := value.GetPosterPhotoID(); ok { + if err := r.addPhoto(id, false); err != nil { + return err + } + } + case *tg.PageBlockEmbedPost: + if err := r.addPhoto(value.AuthorPhotoID, false); err != nil { + return err + } + if err := r.collectBlocks(value.Blocks); err != nil { + return err + } + case *tg.PageBlockRelatedArticles: + for i := range value.Articles { + if id, ok := value.Articles[i].GetPhotoID(); ok { + if err := r.addPhoto(id, false); err != nil { + return err + } + } + } + case *tg.PageBlockList: + for _, item := range value.Items { + if item, ok := item.(*tg.PageListItemBlocks); ok { + if err := r.collectBlocks(item.Blocks); err != nil { + return err + } + } + } + case *tg.PageBlockOrderedList: + for _, item := range value.Items { + if item, ok := item.(*tg.PageListOrderedItemBlocks); ok { + if err := r.collectBlocks(item.Blocks); err != nil { + return err + } + } + } + case *tg.PageBlockCover: + if err := r.collectBlocks([]tg.PageBlockClass{value.Cover}); err != nil { + return err + } + case *tg.PageBlockCollage: + if err := r.collectBlocks(value.Items); err != nil { + return err + } + case *tg.PageBlockSlideshow: + if err := r.collectBlocks(value.Items); err != nil { + return err + } + case *tg.PageBlockDetails: + if err := r.collectBlocks(value.Blocks); err != nil { + return err + } + case *tg.PageBlockBlockquoteBlocks: + if err := r.collectBlocks(value.Blocks); err != nil { + return err + } + } + } + return nil +} + +type richMessagePhotoBatchProvider interface { + GetPhotos(context.Context, []int64) ([]domain.Photo, error) +} + +func (r *Router) resolveRichMessagePhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) { + if len(ids) == 0 { + return nil, nil + } + resolved := make(map[int64]domain.Photo, len(ids)) + if batch, ok := r.deps.Files.(richMessagePhotoBatchProvider); ok { + photos, err := batch.GetPhotos(ctx, ids) + if err != nil { + return nil, internalErr() + } + for _, photo := range photos { + resolved[photo.ID] = photo + } + } else { + for _, id := range ids { + photo, found, err := r.deps.Files.GetPhoto(ctx, id) + if err != nil { + return nil, internalErr() + } + if found { + resolved[id] = photo + } + } + } + out := make([]domain.Photo, 0, len(ids)) + for _, id := range ids { + photo, ok := resolved[id] + if !ok || photo.ID != id || len(photo.Sizes) == 0 { + return nil, photoInvalidErr() + } + out = append(out, photo) + } + return out, nil +} + +func (r *Router) resolveRichMessageDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) { + if len(ids) == 0 { + return nil, nil + } + documents, err := r.deps.Files.GetDocuments(ctx, ids) + if err != nil { + return nil, internalErr() + } + resolved := make(map[int64]domain.Document, len(documents)) + for _, document := range documents { + resolved[document.ID] = document + } + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + document, ok := resolved[id] + if !ok || document.ID != id { + return nil, mediaInvalidErr() + } + out = append(out, document) + } + return out, nil +} + func normalizeRichBlocksForClients(blocks []tg.PageBlockClass) { for _, block := range blocks { normalizeRichBlockForClients(block) @@ -172,7 +358,21 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR if err := validateRichMessageBlocks(in.Blocks); err != nil { return nil, err } - if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil { + for _, photo := range in.Photos { + if _, ok := inputPhotoID(photo); !ok { + return nil, photoInvalidErr() + } + } + for _, document := range in.Documents { + if _, ok := inputDocumentID(document); !ok { + return nil, mediaInvalidErr() + } + } + refs, err := collectRichMessageMediaRefs(in.Blocks) + if err != nil { + return nil, err + } + if (len(refs.photoIDs) > 0 || len(refs.documentIDs) > 0) && r.deps.Files == nil { return nil, notImplementedErr() } normalizeRichBlocksForClients(in.Blocks) @@ -191,33 +391,13 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR if projectionErr == nil { rich.BotAPIProjection = projection } - for _, p := range in.Photos { - id, ok := inputPhotoID(p) - if !ok { - return nil, photoInvalidErr() - } - photo, found, err := r.deps.Files.GetPhoto(ctx, id) - if err != nil { - return nil, internalErr() - } - if !found { - return nil, photoInvalidErr() - } - rich.Photos = append(rich.Photos, photo) + rich.Photos, err = r.resolveRichMessagePhotos(ctx, refs.photoIDs) + if err != nil { + return nil, err } - for _, d := range in.Documents { - id, ok := inputDocumentID(d) - if !ok { - return nil, mediaInvalidErr() - } - doc, found, err := r.deps.Files.GetDocument(ctx, id) - if err != nil { - return nil, internalErr() - } - if !found { - return nil, mediaInvalidErr() - } - rich.Documents = append(rich.Documents, doc) + rich.Documents, err = r.resolveRichMessageDocuments(ctx, refs.documentIDs) + if err != nil { + return nil, err } if rich.IsZero() { return nil, nil diff --git a/internal/rpc/messages_rich_message_rpc_test.go b/internal/rpc/messages_rich_message_rpc_test.go index f3b9dc5f..e1893860 100644 --- a/internal/rpc/messages_rich_message_rpc_test.go +++ b/internal/rpc/messages_rich_message_rpc_test.go @@ -6,9 +6,11 @@ import ( "strings" "testing" + "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" + "github.com/iamxvbaba/td/tlprofile" "go.uber.org/zap/zaptest" appchannels "telesrv/internal/app/channels" @@ -496,7 +498,17 @@ func TestRichMessageBlockFormatsEncodeDecode(t *testing.T) { &tg.PageBlockUnsupported{}, } ctx := context.Background() - r := &Router{} + r, _, _ := newMediaTestRouter(t) + files := r.deps.Files.(*fakeFiles) + for _, id := range []int64{1, 3, 4} { + files.photos[id] = domain.Photo{ + ID: id, AccessHash: id + 100, DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 32, H: 32, Size: 64}}, + } + } + for _, id := range []int64{2, 5, 6} { + files.docs[id] = domain.Document{ID: id, AccessHash: id + 100, DCID: 2, MimeType: "application/octet-stream", Size: 64} + } rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks}) if err != nil { t.Fatalf("domain rich message: %v", err) @@ -789,7 +801,10 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) { Message: "", RandomID: 7003, RichMessage: &tg.InputRichMessage{ - Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}}, + Blocks: []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}, + &tg.PageBlockPhoto{PhotoID: 889, Caption: richEmptyCaption()}, + }, Photos: []tg.InputPhotoClass{&tg.InputPhoto{ID: 889, AccessHash: 42}}, }, }) @@ -811,6 +826,179 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) { if photo.ID != 889 { t.Errorf("rich photo id = %d, want 889", photo.ID) } + block, ok := rich.Blocks[1].(*tg.PageBlockPhoto) + if !ok || block.PhotoID != photo.ID { + t.Fatalf("rich photo block = %#v, photo id = %d", rich.Blocks[1], photo.ID) + } +} + +// TestRichMessageMediaClosureResolvesBlockReferences verifies the server builds the +// output resource tables from the PageBlock graph. The input resource vector is +// optional on the wire; its absence must not leave a dangling block that Web cannot +// render when the referenced upload already exists. +func TestRichMessageMediaClosureResolvesBlockReferences(t *testing.T) { + ctx := context.Background() + r, _, _ := newMediaTestRouter(t) + files := r.deps.Files.(*fakeFiles) + for _, id := range []int64{889, 890, 891} { + files.photos[id] = domain.Photo{ + ID: id, AccessHash: id + 100, DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600, Size: 123}}, + } + } + files.docs[990] = domain.Document{ID: 990, AccessHash: 1090, DCID: 2, MimeType: "video/mp4", Size: 456} + files.docs[991] = domain.Document{ID: 991, AccessHash: 1091, DCID: 2, MimeType: "audio/mpeg", Size: 789} + + embed := &tg.PageBlockEmbed{Caption: richEmptyCaption()} + embed.SetPosterPhotoID(890) + article := tg.PageRelatedArticle{URL: "https://example.test/article", WebpageID: 1} + article.SetPhotoID(891) + blocks := []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "media closure"}}, + &tg.PageBlockCollage{ + Items: []tg.PageBlockClass{ + &tg.PageBlockPhoto{PhotoID: 889, Caption: richEmptyCaption()}, + &tg.PageBlockVideo{VideoID: 990, Caption: richEmptyCaption()}, + }, + Caption: richEmptyCaption(), + }, + &tg.PageBlockDetails{ + Title: &tg.TextPlain{Text: "nested"}, + Blocks: []tg.PageBlockClass{ + &tg.PageBlockAudio{AudioID: 991, Caption: richEmptyCaption()}, + embed, + &tg.PageBlockRelatedArticles{ + Title: &tg.TextPlain{Text: "related"}, + Articles: []tg.PageRelatedArticle{article}, + }, + &tg.PageBlockEmbedPost{ + URL: "https://example.test/post", + WebpageID: 2, + AuthorPhotoID: 890, + Author: "author", + Blocks: []tg.PageBlockClass{ + &tg.PageBlockPhoto{PhotoID: 889, Caption: richEmptyCaption()}, + }, + Caption: richEmptyCaption(), + }, + }, + }, + } + + rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks}) + if err != nil { + t.Fatalf("resolve block media closure: %v", err) + } + if got := []int64{rich.Photos[0].ID, rich.Photos[1].ID, rich.Photos[2].ID}; !slicesEqual(got, []int64{889, 890, 891}) { + t.Fatalf("photo closure = %v, want [889 890 891]", got) + } + if got := []int64{rich.Documents[0].ID, rich.Documents[1].ID}; !slicesEqual(got, []int64{990, 991}) { + t.Fatalf("document closure = %v, want [990 991]", got) + } +} + +func TestRichMessageMediaClosureRejectsUnknownReference(t *testing.T) { + r, _, _ := newMediaTestRouter(t) + _, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessage{ + Blocks: []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "missing photo"}}, + &tg.PageBlockPhoto{PhotoID: 999, Caption: richEmptyCaption()}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("unknown rich photo error = %v, want PHOTO_INVALID", err) + } +} + +func TestChannelRichPhotoHistoryExactLayerRoundTrip(t *testing.T) { + ctx := context.Background() + r, owner, channel := newRichChannelTestRouter(t) + files := r.deps.Files.(*fakeFiles) + files.photos[889] = domain.Photo{ + ID: 889, AccessHash: 42, FileReference: []byte{1, 2, 3}, Date: 1700000000, DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600, Size: 123}}, + } + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: peer, + RandomID: 7202, + RichMessage: &tg.InputRichMessage{ + Blocks: []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "Android full-screen photo"}}, + &tg.PageBlockPhoto{PhotoID: 889, Caption: richEmptyCaption()}, + }, + // Deliberately omit Photos: the PageBlock graph is the authoritative + // selection and the uploaded server object completes the output closure. + }, + }) + if err != nil { + t.Fatalf("send channel rich photo: %v", err) + } + assertRichPhotoReference(t, "channel echo", newMessageFromUpdates(t, updates), 889) + + historyList, err := r.deps.Channels.GetHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel history: %v", err) + } + history := r.tgChannelHistoryMessages(WithUserID(ctx, owner.ID), owner.ID, historyList) + stored := singleChannelStoredMessage(t, history) + assertRichPhotoReference(t, "channel history", stored, 889) + + for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} { + var encoded bin.Buffer + if err := tlprofile.EncodeObject(profile, stored, &encoded); err != nil { + t.Fatalf("layer %d encode: %v", profile, err) + } + decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: encoded.Copy()}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("layer %d decode: %v", profile, err) + } + message, ok := decoded.(*tg.Message) + if !ok { + t.Fatalf("layer %d decoded %T, want *tg.Message", profile, decoded) + } + assertRichPhotoReference(t, "exact layer", message, 889) + } +} + +func assertRichPhotoReference(t *testing.T, label string, message *tg.Message, photoID int64) { + t.Helper() + rich, ok := message.GetRichMessage() + if !ok { + t.Fatalf("%s: missing rich_message", label) + } + var referenced bool + for _, block := range rich.Blocks { + if photo, ok := block.(*tg.PageBlockPhoto); ok && photo.PhotoID == photoID { + referenced = true + break + } + } + if !referenced { + t.Fatalf("%s: missing pageBlockPhoto(%d)", label, photoID) + } + if len(rich.Photos) != 1 { + t.Fatalf("%s: photos = %d, want 1", label, len(rich.Photos)) + } + photo, ok := rich.Photos[0].(*tg.Photo) + if !ok || photo.ID != photoID { + t.Fatalf("%s: photo = %#v, want id %d", label, rich.Photos[0], photoID) + } +} + +func slicesEqual(got, want []int64) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true } // singleStoredMessage 从 messages.messages 取出唯一一条非空 *tg.Message。 diff --git a/internal/rpc/rich_message_limits.go b/internal/rpc/rich_message_limits.go index 02201ac9..a6120142 100644 --- a/internal/rpc/rich_message_limits.go +++ b/internal/rpc/rich_message_limits.go @@ -128,8 +128,21 @@ func collectRichMessageBlockMetrics(blocks []tg.PageBlockClass, depth int, metri collectRichMessageBlockMetrics(value.Items, depth+1, metrics) case *tg.PageBlockCover: collectRichMessageBlockMetrics([]tg.PageBlockClass{value.Cover}, depth+1, metrics) + case *tg.PageBlockEmbed: + if id, ok := value.GetPosterPhotoID(); ok && id != 0 { + metrics.media++ + } case *tg.PageBlockEmbedPost: + if value.AuthorPhotoID != 0 { + metrics.media++ + } collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics) + case *tg.PageBlockRelatedArticles: + for i := range value.Articles { + if id, ok := value.Articles[i].GetPhotoID(); ok && id != 0 { + metrics.media++ + } + } case *tg.PageBlockPhoto, *tg.PageBlockVideo, *tg.PageBlockAudio: metrics.media++ }