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

@ -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
}