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

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+514
View File
@@ -0,0 +1,514 @@
package tgapi
import (
"bytes"
"encoding/json"
"fmt"
)
// 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.
//
// Since: Bot API 10.1
func UnmarshalRichText(data []byte) (RichText, error) {
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
return nil, fmt.Errorf("richtext: null is not a rich text value")
}
// 1. string
var s string
if err := json.Unmarshal(data, &s); err == nil {
return RichTextPlain(s), nil
}
// 2. array
var raw []json.RawMessage
if err := json.Unmarshal(data, &raw); err == nil {
arr := make(RichTextArray, len(raw))
for i, it := range raw {
rt, err := UnmarshalRichText(it)
if err != nil {
return nil, err
}
arr[i] = rt
}
return arr, nil
}
// 3. object -> dispatch on type, grabbing the raw text along the way
var head struct {
Type string `json:"type"`
Text json.RawMessage `json:"text"`
}
if err := json.Unmarshal(data, &head); err != nil {
return nil, fmt.Errorf("richtext: not a string, array or object: %w", err)
}
// Recursively parse the nested text, if any.
var inner RichText
if len(head.Text) > 0 {
var err error
if inner, err = UnmarshalRichText(head.Text); err != nil {
return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err)
}
}
if richTextWrapTags[head.Type] {
return RichTextWrap{Tag: head.Type, Text: inner}, nil
}
switch head.Type {
case "url":
var v struct {
URL string `json:"url"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextURL{inner, v.URL}, nil
case "email_address":
var v struct {
V string `json:"email_address"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextEmailAddress{inner, v.V}, nil
case "phone_number":
var v struct {
V string `json:"phone_number"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextPhoneNumber{inner, v.V}, nil
case "bank_card_number":
var v struct {
V string `json:"bank_card_number"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextBankCardNumber{inner, v.V}, nil
case "mention":
var v struct {
V string `json:"username"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextMention{inner, v.V}, nil
case "hashtag":
var v struct {
V string `json:"hashtag"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextHashtag{inner, v.V}, nil
case "cashtag":
var v struct {
V string `json:"cashtag"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextCashtag{inner, v.V}, nil
case "bot_command":
var v struct {
V string `json:"bot_command"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextBotCommand{inner, v.V}, nil
case "anchor_link":
var v struct {
V string `json:"anchor_name"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextAnchorLink{inner, v.V}, nil
case "reference":
var v struct {
V string `json:"name"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextReference{inner, v.V}, nil
case "reference_link":
var v struct {
V string `json:"reference_name"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextReferenceLink{inner, v.V}, nil
case "date_time":
var v struct {
UnixTime int64 `json:"unix_time"`
DateTimeFormat string `json:"date_time_format"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
case "text_mention":
var v struct {
User User `json:"user"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextTextMention{inner, v.User}, nil
// --- leaves without text ---
case "custom_emoji":
var v struct {
ID string `json:"custom_emoji_id"`
Alt string `json:"alternative_text"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextCustomEmoji{v.ID, v.Alt}, nil
case "mathematical_expression":
var v struct {
Expression string `json:"expression"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextMathematicalExpression{v.Expression}, nil
case "anchor":
var v struct {
Name string `json:"name"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichTextAnchor{v.Name}, 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)
}
}
// 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.
//
// Since: Bot API 10.1
func UnmarshalRichBlock(data []byte) (RichBlock, error) {
var head struct {
Type string `json:"type"`
Text json.RawMessage `json:"text"`
}
if err := json.Unmarshal(data, &head); err != nil {
return nil, fmt.Errorf("richblock: %w", err)
}
if richBlockWrapTags[head.Type] {
text, err := parseOptRichText(head.Text)
if err != nil {
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
}
return RichBlockWrap{Tag: head.Type, Text: text}, nil
}
switch head.Type {
case "heading":
var v struct {
Size int `json:"size"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
text, err := parseOptRichText(head.Text)
if err != nil {
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
}
return RichBlockSectionHeading{text, v.Size}, nil
case "pre":
var v struct {
Language string `json:"language"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
text, err := parseOptRichText(head.Text)
if err != nil {
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
}
return RichBlockPreformatted{text, v.Language}, nil
case "blockquote":
var raw struct {
Blocks json.RawMessage `json:"blocks"`
Credit json.RawMessage `json:"credit"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return nil, err
}
credit, err := parseOptRichText(raw.Credit)
if err != nil {
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
}
return RichBlockQuotation{blocks, credit}, nil
case "pullquote":
var raw struct {
Credit json.RawMessage `json:"credit"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
text, err := parseOptRichText(head.Text)
if err != nil {
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
}
credit, err := parseOptRichText(raw.Credit)
if err != nil {
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
}
return RichBlockPullQuotation{text, credit}, nil
case "list":
var v struct {
Items []RichBlockListItem `json:"items"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockList{v.Items}, nil
case "collage":
var raw struct {
Blocks json.RawMessage `json:"blocks"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return nil, err
}
return RichBlockCollage{blocks, raw.Caption}, nil
case "slideshow":
var raw struct {
Blocks json.RawMessage `json:"blocks"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return nil, err
}
return RichBlockSlideshow{blocks, raw.Caption}, nil
case "details":
var raw struct {
Summary json.RawMessage `json:"summary"`
Blocks json.RawMessage `json:"blocks"`
IsOpen bool `json:"is_open"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
summary, err := parseOptRichText(raw.Summary)
if err != nil {
return nil, fmt.Errorf("richblock %q: summary: %w", head.Type, err)
}
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return nil, err
}
return RichBlockDetails{summary, blocks, raw.IsOpen}, nil
case "table":
var raw struct {
Cells [][]RichBlockTableCell `json:"cells"`
IsBordered bool `json:"is_bordered"`
IsStriped bool `json:"is_striped"`
Caption json.RawMessage `json:"caption"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
caption, err := parseOptRichText(raw.Caption)
if err != nil {
return nil, fmt.Errorf("richblock %q: caption: %w", head.Type, err)
}
return RichBlockTable{raw.Cells, raw.IsBordered, raw.IsStriped, caption}, nil
case "map":
var v struct {
Location Location `json:"location"`
Zoom int `json:"zoom"`
Width int `json:"width"`
Height int `json:"height"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockMap{v.Location, v.Zoom, v.Width, v.Height, v.Caption}, nil
case "photo":
var v struct {
Photo []PhotoSize `json:"photo"`
HasSpoiler bool `json:"has_spoiler"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockPhoto{v.Photo, v.HasSpoiler, v.Caption}, nil
case "video":
var v struct {
Video Video `json:"video"`
HasSpoiler bool `json:"has_spoiler"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockVideo{v.Video, v.HasSpoiler, v.Caption}, nil
case "audio":
var v struct {
Audio Audio `json:"audio"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockAudio{v.Audio, v.Caption}, nil
case "animation":
var v struct {
Animation Animation `json:"animation"`
HasSpoiler bool `json:"has_spoiler"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockAnimation{v.Animation, v.HasSpoiler, v.Caption}, nil
case "voice_note":
var v struct {
VoiceNote Voice `json:"voice_note"`
Caption *RichBlockCaption `json:"caption"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockVoiceNote{v.VoiceNote, v.Caption}, nil
case "divider":
return RichBlockDivider{}, nil
case "mathematical_expression":
var v struct {
Expression string `json:"expression"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return RichBlockMathematicalExpression{v.Expression}, nil
case "anchor":
var v struct {
Name string `json:"name"`
}
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
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)
}
}
// UnmarshalRichMessage parses a root RichMessage from JSON.
//
// Since: Bot API 10.1
func UnmarshalRichMessage(data []byte) (RichMessage, error) {
var raw struct {
Blocks json.RawMessage `json:"blocks"`
IsRTL bool `json:"is_rtl"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return RichMessage{}, fmt.Errorf("richmessage: %w", err)
}
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return RichMessage{}, err
}
return RichMessage{blocks, raw.IsRTL}, nil
}
// UnmarshalJSON implements json.Unmarshaler.
//
// Since: Bot API 10.1
func (m *RichMessage) UnmarshalJSON(data []byte) error {
parsed, err := UnmarshalRichMessage(data)
if err != nil {
return err
}
*m = parsed
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// Optional RichText fields treat absent and null values as nil.
func parseOptRichText(raw json.RawMessage) (RichText, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
return UnmarshalRichText(raw)
}
func unmarshalRichBlocks(raw json.RawMessage) ([]RichBlock, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var raws []json.RawMessage
if err := json.Unmarshal(raw, &raws); err != nil {
return nil, err
}
blocks := make([]RichBlock, len(raws))
for i, r := range raws {
b, err := UnmarshalRichBlock(r)
if err != nil {
return nil, err
}
blocks[i] = b
}
return blocks, nil
}