feat: sync bot rich messages and inline menus

This commit is contained in:
A 2026-07-21 15:44:43 +08:00
parent 2965f5d47d
commit 1a2d03f529
24 changed files with 2073 additions and 38 deletions

View file

@ -200,7 +200,7 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
}
if peer.Type == domain.PeerTypeChannel {
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, replyMarkup, silent, reply)
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, nil, replyMarkup, silent, false, reply)
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
@ -229,6 +229,66 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
return res.SenderMessage, nil
}
// BotAPISendRichMessage sends one durable rich message through the same
// private/channel state machines as messages.sendMessage. The HTTP input is
// parsed into canonical PageBlocks before any message row, pts or outbox entry
// is written.
func (r *Router) BotAPISendRichMessage(ctx context.Context, botID, chatID int64, input domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
if r == nil || botID == 0 {
return domain.Message{}, errors.New("BOT_INVALID")
}
peer, ok := botAPIPeerFromChatID(chatID)
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
if effectID != 0 && (peer.Type != domain.PeerTypeUser || r.messageEffectInvalid(ctx, effectID)) {
return domain.Message{}, effectIDInvalidErr()
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return domain.Message{}, err
}
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
if err != nil {
return domain.Message{}, err
}
if richMessage.IsZero() {
return domain.Message{}, richMessageInvalidErr()
}
var reply *domain.MessageReply
if replyToMessageID > 0 {
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
}
if peer.Type == domain.PeerTypeChannel {
return r.botAPISendChannelMessage(ctx, botID, peer.ID, "", nil, nil, richMessage, replyMarkup, silent, noForwards, reply)
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
}
if r.deps.Users != nil && peer.ID != botID {
if _, found, err := r.deps.Users.ByID(ctx, botID, peer.ID); err != nil {
return domain.Message{}, err
} else if !found {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
}
res, err := r.deps.Messages.SendPrivateText(ctx, botID, domain.SendPrivateTextRequest{
SenderUserID: botID, RecipientUserID: peer.ID, RandomID: randomNonZeroInt64(),
RichMessage: richMessage, Silent: silent, NoForwards: noForwards, ReplyTo: reply,
Date: int(time.Now().Unix()), ReplyMarkup: replyMarkup, Effect: effectID,
})
if err != nil {
return domain.Message{}, err
}
return res.SenderMessage, nil
}
// BotAPISendMedia sends a photo/document message through the same files service
// and private/channel message state machines used by MTProto sendMedia.
func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
@ -257,7 +317,7 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
}
if peer.Type == domain.PeerTypeChannel {
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, replyMarkup, silent, reply)
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, nil, replyMarkup, silent, false, reply)
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
@ -569,7 +629,7 @@ func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
return domain.Peer{}, false
}
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, replyMarkup *domain.MessageReplyMarkup, silent bool, reply *domain.MessageReply) (domain.Message, error) {
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, richMessage *domain.MessageRichMessage, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, reply *domain.MessageReply) (domain.Message, error) {
if r.deps.Channels == nil {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
@ -581,10 +641,12 @@ func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID
Message: text,
Entities: append([]domain.MessageEntity(nil), entities...),
Media: media,
RichMessage: richMessage,
MentionUserIDs: mentionUserIDs,
SkipRecipientLookup: true,
PostAuthor: r.channelPostAuthorName(ctx, botID),
Silent: silent,
NoForwards: noForwards,
ReplyTo: reply,
ReplyMarkup: replyMarkup,
Date: int(time.Now().Unix()),
@ -781,6 +843,9 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
EditDate: int(time.Now().Unix()),
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
// An explicit plain-text edit replaces a previous rich payload. Keeping
// both would create a state that neither Bot API nor TDesktop permits.
SetRichMessage: true,
})
if err != nil {
return domain.Message{}, err
@ -792,6 +857,70 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
return self.Message, nil
}
// BotAPIEditRichMessage replaces message content with one rich payload while
// preserving the existing durable edit/pts/outbox semantics.
func (r *Router) BotAPIEditRichMessage(ctx context.Context, botID, chatID int64, messageID int, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
if r == nil || botID == 0 {
return domain.Message{}, errors.New("BOT_INVALID")
}
peer, ok := botAPIPeerFromChatID(chatID)
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if messageID <= 0 || messageID > domain.MaxMessageBoxID {
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return domain.Message{}, err
}
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
if err != nil {
return domain.Message{}, err
}
if richMessage.IsZero() {
return domain.Message{}, richMessageInvalidErr()
}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
res, err := r.deps.Channels.EditMessage(ctx, botID, domain.EditChannelMessageRequest{
UserID: botID, ChannelID: peer.ID, ID: messageID, Message: "",
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
SetRichMessage: true, RichMessage: richMessage, EditDate: int(time.Now().Unix()),
})
if err != nil {
return domain.Message{}, channelEditErr(err)
}
r.enqueueChannelEditMessageFanout(ctx, botID, res)
return botAPIMessageFromChannel(botID, res.Message), nil
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
}
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
OwnerUserID: botID, Peer: peer, ID: messageID, Message: "", EditDate: int(time.Now().Unix()),
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
SetRichMessage: true, RichMessage: richMessage,
})
if err != nil {
return domain.Message{}, err
}
r.enqueueBotAPIPrivateEditUpdatesAsync(ctx, res)
self := res.Self()
if self.Message.ID == 0 {
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
}
return self.Message, nil
}
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
return false, errors.New("BOT_INVALID")
@ -823,6 +952,29 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
}
func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (bool, error) {
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
return false, errors.New("BOT_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return false, err
}
req := &tg.MessagesEditInlineBotMessageRequest{ID: tgInputBotInlineMessageID(inlineMessageID)}
req.SetRichMessage(wire)
if setReplyMarkup {
markup := tgReplyMarkup(replyMarkup)
if markup == nil {
markup = &tg.ReplyInlineMarkup{}
}
req.SetReplyMarkup(markup)
}
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
}
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
// the target user's MTProto clients observe the normal delete update.
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {

View file

@ -249,6 +249,112 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
}
}
func TestBotAPIRichMessagePrivateSendEditAndPlainReplacement(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
}}}}
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, domain.BotAPIRichMessageInput{
HTML: `<h4>Admin</h4><p>Status: active</p>`, SkipEntityDetection: true,
}, markup, false, false, 0, 0)
if err != nil {
t.Fatalf("BotAPISendRichMessage: %v", err)
}
if sent.ID <= 0 || sent.Pts <= 0 || sent.Body != "" || sent.RichMessage == nil || len(sent.RichMessage.BotAPIProjection) == 0 ||
sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "menu:info" {
t.Fatalf("sent rich message = %+v", sent)
}
botHistory := privateBotAPIHistory(t, fixture, fixture.bot.ID, fixture.owner.ID)
if botHistory.ID != sent.ID || botHistory.RichMessage == nil || botHistory.Body != "" {
t.Fatalf("bot rich history = %+v", botHistory)
}
ownerHistory := privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
if ownerHistory.RichMessage == nil || len(ownerHistory.RichMessage.BotAPIProjection) == 0 || ownerHistory.ReplyMarkup == nil {
t.Fatalf("owner rich history = %+v", ownerHistory)
}
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, domain.BotAPIRichMessageInput{
Markdown: "## Updated\n\nSubscription: active", SkipEntityDetection: true,
}, true, markup)
if err != nil {
t.Fatalf("BotAPIEditRichMessage: %v", err)
}
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated") {
t.Fatalf("edited rich message = %+v projection=%s", edited, edited.RichMessage.BotAPIProjection)
}
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
if ownerHistory.RichMessage == nil || !strings.Contains(string(ownerHistory.RichMessage.BotAPIProjection), "Updated") {
t.Fatalf("owner edited rich history = %+v", ownerHistory)
}
plain, err := fixture.router.BotAPIEditMessageText(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, "Classic menu", nil, false, nil, false)
if err != nil {
t.Fatalf("BotAPIEditMessageText replacing rich: %v", err)
}
if plain.Body != "Classic menu" || plain.RichMessage != nil {
t.Fatalf("plain replacement = %+v", plain)
}
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
if ownerHistory.Body != "Classic menu" || ownerHistory.RichMessage != nil {
t.Fatalf("owner plain replacement history = %+v", ownerHistory)
}
}
func TestBotAPIRichMessageSupergroupSendAndEdit(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
chatID := -botAPIChannelChatIDBase - fixture.channel.ID
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Status", Data: []byte("channel:status"),
}}}}
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, chatID, domain.BotAPIRichMessageInput{
HTML: `<h4>Group menu</h4><p>Status: active</p>`, SkipEntityDetection: true,
}, markup, false, false, 0, 0)
if err != nil {
t.Fatalf("BotAPISendRichMessage channel: %v", err)
}
if sent.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || sent.ID <= 0 || sent.Pts <= 0 ||
sent.RichMessage == nil || sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "channel:status" {
t.Fatalf("sent channel rich message = %+v", sent)
}
history, err := fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
ChannelID: fixture.channel.ID, Limit: 1,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil || history.Messages[0].Body != "" {
t.Fatalf("channel rich history = %+v err=%v", history.Messages, err)
}
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, chatID, sent.ID, domain.BotAPIRichMessageInput{
Markdown: "## Updated group menu\n\nStatus: active", SkipEntityDetection: true,
}, true, markup)
if err != nil {
t.Fatalf("BotAPIEditRichMessage channel: %v", err)
}
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated group") {
t.Fatalf("edited channel rich message = %+v sent_pts=%d projection=%s", edited, sent.Pts, edited.RichMessage.BotAPIProjection)
}
history, err = fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
ChannelID: fixture.channel.ID, Limit: 1,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil ||
!strings.Contains(string(history.Messages[0].RichMessage.BotAPIProjection), "Updated group") {
t.Fatalf("edited channel history = %+v err=%v", history.Messages, err)
}
}
func privateBotAPIHistory(t *testing.T, fixture botAPIReceiveFixture, ownerID, peerID int64) domain.Message {
t.Helper()
history, err := fixture.messages.GetHistory(fixture.ctx, ownerID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, Limit: 1,
})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("GetHistory owner=%d peer=%d len=%d err=%v", ownerID, peerID, len(history.Messages), err)
}
return history.Messages[0]
}
func TestBotAPISendMessageRejectsUnsupportedNegativeChatID(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)

View file

@ -0,0 +1,305 @@
package rpc
import (
"bytes"
"fmt"
"net/url"
"strconv"
"strings"
richbuilder "github.com/iamxvbaba/td/telegram/message/rich"
"github.com/iamxvbaba/td/tg"
"golang.org/x/net/html"
"telesrv/internal/domain"
)
const (
botAPIRichSentinelScheme = "telesrv-rich"
botAPIRichDateMaxUnix = int64(1<<31 - 1)
)
type botAPIHTMLTableSpec struct {
bordered bool
striped bool
cells []botAPIHTMLTableCellSpec
}
type botAPIHTMLTableCellSpec struct {
align string
valign string
}
func tgInputRichMessageFromBotAPI(input domain.BotAPIRichMessageInput) (tg.InputRichMessageClass, error) {
if input.SourceCount() != 1 || len(input.BlocksJSON) != 0 {
return nil, richMessageInvalidErr()
}
if len(input.MediaJSON) != 0 {
return nil, richMessageMediaUnsupportedErr()
}
if input.HTML != "" {
return &tg.InputRichMessageHTML{
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, HTML: input.HTML,
}, nil
}
if input.Markdown != "" {
return &tg.InputRichMessageMarkdown{
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, Markdown: input.Markdown,
}, nil
}
return nil, richMessageInvalidErr()
}
func parseBotAPIRichHTML(source string) ([]tg.PageBlockClass, error) {
doc, err := html.Parse(strings.NewReader(source))
if err != nil {
return nil, richMessageInvalidErr()
}
tables := make([]botAPIHTMLTableSpec, 0)
var transform func(*html.Node) error
transform = func(node *html.Node) error {
if node.Type == html.ElementNode {
switch node.Data {
case "img", "video", "audio", "tg-map", "tg-collage", "tg-slideshow":
// The current local blob backend cannot materialize an arbitrary
// rich HTML media URL atomically. Fail explicitly so Bedolaga's
// documented one-shot no-logo retry is used instead of losing media.
return webpageMediaEmptyErr()
case "tg-time":
unixTime, err := strconv.ParseInt(htmlNodeAttr(node, "unix"), 10, 64)
if err != nil || unixTime <= 0 || unixTime > botAPIRichDateMaxUnix {
return richMessageDateInvalidErr()
}
format := htmlNodeAttr(node, "format")
if _, ok := botAPIRichDateFlags(format); !ok {
return richMessageDateInvalidErr()
}
node.Data = "a"
node.Attr = []html.Attribute{{Key: "href", Val: fmt.Sprintf("%s://time?unix=%d&format=%s", botAPIRichSentinelScheme, unixTime, url.QueryEscape(format))}}
case "footer":
node.Data = "p"
node.Attr = nil
anchor := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{{Key: "href", Val: botAPIRichSentinelScheme + "://footer"}}}
for child := node.FirstChild; child != nil; {
next := child.NextSibling
node.RemoveChild(child)
anchor.AppendChild(child)
child = next
}
node.AppendChild(anchor)
case "table":
tables = append(tables, botAPIHTMLTableSpecFromNode(node))
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if err := transform(child); err != nil {
return err
}
}
return nil
}
if err := transform(doc); err != nil {
return nil, err
}
var normalized bytes.Buffer
if err := html.Render(&normalized, doc); err != nil {
return nil, richMessageInvalidErr()
}
blocks, err := richbuilder.ParseHTML(strings.NewReader(normalized.String()))
if err != nil {
return nil, richMessageInvalidErr()
}
postProcessBotAPIRichHTML(blocks, tables)
return blocks, nil
}
func parseBotAPIRichMarkdown(source string) ([]tg.PageBlockClass, error) {
blocks, err := richbuilder.ParseMarkdown(strings.NewReader(source))
if err != nil {
return nil, richMessageInvalidErr()
}
return blocks, nil
}
func botAPIHTMLTableSpecFromNode(table *html.Node) botAPIHTMLTableSpec {
spec := botAPIHTMLTableSpec{bordered: htmlNodeHasAttr(table, "bordered"), striped: htmlNodeHasAttr(table, "striped")}
var walk func(*html.Node)
walk = func(node *html.Node) {
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && (child.Data == "td" || child.Data == "th") {
spec.cells = append(spec.cells, botAPIHTMLTableCellSpec{
align: strings.ToLower(htmlNodeAttr(child, "align")), valign: strings.ToLower(htmlNodeAttr(child, "valign")),
})
}
walk(child)
}
}
walk(table)
return spec
}
func postProcessBotAPIRichHTML(blocks []tg.PageBlockClass, tables []botAPIHTMLTableSpec) {
tableIndex := 0
var visit func([]tg.PageBlockClass)
visit = func(items []tg.PageBlockClass) {
for index, block := range items {
switch value := block.(type) {
case *tg.PageBlockParagraph:
if footer, ok := botAPIRichFooterText(value.Text); ok {
items[index] = &tg.PageBlockFooter{Text: postProcessBotAPIRichText(footer)}
} else {
value.Text = postProcessBotAPIRichText(value.Text)
}
case *tg.PageBlockHeading1:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading2:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading3:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading4:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading5:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading6:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockFooter:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockPreformatted:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockBlockquote:
value.Text = postProcessBotAPIRichText(value.Text)
value.Caption = postProcessBotAPIRichText(value.Caption)
case *tg.PageBlockBlockquoteBlocks:
value.Caption = postProcessBotAPIRichText(value.Caption)
visit(value.Blocks)
case *tg.PageBlockDetails:
value.Title = postProcessBotAPIRichText(value.Title)
visit(value.Blocks)
case *tg.PageBlockTable:
value.Title = postProcessBotAPIRichText(value.Title)
if tableIndex < len(tables) {
spec := tables[tableIndex]
tableIndex++
value.Bordered, value.Striped = spec.bordered, spec.striped
cellIndex := 0
for rowIndex := range value.Rows {
for columnIndex := range value.Rows[rowIndex].Cells {
cell := &value.Rows[rowIndex].Cells[columnIndex]
cell.Text = postProcessBotAPIRichText(cell.Text)
if cellIndex < len(spec.cells) {
cellSpec := spec.cells[cellIndex]
cell.AlignCenter = cellSpec.align == "center"
cell.AlignRight = cellSpec.align == "right"
cell.ValignMiddle = cellSpec.valign == "middle"
cell.ValignBottom = cellSpec.valign == "bottom"
}
cellIndex++
}
}
}
}
}
}
visit(blocks)
}
func postProcessBotAPIRichText(text tg.RichTextClass) tg.RichTextClass {
switch value := text.(type) {
case *tg.TextConcat:
for i := range value.Texts {
value.Texts[i] = postProcessBotAPIRichText(value.Texts[i])
}
case *tg.TextBold:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextItalic:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextUnderline:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextStrike:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextFixed:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextSubscript:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextSuperscript:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextMarked:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextSpoiler:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextURL:
parsed, err := url.Parse(value.URL)
if err == nil && parsed.Scheme == botAPIRichSentinelScheme && parsed.Host == "time" {
unixTime, unixErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32)
flags, ok := botAPIRichDateFlags(parsed.Query().Get("format"))
if unixErr == nil && ok {
return richbuilder.Date(postProcessBotAPIRichText(value.Text), int(unixTime), flags)
}
}
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextEmail:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextPhone:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextAnchor:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextMentionName:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextDate:
value.Text = postProcessBotAPIRichText(value.Text)
}
return text
}
func botAPIRichFooterText(text tg.RichTextClass) (tg.RichTextClass, bool) {
link, ok := text.(*tg.TextURL)
if !ok || link.URL != botAPIRichSentinelScheme+"://footer" {
return nil, false
}
return link.Text, true
}
func botAPIRichDateFlags(format string) (richbuilder.DateFlags, bool) {
if format == "r" || format == "R" {
return richbuilder.DateFlags{Relative: true}, true
}
var flags richbuilder.DateFlags
if format == "" {
return flags, false
}
for _, value := range format {
switch value {
case 't':
flags.ShortTime = true
case 'T':
flags.LongTime = true
case 'd':
flags.ShortDate = true
case 'D':
flags.LongDate = true
case 'w', 'W':
flags.DayOfWeek = true
default:
return richbuilder.DateFlags{}, false
}
}
return flags, true
}
func htmlNodeAttr(node *html.Node, key string) string {
for _, attribute := range node.Attr {
if attribute.Key == key {
return attribute.Val
}
}
return ""
}
func htmlNodeHasAttr(node *html.Node, key string) bool {
for _, attribute := range node.Attr {
if attribute.Key == key {
return true
}
}
return false
}

View file

@ -0,0 +1,503 @@
package rpc
import (
"encoding/json"
"errors"
"strconv"
"strings"
"github.com/iamxvbaba/td/tg"
)
func botAPIRichMessageProjection(blocks []tg.PageBlockClass, rtl bool) ([]byte, error) {
projected, err := botAPIRichBlocks(blocks)
if err != nil {
return nil, err
}
if len(projected) == 0 {
return nil, richMessageInvalidErr()
}
out := map[string]any{"blocks": projected}
if rtl {
out["is_rtl"] = true
}
return json.Marshal(out)
}
func botAPIRichBlocks(blocks []tg.PageBlockClass) ([]any, error) {
out := make([]any, 0, len(blocks))
for _, block := range blocks {
projected, err := botAPIRichBlock(block)
if err != nil {
return nil, err
}
if projected != nil {
out = append(out, projected)
}
}
return out, nil
}
func botAPIRichBlock(block tg.PageBlockClass) (map[string]any, error) {
textBlock := func(kind string, text tg.RichTextClass) (map[string]any, error) {
value, err := botAPIRichText(text)
if err != nil {
return nil, err
}
return map[string]any{"type": kind, "text": value}, nil
}
heading := func(size int, text tg.RichTextClass) (map[string]any, error) {
value, err := botAPIRichText(text)
if err != nil {
return nil, err
}
return map[string]any{"type": "heading", "text": value, "size": size}, nil
}
switch value := block.(type) {
case *tg.PageBlockParagraph:
return textBlock("paragraph", value.Text)
case *tg.PageBlockTitle:
return heading(1, value.Text)
case *tg.PageBlockSubtitle:
return heading(2, value.Text)
case *tg.PageBlockHeader:
return heading(2, value.Text)
case *tg.PageBlockSubheader:
return heading(3, value.Text)
case *tg.PageBlockKicker:
return heading(6, value.Text)
case *tg.PageBlockHeading1:
return heading(1, value.Text)
case *tg.PageBlockHeading2:
return heading(2, value.Text)
case *tg.PageBlockHeading3:
return heading(3, value.Text)
case *tg.PageBlockHeading4:
return heading(4, value.Text)
case *tg.PageBlockHeading5:
return heading(5, value.Text)
case *tg.PageBlockHeading6:
return heading(6, value.Text)
case *tg.PageBlockPreformatted:
out, err := textBlock("pre", value.Text)
if err == nil && value.Language != "" {
out["language"] = value.Language
}
return out, err
case *tg.PageBlockFooter:
return textBlock("footer", value.Text)
case *tg.PageBlockDivider:
return map[string]any{"type": "divider"}, nil
case *tg.PageBlockMath:
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
case *tg.PageBlockAnchor:
return map[string]any{"type": "anchor", "name": value.Name}, nil
case *tg.PageBlockDetails:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
summary, err := botAPIRichText(value.Title)
if err != nil {
return nil, err
}
out := map[string]any{"type": "details", "summary": summary, "blocks": blocks}
if value.Open {
out["is_open"] = true
}
return out, nil
case *tg.PageBlockBlockquote:
text, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
out := map[string]any{"type": "blockquote", "blocks": []any{map[string]any{"type": "paragraph", "text": text}}}
if !botAPIRichTextEmpty(value.Caption) {
credit, err := botAPIRichText(value.Caption)
if err != nil {
return nil, err
}
out["credit"] = credit
}
return out, nil
case *tg.PageBlockBlockquoteBlocks:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
out := map[string]any{"type": "blockquote", "blocks": blocks}
if !botAPIRichTextEmpty(value.Caption) {
credit, err := botAPIRichText(value.Caption)
if err != nil {
return nil, err
}
out["credit"] = credit
}
return out, nil
case *tg.PageBlockPullquote:
out, err := textBlock("pullquote", value.Text)
if err != nil {
return nil, err
}
if !botAPIRichTextEmpty(value.Caption) {
credit, err := botAPIRichText(value.Caption)
if err != nil {
return nil, err
}
out["credit"] = credit
}
return out, nil
case *tg.PageBlockList:
return botAPIUnorderedRichList(value)
case *tg.PageBlockOrderedList:
return botAPIOrderedRichList(value)
case *tg.PageBlockTable:
return botAPIRichTable(value)
case *tg.PageBlockCollage:
return botAPIRichBlockCollection("collage", value.Items, value.Caption)
case *tg.PageBlockSlideshow:
return botAPIRichBlockCollection("slideshow", value.Items, value.Caption)
case *tg.PageBlockCover:
return botAPIRichBlock(value.Cover)
case *tg.PageBlockThinking:
return textBlock("thinking", value.Text)
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
}
func botAPIUnorderedRichList(list *tg.PageBlockList) (map[string]any, error) {
items := make([]any, 0, len(list.Items))
for _, raw := range list.Items {
item := map[string]any{"label": "•"}
switch value := raw.(type) {
case *tg.PageListItemText:
text, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
if value.Checkbox {
item["has_checkbox"] = true
if value.Checked {
item["is_checked"] = true
}
}
case *tg.PageListItemBlocks:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
item["blocks"] = blocks
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
items = append(items, item)
}
return map[string]any{"type": "list", "items": items}, nil
}
func botAPIOrderedRichList(list *tg.PageBlockOrderedList) (map[string]any, error) {
items := make([]any, 0, len(list.Items))
for index, raw := range list.Items {
item := map[string]any{"label": strconv.Itoa(index + 1)}
switch value := raw.(type) {
case *tg.PageListOrderedItemText:
text, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
case *tg.PageListOrderedItemBlocks:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
item["blocks"] = blocks
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
items = append(items, item)
}
return map[string]any{"type": "list", "items": items}, nil
}
func botAPIFillOrderedListItem(item map[string]any, label string, value int, kind string, checkbox, checked bool) {
if label != "" {
item["label"] = label
}
if value != 0 {
item["value"] = value
}
if kind != "" {
item["type"] = kind
}
if checkbox {
item["has_checkbox"] = true
if checked {
item["is_checked"] = true
}
}
}
func botAPIRichTable(table *tg.PageBlockTable) (map[string]any, error) {
rows := make([]any, 0, len(table.Rows))
for _, row := range table.Rows {
cells := make([]any, 0, len(row.Cells))
for _, cell := range row.Cells {
item := map[string]any{"align": "left", "valign": "top"}
if !botAPIRichTextEmpty(cell.Text) {
text, err := botAPIRichText(cell.Text)
if err != nil {
return nil, err
}
item["text"] = text
}
if cell.Header {
item["is_header"] = true
}
if cell.Colspan > 1 {
item["colspan"] = cell.Colspan
}
if cell.Rowspan > 1 {
item["rowspan"] = cell.Rowspan
}
if cell.AlignCenter {
item["align"] = "center"
} else if cell.AlignRight {
item["align"] = "right"
}
if cell.ValignMiddle {
item["valign"] = "middle"
} else if cell.ValignBottom {
item["valign"] = "bottom"
}
cells = append(cells, item)
}
rows = append(rows, cells)
}
out := map[string]any{"type": "table", "cells": rows}
if table.Bordered {
out["is_bordered"] = true
}
if table.Striped {
out["is_striped"] = true
}
if !botAPIRichTextEmpty(table.Title) {
caption, err := botAPIRichText(table.Title)
if err != nil {
return nil, err
}
out["caption"] = caption
}
return out, nil
}
func botAPIRichBlockCollection(kind string, blocks []tg.PageBlockClass, caption tg.PageCaption) (map[string]any, error) {
items, err := botAPIRichBlocks(blocks)
if err != nil {
return nil, err
}
out := map[string]any{"type": kind, "blocks": items}
if !botAPIRichTextEmpty(caption.Text) || !botAPIRichTextEmpty(caption.Credit) {
projected := map[string]any{}
if !botAPIRichTextEmpty(caption.Text) {
projected["text"], err = botAPIRichText(caption.Text)
}
if err == nil && !botAPIRichTextEmpty(caption.Credit) {
projected["credit"], err = botAPIRichText(caption.Credit)
}
if err != nil {
return nil, err
}
out["caption"] = projected
}
return out, nil
}
func botAPIRichText(text tg.RichTextClass) (any, error) {
wrapped := func(kind string, child tg.RichTextClass) (any, error) {
value, err := botAPIRichText(child)
if err != nil {
return nil, err
}
return map[string]any{"type": kind, "text": value}, nil
}
valued := func(kind, field, value string, child tg.RichTextClass) (any, error) {
out, err := wrapped(kind, child)
if err != nil {
return nil, err
}
out.(map[string]any)[field] = value
return out, nil
}
switch value := text.(type) {
case nil, *tg.TextEmpty:
return "", nil
case *tg.TextPlain:
return value.Text, nil
case *tg.TextConcat:
items := make([]any, 0, len(value.Texts))
for _, child := range value.Texts {
item, err := botAPIRichText(child)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
case *tg.TextBold:
return wrapped("bold", value.Text)
case *tg.TextItalic:
return wrapped("italic", value.Text)
case *tg.TextUnderline:
return wrapped("underline", value.Text)
case *tg.TextStrike:
return wrapped("strikethrough", value.Text)
case *tg.TextSpoiler:
return wrapped("spoiler", value.Text)
case *tg.TextFixed:
return wrapped("code", value.Text)
case *tg.TextSubscript:
return wrapped("subscript", value.Text)
case *tg.TextSuperscript:
return wrapped("superscript", value.Text)
case *tg.TextMarked:
return wrapped("marked", value.Text)
case *tg.TextDate:
out, err := wrapped("date_time", value.Text)
if err != nil {
return nil, err
}
item := out.(map[string]any)
item["unix_time"] = value.Date
item["date_time_format"] = botAPIRichDateFormat(value)
return item, nil
case *tg.TextCustomEmoji:
return map[string]any{"type": "custom_emoji", "custom_emoji_id": strconv.FormatInt(value.DocumentID, 10), "alternative_text": value.Alt}, nil
case *tg.TextMath:
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
case *tg.TextURL:
if strings.HasPrefix(value.URL, "#") {
return valued("anchor_link", "anchor_name", strings.TrimPrefix(value.URL, "#"), value.Text)
}
return valued("url", "url", value.URL, value.Text)
case *tg.TextEmail:
return valued("email_address", "email_address", value.Email, value.Text)
case *tg.TextPhone:
return valued("phone_number", "phone_number", value.Phone, value.Text)
case *tg.TextBankCard:
return valued("bank_card_number", "bank_card_number", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextMention:
return valued("mention", "username", strings.TrimPrefix(botAPIRichPlainText(value.Text), "@"), value.Text)
case *tg.TextHashtag:
return valued("hashtag", "hashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "#"), value.Text)
case *tg.TextCashtag:
return valued("cashtag", "cashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "$"), value.Text)
case *tg.TextBotCommand:
return valued("bot_command", "bot_command", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextAutoURL:
return valued("url", "url", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextAutoEmail:
return valued("email_address", "email_address", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextAutoPhone:
return valued("phone_number", "phone_number", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextMentionName:
out, err := wrapped("text_mention", value.Text)
if err != nil {
return nil, err
}
out.(map[string]any)["user"] = map[string]any{"id": value.UserID, "is_bot": false, "first_name": "User " + strconv.FormatInt(value.UserID, 10)}
return out, nil
case *tg.TextAnchor:
anchor := map[string]any{"type": "anchor", "name": value.Name}
if botAPIRichTextEmpty(value.Text) {
return anchor, nil
}
inner, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
return []any{anchor, inner}, nil
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
}
func botAPIRichDateFormat(date *tg.TextDate) string {
if date.Relative {
return "r"
}
var out strings.Builder
if date.ShortTime {
out.WriteByte('t')
}
if date.LongTime {
out.WriteByte('T')
}
if date.ShortDate {
out.WriteByte('d')
}
if date.LongDate {
out.WriteByte('D')
}
if date.DayOfWeek {
out.WriteByte('w')
}
return out.String()
}
func botAPIRichTextEmpty(text tg.RichTextClass) bool {
return text == nil || botAPIRichPlainText(text) == ""
}
func botAPIRichPlainText(text tg.RichTextClass) string {
var out strings.Builder
var walk func(tg.RichTextClass)
walk = func(value tg.RichTextClass) {
switch value := value.(type) {
case *tg.TextPlain:
out.WriteString(value.Text)
case *tg.TextConcat:
for _, child := range value.Texts {
walk(child)
}
case *tg.TextBold:
walk(value.Text)
case *tg.TextItalic:
walk(value.Text)
case *tg.TextUnderline:
walk(value.Text)
case *tg.TextStrike:
walk(value.Text)
case *tg.TextFixed:
walk(value.Text)
case *tg.TextSubscript:
walk(value.Text)
case *tg.TextSuperscript:
walk(value.Text)
case *tg.TextMarked:
walk(value.Text)
case *tg.TextSpoiler:
walk(value.Text)
case *tg.TextURL:
walk(value.Text)
case *tg.TextEmail:
walk(value.Text)
case *tg.TextPhone:
walk(value.Text)
case *tg.TextAnchor:
walk(value.Text)
case *tg.TextMentionName:
walk(value.Text)
case *tg.TextDate:
walk(value.Text)
case *tg.TextCustomEmoji:
out.WriteString(value.Alt)
}
}
walk(text)
return out.String()
}

View file

@ -10,10 +10,9 @@ import (
"telesrv/internal/domain"
)
// 本文件集中 Layer 227 富文本消息richMessage的 tg.* ↔ domain 转换。
// Phase 1仅支持 inputRichMessageblocks 形态HTML/Markdown 变体(需服务端解析为
// PageBlock尚未实现直接拒绝。blocks 以 TL 向量序列化为不透明字节存 domain详见
// domain.MessageRichMessage
// 本文件集中 Layer 228 富文本消息richMessage的 tg.* ↔ domain 转换。
// inputRichMessage 的 blocks、HTML 与 Markdown 三种输入均在 RPC 边界归一为 PageBlock
// blocks 以 TL 向量序列化为不透明字节存 domain详见 domain.MessageRichMessage
// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)。
func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) {
@ -127,22 +126,51 @@ func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
}
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
// 序列化 blocks + 按 id 解析内嵌 photos/documents复用 sendMedia 同款媒体解析)。
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
// 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
}
in, ok := input.(*tg.InputRichMessage)
if !ok {
// Phase 1HTML/Markdown 变体需服务端解析为 PageBlock尚未支持。
return nil, mediaInvalidErr()
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, mediaInvalidErr()
return nil, richMessageInvalidErr()
}
if err := validateRichMessageBlocks(in.Blocks); err != nil {
return nil, err
}
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
return nil, notImplementedErr()
@ -156,6 +184,13 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
Rtl: in.Rtl,
Blocks: blocks,
}
projection, projectionErr := botAPIRichMessageProjection(in.Blocks, in.Rtl)
if projectionErr != nil && sourceParsed {
return nil, richMessageInvalidErr()
}
if projectionErr == nil {
rich.BotAPIProjection = projection
}
for _, p := range in.Photos {
id, ok := inputPhotoID(p)
if !ok {

View file

@ -79,6 +79,18 @@ func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") }
func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") }
func richMessageInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_INVALID") }
func richMessageTooLongErr() error { return tgerr.New(400, "RICH_MESSAGE_TOO_LONG") }
func richMessageDateInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_DATE_INVALID") }
func richMessageMediaUnsupportedErr() error {
return tgerr.New(400, "RICH_MESSAGE_MEDIA_UNSUPPORTED")
}
func webpageMediaEmptyErr() error { return tgerr.New(400, "WEBPAGE_MEDIA_EMPTY") }
func mediaTypeInvalidErr() error { return tgerr.New(400, "MEDIA_TYPE_INVALID") }
func urlInvalidErr() error { return tgerr.New(400, "URL_INVALID") }

View file

@ -114,7 +114,23 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
}
message := target.Body
entities := append([]domain.MessageEntity(nil), target.Entities...)
if rawMessage, ok := req.GetMessage(); ok {
richMessage := target.RichMessage
setRichMessage := false
rawRichMessage, hasRichMessage := req.GetRichMessage()
rawMessage, hasMessage := req.GetMessage()
if hasMessage && hasRichMessage {
return false, mediaInvalidErr()
}
if hasRichMessage {
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
if err != nil {
return false, err
}
if richMessage.IsZero() {
return false, richMessageInvalidErr()
}
message, entities, setRichMessage = "", nil, true
} else if hasMessage {
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
return false, messageEmptyErr()
}
@ -127,6 +143,7 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
}
message = rawMessage
entities = domainMessageEntitiesForViewer(botID, rawEntities)
richMessage, setRichMessage = nil, true
} else if req.ReplyMarkup == nil && newMedia == nil {
return false, messageNotModifiedErr()
}
@ -152,6 +169,8 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
EditDate: int(r.clock.Now().Unix()),
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: setRichMessage,
RichMessage: richMessage,
ViaBotEditBotID: botID,
})
if err != nil {
@ -171,7 +190,23 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
message := target.Body
entities := append([]domain.MessageEntity(nil), target.Entities...)
var mentionUserIDs []int64
if rawMessage, ok := req.GetMessage(); ok {
richMessage := target.RichMessage
setRichMessage := false
rawRichMessage, hasRichMessage := req.GetRichMessage()
rawMessage, hasMessage := req.GetMessage()
if hasMessage && hasRichMessage {
return false, mediaInvalidErr()
}
if hasRichMessage {
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
if err != nil {
return false, err
}
if richMessage.IsZero() {
return false, richMessageInvalidErr()
}
message, entities, setRichMessage = "", nil, true
} else if hasMessage {
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
return false, messageEmptyErr()
}
@ -184,6 +219,7 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
}
message = rawMessage
entities = domainMessageEntitiesForViewer(botID, rawEntities)
richMessage, setRichMessage = nil, true
var err error
mentionUserIDs, err = r.mentionedUserIDsFromMessage(ctx, botID, message, rawEntities)
if err != nil {
@ -221,6 +257,8 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
EditDate: int(r.clock.Now().Unix()),
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: setRichMessage,
RichMessage: richMessage,
ViaBotEditBotID: botID,
})
if err != nil {

View file

@ -41,6 +41,12 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
return nil, richErr
}
}
if hasMessage && hasRichMessage {
return nil, mediaInvalidErr()
}
// Explicit text and rich edits are replacement operations. A text edit must
// clear a previously stored rich payload; a rich edit already replaces it.
replaceRichMessage := hasRichMessage || hasMessage
if hasMessage && richMessage == nil {
// 编辑后的文本同样补服务端自动实体url/@mention/#hashtag/bot command与发送一致
// 覆盖频道/私聊编辑与各自的定时编辑分支editScheduledMessage 仅由本处调用)。
@ -61,7 +67,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
return nil, mediaInvalidErr()
}
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, replaceRichMessage, scheduleDate)
}
if media, ok := req.GetMedia(); ok {
// 关闭 poll 走 editMessage + InputMediaPoll(closed)TDesktop "Stop poll" 路径)。
@ -119,7 +125,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities),
MentionUserIDs: mentionUserIDs,
SetRichMessage: hasRichMessage,
SetRichMessage: replaceRichMessage,
RichMessage: richMessage,
EditDate: int(r.clock.Now().Unix()),
})
@ -154,7 +160,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
OriginSessionID: sessionID,
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: hasRichMessage,
SetRichMessage: replaceRichMessage,
RichMessage: richMessage,
})
if err != nil {

View file

@ -2,10 +2,13 @@ package rpc
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
@ -248,6 +251,108 @@ func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
}
func TestBotAPIRichHTMLParsesBedolagaMenuStructures(t *testing.T) {
r := &Router{}
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
Rtl: true,
Noautolink: true,
HTML: `<h4>Admin</h4>
<table bordered striped><tr><th>Status</th><td align="right" valign="bottom"><tg-time unix="1700000000" format="R">now</tg-time></td></tr></table>
<details open><summary>More</summary><p><blockquote><code>healthy</code></blockquote></p></details>
<footer>Choose an option</footer>`,
})
if err != nil {
t.Fatalf("parse Bedolaga rich HTML: %v", err)
}
decoded, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("decode rich HTML: %v", err)
}
if !decoded.Rtl {
t.Fatal("rich HTML lost is_rtl")
}
var heading, table, details, footer bool
for _, block := range decoded.Blocks {
switch value := block.(type) {
case *tg.PageBlockHeading4:
heading = true
case *tg.PageBlockTable:
table = true
if !value.Bordered || !value.Striped || len(value.Rows) != 1 || len(value.Rows[0].Cells) != 2 {
t.Fatalf("table shape = %+v", value)
}
cell := value.Rows[0].Cells[1]
if !cell.AlignRight || !cell.ValignBottom || !richTextContainsDate(cell.Text, 1700000000) {
t.Fatalf("table date/alignment = %+v", cell)
}
case *tg.PageBlockDetails:
details = value.Open && len(value.Blocks) != 0
case *tg.PageBlockFooter:
footer = true
}
}
if !heading || !table || !details || !footer {
t.Fatalf("parsed blocks heading=%v table=%v details=%v footer=%v: %#v", heading, table, details, footer, decoded.Blocks)
}
var projected struct {
RTL bool `json:"is_rtl"`
Blocks []struct {
Type string `json:"type"`
} `json:"blocks"`
}
if err := json.Unmarshal(rich.BotAPIProjection, &projected); err != nil {
t.Fatalf("decode Bot API projection: %v", err)
}
if !projected.RTL || len(projected.Blocks) != len(decoded.Blocks) {
t.Fatalf("Bot API projection = %s", rich.BotAPIProjection)
}
if _, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
HTML: `<h4>Admin</h4><img src="https://example.test/logo.png">`,
}); err == nil || !tgerr.Is(err, "WEBPAGE_MEDIA_EMPTY") {
t.Fatalf("HTML media err = %v, want WEBPAGE_MEDIA_EMPTY for Bedolaga no-logo retry", err)
}
}
func TestBotAPIRichMarkdownParsesAndProjects(t *testing.T) {
r := &Router{}
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageMarkdown{
Markdown: "# Bedolaga\n\n**Subscription:** active",
})
if err != nil {
t.Fatalf("parse rich Markdown: %v", err)
}
decoded, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("decode rich Markdown: %v", err)
}
if len(decoded.Blocks) < 2 || len(rich.BotAPIProjection) == 0 || !strings.Contains(string(rich.BotAPIProjection), "Bedolaga") {
t.Fatalf("Markdown decoded=%#v projection=%s", decoded.Blocks, rich.BotAPIProjection)
}
}
func richTextContainsDate(text tg.RichTextClass, want int) bool {
switch value := text.(type) {
case *tg.TextDate:
return value.Date == want
case *tg.TextConcat:
for _, child := range value.Texts {
if richTextContainsDate(child, want) {
return true
}
}
case *tg.TextBold:
return richTextContainsDate(value.Text, want)
case *tg.TextItalic:
return richTextContainsDate(value.Text, want)
case *tg.TextFixed:
return richTextContainsDate(value.Text, want)
case *tg.TextURL:
return richTextContainsDate(value.Text, want)
}
return false
}
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
ctx := context.Background()
r := &Router{}
@ -417,7 +522,7 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "rich",
Message: "",
RandomID: 7001,
RichMessage: &tg.InputRichMessage{
Rtl: true,
@ -642,7 +747,7 @@ func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "rich",
Message: "",
RandomID: 7002,
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
})
@ -681,7 +786,7 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "rich+photo",
Message: "",
RandomID: 7003,
RichMessage: &tg.InputRichMessage{
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}},

View file

@ -184,8 +184,8 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr
}
}
// rich_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
// Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误
// rich_messageLayer 228 富文本blocks、HTML、Markdown 均在边界归一为
// PageBlock + 内嵌媒体快照;普通消息恒 nil
var richMessage *domain.MessageRichMessage
if req.RichMessage != nil {
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
@ -194,6 +194,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr
}
}
if req.Message != "" && richMessage != nil {
sendErr = mediaInvalidErr()
return nil, sendErr
}
if req.Message == "" && richMessage == nil {
sendErr = messageEmptyErr()
return nil, sendErr

View file

@ -0,0 +1,215 @@
package rpc
import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
)
const (
richMessageLengthLimit = 32768
richMessageMaxBlocks = 500
richMessageMaxDepth = 16
richMessageMaxMedia = 50
richMessageMaxTableCols = 20
)
type richMessageMetrics struct {
textLength int
blocks int
depth int
media int
tableCols int
}
func validateRichMessageBlocks(blocks []tg.PageBlockClass) error {
metrics := richMessageMetrics{}
collectRichMessageBlockMetrics(blocks, 1, &metrics)
if metrics.textLength > richMessageLengthLimit || metrics.blocks > richMessageMaxBlocks ||
metrics.depth > richMessageMaxDepth || metrics.media > richMessageMaxMedia || metrics.tableCols > richMessageMaxTableCols {
return richMessageTooLongErr()
}
if metrics.blocks == 0 || metrics.textLength == 0 && metrics.media == 0 {
return richMessageInvalidErr()
}
return nil
}
func collectRichMessageBlockMetrics(blocks []tg.PageBlockClass, depth int, metrics *richMessageMetrics) {
if depth > metrics.depth {
metrics.depth = depth
}
for _, block := range blocks {
metrics.blocks++
switch value := block.(type) {
case *tg.PageBlockTitle:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockSubtitle:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeader:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockSubheader:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockKicker:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockParagraph:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockPreformatted:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockFooter:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading1:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading2:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading3:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading4:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading5:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading6:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockMath:
metrics.textLength += utf16StringLength(value.Source)
case *tg.PageBlockThinking:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockAuthorDate:
metrics.textLength += richTextUTF16Length(value.Author)
case *tg.PageBlockBlockquote:
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
case *tg.PageBlockPullquote:
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
case *tg.PageBlockBlockquoteBlocks:
metrics.textLength += richTextUTF16Length(value.Caption)
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
case *tg.PageBlockDetails:
metrics.textLength += richTextUTF16Length(value.Title)
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
case *tg.PageBlockList:
for _, item := range value.Items {
switch item := item.(type) {
case *tg.PageListItemText:
metrics.textLength += richTextUTF16Length(item.Text)
case *tg.PageListItemBlocks:
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
}
}
case *tg.PageBlockOrderedList:
for _, item := range value.Items {
switch item := item.(type) {
case *tg.PageListOrderedItemText:
metrics.textLength += richTextUTF16Length(item.Text)
case *tg.PageListOrderedItemBlocks:
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
}
}
case *tg.PageBlockTable:
metrics.textLength += richTextUTF16Length(value.Title)
for _, row := range value.Rows {
columns := 0
for _, cell := range row.Cells {
metrics.textLength += richTextUTF16Length(cell.Text)
if cell.Colspan > 1 {
columns += cell.Colspan
} else {
columns++
}
}
if columns > metrics.tableCols {
metrics.tableCols = columns
}
}
case *tg.PageBlockCollage:
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
case *tg.PageBlockSlideshow:
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
case *tg.PageBlockCover:
collectRichMessageBlockMetrics([]tg.PageBlockClass{value.Cover}, depth+1, metrics)
case *tg.PageBlockEmbedPost:
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
case *tg.PageBlockPhoto, *tg.PageBlockVideo, *tg.PageBlockAudio:
metrics.media++
}
}
}
func richTextUTF16Length(text tg.RichTextClass) int {
switch value := text.(type) {
case nil, *tg.TextEmpty:
return 0
case *tg.TextPlain:
return utf16StringLength(value.Text)
case *tg.TextConcat:
total := 0
for _, child := range value.Texts {
total += richTextUTF16Length(child)
}
return total
case *tg.TextBold:
return richTextUTF16Length(value.Text)
case *tg.TextItalic:
return richTextUTF16Length(value.Text)
case *tg.TextUnderline:
return richTextUTF16Length(value.Text)
case *tg.TextStrike:
return richTextUTF16Length(value.Text)
case *tg.TextFixed:
return richTextUTF16Length(value.Text)
case *tg.TextSubscript:
return richTextUTF16Length(value.Text)
case *tg.TextSuperscript:
return richTextUTF16Length(value.Text)
case *tg.TextMarked:
return richTextUTF16Length(value.Text)
case *tg.TextSpoiler:
return richTextUTF16Length(value.Text)
case *tg.TextURL:
return richTextUTF16Length(value.Text)
case *tg.TextMention:
return richTextUTF16Length(value.Text)
case *tg.TextHashtag:
return richTextUTF16Length(value.Text)
case *tg.TextBotCommand:
return richTextUTF16Length(value.Text)
case *tg.TextCashtag:
return richTextUTF16Length(value.Text)
case *tg.TextAutoURL:
return richTextUTF16Length(value.Text)
case *tg.TextAutoEmail:
return richTextUTF16Length(value.Text)
case *tg.TextAutoPhone:
return richTextUTF16Length(value.Text)
case *tg.TextBankCard:
return richTextUTF16Length(value.Text)
case *tg.TextEmail:
return richTextUTF16Length(value.Text)
case *tg.TextPhone:
return richTextUTF16Length(value.Text)
case *tg.TextAnchor:
return richTextUTF16Length(value.Text)
case *tg.TextMentionName:
return richTextUTF16Length(value.Text)
case *tg.TextDate:
return richTextUTF16Length(value.Text)
case *tg.TextCustomEmoji:
return utf16StringLength(value.Alt)
case *tg.TextMath:
return utf16StringLength(value.Source)
default:
return 0
}
}
func utf16StringLength(value string) int {
length := 0
for _, r := range value {
length++
if r > utf8.RuneSelf && r > 0xffff {
length++
}
}
return length
}