(new): rich message support
(fix): runtime reliability (tests): regression coverage (doc): v1.1 release notes
This commit is contained in:
+191
@@ -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) }
|
||||
@@ -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&>"), "<text&>"},
|
||||
{"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"DT">tomorrow</tg-time>`},
|
||||
{"custom emoji", Emoji("🙂", `id"`), `<tg-emoji emoji-id="id"">🙂</tg-emoji>`},
|
||||
{"formula", MathExpression("x < y"), "<tg-math>x < 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 < y</tg-math-block>"},
|
||||
{"anchor", Anchor(`a"b`), `<a name="a"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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
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, " ") + "/>"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user