owpengram-server/internal/rpc/convert_rich_message.go
2026-09-01 12:06:31 +03:00

445 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}