package rpc import ( "context" "fmt" "log" "strconv" "sync/atomic" "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tlprofile" "telesrv/internal/domain" ) // 本文件集中富文本消息(richMessage)的 tg.* ↔ domain 转换。 // inputRichMessage 的 blocks、HTML 与 Markdown 三种输入均在 RPC 边界归一为 PageBlock; // blocks 以 TL 向量序列化为不透明字节存 domain(详见 domain.MessageRichMessage)。 // encodeRichBlocks 把 []tg.PageBlockClass 按明确的存储 profile 序列化为 TL 向量字节。 func encodeRichBlocks(profile tlprofile.Profile, blocks []tg.PageBlockClass) ([]byte, error) { var b bin.Buffer if err := tlprofile.EncodePageBlockVector(profile, blocks, &b); err != nil { return nil, err } return b.Buf, nil } // decodeRichBlocks 按写入时的 exact profile 还原完整 PageBlock 向量。调用方必须提供 // 持久化元数据确定的 profile,不允许失败后换 profile 重试。 func decodeRichBlocks(profile tlprofile.Profile, data []byte) ([]tg.PageBlockClass, error) { if len(data) == 0 { return nil, nil } b := &bin.Buffer{Buf: append([]byte(nil), data...)} return tlprofile.DecodePageBlockVector(profile, b, tlprofile.Limits{}) } // 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) } } func normalizeRichBlockForClients(block tg.PageBlockClass) { switch b := block.(type) { case *tg.PageBlockList: for _, item := range b.Items { if item, ok := item.(*tg.PageListItemBlocks); ok { normalizeRichBlocksForClients(item.Blocks) } } case *tg.PageBlockCover: normalizeRichBlockForClients(b.Cover) case *tg.PageBlockEmbedPost: normalizeRichBlocksForClients(b.Blocks) case *tg.PageBlockCollage: normalizeRichBlocksForClients(b.Items) case *tg.PageBlockSlideshow: normalizeRichBlocksForClients(b.Items) case *tg.PageBlockOrderedList: normalizeOrderedListForClients(b) case *tg.PageBlockDetails: normalizeRichBlocksForClients(b.Blocks) case *tg.PageBlockBlockquoteBlocks: normalizeRichBlocksForClients(b.Blocks) } } func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) { if list == nil { return } reversed := list.Reversed || list.Flags.Has(2) current := 1 if list.Flags.Has(0) || list.Start != 0 { current = list.Start } else if reversed { current = len(list.Items) } step := 1 if reversed { step = -1 } for _, item := range list.Items { value := current switch i := item.(type) { case *tg.PageListOrderedItemText: if v, ok := i.GetValue(); ok || i.Value != 0 { value = v if !ok { value = i.Value } } if num, ok := i.GetNum(); !ok || num == "" { i.SetNum(strconv.Itoa(value)) } case *tg.PageListOrderedItemBlocks: if v, ok := i.GetValue(); ok || i.Value != 0 { value = v if !ok { value = i.Value } } if num, ok := i.GetNum(); !ok || num == "" { i.SetNum(strconv.Itoa(value)) } normalizeRichBlocksForClients(i.Blocks) } current = value + step } } // domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照: // HTML/Markdown 先在服务端解析为 PageBlock,再与 blocks 形态共用限额校验、 // 序列化和 Bot API 输出投影;内嵌 photos/documents 复用 sendMedia 同款媒体解析。 // 返回 nil 表示无富文本载荷。 func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputRichMessageClass) (*domain.MessageRichMessage, error) { if input == nil { return nil, nil } var ( in *tg.InputRichMessage sourceParsed bool ) switch value := input.(type) { case *tg.InputRichMessage: in = value case *tg.InputRichMessageHTML: if value == nil || value.HTML == "" || len(value.Files) != 0 { return nil, richMessageInvalidErr() } blocks, err := parseBotAPIRichHTML(value.HTML) if err != nil { return nil, err } in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks} sourceParsed = true case *tg.InputRichMessageMarkdown: if value == nil || value.Markdown == "" || len(value.Files) != 0 { return nil, richMessageInvalidErr() } blocks, err := parseBotAPIRichMarkdown(value.Markdown) if err != nil { return nil, err } in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks} sourceParsed = true default: return nil, richMessageInvalidErr() } if len(in.Blocks) == 0 { if len(in.Photos) == 0 && len(in.Documents) == 0 { return nil, nil } return nil, richMessageInvalidErr() } if err := validateRichMessageBlocks(in.Blocks); err != nil { return nil, err } 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) blocks, err := encodeRichBlocks(tlprofile.ProfileCanonical, in.Blocks) if err != nil { return nil, err } rich := &domain.MessageRichMessage{ Rtl: in.Rtl, BlocksLayer: int(tlprofile.ProfileCanonical), Blocks: blocks, } projection, projectionErr := botAPIRichMessageProjection(in.Blocks, in.Rtl) if projectionErr != nil && sourceParsed { return nil, richMessageInvalidErr() } if projectionErr == nil { rich.BotAPIProjection = projection } rich.Photos, err = r.resolveRichMessagePhotos(ctx, refs.photoIDs) if err != nil { return nil, err } rich.Documents, err = r.resolveRichMessageDocuments(ctx, refs.documentIDs) if err != nil { return nil, err } if rich.IsZero() { return nil, nil } return rich, nil } // tgRichMessage 把 domain 富文本快照投影为 tg.RichMessage(反序列化 blocks + 复用 // tgPhoto/tgDocument 投影内嵌媒体)。空载荷或 blocks 解码失败返回 (nil, err)。 func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) { if m.IsZero() { return nil, nil } layer := m.EffectiveBlocksLayer() profile, ok := tlprofile.ResolveProfile(layer) if !ok { return nil, fmt.Errorf("stored rich_message blocks layer %d is unavailable", layer) } blocks, err := decodeRichBlocks(profile, m.Blocks) if err != nil { return nil, fmt.Errorf("decode stored rich_message blocks at layer %d: %w", layer, err) } out := &tg.RichMessage{ Rtl: m.Rtl, Part: m.Part, Blocks: blocks, Photos: make([]tg.PhotoClass, 0, len(m.Photos)), Documents: make([]tg.DocumentClass, 0, len(m.Documents)), } for _, p := range m.Photos { out.Photos = append(out.Photos, tgPhoto(p)) } for _, d := range m.Documents { out.Documents = append(out.Documents, tgDocument(d)) } return out, nil } var richMessageProjectionFailureCount atomic.Uint64 // optionalTGRichMessage projects an optional extension without allowing one // malformed persisted snapshot to terminate the RPC worker or server process. // Known historical formats are decoded exactly above. Truly invalid data is // omitted from the base message and compatibility-traced with logarithmic // sampling so a repeatedly requested row cannot create an unbounded log storm. func optionalTGRichMessage(scope string, id int, m *domain.MessageRichMessage) *tg.RichMessage { out, err := tgRichMessage(m) if err != nil { count := richMessageProjectionFailureCount.Add(1) if count <= 10 || count&(count-1) == 0 { log.Printf("rich_message compatibility trace: scope=%s id=%d blocks_layer=%d failures=%d error=%q", scope, id, m.EffectiveBlocksLayer(), count, err) } return nil } return out }