diff --git a/tgfmt/richtext/richblock.go b/tgfmt/richtext/richblock.go new file mode 100644 index 0000000..703ea3f --- /dev/null +++ b/tgfmt/richtext/richblock.go @@ -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 +} diff --git a/tgfmt/richtext/richblock_test.go b/tgfmt/richtext/richblock_test.go new file mode 100644 index 0000000..f6511e4 --- /dev/null +++ b/tgfmt/richtext/richblock_test.go @@ -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") + } +} diff --git a/tgfmt/richtext/richtext.go b/tgfmt/richtext/richtext.go new file mode 100644 index 0000000..43f1016 --- /dev/null +++ b/tgfmt/richtext/richtext.go @@ -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) + } +} diff --git a/tgfmt/richtext/richtext_test.go b/tgfmt/richtext/richtext_test.go new file mode 100644 index 0000000..e0341c2 --- /dev/null +++ b/tgfmt/richtext/richtext_test.go @@ -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) + } +}