(new): rich messages, tgfmt DSL, Bot API 10.1
Golang lint / lint (push) Successful in 29s
Golang lint / lint (pull_request) Successful in 1m47s

(fix): editMessageText rich_message type
(tests): rich and API 10.1 coverage
(doc): rich godoc, CHANGELOG v1.1.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:45:47 +03:00
co-authored by Claude Fable 5
parent b563e695df
commit 48ddf66540
22 changed files with 2232 additions and 1027 deletions
+738
View File
@@ -0,0 +1,738 @@
package tgapi
import (
"encoding/json"
"fmt"
)
// RichBlock is a block in a structured rich message.
type RichBlock interface {
isRichBlock()
}
// ---------------------------------------------------------------------------
// Helper types
// ---------------------------------------------------------------------------
// RichBlockCaption is the caption of a media block or container.
type RichBlockCaption struct {
Text RichText
Credit RichText
}
func (c RichBlockCaption) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Text RichText `json:"text"`
Credit RichText `json:"credit,omitempty"`
}{c.Text, c.Credit})
}
func (c *RichBlockCaption) UnmarshalJSON(data []byte) error {
var raw struct {
Text json.RawMessage `json:"text"`
Credit json.RawMessage `json:"credit"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
text, err := parseOptRichText(raw.Text)
if err != nil {
return err
}
credit, err := parseOptRichText(raw.Credit)
if err != nil {
return err
}
*c = RichBlockCaption{text, credit}
return nil
}
// RichBlockListItem is a single list item. Label is the ready-to-display
// visible marker ("1.", "c.", "vii.", "•"): the server renders it itself
// when parsing html/markdown.
type RichBlockListItem struct {
Label string
Blocks []RichBlock
HasCheckbox bool
IsChecked bool
Value int // for ordered lists: numeric value of the marker
Type string // for ordered lists: "a", "A", "i", "I" or "1"
}
func (i RichBlockListItem) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Label string `json:"label"`
Blocks []RichBlock `json:"blocks"`
HasCheckbox bool `json:"has_checkbox,omitempty"`
IsChecked bool `json:"is_checked,omitempty"`
Value int `json:"value,omitempty"`
Type string `json:"type,omitempty"`
}{i.Label, i.Blocks, i.HasCheckbox, i.IsChecked, i.Value, i.Type})
}
func (i *RichBlockListItem) UnmarshalJSON(data []byte) error {
var raw struct {
Label string `json:"label"`
Blocks json.RawMessage `json:"blocks"`
HasCheckbox bool `json:"has_checkbox"`
IsChecked bool `json:"is_checked"`
Value int `json:"value"`
Type string `json:"type"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return err
}
*i = RichBlockListItem{raw.Label, blocks, raw.HasCheckbox, raw.IsChecked, raw.Value, raw.Type}
return nil
}
// RichBlockTableCell is a table cell. An empty Text means an invisible cell.
type RichBlockTableCell struct {
Text RichText
IsHeader bool
Colspan int
Rowspan int
Align string // "left", "center" or "right"
VAlign string // "top", "middle" or "bottom"
}
func (c RichBlockTableCell) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Text RichText `json:"text,omitempty"`
IsHeader bool `json:"is_header,omitempty"`
Colspan int `json:"colspan,omitempty"`
Rowspan int `json:"rowspan,omitempty"`
Align string `json:"align,omitempty"`
VAlign string `json:"valign,omitempty"`
}{c.Text, c.IsHeader, c.Colspan, c.Rowspan, c.Align, c.VAlign})
}
func (c *RichBlockTableCell) UnmarshalJSON(data []byte) error {
var raw struct {
Text json.RawMessage `json:"text"`
IsHeader bool `json:"is_header"`
Colspan int `json:"colspan"`
Rowspan int `json:"rowspan"`
Align string `json:"align"`
VAlign string `json:"valign"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
text, err := parseOptRichText(raw.Text)
if err != nil {
return err
}
*c = RichBlockTableCell{text, raw.IsHeader, raw.Colspan, raw.Rowspan, raw.Align, raw.VAlign}
return nil
}
// ---------------------------------------------------------------------------
// RichBlockWrap: pure text blocks — paragraph, footer, thinking.
// ---------------------------------------------------------------------------
// RichBlockWrap covers all blocks that have only a text field.
type RichBlockWrap struct {
Tag string
Text RichText
}
func (RichBlockWrap) isRichBlock() {}
func (b RichBlockWrap) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Text RichText `json:"text"`
}{b.Tag, b.Text})
}
var richBlockWrapTags = map[string]bool{
"paragraph": true, "footer": true, "thinking": true,
}
// ---------------------------------------------------------------------------
// Section heading: text + size
// ---------------------------------------------------------------------------
// RichBlockSectionHeading is a section heading block.
type RichBlockSectionHeading struct {
Text RichText
Size int // 1-6, 1 is the largest
}
func (RichBlockSectionHeading) isRichBlock() {}
func (b RichBlockSectionHeading) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Text RichText `json:"text"`
Size int `json:"size"`
}{"heading", b.Text, b.Size})
}
// ---------------------------------------------------------------------------
// Block with text + language
// ---------------------------------------------------------------------------
// RichBlockPreformatted is a preformatted code block.
type RichBlockPreformatted struct {
Text RichText
Language string
}
func (RichBlockPreformatted) isRichBlock() {}
func (b RichBlockPreformatted) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Text RichText `json:"text"`
Language string `json:"language,omitempty"`
}{"pre", b.Text, b.Language})
}
// ---------------------------------------------------------------------------
// Quotations
// ---------------------------------------------------------------------------
// RichBlockQuotation is a block quotation with block-level content
// (officially RichBlockBlockQuotation).
type RichBlockQuotation struct {
Blocks []RichBlock
Credit RichText
}
func (RichBlockQuotation) isRichBlock() {}
func (b RichBlockQuotation) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Blocks []RichBlock `json:"blocks"`
Credit RichText `json:"credit,omitempty"`
}{"blockquote", b.Blocks, b.Credit})
}
// RichBlockPullQuotation is a pull quotation with inline content.
type RichBlockPullQuotation struct {
Text RichText
Credit RichText
}
func (RichBlockPullQuotation) isRichBlock() {}
func (b RichBlockPullQuotation) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Text RichText `json:"text"`
Credit RichText `json:"credit,omitempty"`
}{"pullquote", b.Text, b.Credit})
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
// RichBlockList is a list block.
type RichBlockList struct {
Items []RichBlockListItem
}
func (RichBlockList) isRichBlock() {}
func (b RichBlockList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Items []RichBlockListItem `json:"items"`
}{"list", b.Items})
}
// ---------------------------------------------------------------------------
// Containers with blocks []RichBlock + caption
// ---------------------------------------------------------------------------
// RichBlockCollage is a collage of media blocks.
type RichBlockCollage struct {
Blocks []RichBlock
Caption *RichBlockCaption
}
func (RichBlockCollage) isRichBlock() {}
func (b RichBlockCollage) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Blocks []RichBlock `json:"blocks"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"collage", b.Blocks, b.Caption})
}
// RichBlockSlideshow is a slideshow of media blocks.
type RichBlockSlideshow struct {
Blocks []RichBlock
Caption *RichBlockCaption
}
func (RichBlockSlideshow) isRichBlock() {}
func (b RichBlockSlideshow) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Blocks []RichBlock `json:"blocks"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"slideshow", b.Blocks, b.Caption})
}
// ---------------------------------------------------------------------------
// Details — expandable block
// ---------------------------------------------------------------------------
// RichBlockDetails is an expandable block with an inline summary.
type RichBlockDetails struct {
Summary RichText
Blocks []RichBlock
IsOpen bool
}
func (RichBlockDetails) isRichBlock() {}
func (b RichBlockDetails) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Summary RichText `json:"summary"`
Blocks []RichBlock `json:"blocks"`
IsOpen bool `json:"is_open,omitempty"`
}{"details", b.Summary, b.Blocks, b.IsOpen})
}
// ---------------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------------
// RichBlockTable is a table block.
type RichBlockTable struct {
Cells [][]RichBlockTableCell
IsBordered bool
IsStriped bool
Caption RichText
}
func (RichBlockTable) isRichBlock() {}
func (b RichBlockTable) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Cells [][]RichBlockTableCell `json:"cells"`
IsBordered bool `json:"is_bordered,omitempty"`
IsStriped bool `json:"is_striped,omitempty"`
Caption RichText `json:"caption,omitempty"`
}{"table", b.Cells, b.IsBordered, b.IsStriped, b.Caption})
}
// ---------------------------------------------------------------------------
// Map
// ---------------------------------------------------------------------------
// RichBlockMap is a location map block.
type RichBlockMap struct {
Location Location
Zoom int // 13-20
Width int
Height int
Caption *RichBlockCaption
}
func (RichBlockMap) isRichBlock() {}
func (b RichBlockMap) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Location Location `json:"location"`
Zoom int `json:"zoom"`
Width int `json:"width"`
Height int `json:"height"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"map", b.Location, b.Zoom, b.Width, b.Height, b.Caption})
}
// ---------------------------------------------------------------------------
// Media blocks
// ---------------------------------------------------------------------------
// RichBlockPhoto is a photo block.
type RichBlockPhoto struct {
Photo []PhotoSize
HasSpoiler bool
Caption *RichBlockCaption
}
func (RichBlockPhoto) isRichBlock() {}
func (b RichBlockPhoto) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Photo []PhotoSize `json:"photo"`
HasSpoiler bool `json:"has_spoiler,omitempty"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"photo", b.Photo, b.HasSpoiler, b.Caption})
}
// RichBlockVideo is a video block.
type RichBlockVideo struct {
Video Video
HasSpoiler bool
Caption *RichBlockCaption
}
func (RichBlockVideo) isRichBlock() {}
func (b RichBlockVideo) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Video Video `json:"video"`
HasSpoiler bool `json:"has_spoiler,omitempty"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"video", b.Video, b.HasSpoiler, b.Caption})
}
// RichBlockAudio is an audio block.
type RichBlockAudio struct {
Audio Audio
Caption *RichBlockCaption
}
func (RichBlockAudio) isRichBlock() {}
func (b RichBlockAudio) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Audio Audio `json:"audio"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"audio", b.Audio, b.Caption})
}
// RichBlockAnimation is an animation block.
type RichBlockAnimation struct {
Animation Animation
HasSpoiler bool
Caption *RichBlockCaption
}
func (RichBlockAnimation) isRichBlock() {}
func (b RichBlockAnimation) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Animation Animation `json:"animation"`
HasSpoiler bool `json:"has_spoiler,omitempty"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"animation", b.Animation, b.HasSpoiler, b.Caption})
}
// RichBlockVoiceNote is a voice note block.
type RichBlockVoiceNote struct {
VoiceNote Voice
Caption *RichBlockCaption
}
func (RichBlockVoiceNote) isRichBlock() {}
func (b RichBlockVoiceNote) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
VoiceNote Voice `json:"voice_note"`
Caption *RichBlockCaption `json:"caption,omitempty"`
}{"voice_note", b.VoiceNote, b.Caption})
}
// ---------------------------------------------------------------------------
// Leaves without nested content
// ---------------------------------------------------------------------------
// RichBlockDivider is a horizontal divider block.
type RichBlockDivider struct{}
func (RichBlockDivider) isRichBlock() {}
func (b RichBlockDivider) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
}{"divider"})
}
// RichBlockMathematicalExpression is a block-level mathematical expression.
type RichBlockMathematicalExpression struct {
Expression string
}
func (RichBlockMathematicalExpression) isRichBlock() {}
func (b RichBlockMathematicalExpression) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Expression string `json:"expression"`
}{"mathematical_expression", b.Expression})
}
// RichBlockAnchor is a named anchor block that anchor links can point to.
type RichBlockAnchor struct {
Name string
}
func (RichBlockAnchor) isRichBlock() {}
func (b RichBlockAnchor) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Name string `json:"name"`
}{"anchor", b.Name})
}
// ---------------------------------------------------------------------------
// JSON -> RichBlock parsing
// ---------------------------------------------------------------------------
// UnmarshalRichBlock parses a single RichBlock from JSON, dispatching on the
// type tag. Unknown types that carry a text field are preserved as
// RichBlockWrap for forward compatibility.
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"`
}
_ = json.Unmarshal(data, &v)
text, _ := parseOptRichText(head.Text)
return RichBlockSectionHeading{text, v.Size}, nil
case "pre":
var v struct {
Language string `json:"language"`
}
_ = json.Unmarshal(data, &v)
text, _ := parseOptRichText(head.Text)
return RichBlockPreformatted{text, v.Language}, nil
case "blockquote":
var raw struct {
Blocks json.RawMessage `json:"blocks"`
Credit json.RawMessage `json:"credit"`
}
_ = json.Unmarshal(data, &raw)
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return nil, err
}
credit, _ := parseOptRichText(raw.Credit)
return RichBlockQuotation{blocks, credit}, nil
case "pullquote":
var raw struct {
Credit json.RawMessage `json:"credit"`
}
_ = json.Unmarshal(data, &raw)
text, _ := parseOptRichText(head.Text)
credit, _ := parseOptRichText(raw.Credit)
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"`
}
_ = json.Unmarshal(data, &raw)
summary, _ := parseOptRichText(raw.Summary)
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, _ := parseOptRichText(raw.Caption)
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"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
return RichBlockVideo{v.Video, v.HasSpoiler, v.Caption}, nil
case "audio":
var v struct {
Audio Audio `json:"audio"`
Caption *RichBlockCaption `json:"caption"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
return RichBlockAnimation{v.Animation, v.HasSpoiler, v.Caption}, nil
case "voice_note":
var v struct {
VoiceNote Voice `json:"voice_note"`
Caption *RichBlockCaption `json:"caption"`
}
_ = json.Unmarshal(data, &v)
return RichBlockVoiceNote{v.VoiceNote, v.Caption}, nil
case "divider":
return RichBlockDivider{}, nil
case "mathematical_expression":
var v struct {
Expression string `json:"expression"`
}
_ = json.Unmarshal(data, &v)
return RichBlockMathematicalExpression{v.Expression}, nil
case "anchor":
var v struct {
Name string `json:"name"`
}
_ = json.Unmarshal(data, &v)
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.
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 parses the blocks through UnmarshalRichBlock: the Blocks
// field is interface-typed, so the standard unmarshaler cannot handle it.
func (m *RichMessage) UnmarshalJSON(data []byte) error {
parsed, err := UnmarshalRichMessage(data)
if err != nil {
return err
}
*m = parsed
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// parseOptRichText parses an optional RichText field: absent and null yield 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
}