(new): rich message support
(fix): runtime reliability (tests): regression coverage (doc): v1.1 release notes
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
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:
|
||||
var b strings.Builder
|
||||
for _, item := range el {
|
||||
part, err := renderText(item, step)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user