(new): rich text
This commit is contained in:
@@ -0,0 +1,648 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RichBlock — блок в структурированном rich-сообщении.
|
||||
type RichBlock interface {
|
||||
isRichBlock()
|
||||
}
|
||||
|
||||
// RichMessage — корневой тип структурированного сообщения (Bot API 10.1).
|
||||
type RichMessage struct {
|
||||
Blocks []RichBlock
|
||||
}
|
||||
|
||||
func (m RichMessage) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
}{m.Blocks})
|
||||
}
|
||||
|
||||
func (m *RichMessage) UnmarshalJSON(data []byte) error {
|
||||
msg, err := UnmarshalMessage(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*m = msg
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Вспомогательные типы
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RichBlockListItem — один элемент списка (ordered/unordered).
|
||||
type RichBlockListItem struct {
|
||||
Blocks []RichBlock
|
||||
}
|
||||
|
||||
func (i RichBlockListItem) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
}{i.Blocks})
|
||||
}
|
||||
|
||||
func (i *RichBlockListItem) UnmarshalJSON(data []byte) error {
|
||||
item, err := unmarshalListItem(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*i = item
|
||||
return nil
|
||||
}
|
||||
|
||||
// RichBlockTableCell — ячейка таблицы.
|
||||
type RichBlockTableCell struct {
|
||||
Content []RichBlock
|
||||
ColumnSpan int
|
||||
RowSpan int
|
||||
}
|
||||
|
||||
func (c RichBlockTableCell) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Content []RichBlock `json:"content"`
|
||||
ColumnSpan int `json:"column_span,omitempty"`
|
||||
RowSpan int `json:"row_span,omitempty"`
|
||||
}{c.Content, c.ColumnSpan, c.RowSpan})
|
||||
}
|
||||
|
||||
func (c *RichBlockTableCell) UnmarshalJSON(data []byte) error {
|
||||
cell, err := unmarshalTableCell(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*c = cell
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlockWrap: чистые текстовые блоки — paragraph, section_heading, footer, thinking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BlockWrap покрывает все блоки, у которых есть только поле text.
|
||||
type BlockWrap struct {
|
||||
Tag string
|
||||
Text RichText
|
||||
}
|
||||
|
||||
func (BlockWrap) isRichBlock() {}
|
||||
|
||||
func (b BlockWrap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
}{b.Tag, b.Text})
|
||||
}
|
||||
|
||||
var blockWrapTags = map[string]bool{
|
||||
"paragraph": true, "section_heading": true,
|
||||
"footer": true, "thinking": true,
|
||||
}
|
||||
|
||||
func Paragraph(t RichText) BlockWrap { return BlockWrap{"paragraph", t} }
|
||||
func SectionHeading(t RichText) BlockWrap { return BlockWrap{"section_heading", t} }
|
||||
func Footer(t RichText) BlockWrap { return BlockWrap{"footer", t} }
|
||||
func Thinking(t RichText) BlockWrap { return BlockWrap{"thinking", t} }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Блок с text + language
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockPreformatted struct {
|
||||
Text RichText
|
||||
Language string
|
||||
}
|
||||
|
||||
func (BlockPreformatted) isRichBlock() {}
|
||||
func (b BlockPreformatted) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}{"preformatted", b.Text, b.Language})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Блоки с text + caption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockBlockQuotation struct {
|
||||
Text RichText
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockBlockQuotation) isRichBlock() {}
|
||||
func (b BlockBlockQuotation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"block_quotation", b.Text, b.Caption})
|
||||
}
|
||||
|
||||
type BlockPullQuotation struct {
|
||||
Text RichText
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockPullQuotation) isRichBlock() {}
|
||||
func (b BlockPullQuotation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"pull_quotation", b.Text, b.Caption})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Список
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockList struct {
|
||||
Items []RichBlockListItem
|
||||
Ordered bool
|
||||
}
|
||||
|
||||
func (BlockList) isRichBlock() {}
|
||||
func (b BlockList) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Items []RichBlockListItem `json:"items"`
|
||||
Ordered bool `json:"ordered"`
|
||||
}{"list", b.Items, b.Ordered})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Контейнеры с items []RichBlock + caption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockCollage struct {
|
||||
Items []RichBlock
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockCollage) isRichBlock() {}
|
||||
func (b BlockCollage) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Items []RichBlock `json:"items"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"collage", b.Items, b.Caption})
|
||||
}
|
||||
|
||||
type BlockSlideshow struct {
|
||||
Items []RichBlock
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockSlideshow) isRichBlock() {}
|
||||
func (b BlockSlideshow) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Items []RichBlock `json:"items"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"slideshow", b.Items, b.Caption})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Details — раскрывающийся блок
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockDetails struct {
|
||||
Title RichText
|
||||
Blocks []RichBlock
|
||||
Open bool
|
||||
}
|
||||
|
||||
func (BlockDetails) isRichBlock() {}
|
||||
func (b BlockDetails) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Title RichText `json:"title"`
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
Open bool `json:"open"`
|
||||
}{"details", b.Title, b.Blocks, b.Open})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Таблица
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockTable struct {
|
||||
Title RichText
|
||||
Rows [][]RichBlockTableCell
|
||||
Bordered bool
|
||||
Striped bool
|
||||
}
|
||||
|
||||
func (BlockTable) isRichBlock() {}
|
||||
func (b BlockTable) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Title RichText `json:"title,omitempty"`
|
||||
Rows [][]RichBlockTableCell `json:"rows"`
|
||||
Bordered bool `json:"bordered"`
|
||||
Striped bool `json:"striped"`
|
||||
}{"table", b.Title, b.Rows, b.Bordered, b.Striped})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Карта
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockMap struct {
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Zoom int
|
||||
Width int
|
||||
Height int
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockMap) isRichBlock() {}
|
||||
func (b BlockMap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Zoom int `json:"zoom"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"map", b.Latitude, b.Longitude, b.Zoom, b.Width, b.Height, b.Caption})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Медиа-блоки (file_id + caption)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockPhoto struct {
|
||||
FileID string
|
||||
Caption RichText
|
||||
URL string
|
||||
}
|
||||
|
||||
func (BlockPhoto) isRichBlock() {}
|
||||
func (b BlockPhoto) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"file_id"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}{"photo", b.FileID, b.Caption, b.URL})
|
||||
}
|
||||
|
||||
type BlockVideo struct {
|
||||
FileID string
|
||||
Caption RichText
|
||||
Autoplay bool
|
||||
Loop bool
|
||||
}
|
||||
|
||||
func (BlockVideo) isRichBlock() {}
|
||||
func (b BlockVideo) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"file_id"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
Autoplay bool `json:"autoplay"`
|
||||
Loop bool `json:"loop"`
|
||||
}{"video", b.FileID, b.Caption, b.Autoplay, b.Loop})
|
||||
}
|
||||
|
||||
type BlockAudio struct {
|
||||
FileID string
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockAudio) isRichBlock() {}
|
||||
func (b BlockAudio) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"file_id"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"audio", b.FileID, b.Caption})
|
||||
}
|
||||
|
||||
type BlockAnimation struct {
|
||||
FileID string
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockAnimation) isRichBlock() {}
|
||||
func (b BlockAnimation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"file_id"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"animation", b.FileID, b.Caption})
|
||||
}
|
||||
|
||||
type BlockVoiceNote struct {
|
||||
FileID string
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (BlockVoiceNote) isRichBlock() {}
|
||||
func (b BlockVoiceNote) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"file_id"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"voice_note", b.FileID, b.Caption})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Листья без вложенного контента
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BlockDivider struct{}
|
||||
|
||||
func (BlockDivider) isRichBlock() {}
|
||||
func (b BlockDivider) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
}{"divider"})
|
||||
}
|
||||
|
||||
type BlockMathematicalExpression struct {
|
||||
Expression string
|
||||
}
|
||||
|
||||
func (BlockMathematicalExpression) isRichBlock() {}
|
||||
func (b BlockMathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Expression string `json:"expression"`
|
||||
}{"mathematical_expression", b.Expression})
|
||||
}
|
||||
|
||||
type BlockAnchor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (BlockAnchor) isRichBlock() {}
|
||||
func (b BlockAnchor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}{"anchor", b.Name})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Разбор JSON -> RichBlock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func UnmarshalBlock(data []byte) (RichBlock, error) {
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
Text json.RawMessage `json:"text"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
Title json.RawMessage `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &head); err != nil {
|
||||
return nil, fmt.Errorf("richblock: %w", err)
|
||||
}
|
||||
|
||||
parseText := func(raw json.RawMessage) (RichText, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
return Unmarshal(raw)
|
||||
}
|
||||
|
||||
if blockWrapTags[head.Type] {
|
||||
text, err := parseText(head.Text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||
}
|
||||
return BlockWrap{Tag: head.Type, Text: text}, nil
|
||||
}
|
||||
|
||||
switch head.Type {
|
||||
case "preformatted":
|
||||
var v struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
text, _ := parseText(head.Text)
|
||||
return BlockPreformatted{text, v.Language}, nil
|
||||
|
||||
case "block_quotation":
|
||||
text, _ := parseText(head.Text)
|
||||
caption, _ := parseText(head.Caption)
|
||||
return BlockBlockQuotation{text, caption}, nil
|
||||
|
||||
case "pull_quotation":
|
||||
text, _ := parseText(head.Text)
|
||||
caption, _ := parseText(head.Caption)
|
||||
return BlockPullQuotation{text, caption}, nil
|
||||
|
||||
case "list":
|
||||
var v struct {
|
||||
Items []RichBlockListItem `json:"items"`
|
||||
Ordered bool `json:"ordered"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BlockList{v.Items, v.Ordered}, nil
|
||||
|
||||
case "collage":
|
||||
var raw struct {
|
||||
Items json.RawMessage `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
items, _ := unmarshalBlocks(raw.Items)
|
||||
caption, _ := parseText(head.Caption)
|
||||
return BlockCollage{items, caption}, nil
|
||||
|
||||
case "slideshow":
|
||||
var raw struct {
|
||||
Items json.RawMessage `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
items, _ := unmarshalBlocks(raw.Items)
|
||||
caption, _ := parseText(head.Caption)
|
||||
return BlockSlideshow{items, caption}, nil
|
||||
|
||||
case "details":
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
Open bool `json:"open"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
title, _ := parseText(head.Title)
|
||||
blocks, _ := unmarshalBlocks(raw.Blocks)
|
||||
return BlockDetails{title, blocks, raw.Open}, nil
|
||||
|
||||
case "table":
|
||||
var raw struct {
|
||||
Title json.RawMessage `json:"title"`
|
||||
Rows [][]RichBlockTableCell `json:"rows"`
|
||||
Bordered bool `json:"bordered"`
|
||||
Striped bool `json:"striped"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title, _ := parseText(raw.Title)
|
||||
return BlockTable{title, raw.Rows, raw.Bordered, raw.Striped}, nil
|
||||
|
||||
case "map":
|
||||
var v struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Zoom int `json:"zoom"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
caption, _ := parseText(v.Caption)
|
||||
return BlockMap{v.Latitude, v.Longitude, v.Zoom, v.Width, v.Height, caption}, nil
|
||||
|
||||
case "photo":
|
||||
var v struct {
|
||||
FileID string `json:"file_id"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
caption, _ := parseText(v.Caption)
|
||||
return BlockPhoto{v.FileID, caption, v.URL}, nil
|
||||
|
||||
case "video":
|
||||
var v struct {
|
||||
FileID string `json:"file_id"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
Autoplay bool `json:"autoplay"`
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
caption, _ := parseText(v.Caption)
|
||||
return BlockVideo{v.FileID, caption, v.Autoplay, v.Loop}, nil
|
||||
|
||||
case "audio":
|
||||
var v struct {
|
||||
FileID string `json:"file_id"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
caption, _ := parseText(v.Caption)
|
||||
return BlockAudio{v.FileID, caption}, nil
|
||||
|
||||
case "animation":
|
||||
var v struct {
|
||||
FileID string `json:"file_id"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
caption, _ := parseText(v.Caption)
|
||||
return BlockAnimation{v.FileID, caption}, nil
|
||||
|
||||
case "voice_note":
|
||||
var v struct {
|
||||
FileID string `json:"file_id"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
caption, _ := parseText(v.Caption)
|
||||
return BlockVoiceNote{v.FileID, caption}, nil
|
||||
|
||||
case "divider":
|
||||
return BlockDivider{}, nil
|
||||
|
||||
case "mathematical_expression":
|
||||
var v struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return BlockMathematicalExpression{v.Expression}, nil
|
||||
|
||||
case "anchor":
|
||||
var v struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return BlockAnchor{v.Name}, nil
|
||||
|
||||
default:
|
||||
// forward-compat: неизвестный тип с text → BlockWrap, без text → ошибка.
|
||||
if text, err := parseText(head.Text); err == nil && text != nil {
|
||||
return BlockWrap{Tag: head.Type, Text: text}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("richblock: unknown type %q", head.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalMessage разбирает корневой RichMessage из JSON.
|
||||
func UnmarshalMessage(data []byte) (RichMessage, error) {
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return RichMessage{}, fmt.Errorf("richmessage: %w", err)
|
||||
}
|
||||
blocks, err := unmarshalBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return RichMessage{}, err
|
||||
}
|
||||
return RichMessage{blocks}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Внутренние хелперы
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func unmarshalBlocks(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 := UnmarshalBlock(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks[i] = b
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func unmarshalListItem(data []byte) (RichBlockListItem, error) {
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return RichBlockListItem{}, err
|
||||
}
|
||||
blocks, err := unmarshalBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return RichBlockListItem{}, err
|
||||
}
|
||||
return RichBlockListItem{blocks}, nil
|
||||
}
|
||||
|
||||
func unmarshalTableCell(data []byte) (RichBlockTableCell, error) {
|
||||
var raw struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
ColumnSpan int `json:"column_span"`
|
||||
RowSpan int `json:"row_span"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return RichBlockTableCell{}, err
|
||||
}
|
||||
content, err := unmarshalBlocks(raw.Content)
|
||||
if err != nil {
|
||||
return RichBlockTableCell{}, err
|
||||
}
|
||||
return RichBlockTableCell{content, raw.ColumnSpan, raw.RowSpan}, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func roundtripBlock(t *testing.T, in RichBlock) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
out, err := UnmarshalBlock(b)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", b, err)
|
||||
}
|
||||
b2, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal: %v", err)
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockRoundtrip(t *testing.T) {
|
||||
cases := []RichBlock{
|
||||
// wrap-блоки
|
||||
Paragraph(String("Hello, world")),
|
||||
SectionHeading(Bold(String("Chapter 1"))),
|
||||
Footer(String("© 2024")),
|
||||
Thinking(String("Let me reason step by step.")),
|
||||
|
||||
// preformatted
|
||||
BlockPreformatted{Text: String("fmt.Println(\"hi\")"), Language: "go"},
|
||||
BlockPreformatted{Text: String("no language")},
|
||||
|
||||
// цитаты
|
||||
BlockBlockQuotation{Text: String("To be or not to be"), Caption: String("Shakespeare")},
|
||||
BlockPullQuotation{Text: String("Pull me"), Caption: nil},
|
||||
|
||||
// список
|
||||
BlockList{
|
||||
Items: []RichBlockListItem{
|
||||
{Blocks: []RichBlock{Paragraph(String("item 1"))}},
|
||||
{Blocks: []RichBlock{Paragraph(String("item 2"))}},
|
||||
},
|
||||
Ordered: true,
|
||||
},
|
||||
BlockList{
|
||||
Items: []RichBlockListItem{
|
||||
{Blocks: []RichBlock{Paragraph(String("bullet"))}},
|
||||
},
|
||||
Ordered: false,
|
||||
},
|
||||
|
||||
// коллаж и слайдшоу
|
||||
BlockCollage{
|
||||
Items: []RichBlock{BlockPhoto{FileID: "abc123"}},
|
||||
Caption: String("A photo"),
|
||||
},
|
||||
BlockSlideshow{
|
||||
Items: []RichBlock{BlockVideo{FileID: "vid1", Autoplay: true, Loop: false}},
|
||||
Caption: nil,
|
||||
},
|
||||
|
||||
// details
|
||||
BlockDetails{
|
||||
Title: String("Spoiler"),
|
||||
Blocks: []RichBlock{Paragraph(String("Hidden content"))},
|
||||
Open: false,
|
||||
},
|
||||
BlockDetails{
|
||||
Title: Bold(String("Open details")),
|
||||
Blocks: []RichBlock{BlockDivider{}, Paragraph(String("content"))},
|
||||
Open: true,
|
||||
},
|
||||
|
||||
// таблица
|
||||
BlockTable{
|
||||
Title: String("Results"),
|
||||
Rows: [][]RichBlockTableCell{
|
||||
{
|
||||
{Content: []RichBlock{Paragraph(String("Cell A1"))}},
|
||||
{Content: []RichBlock{Paragraph(String("Cell A2"))}, ColumnSpan: 2},
|
||||
},
|
||||
{
|
||||
{Content: []RichBlock{Paragraph(String("Cell B1"))}, RowSpan: 2},
|
||||
{Content: []RichBlock{Paragraph(String("Cell B2"))}},
|
||||
},
|
||||
},
|
||||
Bordered: true,
|
||||
Striped: false,
|
||||
},
|
||||
|
||||
// карта
|
||||
BlockMap{
|
||||
Latitude: 55.7558, Longitude: 37.6173,
|
||||
Zoom: 12, Width: 800, Height: 400,
|
||||
Caption: String("Moscow"),
|
||||
},
|
||||
|
||||
// медиа
|
||||
BlockPhoto{FileID: "photo_file_id", Caption: String("A cat"), URL: "https://example.com/cat.jpg"},
|
||||
BlockPhoto{FileID: "bare_photo"},
|
||||
BlockVideo{FileID: "video_file_id", Caption: String("Demo"), Autoplay: true, Loop: true},
|
||||
BlockAudio{FileID: "audio_file_id", Caption: String("Podcast ep. 1")},
|
||||
BlockAnimation{FileID: "anim_file_id"},
|
||||
BlockVoiceNote{FileID: "voice_file_id"},
|
||||
|
||||
// листья
|
||||
BlockDivider{},
|
||||
BlockMathematicalExpression{Expression: "E = mc^2"},
|
||||
BlockAnchor{Name: "section-2"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
roundtripBlock(t, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageRoundtrip(t *testing.T) {
|
||||
msg := RichMessage{
|
||||
Blocks: []RichBlock{
|
||||
SectionHeading(String("Title")),
|
||||
Paragraph(Array{String("Some "), Bold(String("bold")), String(" text")}),
|
||||
BlockDivider{},
|
||||
BlockList{
|
||||
Items: []RichBlockListItem{
|
||||
{Blocks: []RichBlock{Paragraph(String("First"))}},
|
||||
{Blocks: []RichBlock{Paragraph(String("Second"))}},
|
||||
},
|
||||
Ordered: true,
|
||||
},
|
||||
BlockPhoto{FileID: "img1", Caption: String("Fig. 1")},
|
||||
},
|
||||
}
|
||||
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out RichMessage
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
b2, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal: %v", err)
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockDividerHasNoContent(t *testing.T) {
|
||||
b, _ := json.Marshal(BlockDivider{})
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if len(m) != 1 {
|
||||
t.Fatalf("divider must only have type field: %s", b)
|
||||
}
|
||||
if m["type"] != "divider" {
|
||||
t.Fatalf("unexpected type: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockUnknownTypeWithTextIsForwardCompat(t *testing.T) {
|
||||
raw := []byte(`{"type":"future_tag","text":"hello"}`)
|
||||
b, err := UnmarshalBlock(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("forward-compat failed: %v", err)
|
||||
}
|
||||
w, ok := b.(BlockWrap)
|
||||
if !ok || w.Tag != "future_tag" {
|
||||
t.Fatalf("expected BlockWrap{future_tag}, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockUnknownTypeWithoutTextIsError(t *testing.T) {
|
||||
raw := []byte(`{"type":"mystery_leaf","value":42}`)
|
||||
_, err := UnmarshalBlock(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown type without text")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// RichText — узел дерева форматированного текста: строка, массив или
|
||||
// один из типизированных объектов ниже.
|
||||
type RichText interface {
|
||||
isRichText()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Базовые формы: строка и массив
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type String string
|
||||
|
||||
func (String) isRichText() {}
|
||||
|
||||
type Array []RichText
|
||||
|
||||
func (Array) isRichText() {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Узлы только с полем text. Их 9; различает только тег.
|
||||
// bold italic underline strikethrough spoiler subscript superscript marked code
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Wrap покрывает все «чистые» оборачивающие узлы одним типом.
|
||||
type Wrap struct {
|
||||
Tag string // "bold", "italic", ...
|
||||
Text RichText
|
||||
}
|
||||
|
||||
func (Wrap) isRichText() {}
|
||||
|
||||
func (w Wrap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
}{w.Tag, w.Text})
|
||||
}
|
||||
|
||||
var wrapTags = map[string]bool{
|
||||
"bold": true, "italic": true, "underline": true,
|
||||
"strikethrough": true, "spoiler": true, "subscript": true,
|
||||
"superscript": true, "marked": true, "code": true,
|
||||
}
|
||||
|
||||
// Удобные конструкторы для wrap-узлов.
|
||||
func Bold(t RichText) Wrap { return Wrap{"bold", t} }
|
||||
func Italic(t RichText) Wrap { return Wrap{"italic", t} }
|
||||
func Underline(t RichText) Wrap { return Wrap{"underline", t} }
|
||||
func Strikethrough(t RichText) Wrap { return Wrap{"strikethrough", t} }
|
||||
func Spoiler(t RichText) Wrap { return Wrap{"spoiler", t} }
|
||||
func Subscript(t RichText) Wrap { return Wrap{"subscript", t} }
|
||||
func Superscript(t RichText) Wrap { return Wrap{"superscript", t} }
|
||||
func Marked(t RichText) Wrap { return Wrap{"marked", t} }
|
||||
func Code(t RichText) Wrap { return Wrap{"code", t} }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Узлы с text + одно строковое доп. поле.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type URL struct {
|
||||
Text RichText
|
||||
URL string
|
||||
}
|
||||
|
||||
func (URL) isRichText() {}
|
||||
func (v URL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
URL string `json:"url"`
|
||||
}{"url", v.Text, v.URL})
|
||||
}
|
||||
|
||||
type EmailAddress struct {
|
||||
Text RichText
|
||||
EmailAddress string
|
||||
}
|
||||
|
||||
func (EmailAddress) isRichText() {}
|
||||
func (v EmailAddress) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
}{"email_address", v.Text, v.EmailAddress})
|
||||
}
|
||||
|
||||
type PhoneNumber struct {
|
||||
Text RichText
|
||||
PhoneNumber string
|
||||
}
|
||||
|
||||
func (PhoneNumber) isRichText() {}
|
||||
func (v PhoneNumber) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}{"phone_number", v.Text, v.PhoneNumber})
|
||||
}
|
||||
|
||||
type BankCardNumber struct {
|
||||
Text RichText
|
||||
BankCardNumber string
|
||||
}
|
||||
|
||||
func (BankCardNumber) isRichText() {}
|
||||
func (v BankCardNumber) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
BankCardNumber string `json:"bank_card_number"`
|
||||
}{"bank_card_number", v.Text, v.BankCardNumber})
|
||||
}
|
||||
|
||||
type Mention struct {
|
||||
Text RichText
|
||||
Username string
|
||||
}
|
||||
|
||||
func (Mention) isRichText() {}
|
||||
func (v Mention) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Username string `json:"username"`
|
||||
}{"mention", v.Text, v.Username})
|
||||
}
|
||||
|
||||
type Hashtag struct {
|
||||
Text RichText
|
||||
Hashtag string
|
||||
}
|
||||
|
||||
func (Hashtag) isRichText() {}
|
||||
func (v Hashtag) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Hashtag string `json:"hashtag"`
|
||||
}{"hashtag", v.Text, v.Hashtag})
|
||||
}
|
||||
|
||||
type Cashtag struct {
|
||||
Text RichText
|
||||
Cashtag string
|
||||
}
|
||||
|
||||
func (Cashtag) isRichText() {}
|
||||
func (v Cashtag) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Cashtag string `json:"cashtag"`
|
||||
}{"cashtag", v.Text, v.Cashtag})
|
||||
}
|
||||
|
||||
type BotCommand struct {
|
||||
Text RichText
|
||||
BotCommand string
|
||||
}
|
||||
|
||||
func (BotCommand) isRichText() {}
|
||||
func (v BotCommand) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
BotCommand string `json:"bot_command"`
|
||||
}{"bot_command", v.Text, v.BotCommand})
|
||||
}
|
||||
|
||||
type AnchorLink struct {
|
||||
Text RichText
|
||||
AnchorName string
|
||||
}
|
||||
|
||||
func (AnchorLink) isRichText() {}
|
||||
func (v AnchorLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
AnchorName string `json:"anchor_name"`
|
||||
}{"anchor_link", v.Text, v.AnchorName})
|
||||
}
|
||||
|
||||
type Reference struct {
|
||||
Text RichText
|
||||
Name string
|
||||
}
|
||||
|
||||
func (Reference) isRichText() {}
|
||||
func (v Reference) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Name string `json:"name"`
|
||||
}{"reference", v.Text, v.Name})
|
||||
}
|
||||
|
||||
type ReferenceLink struct {
|
||||
Text RichText
|
||||
ReferenceName string
|
||||
}
|
||||
|
||||
func (ReferenceLink) isRichText() {}
|
||||
func (v ReferenceLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
ReferenceName string `json:"reference_name"`
|
||||
}{"reference_link", v.Text, v.ReferenceName})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Узлы с text + несколько/нестроковых полей.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DateTime struct {
|
||||
Text RichText
|
||||
UnixTime int64
|
||||
DateTimeFormat string
|
||||
}
|
||||
|
||||
func (DateTime) isRichText() {}
|
||||
func (v DateTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
UnixTime int64 `json:"unix_time"`
|
||||
DateTimeFormat string `json:"date_time_format"`
|
||||
}{"date_time", v.Text, v.UnixTime, v.DateTimeFormat})
|
||||
}
|
||||
|
||||
type TextMention struct {
|
||||
Text RichText
|
||||
User tgapi.User
|
||||
}
|
||||
|
||||
func (TextMention) isRichText() {}
|
||||
func (v TextMention) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
User tgapi.User `json:"user"`
|
||||
}{"text_mention", v.Text, v.User})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ЛИСТЬЯ: без поля text.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CustomEmoji struct {
|
||||
CustomEmojiID string
|
||||
AlternativeText string
|
||||
}
|
||||
|
||||
func (CustomEmoji) isRichText() {}
|
||||
func (v CustomEmoji) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
CustomEmojiID string `json:"custom_emoji_id"`
|
||||
AlternativeText string `json:"alternative_text"`
|
||||
}{"custom_emoji", v.CustomEmojiID, v.AlternativeText})
|
||||
}
|
||||
|
||||
type MathematicalExpression struct {
|
||||
Expression string
|
||||
}
|
||||
|
||||
func (MathematicalExpression) isRichText() {}
|
||||
func (v MathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Expression string `json:"expression"`
|
||||
}{"mathematical_expression", v.Expression})
|
||||
}
|
||||
|
||||
type Anchor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (Anchor) isRichText() {}
|
||||
func (v Anchor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}{"anchor", v.Name})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Разбор JSON -> RichText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Unmarshal(data []byte) (RichText, error) {
|
||||
// 1. строка
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
return String(s), nil
|
||||
}
|
||||
// 2. массив
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err == nil {
|
||||
arr := make(Array, len(raw))
|
||||
for i, it := range raw {
|
||||
rt, err := Unmarshal(it)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr[i] = rt
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
// 3. объект -> смотрим type, попутно вытаскиваем сырой text
|
||||
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)
|
||||
}
|
||||
|
||||
// Рекурсивно разбираем вложенный text, если он есть.
|
||||
var inner RichText
|
||||
if len(head.Text) > 0 {
|
||||
var err error
|
||||
if inner, err = Unmarshal(head.Text); err != nil {
|
||||
return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err)
|
||||
}
|
||||
}
|
||||
|
||||
if wrapTags[head.Type] {
|
||||
return Wrap{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 URL{inner, v.URL}, nil
|
||||
case "email_address":
|
||||
var v struct {
|
||||
V string `json:"email_address"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return EmailAddress{inner, v.V}, nil
|
||||
case "phone_number":
|
||||
var v struct {
|
||||
V string `json:"phone_number"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return PhoneNumber{inner, v.V}, nil
|
||||
case "bank_card_number":
|
||||
var v struct {
|
||||
V string `json:"bank_card_number"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return BankCardNumber{inner, v.V}, nil
|
||||
case "mention":
|
||||
var v struct {
|
||||
V string `json:"username"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return Mention{inner, v.V}, nil
|
||||
case "hashtag":
|
||||
var v struct {
|
||||
V string `json:"hashtag"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return Hashtag{inner, v.V}, nil
|
||||
case "cashtag":
|
||||
var v struct {
|
||||
V string `json:"cashtag"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return Cashtag{inner, v.V}, nil
|
||||
case "bot_command":
|
||||
var v struct {
|
||||
V string `json:"bot_command"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return BotCommand{inner, v.V}, nil
|
||||
case "anchor_link":
|
||||
var v struct {
|
||||
V string `json:"anchor_name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return AnchorLink{inner, v.V}, nil
|
||||
case "reference":
|
||||
var v struct {
|
||||
V string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return Reference{inner, v.V}, nil
|
||||
case "reference_link":
|
||||
var v struct {
|
||||
V string `json:"reference_name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return ReferenceLink{inner, v.V}, nil
|
||||
case "date_time":
|
||||
var v struct {
|
||||
UnixTime int64 `json:"unix_time"`
|
||||
DateTimeFormat string `json:"date_time_format"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return DateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
|
||||
case "text_mention":
|
||||
var v struct {
|
||||
User tgapi.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return TextMention{inner, v.User}, nil
|
||||
|
||||
// --- листья без text ---
|
||||
case "custom_emoji":
|
||||
var v struct {
|
||||
ID string `json:"custom_emoji_id"`
|
||||
Alt string `json:"alternative_text"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return CustomEmoji{v.ID, v.Alt}, nil
|
||||
case "mathematical_expression":
|
||||
var v struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return MathematicalExpression{v.Expression}, nil
|
||||
case "anchor":
|
||||
var v struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return Anchor{v.Name}, nil
|
||||
|
||||
default:
|
||||
// forward-compat: неизвестный тег с полем text сохраняем как Wrap,
|
||||
// без text — как ошибку (нельзя угадать форму).
|
||||
if inner != nil {
|
||||
return Wrap{Tag: head.Type, Text: inner}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("richtext: unknown type %q", head.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func roundtrip(t *testing.T, in RichText) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
out, err := Unmarshal(b)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", b, err)
|
||||
}
|
||||
b2, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal: %v", err)
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundtrip(t *testing.T) {
|
||||
cases := []RichText{
|
||||
String("hello"),
|
||||
Array{String("a "), Bold(String("b")), String(" c")},
|
||||
Bold(Italic(String("nested"))),
|
||||
URL{String("Anthropic"), "https://anthropic.com"},
|
||||
CustomEmoji{"5368324170671202286", "👍"},
|
||||
MathematicalExpression{"x^2 + y^2"},
|
||||
Anchor{"chapter-1"},
|
||||
DateTime{String("22:45 tomorrow"), 1647531900, "wDT"},
|
||||
TextMention{String("Bob"), tgapi.User{ID: 42, FirstName: "Bob"}},
|
||||
AnchorLink{String("back to top"), ""},
|
||||
Reference{String("ref"), "note-1"},
|
||||
// глубокая вложенность
|
||||
Bold(Array{
|
||||
String("bold and "),
|
||||
Italic(Underline(String("deep"))),
|
||||
Spoiler(CustomEmoji{"1", "x"}),
|
||||
}),
|
||||
}
|
||||
for _, c := range cases {
|
||||
roundtrip(t, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainFormsAreBare(t *testing.T) {
|
||||
b, _ := json.Marshal(String("hi"))
|
||||
if string(b) != `"hi"` {
|
||||
t.Fatalf("string should be bare: %s", b)
|
||||
}
|
||||
b, _ = json.Marshal(Array{String("a"), String("b")})
|
||||
if string(b) != `["a","b"]` {
|
||||
t.Fatalf("array should be bare: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafHasNoText(t *testing.T) {
|
||||
b, _ := json.Marshal(Anchor{"x"})
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["text"]; ok {
|
||||
t.Fatalf("anchor must not have text field: %s", b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user