305 lines
9.3 KiB
Go
305 lines
9.3 KiB
Go
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
|
|
}
|