(new): support Bot API 10.3
Golang lint / lint (push) Failing after 1m37s

(fix): finalize v2 contracts
(tests): cover v2 migration
(doc): prepare release guidance
This commit is contained in:
2026-09-08 23:21:38 +03:00
parent 24040fe164
commit d78526242b
88 changed files with 2321 additions and 918 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ package tgrich
import (
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
func TestRenderText(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
func TestBuildHTMLBlocks(t *testing.T) {
+134
View File
@@ -0,0 +1,134 @@
package tgrich
import (
"fmt"
"strings"
"unicode/utf8"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
func (v *richValidator) button(b tgapi.RichMessageButton, depth int) error {
actions := 0
for _, set := range []bool{b.URL != "", b.CallbackData != "", b.WebApp != nil, b.LoginURL != nil, b.SwitchInlineQuery != nil, b.SwitchInlineQueryCurrentChat != nil, b.SwitchInlineQueryChosenChat != nil, b.CopyText != nil, b.Disabled != nil} {
if set {
actions++
}
}
if actions != 1 {
return fmt.Errorf("%w: exactly one action is required", ErrRichInvalidButton)
}
switch b.Style {
case "", "danger", "success", "primary":
case "link":
if b.CallbackData == "" {
return fmt.Errorf("%w: link style requires callback data", ErrRichInvalidButton)
}
default:
return fmt.Errorf("%w: unsupported style", ErrRichInvalidButton)
}
if len(b.CallbackData) > 64 {
return fmt.Errorf("%w: callback data exceeds 64 bytes", ErrRichInvalidButton)
}
if b.CopyText != nil && (utf8.RuneCountInString(b.CopyText.Text) < 1 || utf8.RuneCountInString(b.CopyText.Text) > 256) {
return fmt.Errorf("%w: copy text must contain 1-256 characters", ErrRichInvalidButton)
}
if b.LoginURL != nil && b.LoginURL.BotUsername != "" {
return fmt.Errorf("%w: login bot username is unsupported", ErrRichInvalidButton)
}
return v.buttonText(b.Text, depth)
}
func (v *richValidator) buttonText(text tgapi.RichText, depth int) error {
if depth >= maxRichDepth {
return ErrRichNestingTooDeep
}
switch el := text.(type) {
case tgapi.RichTextPlain:
return v.addChars(string(el))
case tgapi.RichTextCustomEmoji:
return v.addChars(el.AlternativeText)
case tgapi.RichTextDateTime:
return v.buttonText(el.Text, depth+1)
case tgapi.RichTextArray:
for _, item := range el {
if err := v.buttonText(item, depth+1); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("%w: label allows only plain text, custom emoji, and date-time entities", ErrRichInvalidButton)
}
}
func renderButton(b tgapi.RichMessageButton, step int) (string, error) {
if step >= maxRichDepth {
return "", ErrRichNestingTooDeep
}
text, err := renderText(b.Text, step+1)
if err != nil {
return "", err
}
attrs := map[string]string{}
if b.Style != "" {
attrs["style"] = string(b.Style)
}
// Empty query values are significant; render them explicitly below.
var query *string
switch {
case b.URL != "":
attrs["type"], attrs["url"] = "url", b.URL
case b.CallbackData != "":
attrs["type"], attrs["data"] = "callback_data", b.CallbackData
case b.WebApp != nil:
attrs["type"], attrs["url"] = "web_app", b.WebApp.URL
case b.LoginURL != nil:
attrs["type"], attrs["url"] = "login_url", b.LoginURL.URL
if b.LoginURL.ForwardText != "" {
attrs["forward-text"] = b.LoginURL.ForwardText
}
if b.LoginURL.RequestWriteAccess {
attrs["request-write-access"] = ""
}
case b.SwitchInlineQuery != nil:
attrs["type"], query = "switch_inline_query", b.SwitchInlineQuery
case b.SwitchInlineQueryCurrentChat != nil:
attrs["type"], query = "switch_inline_query_current_chat", b.SwitchInlineQueryCurrentChat
case b.SwitchInlineQueryChosenChat != nil:
c := b.SwitchInlineQueryChosenChat
attrs["type"], query = "switch_inline_query_chosen_chat", &c.Query
for key, enabled := range map[string]bool{"allow-user-chats": c.AllowUserChats, "allow-bot-chats": c.AllowBotChats, "allow-group-chats": c.AllowGroupChats, "allow-channel-chats": c.AllowChannelChats} {
if enabled {
attrs[key] = ""
}
}
case b.CopyText != nil:
attrs["type"], attrs["text"] = "copy_text", b.CopyText.Text
case b.Disabled != nil:
attrs["type"] = "disabled"
default:
return "", ErrRichInvalidButton
}
formatted := formatAttrs(attrs)
if query != nil {
formatted = append(formatted, `query="`+escapeHTML(*query)+`"`)
}
return openTag("tg-button", formatted) + text + "</tg-button>", nil
}
func renderButtons(row tgapi.InputRichBlockButtons, step int) (string, error) {
var content strings.Builder
for _, button := range row.Buttons {
html, err := renderButton(button, step+1)
if err != nil {
return "", err
}
content.WriteString(html)
}
attrs := map[string]string{}
if row.Align != "" {
attrs["align"] = string(row.Align)
}
return openTag("tg-button-row", formatAttrs(attrs)) + content.String() + "</tg-button-row>", nil
}
+2
View File
@@ -3,6 +3,8 @@ package tgrich
import "errors"
var (
// ErrRichInvalidButton indicates an invalid rich-button action, label, style, or row.
ErrRichInvalidButton = errors.New("invalid rich button")
// ErrRichTextTooLong indicates that rich-message text exceeds 32768 UTF-8 characters.
ErrRichTextTooLong = errors.New("rich text too long")
// ErrRichTooManyBlocks indicates that a rich message exceeds 500 blocks and counted nested items.
+26 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv"
"strings"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
type buildState struct {
@@ -61,6 +61,8 @@ func (s *buildState) addMedia(media tgapi.InputMedia) (string, error) {
}
var kind string
switch media.Type {
case tgapi.InputMediaTypeDocument:
kind = "document"
case tgapi.InputMediaTypePhoto:
kind = "photo"
case tgapi.InputMediaTypeAnimation, tgapi.InputMediaTypeVideo:
@@ -77,6 +79,8 @@ func (s *buildState) addMedia(media tgapi.InputMedia) (string, error) {
func renderText(t tgapi.RichText, step int) (string, error) {
switch el := t.(type) {
case tgapi.RichTextButton:
return renderButton(el.Button, step+1)
case tgapi.RichTextPlain:
return escapeHTML(string(el)), nil
case tgapi.RichTextArray:
@@ -203,6 +207,9 @@ func renderHTMLTable(t tgapi.InputRichBlockTable, step int, state *buildState) (
if t.IsBordered {
attrs["bordered"] = ""
}
if t.IsCompact {
attrs["compact"] = ""
}
if t.IsStriped {
attrs["striped"] = ""
}
@@ -470,6 +477,20 @@ func renderBlockHTMLState(block tgapi.InputRichBlock, step int, state *buildStat
credit = "<cite>" + credit + "</cite>"
}
return "<blockquote>" + strings.Join(content, "") + credit + "</blockquote>", nil
case tgapi.InputRichBlockExpandableBlockQuotation:
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 "<blockquote expandable>" + text + credit + "</blockquote>", nil
case tgapi.InputRichBlockPullQuotation:
text, err := renderText(el.Text, step+1)
if err != nil {
@@ -532,6 +553,10 @@ func renderBlockHTMLState(block tgapi.InputRichBlock, step int, state *buildStat
return renderHTMLMap(el, step)
case tgapi.InputRichBlockAnimation:
return renderHTMLMedia(el.Animation, el.Caption, "video", step, state)
case tgapi.InputRichBlockDocument:
return renderHTMLMedia(el.Document, el.Caption, "tg-document", step, state)
case tgapi.InputRichBlockButtons:
return renderButtons(el, step)
case tgapi.InputRichBlockAudio:
return renderHTMLMedia(el.Audio, el.Caption, "audio", step, state)
case tgapi.InputRichBlockPhoto:
+66 -1
View File
@@ -1,6 +1,6 @@
package tgrich
import "git.scuroneko.dev/scuroneko/laniakea/tgapi"
import "git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
// Caption creates a media-block caption without a credit.
//
@@ -390,6 +390,8 @@ type RichTable struct {
IsBordered bool
// IsStriped reports whether the table has striped rows.
IsStriped bool
// IsCompact requests smaller table-cell padding.
IsCompact bool
// Caption is the optional table caption.
Caption *tgapi.RichText
}
@@ -425,6 +427,14 @@ func (t *RichTable) SetStriped(b bool) *RichTable {
return t
}
// SetCompact requests smaller table-cell padding.
//
// Since: Bot API 10.3
func (t *RichTable) SetCompact(b bool) *RichTable {
t.IsCompact = b
return t
}
// SetCaption sets the table caption.
//
// Since: Bot API 10.2
@@ -442,6 +452,7 @@ func (t *RichTable) Build() tgapi.InputRichBlockTable {
Cells: t.Cells,
IsBordered: t.IsBordered,
IsStriped: t.IsStriped,
IsCompact: t.IsCompact,
Caption: t.Caption,
}
}
@@ -477,6 +488,20 @@ func MapWithCaption(loc tgapi.Location, zoom uint8, width, height uint16, captio
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height, Caption: &caption}
}
// Buttons creates a row of 1-8 rich buttons.
//
// Since: Bot API 10.3
func Buttons(buttons []tgapi.RichMessageButton) tgapi.InputRichBlockButtons {
return tgapi.InputRichBlockButtons{Type: tgapi.InputRichTypeButtons, Buttons: buttons}
}
// ButtonsWithAlign creates a button row aligned left, center, or right.
//
// Since: Bot API 10.3
func ButtonsWithAlign(buttons []tgapi.RichMessageButton, align tgapi.RichBlockButtonAlign) tgapi.InputRichBlockButtons {
return tgapi.InputRichBlockButtons{Type: tgapi.InputRichTypeButtons, Buttons: buttons, Align: align}
}
// Animation creates an animation block without a caption.
//
// Since: Bot API 10.2
@@ -568,3 +593,43 @@ func VoiceNoteWithCaption(voiceNote tgapi.InputMedia, caption tgapi.RichBlockCap
func Thinking(text tgapi.RichText) tgapi.InputRichBlockThinking {
return tgapi.InputRichBlockThinking{Type: tgapi.InputRichTypeThinking, Text: text}
}
// Button embeds a button in rich text.
//
// Since: Bot API 10.3
func Button(button tgapi.RichMessageButton) tgapi.RichTextButton {
return tgapi.RichTextButton{Button: button}
}
// ExpandableBlockquote creates a quotation corresponding to <blockquote expandable>.
//
// Since: Bot API 10.3
func ExpandableBlockquote(text tgapi.RichText) tgapi.InputRichBlockExpandableBlockQuotation {
return tgapi.InputRichBlockExpandableBlockQuotation{Type: tgapi.InputRichTypeExpandableBlockQuotation, Text: text}
}
// ExpandableBlockquoteWithCredit creates an expandable quotation with its source.
//
// Since: Bot API 10.3
func ExpandableBlockquoteWithCredit(text, credit tgapi.RichText) tgapi.InputRichBlockExpandableBlockQuotation {
block := ExpandableBlockquote(text)
block.Credit = &credit
return block
}
// Document creates a general-file block corresponding to <tg-document>.
//
// Since: Bot API 10.3
func Document(document tgapi.InputMedia) tgapi.InputRichBlockDocument {
document.Type = tgapi.InputMediaTypeDocument
return tgapi.InputRichBlockDocument{Type: tgapi.InputRichTypeDocument, Document: document}
}
// DocumentWithCaption creates a general-file block with a caption.
//
// Since: Bot API 10.3
func DocumentWithCaption(document tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockDocument {
block := Document(document)
block.Caption = &caption
return block
}
+1 -1
View File
@@ -3,7 +3,7 @@ package tgrich
import (
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
func TestMediaConstructorsSetTypes(t *testing.T) {
+142
View File
@@ -0,0 +1,142 @@
package tgrich
import (
"encoding/json"
"errors"
"strings"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
func TestRich103HTML(t *testing.T) {
empty := ""
for _, tc := range []struct {
name string
button tgapi.RichMessageButton
fragment string
}{
{"url", tgapi.RichMessageButton{URL: "https://example.com/?a=1&b=2"}, `url="https://example.com/?a=1&amp;b=2"`},
{"callback", tgapi.RichMessageButton{CallbackData: `a"<&`, Style: tgapi.RichMessageButtonStyleLink}, `data="a&quot;&lt;&amp;"`},
{"web app", tgapi.RichMessageButton{WebApp: &tgapi.WebAppInfo{URL: "https://example.com"}}, `type="web_app"`},
{"login", tgapi.RichMessageButton{LoginURL: &tgapi.LoginURL{URL: "https://example.com", ForwardText: "Forward", RequestWriteAccess: true}}, `request-write-access`},
{"inline", tgapi.RichMessageButton{SwitchInlineQuery: &empty}, `query=""`},
{"current", tgapi.RichMessageButton{SwitchInlineQueryCurrentChat: &empty}, `type="switch_inline_query_current_chat"`},
{"chosen", tgapi.RichMessageButton{SwitchInlineQueryChosenChat: &tgapi.SwitchInlineQueryChosenChat{AllowUserChats: true}}, `allow-user-chats`},
{"copy", tgapi.RichMessageButton{CopyText: &tgapi.CopyTextButton{Text: "copy"}}, `text="copy"`},
{"disabled", tgapi.RichMessageButton{Disabled: &tgapi.DisabledButton{}}, `type="disabled"`},
} {
t.Run(tc.name, func(t *testing.T) {
tc.button.Text = Text("<Go>")
for _, block := range []tgapi.InputRichBlock{P(Button(tc.button)), ButtonsWithAlign([]tgapi.RichMessageButton{tc.button}, tgapi.RichBlockButtonCenter)} {
msg, err := BuildHTML(block)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg.HTML, tc.fragment) || !strings.Contains(msg.HTML, "&lt;Go&gt;</tg-button>") {
t.Fatalf("unexpected HTML: %s", msg.HTML)
}
}
})
}
msg, err := BuildHTML(ExpandableBlockquoteWithCredit(Bold(Text("Quote")), Text("Author")), DocumentWithCaption(tgapi.InputMedia{Media: "attach://notes"}, Caption(Text("Notes"))), NewTable().SetCompact(true).Build())
if err != nil {
t.Fatal(err)
}
for _, fragment := range []string{"<blockquote expandable><b>Quote</b><cite>Author</cite></blockquote>", `<tg-document src="tg://document?id=media_1"></tg-document>`, "<table compact>"} {
if !strings.Contains(msg.HTML, fragment) {
t.Fatalf("missing %s in %s", fragment, msg.HTML)
}
}
if len(msg.Media) != 1 || msg.Media[0].Media.Type != tgapi.InputMediaTypeDocument || msg.Media[0].Media.Media != "attach://notes" {
t.Fatalf("bad media: %#v", msg.Media)
}
if _, err := BuildDraftHTML(Document(tgapi.InputMedia{Media: "attach://notes"})); !errors.Is(err, tgapi.ErrRichMessageDraftUploadUnsupported) {
t.Fatalf("draft: %v", err)
}
}
func TestRich103Validation(t *testing.T) {
good := tgapi.RichMessageButton{Text: Text("Go"), CallbackData: "x"}
for _, n := range []int{0, 1, 8, 9} {
buttons := make([]tgapi.RichMessageButton, n)
for i := range buttons {
buttons[i] = good
}
_, err := BuildHTML(Buttons(buttons))
if (err == nil) != (n >= 1 && n <= 8) {
t.Fatalf("row %d: %v", n, err)
}
}
for _, tc := range []struct {
name string
change func(*tgapi.RichMessageButton)
}{
{"no action", func(b *tgapi.RichMessageButton) { b.CallbackData = "" }},
{"two actions", func(b *tgapi.RichMessageButton) { b.URL = "https://example.com" }},
{"long callback", func(b *tgapi.RichMessageButton) { b.CallbackData = strings.Repeat("я", 33) }},
{"formatting", func(b *tgapi.RichMessageButton) { b.Text = Bold(Text("Go")) }},
{"style", func(b *tgapi.RichMessageButton) { b.Style = "disable" }},
{"link url", func(b *tgapi.RichMessageButton) { b.CallbackData = ""; b.URL = "https://example.com"; b.Style = "link" }},
{"login bot", func(b *tgapi.RichMessageButton) {
b.CallbackData = ""
b.LoginURL = &tgapi.LoginURL{URL: "https://example.com", BotUsername: "other"}
}},
} {
t.Run(tc.name, func(t *testing.T) {
b := good
tc.change(&b)
if _, err := BuildHTML(P(Button(b))); !errors.Is(err, ErrRichInvalidButton) {
t.Fatalf("got %v", err)
}
})
}
good.CallbackData = strings.Repeat("я", 32)
if _, err := BuildHTML(P(Button(good))); err != nil {
t.Fatal(err)
}
if _, err := BuildHTML(ButtonsWithAlign([]tgapi.RichMessageButton{good}, "bottom")); !errors.Is(err, ErrRichInvalidButton) {
t.Fatalf("alignment: %v", err)
}
}
func TestRich103InputJSON(t *testing.T) {
for _, block := range []tgapi.InputRichBlock{ExpandableBlockquote(Text("quote")), Document(tgapi.InputMedia{Media: "file"}), Buttons([]tgapi.RichMessageButton{{Text: Text("Off"), Disabled: &tgapi.DisabledButton{}}})} {
data, err := json.Marshal(block)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
if len(fields["type"]) == 0 {
t.Fatalf("missing discriminator: %s", data)
}
}
}
func TestRichButtonLabelEntitiesAndLimits(t *testing.T) {
label := tgapi.RichTextArray{
Text("At "),
tgapi.RichTextDateTime{Text: Text("noon"), UnixTime: 1000, DateTimeFormat: "t"},
tgapi.RichTextCustomEmoji{CustomEmojiID: "123", AlternativeText: "!"},
}
if _, err := BuildHTML(P(Button(tgapi.RichMessageButton{Text: label, Disabled: &tgapi.DisabledButton{}}))); err != nil {
t.Fatal(err)
}
for _, n := range []int{0, 1, 256, 257} {
_, err := BuildHTML(P(Button(tgapi.RichMessageButton{Text: Text("Copy"), CopyText: &tgapi.CopyTextButton{Text: strings.Repeat("я", n)}})))
if (err == nil) != (n >= 1 && n <= 256) {
t.Fatalf("copy length %d: %v", n, err)
}
}
for _, text := range []tgapi.RichText{
Bold(Text(strings.Repeat("я", maxRichTextChars+1))),
URL(Text(strings.Repeat("я", maxRichTextChars+1)), "https://example.com"),
} {
if _, err := BuildHTML(P(text)); !errors.Is(err, ErrRichTextTooLong) {
t.Fatalf("nested text bypassed limit: %v", err)
}
}
}
+51 -5
View File
@@ -6,7 +6,7 @@ import (
"strings"
"unicode/utf8"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
)
type richValidator struct {
@@ -132,6 +132,8 @@ func (v *richValidator) text(text tgapi.RichText, depth int) error {
return v.addChars(el.AlternativeText)
case tgapi.RichTextMathematicalExpression:
return v.addChars(el.Expression)
case tgapi.RichTextButton:
return v.button(el.Button, depth+1)
case tgapi.RichTextAnchor:
return nil
}
@@ -143,33 +145,46 @@ func (v *richValidator) text(text tgapi.RichText, depth int) error {
var child tgapi.RichText
switch el := text.(type) {
case tgapi.RichTextWrap:
child = el.Text
case tgapi.RichTextURL:
child = el.Text
case tgapi.RichTextEmailAddress:
child = el.Text
case tgapi.RichTextPhoneNumber:
child = el.Text
case tgapi.RichTextBankCardNumber:
child = el.Text
if err := validateAutomaticEntity(el.Text, el.BankCardNumber); err != nil {
return err
}
case tgapi.RichTextMention:
child = el.Text
if err := validateAutomaticEntity(el.Text, "@"+strings.TrimPrefix(el.Username, "@")); err != nil {
return err
}
case tgapi.RichTextHashtag:
child = el.Text
if err := validateAutomaticEntity(el.Text, "#"+strings.TrimPrefix(el.Hashtag, "#")); err != nil {
return err
}
case tgapi.RichTextCashtag:
child = el.Text
if err := validateAutomaticEntity(el.Text, "$"+strings.TrimPrefix(el.Cashtag, "$")); err != nil {
return err
}
case tgapi.RichTextBotCommand:
child = el.Text
if err := validateAutomaticEntity(el.Text, "/"+strings.TrimPrefix(el.BotCommand, "/")); err != nil {
return err
}
case tgapi.RichTextAnchorLink:
child = el.Text
case tgapi.RichTextReference:
child = el.Text
case tgapi.RichTextReferenceLink:
child = el.Text
case tgapi.RichTextDateTime:
child = el.Text
case tgapi.RichTextTextMention:
child = el.Text
default:
@@ -350,6 +365,40 @@ func (v *richValidator) block(block tgapi.InputRichBlock, depth int) error {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockButtons:
if err := expectBlockType(el.Type, tgapi.InputRichTypeButtons); err != nil {
return err
}
if len(el.Buttons) < 1 || len(el.Buttons) > 8 {
return ErrRichInvalidButton
}
switch el.Align {
case "", tgapi.RichBlockButtonLeft, tgapi.RichBlockButtonCenter, tgapi.RichBlockButtonRight:
default:
return ErrRichInvalidButton
}
for _, button := range el.Buttons {
if err := v.button(button, depth+1); err != nil {
return err
}
}
return nil
case tgapi.InputRichBlockExpandableBlockQuotation:
if err := expectBlockType(el.Type, tgapi.InputRichTypeExpandableBlockQuotation); err != nil {
return err
}
if err := v.text(el.Text, depth+1); err != nil {
return err
}
return v.textValue(el.Credit, depth+1)
case tgapi.InputRichBlockDocument:
if err := expectBlockType(el.Type, tgapi.InputRichTypeDocument); err != nil {
return err
}
if err := v.addMedia(el.Document, tgapi.InputMediaTypeDocument); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockPullQuotation:
if err := expectBlockType(el.Type, tgapi.InputRichTypePullQuotation); err != nil {
return err
@@ -390,10 +439,7 @@ func (v *richValidator) block(block tgapi.InputRichBlock, depth int) error {
if err := validateTableCell(cell); err != nil {
return err
}
span := cell.ColSpan
if span < 1 {
span = 1
}
span := max(cell.ColSpan, 1)
columns += span
if err := v.text(cell.Text, depth+1); err != nil {
return err