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