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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:45:47 +03:00
co-authored by Claude Fable 5
parent b563e695df
commit 48ddf66540
22 changed files with 2232 additions and 1027 deletions
+492
View File
@@ -0,0 +1,492 @@
package tgfmt
import (
"fmt"
"strconv"
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Rich is an inline fragment of rich-message HTML (Bot API 10.1).
// Raw text enters through NewRich, which escapes it; fragments compose as-is.
type Rich string
// RichBlock is a block-level fragment of rich-message HTML. Block
// constructors accept only Rich arguments, so invalid nesting (a block
// inside inline content) does not compile.
type RichBlock string
// RichItem is any rich-message fragment: Rich or RichBlock. Both are valid
// at the top level of a message — Telegram merges adjacent inline content
// into paragraphs.
type RichItem interface{ richItem() string }
func (r Rich) richItem() string { return string(r) }
func (r RichBlock) richItem() string { return string(r) }
// RichHTML concatenates fragments into the final rich-message HTML string.
func RichHTML(items ...RichItem) string {
var b strings.Builder
for _, item := range items {
b.WriteString(item.richItem())
}
return b.String()
}
// RichMessage builds a ready-to-send InputRichMessage from fragments.
// SkipEntityDetection is enabled so the server does not add auto-detected
// entities; set IsRTL on the result if needed.
func RichMessage(items ...RichItem) tgapi.InputRichMessage {
return tgapi.InputRichMessage{
HTML: RichHTML(items...),
SkipEntityDetection: true,
}
}
func richJoin(items ...Rich) Rich {
var out Rich
for _, item := range items {
out += item
}
return out
}
func richJoinSep(sep Rich, items ...Rich) Rich {
var out Rich
for i, item := range items {
if i > 0 {
out += sep
}
out += item
}
return out
}
func richBlocksJoin(items ...RichBlock) RichBlock {
var out RichBlock
for _, item := range items {
out += item
}
return out
}
// openTag omits the space when there are no attributes.
func openTag(name string, attrs []string) string {
if len(attrs) == 0 {
return "<" + name + ">"
}
return "<" + name + " " + strings.Join(attrs, " ") + ">"
}
func cite(credit Rich) Rich {
if credit == "" {
return ""
}
return "<cite>" + credit + "</cite>"
}
// NewRich escapes raw text and returns it as an inline fragment.
func NewRich(text string) Rich { return Rich(escapeHTML(text)) }
// Bold wraps the fragment in <b>.
func (r Rich) Bold() Rich { return "<b>" + r + "</b>" }
// Italic wraps the fragment in <i>.
func (r Rich) Italic() Rich { return "<i>" + r + "</i>" }
// Underline wraps the fragment in <u>.
func (r Rich) Underline() Rich { return "<u>" + r + "</u>" }
// Strike wraps the fragment in <s>.
func (r Rich) Strike() Rich { return "<s>" + r + "</s>" }
// Code wraps the fragment in <code>.
func (r Rich) Code() Rich { return "<code>" + r + "</code>" }
// Mark wraps the fragment in <mark>.
func (r Rich) Mark() Rich { return "<mark>" + r + "</mark>" }
// Sub wraps the fragment in <sub>.
func (r Rich) Sub() Rich { return "<sub>" + r + "</sub>" }
// Sup wraps the fragment in <sup>.
func (r Rich) Sup() Rich { return "<sup>" + r + "</sup>" }
// Spoiler wraps the fragment in <tg-spoiler>.
func (r Rich) Spoiler() Rich { return "<tg-spoiler>" + r + "</tg-spoiler>" }
// Link wraps the fragment in a hyperlink to url.
func (r Rich) Link(url string) Rich { return `<a href="` + escapeRich(url) + `">` + r + "</a>" }
// Email wraps the fragment in a mailto: link.
func (r Rich) Email(email string) Rich { return r.Link("mailto:" + email) }
// Phone wraps the fragment in a tel: link.
func (r Rich) Phone(phone string) Rich { return r.Link("tel:" + phone) }
// Mention wraps the fragment in an inline user mention link.
func (r Rich) Mention(userID int64) Rich {
return r.Link("tg://user?id=" + strconv.FormatInt(userID, 10))
}
// Anchor marks the fragment as a named anchor (<a name>).
func (r Rich) Anchor(name string) Rich { return `<a name="` + escapeRich(name) + `">` + r + "</a>" }
// AnchorLink wraps the fragment in an in-document link to a named anchor
// or reference; the server resolves which one by the target name.
func (r Rich) AnchorLink(anchor string) Rich { return r.Link("#" + anchor) }
// Ref wraps the fragment in a <tg-reference> to the named reference.
func (r Rich) Ref(ref string) Rich {
return `<tg-reference name="` + escapeRich(ref) + `">` + r + "</tg-reference>"
}
// Emoji builds a custom emoji fragment with alt text as fallback.
func Emoji(emojiID, alt string) Rich {
return `<tg-emoji emoji-id="` + escapeRich(emojiID) + `">` + escapeRich(alt) + `</tg-emoji>`
}
// Time marks the fragment as a <tg-time> bound to t.
func (r Rich) Time(t time.Time) Rich {
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `">` + r + "</tg-time>"
}
// TimeFormat marks the fragment as a <tg-time> with an explicit display format.
func (r Rich) TimeFormat(t time.Time, format string) Rich {
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `" format="` + escapeRich(format) + `">` + r + "</tg-time>"
}
// Math wraps the fragment in an inline <tg-math> expression.
func (r Rich) Math() Rich { return `<tg-math>` + r + `</tg-math>` }
// Br returns a line break fragment.
func Br() Rich { return "<br>" }
// H1 builds a level-1 heading block.
func H1(items ...Rich) RichBlock { return RichBlock("<h1>" + richJoin(items...) + "</h1>") }
// H2 builds a level-2 heading block.
func H2(items ...Rich) RichBlock { return RichBlock("<h2>" + richJoin(items...) + "</h2>") }
// H3 builds a level-3 heading block.
func H3(items ...Rich) RichBlock { return RichBlock("<h3>" + richJoin(items...) + "</h3>") }
// H4 builds a level-4 heading block.
func H4(items ...Rich) RichBlock { return RichBlock("<h4>" + richJoin(items...) + "</h4>") }
// H5 builds a level-5 heading block.
func H5(items ...Rich) RichBlock { return RichBlock("<h5>" + richJoin(items...) + "</h5>") }
// H6 builds a level-6 heading block.
func H6(items ...Rich) RichBlock { return RichBlock("<h6>" + richJoin(items...) + "</h6>") }
// P builds a paragraph block.
func P(items ...Rich) RichBlock { return RichBlock("<p>" + richJoin(items...) + "</p>") }
// Pre builds a preformatted code block.
func Pre(items ...Rich) RichBlock { return RichBlock("<pre>" + richJoin(items...) + "</pre>") }
// PreCode builds a preformatted code block tagged with a language.
func PreCode(lang string, items ...Rich) RichBlock {
return RichBlock(`<pre><code class="language-` + escapeRich(lang) + `">` + richJoin(items...) + `</code></pre>`)
}
// Footer builds a footer block.
func Footer(items ...Rich) RichBlock { return RichBlock("<footer>" + richJoin(items...) + "</footer>") }
// Hr builds a divider block.
func Hr() RichBlock { return "<hr/>" }
// AnchorBlock builds a standalone named anchor between blocks: <a name></a>.
func AnchorBlock(name string) RichBlock {
return RichBlock(`<a name="` + escapeHTML(name) + `"></a>`)
}
// LiItem is a list item under construction for Ul or Ol.
type LiItem struct {
text Rich
value int
typ string
checkbox bool
checked bool
}
// Li builds a list item from inline fragments.
func Li(items ...Rich) LiItem { return LiItem{text: richJoin(items...)} }
// LiCheckbox builds a checkbox list item.
func LiCheckbox(checked bool, items ...Rich) LiItem {
return LiItem{text: richJoin(items...), checkbox: true, checked: checked}
}
// SetValue sets the explicit ordinal of the item (like <li value>).
func (l LiItem) SetValue(val int) LiItem {
l.value = val
return l
}
// SetType sets the numbering type of the item: "1", "a", "A", "i", "I".
func (l LiItem) SetType(t string) LiItem {
l.typ = t
return l
}
func (l LiItem) build() Rich {
if l.checkbox {
input := Rich(`<input type="checkbox">`)
if l.checked {
input = `<input type="checkbox" checked>`
}
return "<li>" + input + l.text + "</li>"
}
attrs := make([]string, 0, 2)
if l.value != 0 {
attrs = append(attrs, `value="`+strconv.Itoa(l.value)+`"`)
}
if l.typ != "" {
attrs = append(attrs, `type="`+escapeHTML(l.typ)+`"`)
}
return Rich(openTag("li", attrs)) + l.text + "</li>"
}
func joinLiItems(items []LiItem) Rich {
var out Rich
for _, item := range items {
out += item.build()
}
return out
}
// Ul builds an unordered list block.
func Ul(items ...LiItem) RichBlock {
return RichBlock(`<ul>` + joinLiItems(items) + `</ul>`)
}
// OlOpts holds the <ol> numbering attributes.
type OlOpts struct {
Start int
Type string
Reversed bool
}
// Ol builds an ordered list block. Item labels are rendered by the server.
func Ol(opts OlOpts, items ...LiItem) RichBlock {
attrs := make([]string, 0, 3)
if opts.Start > 0 {
attrs = append(attrs, `start="`+strconv.Itoa(opts.Start)+`"`)
}
if opts.Type != "" {
attrs = append(attrs, `type="`+escapeHTML(opts.Type)+`"`)
}
if opts.Reversed {
attrs = append(attrs, "reversed")
}
return RichBlock(openTag("ol", attrs) + string(joinLiItems(items)) + "</ol>")
}
// Blockquote builds a block quotation: lines are joined with <br> (as in the
// official HTML example) and credit renders as a trailing <cite>.
func Blockquote(credit Rich, lines ...Rich) RichBlock {
return RichBlock(`<blockquote>` + richJoinSep(Br(), lines...) + cite(credit) + `</blockquote>`)
}
// Aside builds a pull quote (<aside>) with an optional <cite> credit.
func Aside(credit Rich, lines ...Rich) RichBlock {
return RichBlock(`<aside>` + richJoinSep(Br(), lines...) + cite(credit) + `</aside>`)
}
// RichMedia is a media element for standalone blocks, collages, and
// slideshows. Media is sent by HTTP(S) URL only; file_id does not work in
// html mode.
type RichMedia struct {
tag string
src string
spoiler bool
}
// Photo builds a photo element from an HTTP(S) URL.
func Photo(url string) RichMedia { return RichMedia{tag: "img", src: url} }
// Video builds a video or animation element from an HTTP(S) URL; the server
// distinguishes them by the URL extension.
func Video(url string) RichMedia { return RichMedia{tag: "video", src: url} }
// Audio builds an audio or voice-note element from an HTTP(S) URL; the
// server treats .ogg as a voice note.
func Audio(url string) RichMedia { return RichMedia{tag: "audio", src: url} }
// SetSpoiler hides the media behind a spoiler overlay.
func (m RichMedia) SetSpoiler() RichMedia {
m.spoiler = true
return m
}
func (m RichMedia) build() string {
attrs := []string{`src="` + escapeHTML(m.src) + `"`}
if m.spoiler {
attrs = append(attrs, "tg-spoiler")
}
if m.tag == "img" {
return "<img " + strings.Join(attrs, " ") + "/>"
}
return openTag(m.tag, attrs) + "</" + m.tag + ">"
}
// Block turns the media into a standalone block without a caption.
func (m RichMedia) Block() RichBlock { return RichBlock(m.build()) }
// Caption wraps the media in <figure> with a caption and optional credit.
func (m RichMedia) Caption(credit Rich, caption ...Rich) RichBlock {
return RichBlock(`<figure>` + m.build() + string(figcaption(credit, caption...)) + `</figure>`)
}
func figcaption(credit Rich, caption ...Rich) Rich {
text := richJoin(caption...) + cite(credit)
if text == "" {
return ""
}
return "<figcaption>" + text + "</figcaption>"
}
func richMediaJoin(items []RichMedia) string {
var out string
for _, item := range items {
out += item.build()
}
return out
}
// Map builds a location map block.
func Map(lat, long float64, zoom int) RichBlock {
latString := fmt.Sprintf("%.6f", lat)
longString := fmt.Sprintf("%.6f", long)
zoomString := strconv.Itoa(zoom)
return RichBlock(`<tg-map lat="` + latString + `" long="` + longString + `" zoom="` + zoomString + `"/>`)
}
// MapCaption builds a map block wrapped in <figure> with a caption.
func MapCaption(lat, long float64, zoom int, credit Rich, caption ...Rich) RichBlock {
return `<figure>` + Map(lat, long, zoom) + RichBlock(figcaption(credit, caption...)) + `</figure>`
}
// Collage builds a media collage block.
func Collage(items ...RichMedia) RichBlock {
return RichBlock(`<tg-collage>` + richMediaJoin(items) + `</tg-collage>`)
}
// CollageCaption builds a media collage block with a caption.
func CollageCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
return RichBlock(`<tg-collage>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-collage>`)
}
// Slideshow builds a media slideshow block.
func Slideshow(items ...RichMedia) RichBlock {
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + `</tg-slideshow>`)
}
// SlideshowCaption builds a media slideshow block with a caption.
func SlideshowCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-slideshow>`)
}
// RichCell is a table cell under construction for Row.
type RichCell struct {
text Rich
colspan int
rowspan int
align string
valign string
}
// Cell builds a table cell from inline fragments.
func Cell(items ...Rich) RichCell {
return RichCell{text: richJoin(items...)}
}
// SetSpan sets colspan and rowspan; zero leaves the attribute out.
func (r RichCell) SetSpan(col, row int) RichCell {
r.colspan = col
r.rowspan = row
return r
}
// SetAlign sets horizontal alignment: "left", "center", or "right".
func (r RichCell) SetAlign(align string) RichCell {
r.align = align
return r
}
// SetVAlign sets vertical alignment: "top", "middle", or "bottom".
func (r RichCell) SetVAlign(align string) RichCell {
r.valign = align
return r
}
func (r RichCell) build(isHeader bool) string {
attrs := make([]string, 0, 4)
if r.colspan > 0 {
attrs = append(attrs, `colspan="`+strconv.Itoa(r.colspan)+`"`)
}
if r.rowspan > 0 {
attrs = append(attrs, `rowspan="`+strconv.Itoa(r.rowspan)+`"`)
}
if r.align != "" {
attrs = append(attrs, `align="`+escapeHTML(r.align)+`"`)
}
if r.valign != "" {
attrs = append(attrs, `valign="`+escapeHTML(r.valign)+`"`)
}
tag := "td"
if isHeader {
tag = "th"
}
return openTag(tag, attrs) + string(r.text) + "</" + tag + ">"
}
// RichRow is a table row under construction for Table.
type RichRow struct {
cells []RichCell
isHeader bool
}
// Row builds a table row; isHeader renders every cell as <th>.
func Row(isHeader bool, cells ...RichCell) RichRow {
return RichRow{cells: cells, isHeader: isHeader}
}
func (r RichRow) build() string {
var buildCells string
for _, cell := range r.cells {
buildCells += cell.build(r.isHeader)
}
return `<tr>` + buildCells + `</tr>`
}
// Table builds a table block; an empty caption is omitted.
func Table(bordered, striped bool, caption Rich, rows ...RichRow) RichBlock {
attrs := make([]string, 0, 2)
if bordered {
attrs = append(attrs, "bordered")
}
if striped {
attrs = append(attrs, "striped")
}
out := openTag("table", attrs)
if caption != "" {
out += `<caption>` + string(caption) + `</caption>`
}
for _, row := range rows {
out += row.build()
}
out += `</table>`
return RichBlock(out)
}
// Details builds an expandable block with an inline summary.
func Details(isOpen bool, summary Rich, blocks ...RichBlock) RichBlock {
tag := `<details>`
if isOpen {
tag = `<details open>`
}
return RichBlock(tag) + `<summary>` + RichBlock(summary) + `</summary>` + richBlocksJoin(blocks...) + `</details>`
}
// MathBlock builds a block-level mathematical expression.
func MathBlock(expr string) RichBlock {
return RichBlock(`<tg-math-block>` + escapeHTML(expr) + `</tg-math-block>`)
}
+179
View File
@@ -0,0 +1,179 @@
package tgfmt
import (
"testing"
"time"
)
func TestRichInline(t *testing.T) {
cases := []struct {
name string
got Rich
want string
}{
{"bold", NewRich("bold text").Bold(), "<b>bold text</b>"},
{"spoiler", NewRich("spoiler").Spoiler(), "<tg-spoiler>spoiler</tg-spoiler>"},
{"escape", NewRich(`a<b> & "c"`), "a&lt;b&gt; &amp; &quot;c&quot;"},
{"link", NewRich("inline URL").Link("https://t.me/"), `<a href="https://t.me/">inline URL</a>`},
{"link attr escape", NewRich("x").Link(`https://e/?q="><b>`), `<a href="https://e/?q=&quot;&gt;&lt;b&gt;">x</a>`},
{"mention", NewRich("user").Mention(123456789), `<a href="tg://user?id=123456789">user</a>`},
{"anchor", Rich("").Anchor("chapter-1"), `<a name="chapter-1"></a>`},
{"anchor link", NewRich("in-document link").AnchorLink("chapter-1"), `<a href="#chapter-1">in-document link</a>`},
{"reference", NewRich("Referenced text").Ref("note-1"), `<tg-reference name="note-1">Referenced text</tg-reference>`},
{"emoji", Emoji("5368324170671202286", "👍"), `<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>`},
{"emoji alt escape", Emoji("1", `<x>`), `<tg-emoji emoji-id="1">&lt;x&gt;</tg-emoji>`},
{"time format", NewRich("22:45 tomorrow").TimeFormat(time.Unix(1647531900, 0), "wDT"),
`<tg-time unix="1647531900" format="wDT">22:45 tomorrow</tg-time>`},
{"math", NewRich("x^2 + y^2").Math(), "<tg-math>x^2 + y^2</tg-math>"},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichBlocks(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"h1", H1(NewRich("Heading 1")), "<h1>Heading 1</h1>"},
{"p", P(NewRich("Paragraph text")), "<p>Paragraph text</p>"},
{"pre code", PreCode("python", NewRich("print('x')")),
`<pre><code class="language-python">print('x')</code></pre>`},
{"footer", Footer(NewRich("Footer text")), "<footer>Footer text</footer>"},
{"hr", Hr(), "<hr/>"},
{"anchor block", AnchorBlock("chapter-2"), `<a name="chapter-2"></a>`},
{"math block", MathBlock("E = mc^2"), "<tg-math-block>E = mc^2</tg-math-block>"},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichLists(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"ul", Ul(Li(NewRich("unordered list item"))),
"<ul><li>unordered list item</li></ul>"},
{"ol plain", Ol(OlOpts{}, Li(NewRich("ordered list item"))),
"<ol><li>ordered list item</li></ol>"},
{"ol attrs", Ol(OlOpts{Start: 3, Type: "a", Reversed: true}, Li(NewRich("ordered list item"))),
`<ol start="3" type="a" reversed><li>ordered list item</li></ol>`},
{"li value type", Ol(OlOpts{}, Li(NewRich("item")).SetValue(7).SetType("i")),
`<ol><li value="7" type="i">item</li></ol>`},
{"checkboxes", Ul(
LiCheckbox(true, NewRich("Checked checkbox")),
LiCheckbox(false, NewRich("Unchecked checkbox")),
), `<ul><li><input type="checkbox" checked>Checked checkbox</li><li><input type="checkbox">Unchecked checkbox</li></ul>`},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichQuotes(t *testing.T) {
got := Blockquote(NewRich("The Author"),
NewRich("Block quotation started"),
NewRich("Block quotation continued"),
NewRich("The last line of the block quotation"),
)
want := "<blockquote>Block quotation started<br>Block quotation continued<br>" +
"The last line of the block quotation<cite>The Author</cite></blockquote>"
if string(got) != want {
t.Errorf("blockquote:\n got %s\n want %s", got, want)
}
got = Aside(NewRich("The Author"), NewRich("Pull quote"))
want = "<aside>Pull quote<cite>The Author</cite></aside>"
if string(got) != want {
t.Errorf("aside:\n got %s\n want %s", got, want)
}
got = Blockquote("", NewRich("no credit"))
want = "<blockquote>no credit</blockquote>"
if string(got) != want {
t.Errorf("blockquote without credit:\n got %s\n want %s", got, want)
}
}
func TestRichMedia(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"photo", Photo("https://telegram.org/example/photo.jpg").Block(),
`<img src="https://telegram.org/example/photo.jpg"/>`},
{"video", Video("https://telegram.org/example/video.mp4").Block(),
`<video src="https://telegram.org/example/video.mp4"></video>`},
{"audio", Audio("https://telegram.org/example/audio.mp3").Block(),
`<audio src="https://telegram.org/example/audio.mp3"></audio>`},
{"photo spoiler caption", Photo("https://telegram.org/example/photo.jpg").SetSpoiler().
Caption(NewRich("Photo credit"), NewRich("Photo caption")),
`<figure><img src="https://telegram.org/example/photo.jpg" tg-spoiler/>` +
`<figcaption>Photo caption<cite>Photo credit</cite></figcaption></figure>`},
{"video caption no credit", Video("https://telegram.org/example/video.mp4").
Caption("", NewRich("Video caption")),
`<figure><video src="https://telegram.org/example/video.mp4"></video>` +
`<figcaption>Video caption</figcaption></figure>`},
{"map", Map(41.9, 12.5, 14), `<tg-map lat="41.900000" long="12.500000" zoom="14"/>`},
{"map caption", MapCaption(41.9, 12.5, 14, "", NewRich("Map caption")),
`<figure><tg-map lat="41.900000" long="12.500000" zoom="14"/><figcaption>Map caption</figcaption></figure>`},
{"collage", Collage(
Photo("https://telegram.org/example/photo.jpg"),
Video("https://telegram.org/example/video.mp4"),
), `<tg-collage><img src="https://telegram.org/example/photo.jpg"/>` +
`<video src="https://telegram.org/example/video.mp4"></video></tg-collage>`},
{"slideshow caption", SlideshowCaption("", NewRich("Slideshow caption"),
Photo("https://telegram.org/example/photo.jpg"),
), `<tg-slideshow><img src="https://telegram.org/example/photo.jpg"/>` +
`<figcaption>Slideshow caption</figcaption></tg-slideshow>`},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichTable(t *testing.T) {
got := Table(false, false, "",
Row(true, Cell(NewRich("Header 1")), Cell(NewRich("Header 2"))),
Row(false, Cell(NewRich("Value 1")), Cell(NewRich("Value 2"))),
)
want := "<table><tr><th>Header 1</th><th>Header 2</th></tr>" +
"<tr><td>Value 1</td><td>Value 2</td></tr></table>"
if string(got) != want {
t.Errorf("plain table:\n got %s\n want %s", got, want)
}
got = Table(true, true, NewRich("Table caption"),
Row(false,
Cell(NewRich("Value")).SetSpan(2, 2).SetAlign("left"),
Cell(NewRich("Value2")).SetAlign("center"),
),
)
want = `<table bordered striped><caption>Table caption</caption>` +
`<tr><td colspan="2" rowspan="2" align="left">Value</td><td align="center">Value2</td></tr></table>`
if string(got) != want {
t.Errorf("table attrs:\n got %s\n want %s", got, want)
}
}
func TestRichDetails(t *testing.T) {
got := Details(true, NewRich("Title"), P(NewRich("Content")))
want := "<details open><summary>Title</summary><p>Content</p></details>"
if string(got) != want {
t.Errorf("details:\n got %s\n want %s", got, want)
}
}
-648
View File
@@ -1,648 +0,0 @@
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
@@ -1,186 +0,0 @@
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
@@ -1,456 +0,0 @@
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
@@ -1,72 +0,0 @@
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)
}
}
+9
View File
@@ -2,6 +2,15 @@ package tgfmt
import "strings"
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func escapeRich(s string) Rich { return Rich(escapeHTML(s)) }
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
func EscapePunctuation(s string) string {
symbols := []string{".", "!", "-"}