feat: sync bot rich messages and inline menus
This commit is contained in:
parent
2965f5d47d
commit
1a2d03f529
24 changed files with 2073 additions and 38 deletions
305
internal/rpc/botapi_rich_message.go
Normal file
305
internal/rpc/botapi_rich_message.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue