REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.1.0 #16
@@ -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