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 + "", 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 + "", 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 + "", nil case tgapi.RichTextCustomEmoji: s := escapeHTML(el.AlternativeText) attrs := formatAttrs(map[string]string{"emoji-id": el.CustomEmojiID}) return openTag("tg-emoji", attrs) + s + "", nil case tgapi.RichTextMathematicalExpression: s := escapeHTML(el.Expression) return "" + s + "", 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 + "", 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 + "", 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 + "", 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) + "", 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 + "", 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 + "", 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 + "", 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 = "" + text + "" } return openTag("table", formatAttrs(attrs)) + caption + rows.String() + "", 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 "" + out.String() + "", 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 + "", nil } func renderHTMLList(l tgapi.InputRichBlockList, step int, state *buildState) (string, error) { if len(l.Items) == 0 { return "", 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 "
    \n" + strings.Join(items, "\n") + "
", nil case hasNoType && !hasType: return "", nil } return "", ErrRichListItemMix } func renderHTMLCaption(cap tgapi.RichBlockCaption, step int) (string, error) { caption := "
" 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 += "" + cred + "" } caption += "
" 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 = `` } else { input = `` } 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 + "", 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 "
" + media + caption + "
", 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)) + "" } if caption == nil { return element, nil } renderedCaption, err := renderHTMLCaption(*caption, step+1) if err != nil { return "", err } return "
" + element + renderedCaption + "
", 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 "

" + t + "

", nil case tgapi.InputRichBlockSectionHeading: t, err := renderText(el.Text, step+1) if err != nil { return "", err } size := strconv.Itoa(int(el.Size)) return "" + t + "", nil case tgapi.InputRichBlockPreformatted: t, err := renderText(el.Text, step+1) if err != nil { return "", err } if el.Language != "" { t = fmt.Sprintf(`%s`, escapeHTML(el.Language), t) } return "
" + t + "
", nil case tgapi.InputRichBlockFooter: t, err := renderText(el.Text, step+1) if err != nil { return "", err } return "", nil case tgapi.InputRichBlockDivider: return "
", nil case tgapi.InputRichBlockMath: exp := escapeHTML(el.Expression) return "" + exp + "", nil case tgapi.InputRichBlockAnchor: return fmt.Sprintf(``, 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 = "" + credit + "" } return "
" + strings.Join(content, "") + credit + "
", 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 = "" + credit + "" } return "", 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 "" + strings.Join(blocks, "") + caption + "", 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 "" + strings.Join(blocks, "") + caption + "", 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 + "" + strings.Join(blocks, "") + "", 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 "" + text + "", nil } return "", ErrRichUnknownTag }