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 ") + ")"
+}
+
+// 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)
+}
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: ""},
+ {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)
+ }
+ })
+ }
+}
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 " + ")"
-}
-func WithTimeFormat(text string, unix uint64, format string) string {
- return " +
- "&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