REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.1.0 #16

Merged
ScuroNeko merged 4 commits from dev into main 2026-08-19 13:18:51 +03:00
4 changed files with 1362 additions and 0 deletions
Showing only changes of commit b563e695df - Show all commits
+648
View File
@@ -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
}
+186
View File
@@ -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")
}
}
+456
View File
@@ -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)
}
}
+72
View File
@@ -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)
}
}