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

(fix): finalize v2 contracts
(tests): cover v2 migration
(doc): prepare release guidance
This commit is contained in:
2026-09-08 23:21:38 +03:00
parent 24040fe164
commit d78526242b
88 changed files with 2321 additions and 918 deletions
+62 -29
View File
@@ -13,11 +13,20 @@ const (
maximumRichJSONNodes = 10_000
)
var errInvalidUnknownRichJSON = errors.New("invalid JSON in unknown rich object")
var knownRichTextTypes = map[string]bool{
"url": true, "email_address": true, "phone_number": true,
"bank_card_number": true, "mention": true, "hashtag": true,
"cashtag": true, "bot_command": true, "anchor_link": true,
"reference": true, "reference_link": true, "date_time": true,
"text_mention": true, "custom_emoji": true,
"mathematical_expression": true, "anchor": true, "button": true,
}
// UnmarshalRichText parses a RichText tree from JSON: a string, an array, or
// a typed object. Unknown object types that carry a text field are preserved
// as RichTextWrap so their nested text remains usable; unmodeled fields are
// discarded. The fallback representation is subject to change in v2 so unknown
// fields can be preserved losslessly.
// a typed object. Unknown object types are preserved as RichTextUnknown without
// discarding fields.
//
// Since: Bot API 10.1
func UnmarshalRichText(data []byte) (RichText, error) {
@@ -57,6 +66,12 @@ func unmarshalRichText(data []byte) (RichText, error) {
if err := json.Unmarshal(data, &head); err != nil {
return nil, fmt.Errorf("richtext: not a string, array or object: %w", err)
}
if head.Type == "" {
return nil, errors.New("richtext: object type is required")
}
if !richTextWrapTags[head.Type] && !knownRichTextTypes[head.Type] {
return RichTextUnknown{Raw: cloneRawJSON(data)}, nil
}
// Recursively parse the nested text, if any.
var inner RichText
@@ -204,21 +219,23 @@ func unmarshalRichText(data []byte) (RichText, error) {
return nil, err
}
return RichTextAnchor{v.Name}, nil
case "button":
var raw struct {
Button RichMessageButton `json:"button"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
return RichTextButton{Button: raw.Button}, nil
default:
// forward-compat: keep an unknown tag with a text field as
// RichTextWrap; without text it is an error (the shape cannot be guessed).
if inner != nil {
return RichTextWrap{Tag: head.Type, Text: inner}, nil
}
return nil, fmt.Errorf("richtext: unknown type %q", head.Type)
return nil, fmt.Errorf("richtext: unsupported type %q", head.Type)
}
}
// UnmarshalRichBlock parses a single RichBlock from JSON, dispatching on the
// type tag. Unknown types that carry a text field are decoded as RichBlockWrap
// so their nested text remains usable; unmodeled fields are discarded. The
// fallback representation is subject to change in v2 for lossless round trips.
// type tag. Unknown object types are preserved as RichBlockUnknown without
// discarding fields.
//
// Since: Bot API 10.1
func UnmarshalRichBlock(data []byte) (RichBlock, error) {
@@ -236,6 +253,9 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
if err := json.Unmarshal(data, &head); err != nil {
return nil, fmt.Errorf("richblock: %w", err)
}
if head.Type == "" {
return nil, errors.New("richblock: object type is required")
}
if richBlockWrapTags[head.Type] {
text, err := parseOptRichText(head.Text)
@@ -290,7 +310,7 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
}
return RichBlockQuotation{blocks, credit}, nil
case "pullquote":
case "expandable_blockquote", "pullquote":
var raw struct {
Credit json.RawMessage `json:"credit"`
}
@@ -305,6 +325,9 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
if err != nil {
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
}
if head.Type == "expandable_blockquote" {
return RichBlockExpandableBlockQuotation{text, credit}, nil
}
return RichBlockPullQuotation{text, credit}, nil
case "list":
@@ -368,6 +391,7 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
Cells [][]RichBlockTableCell `json:"cells"`
IsBordered bool `json:"is_bordered"`
IsStriped bool `json:"is_striped"`
IsCompact bool `json:"is_compact"`
Caption json.RawMessage `json:"caption"`
}
if err := json.Unmarshal(data, &raw); err != nil {
@@ -377,7 +401,7 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
if err != nil {
return nil, fmt.Errorf("richblock %q: caption: %w", head.Type, err)
}
return RichBlockTable{raw.Cells, raw.IsBordered, raw.IsStriped, caption}, nil
return RichBlockTable{raw.Cells, raw.IsBordered, raw.IsStriped, raw.IsCompact, caption}, nil
case "map":
var v struct {
@@ -392,6 +416,18 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
}
return RichBlockMap{v.Location, v.Zoom, v.Width, v.Height, v.Caption}, nil
case "buttons":
var block RichBlockButtons
if err := json.Unmarshal(data, &block); err != nil {
return nil, err
}
return block, nil
case "document":
var block RichBlockDocument
if err := json.Unmarshal(data, &block); err != nil {
return nil, err
}
return block, nil
case "photo":
var v struct {
Photo []PhotoSize `json:"photo"`
@@ -467,26 +503,15 @@ func unmarshalRichBlock(data []byte) (RichBlock, error) {
return RichBlockAnchor{v.Name}, nil
default:
// forward-compat: unknown type with text -> RichBlockWrap, without text -> error.
if text, err := parseOptRichText(head.Text); err == nil && text != nil {
return RichBlockWrap{Tag: head.Type, Text: text}, nil
}
return nil, fmt.Errorf("richblock: unknown type %q", head.Type)
return RichBlockUnknown{Raw: cloneRawJSON(data)}, nil
}
}
// UnmarshalRichMessage parses a root RichMessage from JSON.
//
// For v1 compatibility, missing and null blocks are accepted as an empty
// message. This permissive behavior is subject to change in v2; use
// UnmarshalRichMessageStrict when validating untrusted input.
// UnmarshalRichMessage parses a RichMessage and requires a non-null blocks array.
//
// Since: Bot API 10.1
func UnmarshalRichMessage(data []byte) (RichMessage, error) {
if err := validateRichJSON(data); err != nil {
return RichMessage{}, err
}
return unmarshalRichMessage(data)
return unmarshalRichMessageStrict(data)
}
func unmarshalRichMessage(data []byte) (RichMessage, error) {
@@ -508,6 +533,10 @@ func unmarshalRichMessage(data []byte) (RichMessage, error) {
//
// Since: Bot API 10.1
func UnmarshalRichMessageStrict(data []byte) (RichMessage, error) {
return unmarshalRichMessageStrict(data)
}
func unmarshalRichMessageStrict(data []byte) (RichMessage, error) {
if err := validateRichJSON(data); err != nil {
return RichMessage{}, err
}
@@ -550,6 +579,10 @@ func parseOptRichText(raw json.RawMessage) (RichText, error) {
return unmarshalRichText(raw)
}
func cloneRawJSON(raw []byte) json.RawMessage {
return append(json.RawMessage(nil), raw...)
}
func unmarshalRichBlocks(raw json.RawMessage) ([]RichBlock, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil