FILE / ScuroNeko/Laniakea

tgrich/html_render.go

Исходный файл и его история в репозитории.
FILE d3e4276b970e8eeb383355cb38c553deda190acf
Files
Laniakea/tgrich/html_render.go
T
ScuroNeko 24040fe164
Golang lint / lint (push) Successful in 58s
Golang lint / lint (pull_request) Successful in 13m10s
(new): expand runtime APIs
(fix): harden concurrent lifecycle
(tests): add regression coverage
(doc): update v1.2 guidance
2026-08-20 11:08:45 +03:00

552 lines
15 KiB
Go

package tgrich
import (
"fmt"
"strconv"
"strings"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
type buildState struct {
media []tgapi.InputRichMessageMedia
}
// BuildHTML converts input rich blocks to an HTML-based rich message and
// collects media blocks into InputRichMessage.Media.
//
// Since: Bot API 10.2
func BuildHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
return buildHTML(false, blocks...)
}
// BuildDraftHTML converts draft-compatible input rich blocks to HTML.
// Unlike BuildHTML, it permits the draft-only thinking block.
//
// Since: Bot API 10.2
func BuildDraftHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
return buildHTML(true, blocks...)
}
func buildHTML(allowThinking bool, blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
if err := validateRichBlocks(blocks, allowThinking); err != nil {
return tgapi.InputRichMessage{}, err
}
state := new(buildState)
rendered, err := renderBlocksHTMLState(blocks, 0, state)
if err != nil {
return tgapi.InputRichMessage{}, err
}
if allowThinking {
for _, item := range state.media {
if strings.HasPrefix(item.Media.Media, "attach://") {
return tgapi.InputRichMessage{}, tgapi.ErrRichMessageDraftUploadUnsupported
}
}
}
return tgapi.InputRichMessage{
HTML: strings.Join(rendered, ""),
Media: state.media,
}, nil
}
// ToHTML converts one input rich block to an HTML-based rich message.
//
// Since: Bot API 10.2
func ToHTML(block tgapi.InputRichBlock) (tgapi.InputRichMessage, error) { return BuildHTML(block) }
func (s *buildState) addMedia(media tgapi.InputMedia) (string, error) {
if len(s.media) >= maxRichMedia {
return "", ErrRichTooManyMedia
}
var kind string
switch media.Type {
case tgapi.InputMediaTypePhoto:
kind = "photo"
case tgapi.InputMediaTypeAnimation, tgapi.InputMediaTypeVideo:
kind = "video"
case tgapi.InputMediaTypeAudio, tgapi.InputMediaTypeVoiceNote:
kind = "audio"
default:
return "", ErrRichInvalidMedia
}
id := "media_" + strconv.Itoa(len(s.media)+1)
s.media = append(s.media, tgapi.InputRichMessageMedia{ID: id, Media: media})
return "tg://" + kind + "?id=" + id, nil
}
func renderText(t tgapi.RichText, step int) (string, error) {
switch el := t.(type) {
case tgapi.RichTextPlain:
return escapeHTML(string(el)), nil
case tgapi.RichTextArray:
if step >= maxRichDepth {
return "", ErrRichNestingTooDeep
}
var b strings.Builder
for _, item := range el {
part, err := renderText(item, step+1)
if err != nil {
return "", err
}
b.WriteString(part)
}
return b.String(), nil
case tgapi.RichTextWrap:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
tagMap := map[string]string{
"bold": "b", "italic": "i", "underline": "u", "strikethrough": "s",
"subscript": "sub", "superscript": "sup", "marked": "mark",
"spoiler": "tg-spoiler", "code": "code",
}
tag, ok := tagMap[el.Tag]
if !ok {
return "", ErrRichUnknownTag
}
return "<" + tag + ">" + s + "</" + tag + ">", nil
case tgapi.RichTextDateTime:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := []string{`unix="` + strconv.FormatInt(el.UnixTime, 10) + `"`}
if el.DateTimeFormat != "" {
attrs = append(attrs, `format="`+escapeHTML(el.DateTimeFormat)+`"`)
}
return openTag("tg-time", attrs) + s + "</tg-time>", nil
case tgapi.RichTextTextMention:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := []string{`href="` + "tg://user?id=" + strconv.FormatInt(el.User.ID, 10) + `"`}
return openTag("a", attrs) + s + "</a>", nil
case tgapi.RichTextCustomEmoji:
s := escapeHTML(el.AlternativeText)
attrs := formatAttrs(map[string]string{"emoji-id": el.CustomEmojiID})
return openTag("tg-emoji", attrs) + s + "</tg-emoji>", nil
case tgapi.RichTextMathematicalExpression:
s := escapeHTML(el.Expression)
return "<tg-math>" + s + "</tg-math>", nil
case tgapi.RichTextURL:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := formatAttrs(map[string]string{"href": el.URL})
return openTag("a", attrs) + s + "</a>", nil
case tgapi.RichTextEmailAddress:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := formatAttrs(map[string]string{"href": "mailto:" + el.EmailAddress})
return openTag("a", attrs) + s + "</a>", nil
case tgapi.RichTextPhoneNumber:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := formatAttrs(map[string]string{"href": "tel:" + el.PhoneNumber})
return openTag("a", attrs) + s + "</a>", nil
case tgapi.RichTextBankCardNumber:
return renderTextChild(el.Text, step)
case tgapi.RichTextMention:
return renderTextChild(el.Text, step)
case tgapi.RichTextHashtag:
return renderTextChild(el.Text, step)
case tgapi.RichTextCashtag:
return renderTextChild(el.Text, step)
case tgapi.RichTextBotCommand:
return renderTextChild(el.Text, step)
case tgapi.RichTextAnchor:
attrs := formatAttrs(map[string]string{"name": el.Name})
return openTag("a", attrs) + "</a>", nil
case tgapi.RichTextAnchorLink:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := formatAttrs(map[string]string{"href": "#" + el.AnchorName})
return openTag("a", attrs) + s + "</a>", nil
case tgapi.RichTextReference:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := formatAttrs(map[string]string{"name": el.Name})
return openTag("tg-reference", attrs) + s + "</tg-reference>", nil
case tgapi.RichTextReferenceLink:
s, err := renderTextChild(el.Text, step)
if err != nil {
return "", err
}
attrs := formatAttrs(map[string]string{"href": "#" + el.ReferenceName})
return openTag("a", attrs) + s + "</a>", nil
}
return "", ErrRichUnknownTag
}
func renderTextChild(t tgapi.RichText, step int) (string, error) {
if step >= maxRichDepth {
return "", ErrRichNestingTooDeep
}
return renderText(t, step+1)
}
func renderHTMLTable(t tgapi.InputRichBlockTable, step int, state *buildState) (string, error) {
attrs := map[string]string{}
if t.IsBordered {
attrs["bordered"] = ""
}
if t.IsStriped {
attrs["striped"] = ""
}
var rows strings.Builder
for _, row := range t.Cells {
r, err := renderHTMLTableRow(row, step, state)
if err != nil {
return "", err
}
rows.WriteString(r)
}
caption := ""
if t.Caption != nil {
text, err := renderText(*t.Caption, step+1)
if err != nil {
return "", err
}
caption = "<caption>" + text + "</caption>"
}
return openTag("table", formatAttrs(attrs)) + caption + rows.String() + "</table>", nil
}
func renderHTMLTableRow(rows []tgapi.RichBlockTableCell, step int, state *buildState) (string, error) {
var out strings.Builder
for _, cell := range rows {
row, err := renderHTMLTableCell(cell, step, state)
if err != nil {
return "", err
}
out.WriteString(row)
}
return "<tr>" + out.String() + "</tr>", nil
}
func renderHTMLTableCell(c tgapi.RichBlockTableCell, step int, _ *buildState) (string, error) {
var renderedText string
var err error
if c.Text != nil {
renderedText, err = renderText(c.Text, step+1)
}
if err != nil {
return "", err
}
mapAttrs := make(map[string]string)
tag := "td"
if c.IsHeader {
tag = "th"
}
if c.RowSpan > 0 {
mapAttrs["rowspan"] = strconv.Itoa(c.RowSpan)
}
if c.ColSpan > 0 {
mapAttrs["colspan"] = strconv.Itoa(c.ColSpan)
}
if c.Align != "" {
mapAttrs["align"] = c.Align
}
if c.VAlign != "" {
mapAttrs["valign"] = c.VAlign
}
return openTag(tag, formatAttrs(mapAttrs)) + renderedText + "</" + tag + ">", nil
}
func renderHTMLList(l tgapi.InputRichBlockList, step int, state *buildState) (string, error) {
if len(l.Items) == 0 {
return "<ul></ul>", nil
}
hasType := false
hasNoType := false
var items []string
for _, item := range l.Items {
if item.Type != "" {
hasType = true
} else {
hasNoType = true
if item.Value != 0 {
return "", ErrRichInvalidListItem
}
}
rendered, err := renderHTMLListItem(item, step, state)
if err != nil {
return "", err
}
items = append(items, rendered)
}
switch {
case hasType && !hasNoType:
return "<ol>\n" + strings.Join(items, "\n") + "</ol>", nil
case hasNoType && !hasType:
return "<ul>\n" + strings.Join(items, "\n") + "</ul>", nil
}
return "", ErrRichListItemMix
}
func renderHTMLCaption(cap tgapi.RichBlockCaption, step int) (string, error) {
caption := "<figcaption>"
text, err := renderText(cap.Text, step)
if err != nil {
return "", err
}
caption += text
if cap.Credit != nil {
cred, err := renderText(cap.Credit, step)
if err != nil {
return "", err
}
caption += "<cite>" + cred + "</cite>"
}
caption += "</figcaption>"
return caption, nil
}
func renderHTMLListItem(i tgapi.InputRichBlockListItem, step int, state *buildState) (string, error) {
mapAttrs := map[string]string{}
renderedBlocks, err := renderBlocksHTMLState(i.Blocks, step+1, state)
if err != nil {
return "", err
}
blocks := strings.Join(renderedBlocks, "")
if i.HasCheckbox {
var input string
if i.IsChecked {
input = `<input type="checkbox" checked/>`
} else {
input = `<input type="checkbox"/>`
}
blocks = input + blocks
}
if i.Value > 0 {
mapAttrs["value"] = strconv.Itoa(i.Value)
}
if i.Type != "" {
mapAttrs["type"] = string(i.Type)
}
return openTag("li", formatAttrs(mapAttrs)) + blocks + "</li>", nil
}
func renderHTMLMap(block tgapi.InputRichBlockMap, step int) (string, error) {
attrs := map[string]string{
"lat": strconv.FormatFloat(block.Location.Latitude, 'f', -1, 64),
"long": strconv.FormatFloat(block.Location.Longitude, 'f', -1, 64),
}
if block.Zoom != 0 {
attrs["zoom"] = strconv.Itoa(int(block.Zoom))
}
if block.Width != 0 {
attrs["width"] = strconv.Itoa(int(block.Width))
}
if block.Height != 0 {
attrs["height"] = strconv.Itoa(int(block.Height))
}
media := selfClosingTag("tg-map", formatAttrs(attrs))
if block.Caption == nil {
return media, nil
}
caption, err := renderHTMLCaption(*block.Caption, step+1)
if err != nil {
return "", err
}
return "<figure>" + media + caption + "</figure>", nil
}
func renderHTMLMedia(media tgapi.InputMedia, caption *tgapi.RichBlockCaption, tag string, step int, state *buildState) (string, error) {
src, err := state.addMedia(media)
if err != nil {
return "", err
}
attrs := map[string]string{"src": src}
if media.HasSpoiler != nil && *media.HasSpoiler {
attrs["tg-spoiler"] = ""
}
var element string
if tag == "img" {
element = selfClosingTag(tag, formatAttrs(attrs))
} else {
element = openTag(tag, formatAttrs(attrs)) + "</" + tag + ">"
}
if caption == nil {
return element, nil
}
renderedCaption, err := renderHTMLCaption(*caption, step+1)
if err != nil {
return "", err
}
return "<figure>" + element + renderedCaption + "</figure>", nil
}
// func renderBlocksHTML(blocks []tgapi.InputRichBlock, step int) ([]string, error) {
// return renderBlocksHTMLState(blocks, step, new(buildState))
// }
func renderBlocksHTMLState(blocks []tgapi.InputRichBlock, step int, state *buildState) ([]string, error) {
var out []string
for _, b := range blocks {
html, err := renderBlockHTMLState(b, step, state)
if err != nil {
return nil, err
}
out = append(out, html)
}
return out, nil
}
func renderBlockHTML(block tgapi.InputRichBlock, step int) (string, error) {
return renderBlockHTMLState(block, step, new(buildState))
}
func renderBlockHTMLState(block tgapi.InputRichBlock, step int, state *buildState) (string, error) {
if step >= maxRichDepth {
return "", ErrRichNestingTooDeep
}
switch el := block.(type) {
case tgapi.InputRichBlockParagraph:
t, err := renderText(el.Text, step+1)
if err != nil {
return "", err
}
return "<p>" + t + "</p>", nil
case tgapi.InputRichBlockSectionHeading:
t, err := renderText(el.Text, step+1)
if err != nil {
return "", err
}
size := strconv.Itoa(int(el.Size))
return "<h" + size + ">" + t + "</h" + size + ">", nil
case tgapi.InputRichBlockPreformatted:
t, err := renderText(el.Text, step+1)
if err != nil {
return "", err
}
if el.Language != "" {
t = fmt.Sprintf(`<code class="language-%s">%s</code>`, escapeHTML(el.Language), t)
}
return "<pre>" + t + "</pre>", nil
case tgapi.InputRichBlockFooter:
t, err := renderText(el.Text, step+1)
if err != nil {
return "", err
}
return "<footer>" + t + "</footer>", nil
case tgapi.InputRichBlockDivider:
return "<hr/>", nil
case tgapi.InputRichBlockMath:
exp := escapeHTML(el.Expression)
return "<tg-math-block>" + exp + "</tg-math-block>", nil
case tgapi.InputRichBlockAnchor:
return fmt.Sprintf(`<a name="%s"></a>`, escapeHTML(el.Name)), nil
case tgapi.InputRichBlockList:
return renderHTMLList(el, step, state)
case tgapi.InputRichBlockBlockQuotation:
content, err := renderBlocksHTMLState(el.Blocks, step+1, state)
if err != nil {
return "", err
}
credit := ""
if el.Credit != nil {
credit, err = renderText(*el.Credit, step+1)
if err != nil {
return "", err
}
credit = "<cite>" + credit + "</cite>"
}
return "<blockquote>" + strings.Join(content, "") + credit + "</blockquote>", nil
case tgapi.InputRichBlockPullQuotation:
text, err := renderText(el.Text, step+1)
if err != nil {
return "", err
}
credit := ""
if el.Credit != nil {
credit, err = renderText(*el.Credit, step+1)
if err != nil {
return "", err
}
credit = "<cite>" + credit + "</cite>"
}
return "<aside>" + text + credit + "</aside>", nil
case tgapi.InputRichBlockCollage:
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
if err != nil {
return "", err
}
caption := ""
if el.Caption != nil {
caption, err = renderHTMLCaption(*el.Caption, step+1)
if err != nil {
return "", err
}
}
return "<tg-collage>" + strings.Join(blocks, "") + caption + "</tg-collage>", err
case tgapi.InputRichBlockSlideshow:
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
if err != nil {
return "", err
}
caption := ""
if el.Caption != nil {
caption, err = renderHTMLCaption(*el.Caption, step+1)
if err != nil {
return "", err
}
}
return "<tg-slideshow>" + strings.Join(blocks, "") + caption + "</tg-slideshow>", err
case tgapi.InputRichBlockTable:
return renderHTMLTable(el, step, state)
case tgapi.InputRichBlockDetails:
summary, err := renderText(el.Summary, step+1)
if err != nil {
return "", err
}
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
if err != nil {
return "", err
}
attrs := make(map[string]string)
if el.IsOpen {
attrs["open"] = ""
}
return openTag("details", formatAttrs(attrs)) + "<summary>" + summary + "</summary>" + strings.Join(blocks, "") + "</details>", nil
case tgapi.InputRichBlockMap:
return renderHTMLMap(el, step)
case tgapi.InputRichBlockAnimation:
return renderHTMLMedia(el.Animation, el.Caption, "video", step, state)
case tgapi.InputRichBlockAudio:
return renderHTMLMedia(el.Audio, el.Caption, "audio", step, state)
case tgapi.InputRichBlockPhoto:
return renderHTMLMedia(el.Photo, el.Caption, "img", step, state)
case tgapi.InputRichBlockVideo:
return renderHTMLMedia(el.Video, el.Caption, "video", step, state)
case tgapi.InputRichBlockVoiceNote:
return renderHTMLMedia(el.VoiceNote, el.Caption, "audio", step, state)
case tgapi.InputRichBlockThinking:
text, err := renderText(el.Text, step+1)
if err != nil {
return "", err
}
return "<tg-thinking>" + text + "</tg-thinking>", nil
}
return "", ErrRichUnknownTag
}