(new): rich message support
Golang lint / lint (pull_request) Successful in 1m20s
Golang lint / lint (push) Successful in 4m8s

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+191
View File
@@ -0,0 +1,191 @@
package tgrich
import (
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
const (
maxRichTextChars = 32768
maxRichBlocks = 500
maxRichDepth = 16
maxRichMedia = 50
maxTableColumns = 20
)
// Text creates a plain rich-text node.
//
// Since: Bot API 10.1
func Text(s string) tgapi.RichText { return tgapi.RichTextPlain(s) }
// Bold applies bold formatting to t.
//
// Since: Bot API 10.1
func Bold(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "bold", Text: t} }
// Italic applies italic formatting to t.
//
// Since: Bot API 10.1
func Italic(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "italic", Text: t} }
// Underline applies underline formatting to t.
//
// Since: Bot API 10.1
func Underline(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "underline", Text: t} }
// Strikethrough applies strikethrough formatting to t.
//
// Since: Bot API 10.1
func Strikethrough(t tgapi.RichText) tgapi.RichText {
return tgapi.RichTextWrap{Tag: "strikethrough", Text: t}
}
// Spoiler hides t behind a spoiler.
//
// Since: Bot API 10.1
func Spoiler(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "spoiler", Text: t} }
// DateTime associates t with ts using Telegram's default date-time format.
//
// Since: Bot API 10.1
func DateTime(t tgapi.RichText, ts time.Time) tgapi.RichText {
return tgapi.RichTextDateTime{Text: t, UnixTime: ts.Unix()}
}
// DateTimeWithFormat associates t with ts using format.
//
// Since: Bot API 10.1
func DateTimeWithFormat(t tgapi.RichText, ts time.Time, format string) tgapi.RichText {
return tgapi.RichTextDateTime{Text: t, UnixTime: ts.Unix(), DateTimeFormat: format}
}
// TextMention mentions u with the display text t.
//
// Since: Bot API 10.1
func TextMention(t tgapi.RichText, u tgapi.User) tgapi.RichText {
return tgapi.RichTextTextMention{Text: t, User: u}
}
// Subscript applies subscript formatting to t.
//
// Since: Bot API 10.1
func Subscript(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "subscript", Text: t} }
// Superscript applies superscript formatting to t.
//
// Since: Bot API 10.1
func Superscript(t tgapi.RichText) tgapi.RichText {
return tgapi.RichTextWrap{Tag: "superscript", Text: t}
}
// Marked highlights t.
//
// Since: Bot API 10.1
func Marked(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "marked", Text: t} }
// Code applies monospaced formatting to t.
//
// Since: Bot API 10.1
func Code(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "code", Text: t} }
// Emoji creates a custom emoji with emojiID and alternative text t.
//
// Since: Bot API 10.1
func Emoji(t, emojiID string) tgapi.RichText {
return tgapi.RichTextCustomEmoji{CustomEmojiID: emojiID, AlternativeText: t}
}
// MathExpression creates an inline LaTeX expression.
//
// Since: Bot API 10.1
func MathExpression(exp string) tgapi.RichTextMathematicalExpression {
return tgapi.RichTextMathematicalExpression{Expression: exp}
}
// URL links t to url.
//
// Since: Bot API 10.1
func URL(t tgapi.RichText, url string) tgapi.RichTextURL { return tgapi.RichTextURL{Text: t, URL: url} }
// Email marks t as an email address.
//
// Since: Bot API 10.1
func Email(t tgapi.RichText, email string) tgapi.RichTextEmailAddress {
return tgapi.RichTextEmailAddress{Text: t, EmailAddress: email}
}
// Phone marks t as a phone number.
//
// Since: Bot API 10.1
func Phone(t tgapi.RichText, phone string) tgapi.RichTextPhoneNumber {
return tgapi.RichTextPhoneNumber{Text: t, PhoneNumber: phone}
}
// BankCardNumber marks t as a bank card number.
//
// Since: Bot API 10.1
func BankCardNumber(t tgapi.RichText, number string) tgapi.RichTextBankCardNumber {
return tgapi.RichTextBankCardNumber{Text: t, BankCardNumber: number}
}
// Mention marks t as a mention of username.
//
// Since: Bot API 10.1
func Mention(t tgapi.RichText, username string) tgapi.RichTextMention {
return tgapi.RichTextMention{Text: t, Username: username}
}
// Hashtag marks t as hashtag.
//
// Since: Bot API 10.1
func Hashtag(t tgapi.RichText, hashtag string) tgapi.RichTextHashtag {
return tgapi.RichTextHashtag{Text: t, Hashtag: hashtag}
}
// Cashtag marks t as cashtag.
//
// Since: Bot API 10.1
func Cashtag(t tgapi.RichText, cashtag string) tgapi.RichTextCashtag {
return tgapi.RichTextCashtag{Text: t, Cashtag: cashtag}
}
// BotCommand marks t as command.
//
// Since: Bot API 10.1
func BotCommand(t tgapi.RichText, command string) tgapi.RichTextBotCommand {
return tgapi.RichTextBotCommand{Text: t, BotCommand: command}
}
// TextAnchor creates an inline anchor named name.
//
// Since: Bot API 10.1
func TextAnchor(name string) tgapi.RichTextAnchor {
return tgapi.RichTextAnchor{Name: name}
}
// AnchorLink links t to the anchor named name.
//
// Since: Bot API 10.1
func AnchorLink(t tgapi.RichText, name string) tgapi.RichTextAnchorLink {
return tgapi.RichTextAnchorLink{Text: t, AnchorName: name}
}
// Reference defines t as a named reference target.
//
// Since: Bot API 10.1
func Reference(t tgapi.RichText, name string) tgapi.RichTextReference {
return tgapi.RichTextReference{Text: t, Name: name}
}
// ReferenceLink links t to the named reference name.
//
// Since: Bot API 10.1
func ReferenceLink(t tgapi.RichText, name string) tgapi.RichTextReferenceLink {
return tgapi.RichTextReferenceLink{Text: t, ReferenceName: name}
}
// Concat concatenates rich-text nodes.
//
// Since: Bot API 10.1
func Concat(items ...tgapi.RichText) tgapi.RichText { return tgapi.RichTextArray(items) }
+112
View File
@@ -0,0 +1,112 @@
package tgrich
import (
"errors"
"testing"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestRenderText(t *testing.T) {
tests := []struct {
name string
text tgapi.RichText
want string
}{
{"plain", Text("<text&>"), "&lt;text&amp;&gt;"},
{"array", Concat(Bold(Text("bold")), Text(" plain")), "<b>bold</b> plain"},
{"spoiler", Spoiler(Text("secret")), "<tg-spoiler>secret</tg-spoiler>"},
{"date time", DateTimeWithFormat(Text("tomorrow"), time.Unix(1, 0), `w"DT`), `<tg-time unix="1" format="w&quot;DT">tomorrow</tg-time>`},
{"custom emoji", Emoji("🙂", `id"`), `<tg-emoji emoji-id="id&quot;">🙂</tg-emoji>`},
{"formula", MathExpression("x < y"), "<tg-math>x &lt; y</tg-math>"},
{"automatic entity", Hashtag(Text("#go"), "go"), "#go"},
{"reference", Reference(Text("note"), "note-1"), `<tg-reference name="note-1">note</tg-reference>`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderText(tt.text, 0)
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("renderText() = %q, want %q", got, tt.want)
}
})
}
}
func TestRenderTextRejectsUnknownWrapper(t *testing.T) {
_, err := renderText(tgapi.RichTextWrap{Tag: "unknown", Text: Text("text")}, 0)
if !errors.Is(err, ErrRichUnknownTag) {
t.Fatalf("renderText() error = %v, want %v", err, ErrRichUnknownTag)
}
}
func TestRenderTextNestingLimit(t *testing.T) {
for _, tt := range []struct {
name string
depth int
want error
}{
{"maximum", maxRichDepth, nil},
{"too deep", maxRichDepth + 1, ErrRichNestingTooDeep},
} {
t.Run(tt.name, func(t *testing.T) {
text := tgapi.RichText(Text("text"))
for range tt.depth {
text = Bold(text)
}
_, err := renderText(text, 0)
if !errors.Is(err, tt.want) {
t.Fatalf("renderText() error = %v, want %v", err, tt.want)
}
})
}
}
func TestRenderBlockNestingLimit(t *testing.T) {
for _, tt := range []struct {
name string
depth int
want error
}{
{"maximum", maxRichDepth, nil},
{"too deep", maxRichDepth + 1, ErrRichNestingTooDeep},
} {
t.Run(tt.name, func(t *testing.T) {
var block tgapi.InputRichBlock = P(Text("text"))
for range tt.depth - 1 {
block = Details(Text("summary"), block)
}
_, err := renderBlockHTML(block, 0)
if !errors.Is(err, tt.want) {
t.Fatalf("renderBlockHTML() error = %v, want %v", err, tt.want)
}
})
}
}
func TestRenderCombinedNestingLimit(t *testing.T) {
for _, tt := range []struct {
name string
textDepth int
want error
}{
{"maximum", maxRichDepth - 1, nil},
{"too deep", maxRichDepth, ErrRichNestingTooDeep},
} {
t.Run(tt.name, func(t *testing.T) {
text := tgapi.RichText(Text("text"))
for range tt.textDepth {
text = Bold(text)
}
_, err := renderBlockHTML(P(text), 0)
if !errors.Is(err, tt.want) {
t.Fatalf("renderBlockHTML() error = %v, want %v", err, tt.want)
}
})
}
}
+297
View File
@@ -0,0 +1,297 @@
package tgrich
import (
"errors"
"strings"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestBuildHTMLBlocks(t *testing.T) {
credit := Text("author")
tests := []struct {
name string
block tgapi.InputRichBlock
want string
}{
{"paragraph", P(Bold(Text("text"))), "<p><b>text</b></p>"},
{"heading", H2(Text("heading")), "<h2>heading</h2>"},
{"pre", CodeBlock(Text("code"), "go"), `<pre><code class="language-go">code</code></pre>`},
{"footer", Footer(Text("footer")), "<footer>footer</footer>"},
{"divider", Hr(), "<hr/>"},
{"math", Math("x < y"), "<tg-math-block>x &lt; y</tg-math-block>"},
{"anchor", Anchor(`a"b`), `<a name="a&quot;b"></a>`},
{"unordered list", Ul(NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()), "<ul>\n<li><input type=\"checkbox\" checked/><p>item</p></li></ul>"},
{"blockquote", BlockQuoteWithCredit(credit, P(Text("quote"))), "<blockquote><p>quote</p><cite>author</cite></blockquote>"},
{"pullquote", PullQuoteWithCredit(Text("quote"), credit), "<aside>quote<cite>author</cite></aside>"},
{"table", NewTable(Row(CellWithText(Text("value")).Build())).SetCaption(&credit).Build(), "<table><caption>author</caption><tr><td>value</td></tr></table>"},
{"details", DetailsOpen(Text("summary"), P(Text("body"))), "<details open><summary>summary</summary><p>body</p></details>"},
{"map", Map(tgapi.Location{Latitude: 41.9, Longitude: 12.5}, 14, 640, 320), `<tg-map height="320" lat="41.9" long="12.5" width="640" zoom="14"/>`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
message, err := BuildHTML(tt.block)
if err != nil {
t.Fatal(err)
}
if message.HTML != tt.want {
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.want)
}
if message.SkipEntityDetection {
t.Fatal("BuildHTML() must preserve Telegram's automatic entity detection")
}
})
}
}
func TestThinkingBlockIsDraftOnly(t *testing.T) {
block := Thinking(Text("Thinking…"))
if _, err := BuildHTML(block); !errors.Is(err, ErrRichThinkingDraftOnly) {
t.Fatalf("BuildHTML() error = %v, want %v", err, ErrRichThinkingDraftOnly)
}
message, err := BuildDraftHTML(block)
if err != nil {
t.Fatalf("BuildDraftHTML() returned error: %v", err)
}
if message.HTML != "<tg-thinking>Thinking…</tg-thinking>" {
t.Fatalf("BuildDraftHTML() HTML = %q", message.HTML)
}
}
func TestBuildHTMLValidatesAutomaticallyDetectedEntities(t *testing.T) {
tests := []struct {
name string
valid tgapi.RichText
invalid tgapi.RichText
}{
{name: "bank card", valid: BankCardNumber(Text("1234"), "1234"), invalid: BankCardNumber(Text("5678"), "1234")},
{name: "mention", valid: Mention(Text("@alice"), "alice"), invalid: Mention(Text("Alice"), "alice")},
{name: "hashtag", valid: Hashtag(Text("#go"), "go"), invalid: Hashtag(Text("Go"), "go")},
{name: "cashtag", valid: Cashtag(Text("$TON"), "TON"), invalid: Cashtag(Text("Toncoin"), "TON")},
{name: "bot command", valid: BotCommand(Text("/start"), "start"), invalid: BotCommand(Text("Start"), "start")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := BuildHTML(P(tt.valid)); err != nil {
t.Fatalf("valid entity returned error: %v", err)
}
if _, err := BuildHTML(P(tt.invalid)); !errors.Is(err, ErrRichEntityMismatch) {
t.Fatalf("invalid entity error = %v, want ErrRichEntityMismatch", err)
}
})
}
}
func TestBuildDraftHTMLRejectsDirectUpload(t *testing.T) {
_, err := BuildDraftHTML(Photo(tgapi.InputMedia{
Type: tgapi.InputMediaTypePhoto,
Media: "attach://photo",
}))
if !errors.Is(err, tgapi.ErrRichMessageDraftUploadUnsupported) {
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
}
}
func TestBuildHTMLMedia(t *testing.T) {
spoiler := true
caption := CaptionWithCredit(Text("caption"), Text("credit"))
tests := []struct {
name string
block tgapi.InputRichBlock
wantHTML string
wantType tgapi.InputMediaType
wantMedia string
}{
{"multipart photo", Photo(tgapi.InputMedia{Media: "attach://photo"}), `<img src="tg://photo?id=media_1"/>`, tgapi.InputMediaTypePhoto, "attach://photo"},
{"video", Video(tgapi.InputMedia{Media: "video-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeVideo, "video-id"},
{"animation", Animation(tgapi.InputMedia{Media: "animation-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeAnimation, "animation-id"},
{"audio", Audio(tgapi.InputMedia{Media: "audio-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeAudio, "audio-id"},
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeVoiceNote, "voice-id"},
{"caption and spoiler", PhotoWithCaption(tgapi.InputMedia{Media: "photo-id", HasSpoiler: &spoiler}, caption), `<figure><img src="tg://photo?id=media_1" tg-spoiler/><figcaption>caption<cite>credit</cite></figcaption></figure>`, tgapi.InputMediaTypePhoto, "photo-id"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
message, err := BuildHTML(tt.block)
if err != nil {
t.Fatal(err)
}
if message.HTML != tt.wantHTML {
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.wantHTML)
}
if len(message.Media) != 1 {
t.Fatalf("BuildHTML() media count = %d, want 1", len(message.Media))
}
media := message.Media[0]
if media.ID != "media_1" || media.Media.Type != tt.wantType || media.Media.Media != tt.wantMedia {
t.Fatalf("BuildHTML() media = %#v", media)
}
})
}
}
func TestBuildHTMLNestedMediaUsesSharedIDs(t *testing.T) {
message, err := BuildHTML(
Collage(Photo(tgapi.InputMedia{Media: "photo"}), Video(tgapi.InputMedia{Media: "video"})),
Audio(tgapi.InputMedia{Media: "audio"}),
)
if err != nil {
t.Fatal(err)
}
want := `<tg-collage><img src="tg://photo?id=media_1"/><video src="tg://video?id=media_2"></video></tg-collage><audio src="tg://audio?id=media_3"></audio>`
if message.HTML != want {
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, want)
}
if len(message.Media) != 3 {
t.Fatalf("BuildHTML() media count = %d, want 3", len(message.Media))
}
}
func TestBuildHTMLLimits(t *testing.T) {
media := func() tgapi.InputRichBlock {
return Photo(tgapi.InputMedia{Media: "photo"})
}
tests := []struct {
name string
blocks func() []tgapi.InputRichBlock
want error
}{
{"maximum UTF-8 characters", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars)))}
}, nil},
{"too many UTF-8 characters", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars+1)))}
}, ErrRichTextTooLong},
{"maximum blocks", func() []tgapi.InputRichBlock {
blocks := make([]tgapi.InputRichBlock, maxRichBlocks)
for i := range blocks {
blocks[i] = Hr()
}
return blocks
}, nil},
{"too many blocks", func() []tgapi.InputRichBlock {
blocks := make([]tgapi.InputRichBlock, maxRichBlocks+1)
for i := range blocks {
blocks[i] = Hr()
}
return blocks
}, ErrRichTooManyBlocks},
{"list items count as blocks", func() []tgapi.InputRichBlock {
items := make([]tgapi.InputRichBlockListItem, maxRichBlocks)
return []tgapi.InputRichBlock{List(items...)}
}, ErrRichTooManyBlocks},
{"table rows count as blocks", func() []tgapi.InputRichBlock {
rows := make([][]tgapi.RichBlockTableCell, maxRichBlocks)
return []tgapi.InputRichBlock{NewTable(rows...).Build()}
}, ErrRichTooManyBlocks},
{"maximum media", func() []tgapi.InputRichBlock {
blocks := make([]tgapi.InputRichBlock, maxRichMedia)
for i := range blocks {
blocks[i] = media()
}
return blocks
}, nil},
{"too many media", func() []tgapi.InputRichBlock {
blocks := make([]tgapi.InputRichBlock, maxRichMedia+1)
for i := range blocks {
blocks[i] = media()
}
return blocks
}, ErrRichTooManyMedia},
{"maximum table width", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns)...)).Build()}
}, nil},
{"table too wide", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns + 1)...)).Build()}
}, ErrRichTableTooWide},
{"colspan counts toward width", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{NewTable(Row(Cell().SetSpan(maxTableColumns+1, 1).Build())).Build()}
}, ErrRichTableTooWide},
{"maximum combined nesting", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth-1))}
}, nil},
{"combined nesting too deep", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth))}
}, ErrRichNestingTooDeep},
{"maximum block nesting", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth-1)}
}, nil},
{"block nesting too deep", func() []tgapi.InputRichBlock {
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth)}
}, ErrRichNestingTooDeep},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := BuildHTML(tt.blocks()...)
if !errors.Is(err, tt.want) {
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
}
})
}
}
func TestBuildHTMLRejectsInvalidFields(t *testing.T) {
tests := []struct {
name string
block tgapi.InputRichBlock
want error
}{
{"mixed list", List(NewListItem().SetType(tgapi.InputRichBlockListItemTypeDecimal).Build(), NewListItem().Build()), ErrRichListItemMix},
{"unordered value", List(NewListItem().SetValue(2).Build()), ErrRichInvalidListItem},
{"invalid list type", List(NewListItem().SetType("x").Build()), ErrRichInvalidListItemType},
{"checked without checkbox", List(NewListItem().SetChecked().Build()), ErrRichInvalidCheckbox},
{"invalid block type", tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeFooter, Text: Text("text")}, ErrRichInvalidBlockType},
{"heading too small", H(Text("heading"), 0), ErrRichInvalidHeading},
{"heading too large", H(Text("heading"), 7), ErrRichInvalidHeading},
{"media mismatch", tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: tgapi.InputMedia{Type: tgapi.InputMediaTypeVideo, Media: "video"}}, ErrRichInvalidMedia},
{"empty media", Photo(tgapi.InputMedia{}), ErrRichInvalidMedia},
{"map latitude", Map(tgapi.Location{Latitude: 91}, 0, 0, 0), ErrRichInvalidMap},
{"map longitude", Map(tgapi.Location{Longitude: 181}, 0, 0, 0), ErrRichInvalidMap},
{"map zoom", Map(tgapi.Location{}, 25, 0, 0), ErrRichInvalidMap},
{"map total dimensions", Map(tgapi.Location{}, 0, 9000, 1001), ErrRichInvalidMap},
{"map aspect ratio", Map(tgapi.Location{}, 0, 100, 4), ErrRichInvalidMap},
{"negative colspan", NewTable(Row(Cell().SetSpan(-1, 0).Build())).Build(), ErrRichInvalidTableCell},
{"invalid horizontal alignment", NewTable(Row(Cell().SetAlign("justify").Build())).Build(), ErrRichInvalidTableCell},
{"invalid vertical alignment", NewTable(Row(Cell().SetVAlign("center").Build())).Build(), ErrRichInvalidTableCell},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := BuildHTML(tt.block)
if !errors.Is(err, tt.want) {
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
}
})
}
}
func TestToHTML(t *testing.T) {
message, err := ToHTML(P(Text("text")))
if err != nil {
t.Fatal(err)
}
if message.HTML != "<p>text</p>" {
t.Fatalf("ToHTML() HTML = %q", message.HTML)
}
}
func makeCells(n int) []tgapi.RichBlockTableCell {
return make([]tgapi.RichBlockTableCell, n)
}
func wrapBold(text tgapi.RichText, depth int) tgapi.RichText {
for range depth {
text = Bold(text)
}
return text
}
func wrapDetails(block tgapi.InputRichBlock, depth int) tgapi.InputRichBlock {
for range depth {
block = Details(Text("summary"), block)
}
return block
}
+40
View File
@@ -0,0 +1,40 @@
package tgrich
import "errors"
var (
// 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.
ErrRichTooManyBlocks = errors.New("rich text too many blocks")
// ErrRichNestingTooDeep indicates that rich formatting exceeds 16 nested levels.
ErrRichNestingTooDeep = errors.New("rich text nesting deep")
// ErrRichTooManyMedia indicates that a rich message contains more than 50 media attachments.
ErrRichTooManyMedia = errors.New("rich text too many media")
// ErrRichTableTooWide indicates that a table contains more than 20 columns.
ErrRichTableTooWide = errors.New("rich text table too wide")
// ErrRichUnknownTag indicates that a rich-text or rich-block implementation is unsupported.
ErrRichUnknownTag = errors.New("rich unknown tag")
// ErrRichListItemMix indicates that ordered and unordered items were mixed in one list.
ErrRichListItemMix = errors.New("rich list items mixed: ordered and unordered")
// ErrRichInvalidListItem indicates that an unordered item uses ordered-list attributes.
ErrRichInvalidListItem = errors.New("rich unordered list item has ordered attributes")
// ErrRichInvalidMedia indicates that a media block contains an incompatible media type.
ErrRichInvalidMedia = errors.New("rich block has incompatible media type")
// ErrRichInvalidBlockType indicates that a block's type discriminator doesn't match its Go type.
ErrRichInvalidBlockType = errors.New("rich block has invalid type")
// ErrRichInvalidHeading indicates that a heading has a size outside 1-6.
ErrRichInvalidHeading = errors.New("rich heading has invalid size")
// ErrRichInvalidMap indicates that a map has invalid coordinates, zoom, or dimensions.
ErrRichInvalidMap = errors.New("rich map has invalid parameters")
// ErrRichInvalidListItemType indicates that an ordered-list marker type is unsupported.
ErrRichInvalidListItemType = errors.New("rich list item has invalid type")
// ErrRichInvalidCheckbox indicates that a checked list item has no checkbox.
ErrRichInvalidCheckbox = errors.New("rich list item is checked without a checkbox")
// ErrRichInvalidTableCell indicates that a table cell has invalid spans or alignment.
ErrRichInvalidTableCell = errors.New("rich table cell has invalid parameters")
// ErrRichThinkingDraftOnly indicates that a thinking block was used outside a draft.
ErrRichThinkingDraftOnly = errors.New("thinking blocks are valid only in rich-message drafts")
// ErrRichEntityMismatch indicates that HTML conversion would lose an explicit entity value.
ErrRichEntityMismatch = errors.New("rich entity value doesn't match visible text")
)
+548
View File
@@ -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
}
+570
View File
@@ -0,0 +1,570 @@
package tgrich
import "git.scuroneko.dev/scuroneko/laniakea/tgapi"
// Caption creates a media-block caption without a credit.
//
// Since: Bot API 10.2
func Caption(text tgapi.RichText) tgapi.RichBlockCaption { return tgapi.RichBlockCaption{Text: text} }
// CaptionWithCredit creates a media-block caption with a credit.
//
// Since: Bot API 10.2
func CaptionWithCredit(text, credit tgapi.RichText) tgapi.RichBlockCaption {
return tgapi.RichBlockCaption{Text: text, Credit: credit}
}
// P creates a text paragraph corresponding to the HTML <p> tag.
//
// Since: Bot API 10.2
func P(text tgapi.RichText) tgapi.InputRichBlockParagraph {
return tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeParagraph, Text: text}
}
// H creates a section heading with a relative font size from 1 (largest) to 6 (smallest).
//
// Since: Bot API 10.2
func H(text tgapi.RichText, size uint8) tgapi.InputRichBlockSectionHeading {
return tgapi.InputRichBlockSectionHeading{
Type: tgapi.InputRichTypeSectionHeading,
Text: text, Size: size,
}
}
// H1 creates a level-one section heading.
//
// Since: Bot API 10.2
func H1(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 1) }
// H2 creates a level-two section heading.
//
// Since: Bot API 10.2
func H2(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 2) }
// H3 creates a level-three section heading.
//
// Since: Bot API 10.2
func H3(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 3) }
// H4 creates a level-four section heading.
//
// Since: Bot API 10.2
func H4(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 4) }
// H5 creates a level-five section heading.
//
// Since: Bot API 10.2
func H5(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 5) }
// H6 creates a level-six section heading.
//
// Since: Bot API 10.2
func H6(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 6) }
// Pre creates a preformatted text block without a programming language.
//
// Since: Bot API 10.2
func Pre(text tgapi.RichText) tgapi.InputRichBlockPreformatted {
return tgapi.InputRichBlockPreformatted{Type: tgapi.InputRichTypePre, Text: text}
}
// CodeBlock creates a preformatted text block with its programming language.
//
// Since: Bot API 10.2
func CodeBlock(text tgapi.RichText, lang string) tgapi.InputRichBlockPreformatted {
return tgapi.InputRichBlockPreformatted{Type: tgapi.InputRichTypePre, Text: text, Language: lang}
}
// Footer creates a footer block.
//
// Since: Bot API 10.2
func Footer(text tgapi.RichText) tgapi.InputRichBlockFooter {
return tgapi.InputRichBlockFooter{Type: tgapi.InputRichTypeFooter, Text: text}
}
// Hr creates a divider corresponding to the HTML <hr/> tag.
//
// Since: Bot API 10.2
func Hr() tgapi.InputRichBlockDivider {
return tgapi.InputRichBlockDivider{Type: tgapi.InputRichTypeDivider}
}
// Math creates a mathematical expression block from a LaTeX expression.
//
// Since: Bot API 10.2
func Math(expression string) tgapi.InputRichBlockMath {
return tgapi.InputRichBlockMath{Type: tgapi.InputRichTypeMathematicalExpression, Expression: expression}
}
// Anchor creates a block containing an anchor with the given name.
//
// Since: Bot API 10.2
func Anchor(name string) tgapi.InputRichBlockAnchor {
return tgapi.InputRichBlockAnchor{Type: tgapi.InputRichTypeAnchor, Name: name}
}
// ListItem builds an input rich-message list item.
//
// Since: Bot API 10.2
type ListItem struct {
blocks []tgapi.InputRichBlock
hasCheckbox bool
isChecked bool
value int
t tgapi.RichBlockListItemType
}
// NewListItem creates a list-item builder containing blocks.
//
// Since: Bot API 10.2
func NewListItem(blocks ...tgapi.InputRichBlock) *ListItem {
return &ListItem{blocks: blocks}
}
// SetBlocks replaces the blocks in the list item.
//
// Since: Bot API 10.2
func (i *ListItem) SetBlocks(blocks ...tgapi.InputRichBlock) *ListItem {
i.blocks = blocks
return i
}
// SetCheckbox adds an unchecked checkbox to the list item.
//
// Since: Bot API 10.2
func (i *ListItem) SetCheckbox() *ListItem {
i.hasCheckbox = true
return i
}
// SetChecked marks the list item's checkbox as checked.
//
// Since: Bot API 10.2
func (i *ListItem) SetChecked() *ListItem {
i.isChecked = true
return i
}
// SetValue sets the explicit number of an ordered-list item.
//
// Since: Bot API 10.2
func (i *ListItem) SetValue(val int) *ListItem {
i.value = val
return i
}
// SetType sets the marker style of an ordered-list item.
//
// Since: Bot API 10.2
func (i *ListItem) SetType(t tgapi.RichBlockListItemType) *ListItem {
i.t = t
return i
}
// Build returns the configured input rich-message list item.
//
// Since: Bot API 10.2
func (i *ListItem) Build() tgapi.InputRichBlockListItem {
return tgapi.InputRichBlockListItem{
Blocks: i.blocks,
HasCheckbox: i.hasCheckbox,
IsChecked: i.isChecked,
Value: i.value,
Type: i.t,
}
}
// List creates a list block from fully configured items.
//
// Since: Bot API 10.2
func List(items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
return tgapi.InputRichBlockList{Type: tgapi.InputRichTypeList, Items: items}
}
// Ul creates an unordered list and clears ordered-list attributes.
//
// Since: Bot API 10.2
func Ul(items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
newItems := make([]tgapi.InputRichBlockListItem, len(items))
for index, item := range items {
newItems[index] = tgapi.InputRichBlockListItem{
Blocks: item.Blocks,
HasCheckbox: item.HasCheckbox,
IsChecked: item.IsChecked,
}
}
return List(newItems...)
}
// OlOpts configures ordered-list numbering.
//
// Since: Bot API 10.2
type OlOpts struct {
// Type is the marker style: "1", "a", "A", "i", or "I".
Type tgapi.RichBlockListItemType
// Start is the number of the first item; values below 1 use the default.
Start int
// IsReversed reports whether numbering decreases from Start.
IsReversed bool
}
// Ol creates an ordered list using opts for numbering.
//
// Since: Bot API 10.2
func Ol(opts OlOpts, items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
newItems := make([]tgapi.InputRichBlockListItem, len(items))
start := opts.Start
if start < 1 {
start = 1
if opts.IsReversed {
start = len(items)
}
}
typ := opts.Type
if typ == "" {
typ = tgapi.InputRichBlockListItemTypeDecimal
}
for index, item := range items {
val := index + start
if opts.IsReversed {
val = start - index
}
newItems[index] = tgapi.InputRichBlockListItem{
Blocks: item.Blocks,
HasCheckbox: item.HasCheckbox,
IsChecked: item.IsChecked,
Value: val,
Type: typ,
}
}
return List(newItems...)
}
// BlockQuote creates a block quotation without a credit.
//
// Since: Bot API 10.2
func BlockQuote(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockBlockQuotation {
return tgapi.InputRichBlockBlockQuotation{Type: tgapi.InputRichTypeBlockQuotation, Blocks: blocks}
}
// BlockQuoteWithCredit creates a block quotation with a credit.
//
// Since: Bot API 10.2
func BlockQuoteWithCredit(credit tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockBlockQuotation {
return tgapi.InputRichBlockBlockQuotation{Type: tgapi.InputRichTypeBlockQuotation, Blocks: blocks, Credit: &credit}
}
// PullQuote creates a centered quotation without a credit.
//
// Since: Bot API 10.2
func PullQuote(text tgapi.RichText) tgapi.InputRichBlockPullQuotation {
return tgapi.InputRichBlockPullQuotation{Type: tgapi.InputRichTypePullQuotation, Text: text}
}
// PullQuoteWithCredit creates a centered quotation with a credit.
//
// Since: Bot API 10.2
func PullQuoteWithCredit(text tgapi.RichText, credit tgapi.RichText) tgapi.InputRichBlockPullQuotation {
return tgapi.InputRichBlockPullQuotation{Type: tgapi.InputRichTypePullQuotation, Text: text, Credit: &credit}
}
// Collage creates a media collage without a caption.
//
// Since: Bot API 10.2
func Collage(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockCollage {
return tgapi.InputRichBlockCollage{Type: tgapi.InputRichTypeCollage, Blocks: blocks}
}
// CollageWithCaption creates a media collage with a caption.
//
// Since: Bot API 10.2
func CollageWithCaption(caption tgapi.RichBlockCaption, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockCollage {
return tgapi.InputRichBlockCollage{Type: tgapi.InputRichTypeCollage, Blocks: blocks, Caption: &caption}
}
// Slideshow creates a media slideshow without a caption.
//
// Since: Bot API 10.2
func Slideshow(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockSlideshow {
return tgapi.InputRichBlockSlideshow{Type: tgapi.InputRichTypeSlideshow, Blocks: blocks}
}
// SlideshowWithCaption creates a media slideshow with a caption.
//
// Since: Bot API 10.2
func SlideshowWithCaption(caption tgapi.RichBlockCaption, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockSlideshow {
return tgapi.InputRichBlockSlideshow{Type: tgapi.InputRichTypeSlideshow, Blocks: blocks, Caption: &caption}
}
// TableCell builds a rich-message table cell.
//
// Since: Bot API 10.2
type TableCell struct {
text tgapi.RichText
isHeader bool
colSpan int
rowSpan int
align string
vAlign string
}
// Cell creates an empty table-cell builder.
//
// Since: Bot API 10.2
func Cell() *TableCell { return &TableCell{} }
// CellWithText creates a table-cell builder containing text.
//
// Since: Bot API 10.2
func CellWithText(text tgapi.RichText) *TableCell { return &TableCell{text: text} }
// SetText replaces the text in the table cell.
//
// Since: Bot API 10.2
func (c *TableCell) SetText(text tgapi.RichText) *TableCell {
c.text = text
return c
}
// SetHeader marks the cell as a table header.
//
// Since: Bot API 10.2
func (c *TableCell) SetHeader() *TableCell {
c.isHeader = true
return c
}
// SetSpan sets the cell's column and row spans.
//
// Since: Bot API 10.2
func (c *TableCell) SetSpan(col, row int) *TableCell {
c.colSpan = col
c.rowSpan = row
return c
}
// SetAlign sets horizontal alignment to left, center, or right.
//
// Since: Bot API 10.2
func (c *TableCell) SetAlign(align string) *TableCell {
c.align = align
return c
}
// SetVAlign sets vertical alignment to top, middle, or bottom.
//
// Since: Bot API 10.2
func (c *TableCell) SetVAlign(vAlign string) *TableCell {
c.vAlign = vAlign
return c
}
// Build returns the configured rich-message table cell.
//
// Since: Bot API 10.2
func (c *TableCell) Build() tgapi.RichBlockTableCell {
return tgapi.RichBlockTableCell{
Text: c.text,
IsHeader: c.isHeader,
ColSpan: c.colSpan,
RowSpan: c.rowSpan,
Align: c.align,
VAlign: c.vAlign,
}
}
// Row creates a table row containing cells.
//
// Since: Bot API 10.2
func Row(cells ...tgapi.RichBlockTableCell) []tgapi.RichBlockTableCell {
return cells
}
// RichTable builds an input rich-message table block.
//
// Since: Bot API 10.2
type RichTable struct {
// Cells contains table rows and their cells.
Cells [][]tgapi.RichBlockTableCell
// IsBordered reports whether the table has borders.
IsBordered bool
// IsStriped reports whether the table has striped rows.
IsStriped bool
// Caption is the optional table caption.
Caption *tgapi.RichText
}
// NewTable creates a table builder containing rows.
//
// Since: Bot API 10.2
func NewTable(rows ...[]tgapi.RichBlockTableCell) *RichTable {
return &RichTable{Cells: rows}
}
// AddRows appends rows to the table.
//
// Since: Bot API 10.2
func (t *RichTable) AddRows(rows ...[]tgapi.RichBlockTableCell) *RichTable {
t.Cells = append(t.Cells, rows...)
return t
}
// SetBordered controls whether the table has borders.
//
// Since: Bot API 10.2
func (t *RichTable) SetBordered(b bool) *RichTable {
t.IsBordered = b
return t
}
// SetStriped controls whether the table has striped rows.
//
// Since: Bot API 10.2
func (t *RichTable) SetStriped(b bool) *RichTable {
t.IsStriped = b
return t
}
// SetCaption sets the table caption.
//
// Since: Bot API 10.2
func (t *RichTable) SetCaption(cap *tgapi.RichText) *RichTable {
t.Caption = cap
return t
}
// Build returns the configured input rich-message table.
//
// Since: Bot API 10.2
func (t *RichTable) Build() tgapi.InputRichBlockTable {
return tgapi.InputRichBlockTable{
Type: tgapi.InputRichTypeTable,
Cells: t.Cells,
IsBordered: t.IsBordered,
IsStriped: t.IsStriped,
Caption: t.Caption,
}
}
// Details creates a collapsed details block.
//
// Since: Bot API 10.2
func Details(sum tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockDetails {
return tgapi.InputRichBlockDetails{Type: tgapi.InputRichTypeDetails, Summary: sum, Blocks: blocks}
}
// DetailsOpen creates a details block expanded by default.
//
// Since: Bot API 10.2
func DetailsOpen(sum tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockDetails {
return tgapi.InputRichBlockDetails{Type: tgapi.InputRichTypeDetails, Summary: sum, Blocks: blocks, IsOpen: true}
}
// Map creates a map block centered on loc.
//
// Zoom accepts 0-24; width and height accept 0-10000 subject to Telegram's
// total-size and aspect-ratio restrictions.
//
// Since: Bot API 10.2
func Map(loc tgapi.Location, zoom uint8, width, height uint16) tgapi.InputRichBlockMap {
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height}
}
// MapWithCaption creates a map block with a caption.
//
// Since: Bot API 10.2
func MapWithCaption(loc tgapi.Location, zoom uint8, width, height uint16, caption tgapi.RichBlockCaption) tgapi.InputRichBlockMap {
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height, Caption: &caption}
}
// Animation creates an animation block without a caption.
//
// Since: Bot API 10.2
func Animation(animation tgapi.InputMedia) tgapi.InputRichBlockAnimation {
animation.Type = tgapi.InputMediaTypeAnimation
return tgapi.InputRichBlockAnimation{Type: tgapi.InputRichTypeAnimation, Animation: animation}
}
// AnimationWithCaption creates an animation block with a caption.
//
// Since: Bot API 10.2
func AnimationWithCaption(animation tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockAnimation {
block := Animation(animation)
block.Caption = &caption
return block
}
// Audio creates a music-file block without a caption.
//
// Since: Bot API 10.2
func Audio(audio tgapi.InputMedia) tgapi.InputRichBlockAudio {
audio.Type = tgapi.InputMediaTypeAudio
return tgapi.InputRichBlockAudio{Type: tgapi.InputRichTypeAudio, Audio: audio}
}
// AudioWithCaption creates a music-file block with a caption.
//
// Since: Bot API 10.2
func AudioWithCaption(audio tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockAudio {
block := Audio(audio)
block.Caption = &caption
return block
}
// Photo creates a photo block without a caption.
//
// Since: Bot API 10.2
func Photo(photo tgapi.InputMedia) tgapi.InputRichBlockPhoto {
photo.Type = tgapi.InputMediaTypePhoto
return tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: photo}
}
// PhotoWithCaption creates a photo block with a caption.
//
// Since: Bot API 10.2
func PhotoWithCaption(photo tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockPhoto {
block := Photo(photo)
block.Caption = &caption
return block
}
// Video creates a video block without a caption.
//
// Since: Bot API 10.2
func Video(video tgapi.InputMedia) tgapi.InputRichBlockVideo {
video.Type = tgapi.InputMediaTypeVideo
return tgapi.InputRichBlockVideo{Type: tgapi.InputRichTypeVideo, Video: video}
}
// VideoWithCaption creates a video block with a caption.
//
// Since: Bot API 10.2
func VideoWithCaption(video tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockVideo {
block := Video(video)
block.Caption = &caption
return block
}
// VoiceNote creates a voice-note block without a caption.
//
// Since: Bot API 10.2
func VoiceNote(voiceNote tgapi.InputMedia) tgapi.InputRichBlockVoiceNote {
voiceNote.Type = tgapi.InputMediaTypeVoiceNote
return tgapi.InputRichBlockVoiceNote{Type: tgapi.InputRichTypeVoiceNote, VoiceNote: voiceNote}
}
// VoiceNoteWithCaption creates a voice-note block with a caption.
//
// Since: Bot API 10.2
func VoiceNoteWithCaption(voiceNote tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockVoiceNote {
block := VoiceNote(voiceNote)
block.Caption = &caption
return block
}
// Thinking creates a draft-only thinking placeholder block.
//
// Since: Bot API 10.2
func Thinking(text tgapi.RichText) tgapi.InputRichBlockThinking {
return tgapi.InputRichBlockThinking{Type: tgapi.InputRichTypeThinking, Text: text}
}
+44
View File
@@ -0,0 +1,44 @@
package tgrich
import (
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestMediaConstructorsSetTypes(t *testing.T) {
cases := []struct {
name string
blockType tgapi.InputRichType
mediaType tgapi.InputMediaType
}{
{"animation", Animation(tgapi.InputMedia{Media: "animation"}).Type, Animation(tgapi.InputMedia{Media: "animation"}).Animation.Type},
{"audio", Audio(tgapi.InputMedia{Media: "audio"}).Type, Audio(tgapi.InputMedia{Media: "audio"}).Audio.Type},
{"photo", Photo(tgapi.InputMedia{Media: "photo"}).Type, Photo(tgapi.InputMedia{Media: "photo"}).Photo.Type},
{"video", Video(tgapi.InputMedia{Media: "video"}).Type, Video(tgapi.InputMedia{Media: "video"}).Video.Type},
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice"}).Type, VoiceNote(tgapi.InputMedia{Media: "voice"}).VoiceNote.Type},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if string(tt.blockType) != string(tt.mediaType) {
t.Errorf("block type = %q, media type = %q", tt.blockType, tt.mediaType)
}
})
}
}
func TestMediaConstructorWithCaption(t *testing.T) {
caption := tgapi.RichBlockCaption{Text: tgapi.RichTextPlain("caption")}
if block := AnimationWithCaption(tgapi.InputMedia{Media: "animation"}, caption); block.Caption == nil || *block.Caption != caption {
t.Fatal("AnimationWithCaption did not preserve the caption")
}
}
func TestOlPreservesCheckboxState(t *testing.T) {
item := NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()
list := Ol(OlOpts{}, item)
if len(list.Items) != 1 || !list.Items[0].HasCheckbox || !list.Items[0].IsChecked {
t.Fatalf("Ol() item = %#v", list.Items)
}
}
+41
View File
@@ -0,0 +1,41 @@
package tgrich
import (
"fmt"
"sort"
"strings"
)
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func formatAttrs(attrs map[string]string) []string {
out := make([]string, 0, len(attrs))
for k, v := range attrs {
if v == "" {
out = append(out, k)
continue
}
out = append(out, fmt.Sprintf(`%s="%s"`, k, escapeHTML(v)))
}
sort.Strings(out)
return out
}
func openTag(name string, attrs []string) string {
if len(attrs) == 0 {
return "<" + name + ">"
}
return "<" + name + " " + strings.Join(attrs, " ") + ">"
}
func selfClosingTag(name string, attrs []string) string {
if len(attrs) == 0 {
return "<" + name + "/>"
}
return "<" + name + " " + strings.Join(attrs, " ") + "/>"
}
+472
View File
@@ -0,0 +1,472 @@
package tgrich
import (
"fmt"
"math"
"strings"
"unicode/utf8"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
type richValidator struct {
chars int
blocks int
media int
allowThinking bool
}
func validateRichBlocks(blocks []tgapi.InputRichBlock, allowThinking bool) error {
v := &richValidator{allowThinking: allowThinking}
for _, block := range blocks {
if err := v.block(block, 0); err != nil {
return err
}
}
return nil
}
func (v *richValidator) addChars(s string) error {
v.chars += utf8.RuneCountInString(s)
if v.chars > maxRichTextChars {
return ErrRichTextTooLong
}
return nil
}
func (v *richValidator) addBlocks(n int) error {
v.blocks += n
if v.blocks > maxRichBlocks {
return ErrRichTooManyBlocks
}
return nil
}
func (v *richValidator) addMedia(media tgapi.InputMedia, want tgapi.InputMediaType) error {
if media.Type != want || media.Media == "" {
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidMedia, media.Type, want)
}
v.media++
if v.media > maxRichMedia {
return ErrRichTooManyMedia
}
return nil
}
func expectBlockType(got, want tgapi.InputRichType) error {
if got != want {
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidBlockType, got, want)
}
return nil
}
func validListItemType(t tgapi.RichBlockListItemType) bool {
switch t {
case tgapi.InputRichBlockListItemTypeLower,
tgapi.InputRichBlockListItemTypeUpper,
tgapi.InputRichBlockListItemTypeRomanLow,
tgapi.InputRichBlockListItemTypeRomanUpper,
tgapi.InputRichBlockListItemTypeDecimal:
return true
default:
return false
}
}
func validateTableCell(cell tgapi.RichBlockTableCell) error {
if cell.ColSpan < 0 || cell.RowSpan < 0 {
return ErrRichInvalidTableCell
}
switch cell.Align {
case "", "left", "center", "right":
default:
return ErrRichInvalidTableCell
}
switch cell.VAlign {
case "", "top", "middle", "bottom":
default:
return ErrRichInvalidTableCell
}
return nil
}
func validateMap(block tgapi.InputRichBlockMap) error {
if math.IsNaN(block.Location.Latitude) || math.IsInf(block.Location.Latitude, 0) ||
math.IsNaN(block.Location.Longitude) || math.IsInf(block.Location.Longitude, 0) ||
block.Location.Latitude < -90 || block.Location.Latitude > 90 ||
block.Location.Longitude < -180 || block.Location.Longitude > 180 ||
block.Zoom > 24 || block.Width > 10000 || block.Height > 10000 ||
uint32(block.Width)+uint32(block.Height) > 10000 {
return ErrRichInvalidMap
}
if block.Width != 0 && block.Height != 0 {
longer := float64(block.Width)
shorter := float64(block.Height)
if longer < shorter {
longer, shorter = shorter, longer
}
if longer/shorter > 20 {
return ErrRichInvalidMap
}
}
return nil
}
func (v *richValidator) text(text tgapi.RichText, depth int) error {
switch el := text.(type) {
case nil:
return nil
case tgapi.RichTextPlain:
return v.addChars(string(el))
case tgapi.RichTextArray:
for _, item := range el {
if err := v.text(item, depth); err != nil {
return err
}
}
return nil
case tgapi.RichTextCustomEmoji:
return v.addChars(el.AlternativeText)
case tgapi.RichTextMathematicalExpression:
return v.addChars(el.Expression)
case tgapi.RichTextAnchor:
return nil
}
if depth >= maxRichDepth {
return ErrRichNestingTooDeep
}
var child tgapi.RichText
switch el := text.(type) {
case tgapi.RichTextWrap:
case tgapi.RichTextURL:
case tgapi.RichTextEmailAddress:
case tgapi.RichTextPhoneNumber:
case tgapi.RichTextBankCardNumber:
if err := validateAutomaticEntity(el.Text, el.BankCardNumber); err != nil {
return err
}
case tgapi.RichTextMention:
if err := validateAutomaticEntity(el.Text, "@"+strings.TrimPrefix(el.Username, "@")); err != nil {
return err
}
case tgapi.RichTextHashtag:
if err := validateAutomaticEntity(el.Text, "#"+strings.TrimPrefix(el.Hashtag, "#")); err != nil {
return err
}
case tgapi.RichTextCashtag:
if err := validateAutomaticEntity(el.Text, "$"+strings.TrimPrefix(el.Cashtag, "$")); err != nil {
return err
}
case tgapi.RichTextBotCommand:
if err := validateAutomaticEntity(el.Text, "/"+strings.TrimPrefix(el.BotCommand, "/")); err != nil {
return err
}
case tgapi.RichTextAnchorLink:
case tgapi.RichTextReference:
case tgapi.RichTextReferenceLink:
case tgapi.RichTextDateTime:
case tgapi.RichTextTextMention:
child = el.Text
default:
return ErrRichUnknownTag
}
return v.text(child, depth+1)
}
func validateAutomaticEntity(text tgapi.RichText, semantic string) error {
visible, err := visibleRichText(text)
if err != nil {
return err
}
if visible != semantic {
return fmt.Errorf("%w: visible %q, semantic %q", ErrRichEntityMismatch, visible, semantic)
}
return nil
}
func visibleRichText(text tgapi.RichText) (string, error) {
switch el := text.(type) {
case nil:
return "", nil
case tgapi.RichTextPlain:
return string(el), nil
case tgapi.RichTextArray:
var result strings.Builder
for _, item := range el {
part, err := visibleRichText(item)
if err != nil {
return "", err
}
result.WriteString(part)
}
return result.String(), nil
case tgapi.RichTextCustomEmoji:
return el.AlternativeText, nil
case tgapi.RichTextMathematicalExpression:
return el.Expression, nil
case tgapi.RichTextAnchor:
return "", nil
case tgapi.RichTextWrap:
return visibleRichText(el.Text)
case tgapi.RichTextURL:
return visibleRichText(el.Text)
case tgapi.RichTextEmailAddress:
return visibleRichText(el.Text)
case tgapi.RichTextPhoneNumber:
return visibleRichText(el.Text)
case tgapi.RichTextBankCardNumber:
return visibleRichText(el.Text)
case tgapi.RichTextMention:
return visibleRichText(el.Text)
case tgapi.RichTextHashtag:
return visibleRichText(el.Text)
case tgapi.RichTextCashtag:
return visibleRichText(el.Text)
case tgapi.RichTextBotCommand:
return visibleRichText(el.Text)
case tgapi.RichTextAnchorLink:
return visibleRichText(el.Text)
case tgapi.RichTextReference:
return visibleRichText(el.Text)
case tgapi.RichTextReferenceLink:
return visibleRichText(el.Text)
case tgapi.RichTextDateTime:
return visibleRichText(el.Text)
case tgapi.RichTextTextMention:
return visibleRichText(el.Text)
default:
return "", ErrRichUnknownTag
}
}
func (v *richValidator) caption(caption *tgapi.RichBlockCaption, depth int) error {
if caption == nil {
return nil
}
if err := v.text(caption.Text, depth); err != nil {
return err
}
return v.text(caption.Credit, depth)
}
func (v *richValidator) nested(blocks []tgapi.InputRichBlock, depth int) error {
for _, block := range blocks {
if err := v.block(block, depth); err != nil {
return err
}
}
return nil
}
func (v *richValidator) block(block tgapi.InputRichBlock, depth int) error {
if depth >= maxRichDepth {
return ErrRichNestingTooDeep
}
if err := v.addBlocks(1); err != nil {
return err
}
switch el := block.(type) {
case tgapi.InputRichBlockParagraph:
if err := expectBlockType(el.Type, tgapi.InputRichTypeParagraph); err != nil {
return err
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockSectionHeading:
if err := expectBlockType(el.Type, tgapi.InputRichTypeSectionHeading); err != nil {
return err
}
if el.Size < 1 || el.Size > 6 {
return ErrRichInvalidHeading
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockPreformatted:
if err := expectBlockType(el.Type, tgapi.InputRichTypePre); err != nil {
return err
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockFooter:
if err := expectBlockType(el.Type, tgapi.InputRichTypeFooter); err != nil {
return err
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockDivider:
return expectBlockType(el.Type, tgapi.InputRichTypeDivider)
case tgapi.InputRichBlockAnchor:
return expectBlockType(el.Type, tgapi.InputRichTypeAnchor)
case tgapi.InputRichBlockMath:
if err := expectBlockType(el.Type, tgapi.InputRichTypeMathematicalExpression); err != nil {
return err
}
return v.addChars(el.Expression)
case tgapi.InputRichBlockList:
if err := expectBlockType(el.Type, tgapi.InputRichTypeList); err != nil {
return err
}
if err := v.addBlocks(len(el.Items)); err != nil {
return err
}
ordered := false
unordered := false
for _, item := range el.Items {
if item.IsChecked && !item.HasCheckbox {
return ErrRichInvalidCheckbox
}
if item.Type == "" {
unordered = true
if item.Value != 0 {
return ErrRichInvalidListItem
}
} else {
ordered = true
if !validListItemType(item.Type) {
return ErrRichInvalidListItemType
}
}
if err := v.nested(item.Blocks, depth+1); err != nil {
return err
}
}
if ordered && unordered {
return ErrRichListItemMix
}
return nil
case tgapi.InputRichBlockBlockQuotation:
if err := expectBlockType(el.Type, tgapi.InputRichTypeBlockQuotation); err != nil {
return err
}
if err := v.textValue(el.Credit, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockPullQuotation:
if err := expectBlockType(el.Type, tgapi.InputRichTypePullQuotation); 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.InputRichBlockCollage:
if err := expectBlockType(el.Type, tgapi.InputRichTypeCollage); err != nil {
return err
}
if err := v.caption(el.Caption, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockSlideshow:
if err := expectBlockType(el.Type, tgapi.InputRichTypeSlideshow); err != nil {
return err
}
if err := v.caption(el.Caption, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockTable:
if err := expectBlockType(el.Type, tgapi.InputRichTypeTable); err != nil {
return err
}
if err := v.addBlocks(len(el.Cells)); err != nil {
return err
}
if err := v.textValue(el.Caption, depth+1); err != nil {
return err
}
for _, row := range el.Cells {
columns := 0
for _, cell := range row {
if err := validateTableCell(cell); err != nil {
return err
}
span := cell.ColSpan
if span < 1 {
span = 1
}
columns += span
if err := v.text(cell.Text, depth+1); err != nil {
return err
}
}
if columns > maxTableColumns {
return ErrRichTableTooWide
}
}
return nil
case tgapi.InputRichBlockDetails:
if err := expectBlockType(el.Type, tgapi.InputRichTypeDetails); err != nil {
return err
}
if err := v.text(el.Summary, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockMap:
if err := expectBlockType(el.Type, tgapi.InputRichTypeMap); err != nil {
return err
}
if err := validateMap(el); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockAnimation:
if err := expectBlockType(el.Type, tgapi.InputRichTypeAnimation); err != nil {
return err
}
if err := v.addMedia(el.Animation, tgapi.InputMediaTypeAnimation); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockAudio:
if err := expectBlockType(el.Type, tgapi.InputRichTypeAudio); err != nil {
return err
}
if err := v.addMedia(el.Audio, tgapi.InputMediaTypeAudio); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockPhoto:
if err := expectBlockType(el.Type, tgapi.InputRichTypePhoto); err != nil {
return err
}
if err := v.addMedia(el.Photo, tgapi.InputMediaTypePhoto); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockVideo:
if err := expectBlockType(el.Type, tgapi.InputRichTypeVideo); err != nil {
return err
}
if err := v.addMedia(el.Video, tgapi.InputMediaTypeVideo); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockVoiceNote:
if err := expectBlockType(el.Type, tgapi.InputRichTypeVoiceNote); err != nil {
return err
}
if err := v.addMedia(el.VoiceNote, tgapi.InputMediaTypeVoiceNote); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockThinking:
if !v.allowThinking {
return ErrRichThinkingDraftOnly
}
if err := expectBlockType(el.Type, tgapi.InputRichTypeThinking); err != nil {
return err
}
return v.text(el.Text, depth+1)
default:
return ErrRichUnknownTag
}
}
func (v *richValidator) textValue(text *tgapi.RichText, depth int) error {
if text == nil {
return nil
}
return v.text(*text, depth)
}