(new): tgfmt package
Golang lint / lint (pull_request) Successful in 7m10s
Golang lint / lint (push) Successful in 7m13s

(fix): formatting helpers
(ci/cd): go checks
(tests): tgfmt coverage
This commit is contained in:
2026-05-04 11:20:09 +03:00
parent 6595265cb3
commit 7205b21fa2
18 changed files with 532 additions and 155 deletions
+11 -2
View File
@@ -1,6 +1,6 @@
name: Golang lint name: Golang lint
run-name: Linting code run-name: Linting code
on: [push] on: [push, pull_request]
jobs: jobs:
lint: lint:
@@ -10,10 +10,19 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Verify formatting - name: Verify formatting
run: test -z "$(gofmt -l .)" run: |
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "These files are not gofmt-formatted:"
echo "$files"
exit 1
fi
- name: Run go test - name: Run go test
run: go test ./... run: go test ./...
- name: Run go vet
run: go vet ./...
- name: Run golangci-lint - name: Run golangci-lint
run: golangci-lint run run: golangci-lint run
-1
View File
@@ -5,7 +5,6 @@ linters:
disable-all: true disable-all: true
enable: enable:
- errcheck - errcheck
- govet
- ineffassign - ineffassign
- staticcheck - staticcheck
- unused - unused
+3 -1
View File
@@ -13,12 +13,13 @@
- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. - Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code.
- Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow.
- Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware.
- Added the `tgmd` package with Telegram Markdown formatting helpers and a message entity builder. - Added the `tgfmt` package with typed MarkdownV2, HTML, legacy Markdown formatting helpers, and a message entity builder.
### Changed ### Changed
- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`.
- Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced.
- `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently. - `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently.
- CI now checks formatting, tests, vet, and lint on both pushes and pull requests.
### Fixed ### Fixed
- Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized.
@@ -29,6 +30,7 @@
- Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. - Added webhook runtime regression coverage for request enqueue through worker execution of a command handler.
- Added regression coverage for inline callback keyboard upserts and callback target detection. - Added regression coverage for inline callback keyboard upserts and callback target detection.
- Added regression coverage for command group prefixing, middleware order, clone behavior, and plugin registration. - Added regression coverage for command group prefixing, middleware order, clone behavior, and plugin registration.
- Added formatting coverage for escaping, composition, link destinations, HTML attributes, and legacy Markdown code blocks.
## v1.0.0-rc.16 ## v1.0.0-rc.16
+11 -11
View File
@@ -139,7 +139,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage {
// EditMarkdown replaces the text of the message using MarkdownV2 formatting. // EditMarkdown replaces the text of the message using MarkdownV2 formatting.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
// Unescaped input may cause Telegram API errors or broken formatting. // Unescaped input may cause Telegram API errors or broken formatting.
func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2) return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
@@ -161,7 +161,7 @@ func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *Answ
// EditCallbackMarkdown edits the callback message using MarkdownV2. // EditCallbackMarkdown edits the callback message using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2) return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2)
} }
@@ -173,7 +173,7 @@ func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, ar
// EditCallbackfMarkdown formats a string using fmt.Sprintf and edits the callback message with MarkdownV2. // EditCallbackfMarkdown formats a string using fmt.Sprintf and edits the callback message with MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage {
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2)
} }
@@ -223,7 +223,7 @@ func (m *AnswerMessage) EditCaption(text string) *AnswerMessage {
// EditCaptionMarkdown edits the caption of a media message using MarkdownV2. // EditCaptionMarkdown edits the caption of a media message using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage { func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage {
return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMarkdownV2) return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
} }
@@ -235,7 +235,7 @@ func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *An
// EditCaptionKeyboardMarkdown edits the caption of a media message with a new inline keyboard using MarkdownV2. // EditCaptionKeyboardMarkdown edits the caption of a media message with a new inline keyboard using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage { func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage {
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2) return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2)
} }
@@ -290,7 +290,7 @@ func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
// AnswerMarkdown sends a message using MarkdownV2 formatting. // AnswerMarkdown sends a message using MarkdownV2 formatting.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage {
return ctx.answer(text, nil, tgapi.ParseMarkdownV2) return ctx.answer(text, nil, tgapi.ParseMarkdownV2)
} }
@@ -307,7 +307,7 @@ func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessag
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2. // AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage {
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
} }
@@ -326,7 +326,7 @@ func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMe
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2. // KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2) return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2)
} }
@@ -413,7 +413,7 @@ func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage {
// AnswerPhotoMarkdown sends a photo with MarkdownV2 caption. // AnswerPhotoMarkdown sends a photo with MarkdownV2 caption.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage {
return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2) return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2)
} }
@@ -425,7 +425,7 @@ func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeybo
// AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2. // AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage {
return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2) return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2)
} }
@@ -437,7 +437,7 @@ func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *Answ
// AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2. // AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage {
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
} }
+2
View File
@@ -0,0 +1,2 @@
// Package tgfmt provides small helpers for Telegram text formatting.
package tgfmt
+99
View File
@@ -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, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
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)
}
+78
View File
@@ -0,0 +1,78 @@
package tgfmt
import "testing"
func TestEscapeHTML(t *testing.T) {
got := EscapeHTML(`<tag attr="a&b">`)
want := HTML(`&lt;tag attr=&quot;a&amp;b&quot;&gt;`)
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>&lt;b&gt;</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&quot;">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=&quot;a&amp;b&quot;">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&quot;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 &quot; 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
View File
@@ -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```"
}
+36
View File
@@ -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
View File
@@ -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 "![" + s + "](tg://time?unix=" + MarkdownV2(strconv.FormatUint(unix, 10)) + ")"
}
// 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 "![" + s + "](" + escapeMarkdownV2LinkDestination(dest) + ")"
}
// 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)
}
+78
View File
@@ -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: "![date](tg://time?unix=1772323200)"},
{name: "time format", got: EscapeMarkdownV2("date").TimeFormat(1772323200, `MMM ) yyyy`), want: `![date](tg://time?unix=1772323200&format=MMM \) 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)
}
})
}
}
@@ -1,4 +1,4 @@
package tgmd package tgfmt
import ( import (
"strings" "strings"
@@ -8,8 +8,6 @@ import (
"git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
//TODO GoDoc, tests. Maybe escape Markdown v2
// MessageBuilder builds Telegram message text with explicit message entities. // MessageBuilder builds Telegram message text with explicit message entities.
// MessageBuilder is not safe for concurrent use. // MessageBuilder is not safe for concurrent use.
type MessageBuilder struct { type MessageBuilder struct {
@@ -46,6 +44,7 @@ func (b *MessageBuilder) Entities() []tgapi.MessageEntity {
return append([]tgapi.MessageEntity(nil), b.entities...) 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) { func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) {
if b.isDirty { if b.isDirty {
b.update() b.update()
@@ -53,6 +52,7 @@ func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) {
return b.str, append([]tgapi.MessageEntity(nil), b.entities...) return b.str, append([]tgapi.MessageEntity(nil), b.entities...)
} }
// Reset clears the builder and keeps it ready for reuse.
func (b *MessageBuilder) Reset() { func (b *MessageBuilder) Reset() {
b.str = "" b.str = ""
b.offset = 0 b.offset = 0
@@ -67,7 +67,7 @@ func (b *MessageBuilder) update() *MessageBuilder {
var textLen int var textLen int
var entitiesLen int var entitiesLen int
for _, e := range b.entries { for _, e := range b.entries {
textLen += len(e.text) // bytes, для Grow нормально textLen += len(e.text)
entitiesLen += len(e.entities) entitiesLen += len(e.entities)
} }
@@ -91,10 +91,12 @@ func (b *MessageBuilder) update() *MessageBuilder {
b.isDirty = false b.isDirty = false
return b return b
} }
func (b *MessageBuilder) markDirty() { func (b *MessageBuilder) markDirty() {
b.isDirty = true b.isDirty = true
} }
// MessageBuilderEntry represents text appended to a MessageBuilder.
type MessageBuilderEntry struct { type MessageBuilderEntry struct {
text string text string
length int length int
@@ -118,6 +120,7 @@ func (b *MessageBuilder) Add(text string) *MessageBuilderEntry {
return e return e
} }
// Mention marks the entry as a Telegram mention.
func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry { func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityMention, Type: tgapi.MessageEntityMention,
@@ -126,6 +129,7 @@ func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry {
return e return e
} }
// Hashtag marks the entry as a Telegram hashtag.
func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry { func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityHashtag, Type: tgapi.MessageEntityHashtag,
@@ -134,6 +138,7 @@ func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry {
return e return e
} }
// Cashtag marks the entry as a Telegram cashtag.
func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry { func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityCashtag, Type: tgapi.MessageEntityCashtag,
@@ -142,6 +147,7 @@ func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry {
return e return e
} }
// BotCommand marks the entry as a Telegram bot command.
func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry { func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityBotCommand, Type: tgapi.MessageEntityBotCommand,
@@ -150,6 +156,7 @@ func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry {
return e return e
} }
// Email marks the entry as an email address.
func (e *MessageBuilderEntry) Email() *MessageBuilderEntry { func (e *MessageBuilderEntry) Email() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityEmail, Type: tgapi.MessageEntityEmail,
@@ -158,6 +165,7 @@ func (e *MessageBuilderEntry) Email() *MessageBuilderEntry {
return e return e
} }
// Phone marks the entry as a phone number.
func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry { func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityPhoneNumber, Type: tgapi.MessageEntityPhoneNumber,
@@ -165,6 +173,8 @@ func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry {
}) })
return e return e
} }
// Bold marks the entry as bold text.
func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry { func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityBold, Type: tgapi.MessageEntityBold,
@@ -172,6 +182,8 @@ func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry {
}) })
return e return e
} }
// Italic marks the entry as italic text.
func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry { func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityItalic, Type: tgapi.MessageEntityItalic,
@@ -179,6 +191,8 @@ func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry {
}) })
return e return e
} }
// Underline marks the entry as underlined text.
func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry { func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityUnderline, Type: tgapi.MessageEntityUnderline,
@@ -186,6 +200,8 @@ func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry {
}) })
return e return e
} }
// Strikethrough marks the entry as strikethrough text.
func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry { func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityStrike, Type: tgapi.MessageEntityStrike,
@@ -193,6 +209,8 @@ func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry {
}) })
return e return e
} }
// Spoiler marks the entry as spoiler text.
func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry { func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntitySpoiler, Type: tgapi.MessageEntitySpoiler,
@@ -200,6 +218,8 @@ func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry {
}) })
return e return e
} }
// Quote marks the entry as a blockquote.
func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry { func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityBlockquote, Type: tgapi.MessageEntityBlockquote,
@@ -207,6 +227,8 @@ func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry {
}) })
return e return e
} }
// ExpandableQuote marks the entry as an expandable blockquote.
func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry { func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityExpandableBlockquote, Type: tgapi.MessageEntityExpandableBlockquote,
@@ -214,6 +236,8 @@ func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry {
}) })
return e return e
} }
// InlineCode marks the entry as inline code.
func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry { func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityCode, Type: tgapi.MessageEntityCode,
@@ -221,6 +245,8 @@ func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry {
}) })
return e return e
} }
// CodeBlock marks the entry as a preformatted code block.
func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry { func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityPre, Type: tgapi.MessageEntityPre,
@@ -228,6 +254,8 @@ func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry {
}) })
return e return e
} }
// CodeBlockWithLanguage marks the entry as a preformatted code block with language.
func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry { func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityPre, Type: tgapi.MessageEntityPre,
@@ -235,6 +263,8 @@ func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilder
}) })
return e return e
} }
// Link marks the entry as a text link.
func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry { func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityTextLink, Type: tgapi.MessageEntityTextLink,
@@ -242,6 +272,8 @@ func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry {
}) })
return e return e
} }
// TextMention marks the entry as a mention of user.
func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry { func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityTextMention, Type: tgapi.MessageEntityTextMention,
@@ -249,6 +281,8 @@ func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry
}) })
return e return e
} }
// CustomEmoji marks the entry as a custom emoji.
func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry { func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityCustomEmoji, Type: tgapi.MessageEntityCustomEmoji,
@@ -256,6 +290,8 @@ func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry {
}) })
return e return e
} }
// DateTime marks the entry as a localized timestamp.
func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry { func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityDateTime, Type: tgapi.MessageEntityDateTime,
@@ -263,6 +299,8 @@ func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry {
}) })
return e return e
} }
// DateTimeFormat marks the entry as a localized timestamp with format.
func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry { func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{ e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityDateTime, Type: tgapi.MessageEntityDateTime,
@@ -1,4 +1,4 @@
package tgmd package tgfmt
import ( import (
"reflect" "reflect"
+12
View File
@@ -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
}
-2
View File
@@ -1,2 +0,0 @@
// Package tgmd provides small helpers for Telegram Markdown text.
package tgmd
-71
View File
@@ -1,71 +0,0 @@
package tgmd
import (
"strconv"
"strings"
"git.scuroneko.dev/scuroneko/laniakea"
)
// Helpers in this file generate Telegram MarkdownV2.
// All user-provided text is escaped.
// TODO Markdown v2 escaping. GoDoc and tests
// WithBold returns s wrapped as bold Telegram Markdown text.
func WithBold(s string) string {
return "*" + s + "*"
}
// WithItalic returns s wrapped as italic Telegram Markdown text.
func WithItalic(s string) string {
return "_" + s + "_"
}
func WithUnderline(s string) string {
return "__" + s + "__"
}
func WithStrikethrough(s string) string {
return "~" + s + "~"
}
func WithSpoiler(s string) string {
return "||" + s + "||"
}
// WithLink returns a Telegram Markdown link for text and URL.
func WithLink(text, url string) string {
return "[" + text + "](" + url + ")"
}
func WithMention(text string, userID uint64) string {
return "[" + text + "](tg://user?id=" + strconv.FormatUint(userID, 10) + ")"
}
func WithEmoji(text, emojiID string) string {
return "[" + text + "](tg://emoji?id=" + emojiID + ")"
}
func WithTime(text string, unix uint64) string {
return "![" + text + "](tg://time?unix=" + strconv.FormatUint(unix, 10) + ")"
}
func WithTimeFormat(text string, unix uint64, format string) string {
return "![" + text + "](tg://time?unix=" +
strconv.FormatUint(unix, 10) +
"&format=" + format + ")"
}
// WithInlineCode returns s wrapped as inline code Telegram Markdown text.
func WithInlineCode(s string) string {
return "`" + s + "`"
}
func WithBlockCode(s string) string {
return "```\n" + s + "\n```"
}
func WithBlockCodeLanguage(s, lang string) string {
return "```" + lang + "\n" + s + "\n```"
}
func WithQuote(s string) string {
return ">" + strings.ReplaceAll(laniakea.EscapeMarkdownV2(s), "\n", "\n>")
}
func WithQuoteExpandable(s string) string {
return "**>" + s
}
-24
View File
@@ -1,24 +0,0 @@
package tgmd
import "testing"
func TestFormattingHelpers(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{name: "bold", got: WithBold("text"), want: "*text*"},
{name: "italic", got: WithItalic("text"), want: "_text_"},
{name: "inline code", got: WithInlineCode("text"), want: "`text`"},
{name: "link", got: WithLink("Laniakea", "https://example.test"), want: "[Laniakea](https://example.test)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.got != tc.want {
t.Fatalf("unexpected formatted text: got %q want %q", tc.got, tc.want)
}
})
}
}
-38
View File
@@ -1,8 +1,6 @@
package laniakea package laniakea
import ( import (
"strings"
"git.scuroneko.dev/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
@@ -17,42 +15,6 @@ func Val[T any](p *T, def T) T {
return def return def
} }
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
// Deprecated: Use EscapeMarkdownV2.
func EscapeMarkdown(s string) string {
s = strings.ReplaceAll(s, "_", `\_`)
s = strings.ReplaceAll(s, "*", `\*`)
s = strings.ReplaceAll(s, "[", `\[`)
return strings.ReplaceAll(s, "`", "\\`")
}
// EscapeHTML escapes special characters for Telegram HTML parse mode.
func EscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
// https://core.telegram.org/bots/api#markdownv2-style
func EscapeMarkdownV2(s string) string {
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
for _, symbol := range symbols {
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
}
return s
}
// 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
}
const ( const (
// VersionString re-exports the module version string. // VersionString re-exports the module version string.
VersionString = utils.VersionString VersionString = utils.VersionString