(new): tgfmt package
(fix): formatting helpers (ci/cd): go checks (tests): tgfmt coverage
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Package tgfmt provides small helpers for Telegram text formatting.
|
||||
package tgfmt
|
||||
@@ -0,0 +1,99 @@
|
||||
package tgfmt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTML is an escaped Telegram HTML fragment.
|
||||
//
|
||||
// Methods on HTML compose formatting without escaping the fragment again.
|
||||
type HTML string
|
||||
|
||||
// EscapeHTML escapes special characters for Telegram HTML parse mode.
|
||||
func EscapeHTML(s string) HTML {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return HTML(s)
|
||||
}
|
||||
|
||||
// Bold returns h wrapped as bold Telegram HTML text.
|
||||
func (h HTML) Bold() HTML {
|
||||
return "<b>" + h + "</b>"
|
||||
}
|
||||
|
||||
// Italic returns h wrapped as italic Telegram HTML text.
|
||||
func (h HTML) Italic() HTML {
|
||||
return "<i>" + h + "</i>"
|
||||
}
|
||||
|
||||
// Underline returns h wrapped as underlined Telegram HTML text.
|
||||
func (h HTML) Underline() HTML {
|
||||
return "<u>" + h + "</u>"
|
||||
}
|
||||
|
||||
// Strikethrough returns h wrapped as strikethrough Telegram HTML text.
|
||||
func (h HTML) Strikethrough() HTML {
|
||||
return "<s>" + h + "</s>"
|
||||
}
|
||||
|
||||
// Spoiler returns h wrapped as spoiler Telegram HTML text.
|
||||
func (h HTML) Spoiler() HTML {
|
||||
return "<tg-spoiler>" + h + "</tg-spoiler>"
|
||||
}
|
||||
|
||||
// Link returns h as a Telegram HTML text link.
|
||||
func (h HTML) Link(url string) HTML {
|
||||
return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>"
|
||||
}
|
||||
|
||||
// Mention returns h as a Telegram HTML user mention.
|
||||
func (h HTML) Mention(userID int64) HTML {
|
||||
return `<a href="tg://user?id=` + HTML(strconv.FormatInt(userID, 10)) + `">` + h + "</a>"
|
||||
}
|
||||
|
||||
// Emoji returns h as a Telegram HTML custom emoji.
|
||||
func (h HTML) Emoji(emojiID string) HTML {
|
||||
return `<tg-emoji emoji-id="` + escapeHTMLAttr(emojiID) + `">` + h + "</tg-emoji>"
|
||||
}
|
||||
|
||||
// Time returns h as a Telegram HTML localized timestamp.
|
||||
func (h HTML) Time(unix int64) HTML {
|
||||
return `<tg-time unix="` + HTML(strconv.FormatInt(unix, 10)) + `">` + h + "</tg-time>"
|
||||
}
|
||||
|
||||
// TimeFormat returns h as a Telegram HTML localized timestamp with format.
|
||||
func (h HTML) TimeFormat(unix int64, format string) HTML {
|
||||
return `<tg-time unix="` + HTML(strconv.FormatInt(unix, 10)) + `" format="` + escapeHTMLAttr(format) + `">` + h + "</tg-time>"
|
||||
}
|
||||
|
||||
// InlineCode returns h wrapped as inline code Telegram HTML text.
|
||||
func (h HTML) InlineCode() HTML {
|
||||
return "<code>" + h + "</code>"
|
||||
}
|
||||
|
||||
// BlockCode returns h wrapped as a Telegram HTML code block.
|
||||
func (h HTML) BlockCode() HTML {
|
||||
return "<pre>" + h + "</pre>"
|
||||
}
|
||||
|
||||
// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language.
|
||||
func (h HTML) BlockCodeLanguage(lang string) HTML {
|
||||
return `<pre><code class="language-` + escapeHTMLAttr(lang) + `">` + h + "</code></pre>"
|
||||
}
|
||||
|
||||
// Quote returns h as a Telegram HTML blockquote.
|
||||
func (h HTML) Quote() HTML {
|
||||
return "<blockquote>" + h + "</blockquote>"
|
||||
}
|
||||
|
||||
// QuoteExpandable returns h as a Telegram HTML expandable blockquote.
|
||||
func (h HTML) QuoteExpandable() HTML {
|
||||
return "<blockquote expandable>" + h + "</blockquote>"
|
||||
}
|
||||
|
||||
func escapeHTMLAttr(s string) HTML {
|
||||
return EscapeHTML(s)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tgfmt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEscapeHTML(t *testing.T) {
|
||||
got := EscapeHTML(`<tag attr="a&b">`)
|
||||
want := HTML(`<tag attr="a&b">`)
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("EscapeHTML() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLComposesWithoutDoubleEscaping(t *testing.T) {
|
||||
got := EscapeHTML("<b>").Bold().Italic()
|
||||
want := HTML("<i><b><b></b></i>")
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("formatted HTML = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLFormattingMethods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got HTML
|
||||
want HTML
|
||||
}{
|
||||
{name: "bold", got: EscapeHTML("text").Bold(), want: "<b>text</b>"},
|
||||
{name: "italic", got: EscapeHTML("text").Italic(), want: "<i>text</i>"},
|
||||
{name: "underline", got: EscapeHTML("text").Underline(), want: "<u>text</u>"},
|
||||
{name: "strikethrough", got: EscapeHTML("text").Strikethrough(), want: "<s>text</s>"},
|
||||
{name: "spoiler", got: EscapeHTML("text").Spoiler(), want: "<tg-spoiler>text</tg-spoiler>"},
|
||||
{name: "inline code", got: EscapeHTML("text").InlineCode(), want: "<code>text</code>"},
|
||||
{name: "block code", got: EscapeHTML("text").BlockCode(), want: "<pre>text</pre>"},
|
||||
{name: "block code language", got: EscapeHTML("text").BlockCodeLanguage(`go"`), want: `<pre><code class="language-go"">text</code></pre>`},
|
||||
{name: "quote", got: EscapeHTML("text").Quote(), want: "<blockquote>text</blockquote>"},
|
||||
{name: "expandable quote", got: EscapeHTML("text").QuoteExpandable(), want: "<blockquote expandable>text</blockquote>"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("formatted HTML = %q, want %q", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLLinkEscapesAttributes(t *testing.T) {
|
||||
got := EscapeHTML("Laniakea").Link(`https://example.test/?q="a&b"`)
|
||||
want := HTML(`<a href="https://example.test/?q="a&b"">Laniakea</a>`)
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("Link() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLSpecialLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got HTML
|
||||
want HTML
|
||||
}{
|
||||
{name: "mention", got: EscapeHTML("User").Mention(123), want: `<a href="tg://user?id=123">User</a>`},
|
||||
{name: "emoji", got: EscapeHTML("emoji").Emoji(`12"3`), want: `<tg-emoji emoji-id="12"3">emoji</tg-emoji>`},
|
||||
{name: "time", got: EscapeHTML("date").Time(1772323200), want: `<tg-time unix="1772323200">date</tg-time>`},
|
||||
{name: "time format", got: EscapeHTML("date").TimeFormat(1772323200, `MMM " yyyy`), want: `<tg-time unix="1772323200" format="MMM " yyyy">date</tg-time>`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("formatted HTML link = %q, want %q", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package tgfmt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Markdown is an escaped legacy Telegram Markdown fragment.
|
||||
//
|
||||
// Deprecated: Use MarkdownV2 instead.
|
||||
type Markdown string
|
||||
|
||||
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
|
||||
//
|
||||
// Deprecated: Use EscapeMarkdownV2 instead.
|
||||
func EscapeMarkdown(s string) Markdown {
|
||||
s = strings.ReplaceAll(s, "_", `\_`)
|
||||
s = strings.ReplaceAll(s, "*", `\*`)
|
||||
s = strings.ReplaceAll(s, "[", `\[`)
|
||||
return Markdown(strings.ReplaceAll(s, "`", "\\`"))
|
||||
}
|
||||
|
||||
// Bold returns s wrapped as bold legacy Telegram Markdown text.
|
||||
func (s Markdown) Bold() Markdown {
|
||||
return "*" + s + "*"
|
||||
}
|
||||
|
||||
// Italic returns s wrapped as italic legacy Telegram Markdown text.
|
||||
func (s Markdown) Italic() Markdown {
|
||||
return "_" + s + "_"
|
||||
}
|
||||
|
||||
// Link returns s as a legacy Telegram Markdown text link.
|
||||
func (s Markdown) Link(url string) Markdown {
|
||||
return "[" + s + "](" + Markdown(url) + ")"
|
||||
}
|
||||
|
||||
// Mention returns s as a legacy Telegram Markdown user mention.
|
||||
func (s Markdown) Mention(userID int64) Markdown {
|
||||
return "[" + s + "](tg://user?id=" + Markdown(strconv.FormatInt(userID, 10)) + ")"
|
||||
}
|
||||
|
||||
// InlineCode returns s wrapped as inline code legacy Telegram Markdown text.
|
||||
func (s Markdown) InlineCode() Markdown {
|
||||
return "`" + s + "`"
|
||||
}
|
||||
|
||||
// BlockCode returns s wrapped as a legacy Telegram Markdown code block.
|
||||
func (s Markdown) BlockCode() Markdown {
|
||||
return "```\n" + s + "\n```"
|
||||
}
|
||||
|
||||
// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block.
|
||||
func (s Markdown) BlockCodeLanguage(lang string) Markdown {
|
||||
return "```" + Markdown(lang) + "\n" + s + "\n```"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tgfmt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEscapeMarkdown(t *testing.T) {
|
||||
got := EscapeMarkdown("a_b*c[1]`x`")
|
||||
want := Markdown("a\\_b\\*c\\[1]\\`x\\`")
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("EscapeMarkdown() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownFormattingMethods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Markdown
|
||||
want Markdown
|
||||
}{
|
||||
{name: "bold", got: EscapeMarkdown("text").Bold(), want: "*text*"},
|
||||
{name: "italic", got: EscapeMarkdown("text").Italic(), want: "_text_"},
|
||||
{name: "link", got: EscapeMarkdown("Laniakea").Link("https://example.test"), want: "[Laniakea](https://example.test)"},
|
||||
{name: "mention", got: EscapeMarkdown("User").Mention(123), want: "[User](tg://user?id=123)"},
|
||||
{name: "inline code", got: EscapeMarkdown("text").InlineCode(), want: "`text`"},
|
||||
{name: "block code", got: EscapeMarkdown("text").BlockCode(), want: "```\ntext\n```"},
|
||||
{name: "block code language", got: EscapeMarkdown("text").BlockCodeLanguage("go"), want: "```go\ntext\n```"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("formatted Markdown = %q, want %q", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package tgfmt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MarkdownV2 is an escaped Telegram MarkdownV2 fragment.
|
||||
//
|
||||
// Methods on MarkdownV2 compose formatting without escaping the fragment again.
|
||||
type MarkdownV2 string
|
||||
|
||||
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
|
||||
// https://core.telegram.org/bots/api#markdownv2-style
|
||||
func EscapeMarkdownV2(s string) MarkdownV2 {
|
||||
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
|
||||
for _, symbol := range symbols {
|
||||
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
||||
}
|
||||
return MarkdownV2(s)
|
||||
}
|
||||
|
||||
// Bold returns s wrapped as bold Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Bold() MarkdownV2 {
|
||||
return "*" + s + "*"
|
||||
}
|
||||
|
||||
// Italic returns s wrapped as italic Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Italic() MarkdownV2 {
|
||||
return "_" + s + "_"
|
||||
}
|
||||
|
||||
// Underline returns s wrapped as underlined Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Underline() MarkdownV2 {
|
||||
return "__" + s + "__"
|
||||
}
|
||||
|
||||
// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Strikethrough() MarkdownV2 {
|
||||
return "~" + s + "~"
|
||||
}
|
||||
|
||||
// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Spoiler() MarkdownV2 {
|
||||
return "||" + s + "||"
|
||||
}
|
||||
|
||||
// Link returns s as a Telegram MarkdownV2 text link.
|
||||
func (s MarkdownV2) Link(url string) MarkdownV2 {
|
||||
return "[" + s + "](" + escapeMarkdownV2LinkDestination(url) + ")"
|
||||
}
|
||||
|
||||
// Mention returns s as a Telegram MarkdownV2 user mention.
|
||||
func (s MarkdownV2) Mention(userID uint64) MarkdownV2 {
|
||||
return "[" + s + "](tg://user?id=" + MarkdownV2(strconv.FormatUint(userID, 10)) + ")"
|
||||
}
|
||||
|
||||
// Emoji returns s as a Telegram MarkdownV2 custom emoji.
|
||||
func (s MarkdownV2) Emoji(emojiID string) MarkdownV2 {
|
||||
return "[" + s + "](tg://emoji?id=" + escapeMarkdownV2LinkDestination(emojiID) + ")"
|
||||
}
|
||||
|
||||
// Time returns s as a Telegram MarkdownV2 localized timestamp.
|
||||
func (s MarkdownV2) Time(unix uint64) MarkdownV2 {
|
||||
return ") + ")"
|
||||
}
|
||||
|
||||
// TimeFormat returns s as a Telegram MarkdownV2 localized timestamp with format.
|
||||
func (s MarkdownV2) TimeFormat(unix uint64, format string) MarkdownV2 {
|
||||
dest := "tg://time?unix=" + strconv.FormatUint(unix, 10) + "&format=" + format
|
||||
return " + ")"
|
||||
}
|
||||
|
||||
// InlineCode returns s wrapped as inline code Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) InlineCode() MarkdownV2 {
|
||||
return "`" + s + "`"
|
||||
}
|
||||
|
||||
// BlockCode returns s wrapped as a Telegram MarkdownV2 code block.
|
||||
func (s MarkdownV2) BlockCode() MarkdownV2 {
|
||||
return "```\n" + s + "\n```"
|
||||
}
|
||||
|
||||
// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language.
|
||||
func (s MarkdownV2) BlockCodeLanguage(lang string) MarkdownV2 {
|
||||
return "```" + MarkdownV2(lang) + "\n" + s + "\n```"
|
||||
}
|
||||
|
||||
// Quote returns s as a Telegram MarkdownV2 blockquote.
|
||||
func (s MarkdownV2) Quote() MarkdownV2 {
|
||||
return MarkdownV2(">" + strings.ReplaceAll(string(s), "\n", "\n>"))
|
||||
}
|
||||
|
||||
// QuoteExpandable returns s as a Telegram MarkdownV2 expandable blockquote.
|
||||
func (s MarkdownV2) QuoteExpandable() MarkdownV2 {
|
||||
return "**>" + s
|
||||
}
|
||||
|
||||
func escapeMarkdownV2LinkDestination(s string) MarkdownV2 {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, ")", "\\)")
|
||||
return MarkdownV2(s)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tgfmt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEscapeMarkdownV2(t *testing.T) {
|
||||
got := EscapeMarkdownV2(`a_b*c[1](x)!`)
|
||||
want := MarkdownV2(`a\_b\*c\[1\]\(x\)\!`)
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("EscapeMarkdownV2() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownV2ComposesWithoutDoubleEscaping(t *testing.T) {
|
||||
got := EscapeMarkdownV2("a*b").Bold().Italic()
|
||||
want := MarkdownV2(`_*a\*b*_`)
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("formatted text = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownV2FormattingMethods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got MarkdownV2
|
||||
want MarkdownV2
|
||||
}{
|
||||
{name: "bold", got: EscapeMarkdownV2("text").Bold(), want: "*text*"},
|
||||
{name: "italic", got: EscapeMarkdownV2("text").Italic(), want: "_text_"},
|
||||
{name: "underline", got: EscapeMarkdownV2("text").Underline(), want: "__text__"},
|
||||
{name: "strikethrough", got: EscapeMarkdownV2("text").Strikethrough(), want: "~text~"},
|
||||
{name: "spoiler", got: EscapeMarkdownV2("text").Spoiler(), want: "||text||"},
|
||||
{name: "inline code", got: EscapeMarkdownV2("text").InlineCode(), want: "`text`"},
|
||||
{name: "block code", got: EscapeMarkdownV2("text").BlockCode(), want: "```\ntext\n```"},
|
||||
{name: "block code language", got: EscapeMarkdownV2("text").BlockCodeLanguage("go"), want: "```go\ntext\n```"},
|
||||
{name: "quote", got: EscapeMarkdownV2("a\nb").Quote(), want: ">a\n>b"},
|
||||
{name: "expandable quote", got: EscapeMarkdownV2("text").QuoteExpandable(), want: "**>text"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("formatted text = %q, want %q", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownV2LinkEscapesDestination(t *testing.T) {
|
||||
got := EscapeMarkdownV2("Laniakea").Link(`https://example.test/a)b\c`)
|
||||
want := MarkdownV2(`[Laniakea](https://example.test/a\)b\\c)`)
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("Link() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownV2SpecialLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got MarkdownV2
|
||||
want MarkdownV2
|
||||
}{
|
||||
{name: "mention", got: EscapeMarkdownV2("User").Mention(123), want: "[User](tg://user?id=123)"},
|
||||
{name: "emoji", got: EscapeMarkdownV2("emoji").Emoji(`12)3`), want: `[emoji](tg://emoji?id=12\)3)`},
|
||||
{name: "time", got: EscapeMarkdownV2("date").Time(1772323200), want: ""},
|
||||
{name: "time format", got: EscapeMarkdownV2("date").TimeFormat(1772323200, `MMM ) yyyy`), want: ` yyyy)`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("formatted link = %q, want %q", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package tgfmt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// MessageBuilder builds Telegram message text with explicit message entities.
|
||||
// MessageBuilder is not safe for concurrent use.
|
||||
type MessageBuilder struct {
|
||||
str string
|
||||
offset int
|
||||
entities extypes.Slice[tgapi.MessageEntity]
|
||||
|
||||
entries extypes.Slice[*MessageBuilderEntry]
|
||||
isDirty bool
|
||||
}
|
||||
|
||||
// NewMessageBuilder returns an empty MessageBuilder.
|
||||
func NewMessageBuilder() *MessageBuilder {
|
||||
return &MessageBuilder{
|
||||
entities: make([]tgapi.MessageEntity, 0),
|
||||
entries: make(extypes.Slice[*MessageBuilderEntry], 0),
|
||||
isDirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the built message text.
|
||||
func (b *MessageBuilder) String() string {
|
||||
if b.isDirty {
|
||||
b.update()
|
||||
}
|
||||
return b.str
|
||||
}
|
||||
|
||||
// Entities returns a copy of the built message entities.
|
||||
func (b *MessageBuilder) Entities() []tgapi.MessageEntity {
|
||||
if b.isDirty {
|
||||
b.update()
|
||||
}
|
||||
return append([]tgapi.MessageEntity(nil), b.entities...)
|
||||
}
|
||||
|
||||
// Build returns the built message text and a copy of its entities.
|
||||
func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) {
|
||||
if b.isDirty {
|
||||
b.update()
|
||||
}
|
||||
return b.str, append([]tgapi.MessageEntity(nil), b.entities...)
|
||||
}
|
||||
|
||||
// Reset clears the builder and keeps it ready for reuse.
|
||||
func (b *MessageBuilder) Reset() {
|
||||
b.str = ""
|
||||
b.offset = 0
|
||||
b.entities = b.entities[:0]
|
||||
b.entries = b.entries[:0]
|
||||
b.isDirty = false
|
||||
}
|
||||
|
||||
func (b *MessageBuilder) update() *MessageBuilder {
|
||||
b.offset = 0
|
||||
|
||||
var textLen int
|
||||
var entitiesLen int
|
||||
for _, e := range b.entries {
|
||||
textLen += len(e.text)
|
||||
entitiesLen += len(e.entities)
|
||||
}
|
||||
|
||||
b.entities = make(extypes.Slice[tgapi.MessageEntity], 0, entitiesLen)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(textLen)
|
||||
|
||||
for _, e := range b.entries {
|
||||
sb.WriteString(e.text)
|
||||
|
||||
for _, entity := range e.entities {
|
||||
entity.Offset += b.offset
|
||||
b.entities = append(b.entities, entity)
|
||||
}
|
||||
|
||||
b.offset += e.length
|
||||
}
|
||||
|
||||
b.str = sb.String()
|
||||
b.isDirty = false
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MessageBuilder) markDirty() {
|
||||
b.isDirty = true
|
||||
}
|
||||
|
||||
// MessageBuilderEntry represents text appended to a MessageBuilder.
|
||||
type MessageBuilderEntry struct {
|
||||
text string
|
||||
length int
|
||||
|
||||
b *MessageBuilder
|
||||
entities extypes.Slice[tgapi.MessageEntity]
|
||||
}
|
||||
|
||||
// Add appends plain text to the message and returns its entry for formatting.
|
||||
func (b *MessageBuilder) Add(text string) *MessageBuilderEntry {
|
||||
e := &MessageBuilderEntry{
|
||||
b: b,
|
||||
entities: make(extypes.Slice[tgapi.MessageEntity], 0),
|
||||
|
||||
text: text,
|
||||
length: telegramTextLen(text),
|
||||
}
|
||||
b.entries = b.entries.Push(e)
|
||||
b.markDirty()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// Mention marks the entry as a Telegram mention.
|
||||
func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityMention,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Hashtag marks the entry as a Telegram hashtag.
|
||||
func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityHashtag,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Cashtag marks the entry as a Telegram cashtag.
|
||||
func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityCashtag,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// BotCommand marks the entry as a Telegram bot command.
|
||||
func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityBotCommand,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Email marks the entry as an email address.
|
||||
func (e *MessageBuilderEntry) Email() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityEmail,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Phone marks the entry as a phone number.
|
||||
func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityPhoneNumber,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Bold marks the entry as bold text.
|
||||
func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Italic marks the entry as italic text.
|
||||
func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityItalic,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Underline marks the entry as underlined text.
|
||||
func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityUnderline,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Strikethrough marks the entry as strikethrough text.
|
||||
func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityStrike,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Spoiler marks the entry as spoiler text.
|
||||
func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntitySpoiler,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Quote marks the entry as a blockquote.
|
||||
func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityBlockquote,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// ExpandableQuote marks the entry as an expandable blockquote.
|
||||
func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityExpandableBlockquote,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// InlineCode marks the entry as inline code.
|
||||
func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityCode,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// CodeBlock marks the entry as a preformatted code block.
|
||||
func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityPre,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// CodeBlockWithLanguage marks the entry as a preformatted code block with language.
|
||||
func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityPre,
|
||||
Offset: 0, Length: e.length, Language: lang,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Link marks the entry as a text link.
|
||||
func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityTextLink,
|
||||
Offset: 0, Length: e.length, URL: url,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// TextMention marks the entry as a mention of user.
|
||||
func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityTextMention,
|
||||
Offset: 0, Length: e.length, User: user,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// CustomEmoji marks the entry as a custom emoji.
|
||||
func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityCustomEmoji,
|
||||
Offset: 0, Length: e.length, CustomEmojiID: emojiID,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// DateTime marks the entry as a localized timestamp.
|
||||
func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityDateTime,
|
||||
Offset: 0, Length: e.length, UnixTime: time.Unix(),
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// DateTimeFormat marks the entry as a localized timestamp with format.
|
||||
func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityDateTime,
|
||||
Offset: 0, Length: e.length,
|
||||
UnixTime: time.Unix(), DateTimeFormat: format,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func telegramTextLen(text string) int {
|
||||
n := 0
|
||||
for _, r := range text {
|
||||
if r <= 0xFFFF {
|
||||
n++
|
||||
} else {
|
||||
n += 2
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) addEntity(entity tgapi.MessageEntity) {
|
||||
if entity.Length <= 0 {
|
||||
return
|
||||
}
|
||||
e.entities = append(e.entities, entity)
|
||||
if e.b != nil {
|
||||
e.b.markDirty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package tgfmt
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestMessageBuilder_BuildPlainText(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("Hello")
|
||||
b.Add(", ")
|
||||
b.Add("world")
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "Hello, world" {
|
||||
t.Fatalf("text = %q, want %q", text, "Hello, world")
|
||||
}
|
||||
|
||||
if len(entities) != 0 {
|
||||
t.Fatalf("entities len = %d, want 0", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EntityOffsetsAreUTF16(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("Hi ")
|
||||
b.Add("👋") // 2 UTF-16 code units
|
||||
b.Add(" ")
|
||||
b.Add("world").Bold()
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "Hi 👋 world" {
|
||||
t.Fatalf("text = %q, want %q", text, "Hi 👋 world")
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 6, // H i space = 3, 👋 = 2, space = 1
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EntityLengthIsUTF16(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("👋").Bold()
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "👋" {
|
||||
t.Fatalf("text = %q, want %q", text, "👋")
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 2,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_MultipleEntitiesOnSameEntry(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold().Italic()
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
{
|
||||
Type: tgapi.MessageEntityItalic,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_DoesNotDuplicateAfterRepeatedReads(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
text1 := b.String()
|
||||
entities1 := b.Entities()
|
||||
|
||||
text2 := b.String()
|
||||
entities2 := b.Entities()
|
||||
|
||||
if text1 != text2 {
|
||||
t.Fatalf("texts differ: %q != %q", text1, text2)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities1, entities2) {
|
||||
t.Fatalf("entities differ: %#v != %#v", entities1, entities2)
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities2, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities2, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_AddEntityAfterStringMarksDirty(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
entry := b.Add("hello")
|
||||
|
||||
if got := b.String(); got != "hello" {
|
||||
t.Fatalf("String() = %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
entry.Bold()
|
||||
|
||||
entities := b.Entities()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EntitiesReturnsCopy(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
entities1 := b.Entities()
|
||||
entities1[0].Offset = 999
|
||||
|
||||
entities2 := b.Entities()
|
||||
|
||||
if entities2[0].Offset != 0 {
|
||||
t.Fatalf("Entities() did not return copy: offset = %d, want 0", entities2[0].Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_BuildReturnsEntitiesCopy(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
_, entities1 := b.Build()
|
||||
entities1[0].Offset = 999
|
||||
|
||||
_, entities2 := b.Build()
|
||||
|
||||
if entities2[0].Offset != 0 {
|
||||
t.Fatalf("Build() did not return entities copy: offset = %d, want 0", entities2[0].Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_Reset(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
if got := b.String(); got != "hello" {
|
||||
t.Fatalf("String() before Reset = %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "" {
|
||||
t.Fatalf("text after Reset = %q, want empty", text)
|
||||
}
|
||||
|
||||
if len(entities) != 0 {
|
||||
t.Fatalf("entities len after Reset = %d, want 0", len(entities))
|
||||
}
|
||||
|
||||
b.Add("world").Italic()
|
||||
|
||||
text, entities = b.Build()
|
||||
|
||||
if text != "world" {
|
||||
t.Fatalf("text after reuse = %q, want %q", text, "world")
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityItalic,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities after reuse = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EmptyEntryDoesNotCreateEntity(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("").Bold()
|
||||
b.Add("x")
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "x" {
|
||||
t.Fatalf("text = %q, want %q", text, "x")
|
||||
}
|
||||
|
||||
if len(entities) != 0 {
|
||||
t.Fatalf("entities len = %d, want 0: %#v", len(entities), entities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_Link(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("OpenAI").Link("https://openai.com")
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityTextLink,
|
||||
Offset: 0,
|
||||
Length: 6,
|
||||
URL: "https://openai.com",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_CodeBlockWithLanguage(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("fmt.Println(\"hi\")").CodeBlockWithLanguage("go")
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityPre,
|
||||
Offset: 0,
|
||||
Length: 17,
|
||||
Language: "go",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_DateTimeFormat(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
ts := time.Unix(1772323200, 0)
|
||||
|
||||
b.Add("date").DateTimeFormat(ts, "MMMM d, yyyy")
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityDateTime,
|
||||
Offset: 0,
|
||||
Length: 4,
|
||||
UnixTime: 1772323200,
|
||||
DateTimeFormat: "MMMM d, yyyy",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramTextLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "ascii",
|
||||
text: "hello",
|
||||
want: 5,
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
text: "привет",
|
||||
want: 6,
|
||||
},
|
||||
{
|
||||
name: "emoji",
|
||||
text: "👋",
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
text: "a👋b",
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "zwj sequence",
|
||||
text: "👨👩👧👦",
|
||||
want: 11,
|
||||
},
|
||||
{
|
||||
name: "flag",
|
||||
text: "🇫🇮",
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "variation selector",
|
||||
text: "❤️",
|
||||
want: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := telegramTextLen(tt.text)
|
||||
if got != tt.want {
|
||||
t.Fatalf("telegramTextLen(%q) = %d, want %d", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_SimpleEntityTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
add func(*MessageBuilderEntry)
|
||||
want tgapi.MessageEntityType
|
||||
}{
|
||||
{"mention", func(e *MessageBuilderEntry) { e.Mention() }, tgapi.MessageEntityMention},
|
||||
{"hashtag", func(e *MessageBuilderEntry) { e.Hashtag() }, tgapi.MessageEntityHashtag},
|
||||
{"cashtag", func(e *MessageBuilderEntry) { e.Cashtag() }, tgapi.MessageEntityCashtag},
|
||||
{"bot command", func(e *MessageBuilderEntry) { e.BotCommand() }, tgapi.MessageEntityBotCommand},
|
||||
{"email", func(e *MessageBuilderEntry) { e.Email() }, tgapi.MessageEntityEmail},
|
||||
{"phone", func(e *MessageBuilderEntry) { e.Phone() }, tgapi.MessageEntityPhoneNumber},
|
||||
{"bold", func(e *MessageBuilderEntry) { e.Bold() }, tgapi.MessageEntityBold},
|
||||
{"italic", func(e *MessageBuilderEntry) { e.Italic() }, tgapi.MessageEntityItalic},
|
||||
{"underline", func(e *MessageBuilderEntry) { e.Underline() }, tgapi.MessageEntityUnderline},
|
||||
{"strikethrough", func(e *MessageBuilderEntry) { e.Strikethrough() }, tgapi.MessageEntityStrike},
|
||||
{"spoiler", func(e *MessageBuilderEntry) { e.Spoiler() }, tgapi.MessageEntitySpoiler},
|
||||
{"quote", func(e *MessageBuilderEntry) { e.Quote() }, tgapi.MessageEntityBlockquote},
|
||||
{"expandable quote", func(e *MessageBuilderEntry) { e.ExpandableQuote() }, tgapi.MessageEntityExpandableBlockquote},
|
||||
{"inline code", func(e *MessageBuilderEntry) { e.InlineCode() }, tgapi.MessageEntityCode},
|
||||
{"code block", func(e *MessageBuilderEntry) { e.CodeBlock() }, tgapi.MessageEntityPre},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
e := b.Add("hello")
|
||||
tt.add(e)
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tt.want,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package tgfmt
|
||||
|
||||
import "strings"
|
||||
|
||||
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
|
||||
func EscapePunctuation(s string) string {
|
||||
symbols := []string{".", "!", "-"}
|
||||
for _, symbol := range symbols {
|
||||
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user