From 7205b21fa2de14af6ac609e7f22165ce3c2ac8d5 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 4 May 2026 11:20:09 +0300 Subject: [PATCH] (new): tgfmt package (fix): formatting helpers (ci/cd): go checks (tests): tgfmt coverage --- .gitea/workflows/go-lint.yaml | 13 ++- .golangci.yml | 1 - CHANGELOG.md | 4 +- msg_context.go | 22 ++--- tgfmt/doc.go | 2 + tgfmt/html.go | 99 +++++++++++++++++++++++ tgfmt/html_test.go | 78 ++++++++++++++++++ tgfmt/md.go | 56 +++++++++++++ tgfmt/md_test.go | 36 +++++++++ tgfmt/mdv2.go | 103 ++++++++++++++++++++++++ tgfmt/mdv2_test.go | 78 ++++++++++++++++++ {tgmd => tgfmt}/message_builder.go | 46 ++++++++++- {tgmd => tgfmt}/message_builder_test.go | 2 +- tgfmt/utils.go | 12 +++ tgmd/doc.go | 2 - tgmd/utils.go | 71 ---------------- tgmd/utils_test.go | 24 ------ utils.go | 38 --------- 18 files changed, 532 insertions(+), 155 deletions(-) create mode 100644 tgfmt/doc.go create mode 100644 tgfmt/html.go create mode 100644 tgfmt/html_test.go create mode 100644 tgfmt/md.go create mode 100644 tgfmt/md_test.go create mode 100644 tgfmt/mdv2.go create mode 100644 tgfmt/mdv2_test.go rename {tgmd => tgfmt}/message_builder.go (83%) rename {tgmd => tgfmt}/message_builder_test.go (99%) create mode 100644 tgfmt/utils.go delete mode 100644 tgmd/doc.go delete mode 100644 tgmd/utils.go delete mode 100644 tgmd/utils_test.go diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml index 3ebe461..fafc64e 100644 --- a/.gitea/workflows/go-lint.yaml +++ b/.gitea/workflows/go-lint.yaml @@ -1,6 +1,6 @@ name: Golang lint run-name: Linting code -on: [push] +on: [push, pull_request] jobs: lint: @@ -10,10 +10,19 @@ jobs: uses: actions/checkout@v6 - 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 run: go test ./... + - name: Run go vet + run: go vet ./... + - name: Run golangci-lint run: golangci-lint run diff --git a/.golangci.yml b/.golangci.yml index a7dcbc3..7ea3993 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,7 +5,6 @@ linters: disable-all: true enable: - errcheck - - govet - ineffassign - staticcheck - unused diff --git a/CHANGELOG.md b/CHANGELOG.md index 82879cf..4a49e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,13 @@ - 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 `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 - 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. - `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 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 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 formatting coverage for escaping, composition, link destinations, HTML attributes, and legacy Markdown code blocks. ## v1.0.0-rc.16 diff --git a/msg_context.go b/msg_context.go index 44862fe..cdbf42a 100644 --- a/msg_context.go +++ b/msg_context.go @@ -139,7 +139,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage { // 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. func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { 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. // -// ⚠️ 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 { return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } diff --git a/tgfmt/doc.go b/tgfmt/doc.go new file mode 100644 index 0000000..e2a44c0 --- /dev/null +++ b/tgfmt/doc.go @@ -0,0 +1,2 @@ +// Package tgfmt provides small helpers for Telegram text formatting. +package tgfmt diff --git a/tgfmt/html.go b/tgfmt/html.go new file mode 100644 index 0000000..3460195 --- /dev/null +++ b/tgfmt/html.go @@ -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 "" + h + "" +} + +// Italic returns h wrapped as italic Telegram HTML text. +func (h HTML) Italic() HTML { + return "" + h + "" +} + +// Underline returns h wrapped as underlined Telegram HTML text. +func (h HTML) Underline() HTML { + return "" + h + "" +} + +// Strikethrough returns h wrapped as strikethrough Telegram HTML text. +func (h HTML) Strikethrough() HTML { + return "" + h + "" +} + +// Spoiler returns h wrapped as spoiler Telegram HTML text. +func (h HTML) Spoiler() HTML { + return "" + h + "" +} + +// Link returns h as a Telegram HTML text link. +func (h HTML) Link(url string) HTML { + return `` + h + "" +} + +// Mention returns h as a Telegram HTML user mention. +func (h HTML) Mention(userID int64) HTML { + return `` + h + "" +} + +// Emoji returns h as a Telegram HTML custom emoji. +func (h HTML) Emoji(emojiID string) HTML { + return `` + h + "" +} + +// Time returns h as a Telegram HTML localized timestamp. +func (h HTML) Time(unix int64) HTML { + return `` + h + "" +} + +// TimeFormat returns h as a Telegram HTML localized timestamp with format. +func (h HTML) TimeFormat(unix int64, format string) HTML { + return `` + h + "" +} + +// InlineCode returns h wrapped as inline code Telegram HTML text. +func (h HTML) InlineCode() HTML { + return "" + h + "" +} + +// BlockCode returns h wrapped as a Telegram HTML code block. +func (h HTML) BlockCode() HTML { + return "
" + h + "
" +} + +// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language. +func (h HTML) BlockCodeLanguage(lang string) HTML { + return `
` + h + "
" +} + +// Quote returns h as a Telegram HTML blockquote. +func (h HTML) Quote() HTML { + return "
" + h + "
" +} + +// QuoteExpandable returns h as a Telegram HTML expandable blockquote. +func (h HTML) QuoteExpandable() HTML { + return "
" + h + "
" +} + +func escapeHTMLAttr(s string) HTML { + return EscapeHTML(s) +} diff --git a/tgfmt/html_test.go b/tgfmt/html_test.go new file mode 100644 index 0000000..338bc37 --- /dev/null +++ b/tgfmt/html_test.go @@ -0,0 +1,78 @@ +package tgfmt + +import "testing" + +func TestEscapeHTML(t *testing.T) { + got := EscapeHTML(``) + want := HTML(`<tag attr="a&b">`) + + if got != want { + t.Fatalf("EscapeHTML() = %q, want %q", got, want) + } +} + +func TestHTMLComposesWithoutDoubleEscaping(t *testing.T) { + got := EscapeHTML("").Bold().Italic() + want := HTML("<b>") + + 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: "text"}, + {name: "italic", got: EscapeHTML("text").Italic(), want: "text"}, + {name: "underline", got: EscapeHTML("text").Underline(), want: "text"}, + {name: "strikethrough", got: EscapeHTML("text").Strikethrough(), want: "text"}, + {name: "spoiler", got: EscapeHTML("text").Spoiler(), want: "text"}, + {name: "inline code", got: EscapeHTML("text").InlineCode(), want: "text"}, + {name: "block code", got: EscapeHTML("text").BlockCode(), want: "
text
"}, + {name: "block code language", got: EscapeHTML("text").BlockCodeLanguage(`go"`), want: `
text
`}, + {name: "quote", got: EscapeHTML("text").Quote(), want: "
text
"}, + {name: "expandable quote", got: EscapeHTML("text").QuoteExpandable(), want: "
text
"}, + } + + 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(`Laniakea`) + + 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: `User`}, + {name: "emoji", got: EscapeHTML("emoji").Emoji(`12"3`), want: `emoji`}, + {name: "time", got: EscapeHTML("date").Time(1772323200), want: `date`}, + {name: "time format", got: EscapeHTML("date").TimeFormat(1772323200, `MMM " yyyy`), want: `date`}, + } + + 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) + } + }) + } +} diff --git a/tgfmt/md.go b/tgfmt/md.go new file mode 100644 index 0000000..3269d49 --- /dev/null +++ b/tgfmt/md.go @@ -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```" +} diff --git a/tgfmt/md_test.go b/tgfmt/md_test.go new file mode 100644 index 0000000..beea1da --- /dev/null +++ b/tgfmt/md_test.go @@ -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) + } + }) + } +} diff --git a/tgfmt/mdv2.go b/tgfmt/mdv2.go new file mode 100644 index 0000000..2274a89 --- /dev/null +++ b/tgfmt/mdv2.go @@ -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) +} diff --git a/tgfmt/mdv2_test.go b/tgfmt/mdv2_test.go new file mode 100644 index 0000000..ef3de7d --- /dev/null +++ b/tgfmt/mdv2_test.go @@ -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) + } + }) + } +} diff --git a/tgmd/message_builder.go b/tgfmt/message_builder.go similarity index 83% rename from tgmd/message_builder.go rename to tgfmt/message_builder.go index 3e86bac..03b771d 100644 --- a/tgmd/message_builder.go +++ b/tgfmt/message_builder.go @@ -1,4 +1,4 @@ -package tgmd +package tgfmt import ( "strings" @@ -8,8 +8,6 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -//TODO GoDoc, tests. Maybe escape Markdown v2 - // MessageBuilder builds Telegram message text with explicit message entities. // MessageBuilder is not safe for concurrent use. type MessageBuilder struct { @@ -46,6 +44,7 @@ func (b *MessageBuilder) Entities() []tgapi.MessageEntity { 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() @@ -53,6 +52,7 @@ func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) { 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 @@ -67,7 +67,7 @@ func (b *MessageBuilder) update() *MessageBuilder { var textLen int var entitiesLen int for _, e := range b.entries { - textLen += len(e.text) // bytes, для Grow нормально + textLen += len(e.text) entitiesLen += len(e.entities) } @@ -91,10 +91,12 @@ func (b *MessageBuilder) update() *MessageBuilder { 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 @@ -118,6 +120,7 @@ func (b *MessageBuilder) Add(text string) *MessageBuilderEntry { return e } +// Mention marks the entry as a Telegram mention. func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityMention, @@ -126,6 +129,7 @@ func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry { return e } +// Hashtag marks the entry as a Telegram hashtag. func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityHashtag, @@ -134,6 +138,7 @@ func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry { return e } +// Cashtag marks the entry as a Telegram cashtag. func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityCashtag, @@ -142,6 +147,7 @@ func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry { return e } +// BotCommand marks the entry as a Telegram bot command. func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityBotCommand, @@ -150,6 +156,7 @@ func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry { return e } +// Email marks the entry as an email address. func (e *MessageBuilderEntry) Email() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityEmail, @@ -158,6 +165,7 @@ func (e *MessageBuilderEntry) Email() *MessageBuilderEntry { return e } +// Phone marks the entry as a phone number. func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityPhoneNumber, @@ -165,6 +173,8 @@ func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry { }) return e } + +// Bold marks the entry as bold text. func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityBold, @@ -172,6 +182,8 @@ func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry { }) return e } + +// Italic marks the entry as italic text. func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityItalic, @@ -179,6 +191,8 @@ func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry { }) return e } + +// Underline marks the entry as underlined text. func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityUnderline, @@ -186,6 +200,8 @@ func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry { }) return e } + +// Strikethrough marks the entry as strikethrough text. func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityStrike, @@ -193,6 +209,8 @@ func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry { }) return e } + +// Spoiler marks the entry as spoiler text. func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntitySpoiler, @@ -200,6 +218,8 @@ func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry { }) return e } + +// Quote marks the entry as a blockquote. func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityBlockquote, @@ -207,6 +227,8 @@ func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry { }) return e } + +// ExpandableQuote marks the entry as an expandable blockquote. func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityExpandableBlockquote, @@ -214,6 +236,8 @@ func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry { }) return e } + +// InlineCode marks the entry as inline code. func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityCode, @@ -221,6 +245,8 @@ func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry { }) return e } + +// CodeBlock marks the entry as a preformatted code block. func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityPre, @@ -228,6 +254,8 @@ func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry { }) 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, @@ -235,6 +263,8 @@ func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilder }) return e } + +// Link marks the entry as a text link. func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityTextLink, @@ -242,6 +272,8 @@ func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry { }) 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, @@ -249,6 +281,8 @@ func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry }) return e } + +// CustomEmoji marks the entry as a custom emoji. func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityCustomEmoji, @@ -256,6 +290,8 @@ func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry { }) 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, @@ -263,6 +299,8 @@ func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry { }) 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, diff --git a/tgmd/message_builder_test.go b/tgfmt/message_builder_test.go similarity index 99% rename from tgmd/message_builder_test.go rename to tgfmt/message_builder_test.go index d4f0d69..2dcad85 100644 --- a/tgmd/message_builder_test.go +++ b/tgfmt/message_builder_test.go @@ -1,4 +1,4 @@ -package tgmd +package tgfmt import ( "reflect" diff --git a/tgfmt/utils.go b/tgfmt/utils.go new file mode 100644 index 0000000..47d47eb --- /dev/null +++ b/tgfmt/utils.go @@ -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 +} diff --git a/tgmd/doc.go b/tgmd/doc.go deleted file mode 100644 index f480af2..0000000 --- a/tgmd/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package tgmd provides small helpers for Telegram Markdown text. -package tgmd diff --git a/tgmd/utils.go b/tgmd/utils.go deleted file mode 100644 index 4de1911..0000000 --- a/tgmd/utils.go +++ /dev/null @@ -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 -} diff --git a/tgmd/utils_test.go b/tgmd/utils_test.go deleted file mode 100644 index 8b6ec1b..0000000 --- a/tgmd/utils_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/utils.go b/utils.go index 0cb42ac..3de6014 100644 --- a/utils.go +++ b/utils.go @@ -1,8 +1,6 @@ package laniakea import ( - "strings" - "git.scuroneko.dev/scuroneko/laniakea/utils" ) @@ -17,42 +15,6 @@ func Val[T any](p *T, def T) T { 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, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - 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 ( // VersionString re-exports the module version string. VersionString = utils.VersionString