REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ linters:
|
|||||||
disable-all: true
|
disable-all: true
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- govet
|
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- unused
|
||||||
|
|||||||
+3
-1
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package tgfmt provides small helpers for Telegram text formatting.
|
||||||
|
package tgfmt
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTML is an escaped Telegram HTML fragment.
|
||||||
|
//
|
||||||
|
// Methods on HTML compose formatting without escaping the fragment again.
|
||||||
|
type HTML string
|
||||||
|
|
||||||
|
// EscapeHTML escapes special characters for Telegram HTML parse mode.
|
||||||
|
func EscapeHTML(s string) HTML {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
return HTML(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold returns h wrapped as bold Telegram HTML text.
|
||||||
|
func (h HTML) Bold() HTML {
|
||||||
|
return "<b>" + h + "</b>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Italic returns h wrapped as italic Telegram HTML text.
|
||||||
|
func (h HTML) Italic() HTML {
|
||||||
|
return "<i>" + h + "</i>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Underline returns h wrapped as underlined Telegram HTML text.
|
||||||
|
func (h HTML) Underline() HTML {
|
||||||
|
return "<u>" + h + "</u>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strikethrough returns h wrapped as strikethrough Telegram HTML text.
|
||||||
|
func (h HTML) Strikethrough() HTML {
|
||||||
|
return "<s>" + h + "</s>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoiler returns h wrapped as spoiler Telegram HTML text.
|
||||||
|
func (h HTML) Spoiler() HTML {
|
||||||
|
return "<tg-spoiler>" + h + "</tg-spoiler>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link returns h as a Telegram HTML text link.
|
||||||
|
func (h HTML) Link(url string) HTML {
|
||||||
|
return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mention returns h as a Telegram HTML user mention.
|
||||||
|
func (h HTML) Mention(userID int64) HTML {
|
||||||
|
return `<a href="tg://user?id=` + HTML(strconv.FormatInt(userID, 10)) + `">` + h + "</a>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji returns h as a Telegram HTML custom emoji.
|
||||||
|
func (h HTML) Emoji(emojiID string) HTML {
|
||||||
|
return `<tg-emoji emoji-id="` + escapeHTMLAttr(emojiID) + `">` + h + "</tg-emoji>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time returns h as a Telegram HTML localized timestamp.
|
||||||
|
func (h HTML) Time(unix int64) HTML {
|
||||||
|
return `<tg-time unix="` + HTML(strconv.FormatInt(unix, 10)) + `">` + h + "</tg-time>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeFormat returns h as a Telegram HTML localized timestamp with format.
|
||||||
|
func (h HTML) TimeFormat(unix int64, format string) HTML {
|
||||||
|
return `<tg-time unix="` + HTML(strconv.FormatInt(unix, 10)) + `" format="` + escapeHTMLAttr(format) + `">` + h + "</tg-time>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineCode returns h wrapped as inline code Telegram HTML text.
|
||||||
|
func (h HTML) InlineCode() HTML {
|
||||||
|
return "<code>" + h + "</code>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockCode returns h wrapped as a Telegram HTML code block.
|
||||||
|
func (h HTML) BlockCode() HTML {
|
||||||
|
return "<pre>" + h + "</pre>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language.
|
||||||
|
func (h HTML) BlockCodeLanguage(lang string) HTML {
|
||||||
|
return `<pre><code class="language-` + escapeHTMLAttr(lang) + `">` + h + "</code></pre>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quote returns h as a Telegram HTML blockquote.
|
||||||
|
func (h HTML) Quote() HTML {
|
||||||
|
return "<blockquote>" + h + "</blockquote>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteExpandable returns h as a Telegram HTML expandable blockquote.
|
||||||
|
func (h HTML) QuoteExpandable() HTML {
|
||||||
|
return "<blockquote expandable>" + h + "</blockquote>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeHTMLAttr(s string) HTML {
|
||||||
|
return EscapeHTML(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEscapeHTML(t *testing.T) {
|
||||||
|
got := EscapeHTML(`<tag attr="a&b">`)
|
||||||
|
want := HTML(`<tag attr="a&b">`)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("EscapeHTML() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMLComposesWithoutDoubleEscaping(t *testing.T) {
|
||||||
|
got := EscapeHTML("<b>").Bold().Italic()
|
||||||
|
want := HTML("<i><b><b></b></i>")
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatted HTML = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMLFormattingMethods(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got HTML
|
||||||
|
want HTML
|
||||||
|
}{
|
||||||
|
{name: "bold", got: EscapeHTML("text").Bold(), want: "<b>text</b>"},
|
||||||
|
{name: "italic", got: EscapeHTML("text").Italic(), want: "<i>text</i>"},
|
||||||
|
{name: "underline", got: EscapeHTML("text").Underline(), want: "<u>text</u>"},
|
||||||
|
{name: "strikethrough", got: EscapeHTML("text").Strikethrough(), want: "<s>text</s>"},
|
||||||
|
{name: "spoiler", got: EscapeHTML("text").Spoiler(), want: "<tg-spoiler>text</tg-spoiler>"},
|
||||||
|
{name: "inline code", got: EscapeHTML("text").InlineCode(), want: "<code>text</code>"},
|
||||||
|
{name: "block code", got: EscapeHTML("text").BlockCode(), want: "<pre>text</pre>"},
|
||||||
|
{name: "block code language", got: EscapeHTML("text").BlockCodeLanguage(`go"`), want: `<pre><code class="language-go"">text</code></pre>`},
|
||||||
|
{name: "quote", got: EscapeHTML("text").Quote(), want: "<blockquote>text</blockquote>"},
|
||||||
|
{name: "expandable quote", got: EscapeHTML("text").QuoteExpandable(), want: "<blockquote expandable>text</blockquote>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Fatalf("formatted HTML = %q, want %q", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMLLinkEscapesAttributes(t *testing.T) {
|
||||||
|
got := EscapeHTML("Laniakea").Link(`https://example.test/?q="a&b"`)
|
||||||
|
want := HTML(`<a href="https://example.test/?q="a&b"">Laniakea</a>`)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Link() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMLSpecialLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got HTML
|
||||||
|
want HTML
|
||||||
|
}{
|
||||||
|
{name: "mention", got: EscapeHTML("User").Mention(123), want: `<a href="tg://user?id=123">User</a>`},
|
||||||
|
{name: "emoji", got: EscapeHTML("emoji").Emoji(`12"3`), want: `<tg-emoji emoji-id="12"3">emoji</tg-emoji>`},
|
||||||
|
{name: "time", got: EscapeHTML("date").Time(1772323200), want: `<tg-time unix="1772323200">date</tg-time>`},
|
||||||
|
{name: "time format", got: EscapeHTML("date").TimeFormat(1772323200, `MMM " yyyy`), want: `<tg-time unix="1772323200" format="MMM " yyyy">date</tg-time>`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Fatalf("formatted HTML link = %q, want %q", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Markdown is an escaped legacy Telegram Markdown fragment.
|
||||||
|
//
|
||||||
|
// Deprecated: Use MarkdownV2 instead.
|
||||||
|
type Markdown string
|
||||||
|
|
||||||
|
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
|
||||||
|
//
|
||||||
|
// Deprecated: Use EscapeMarkdownV2 instead.
|
||||||
|
func EscapeMarkdown(s string) Markdown {
|
||||||
|
s = strings.ReplaceAll(s, "_", `\_`)
|
||||||
|
s = strings.ReplaceAll(s, "*", `\*`)
|
||||||
|
s = strings.ReplaceAll(s, "[", `\[`)
|
||||||
|
return Markdown(strings.ReplaceAll(s, "`", "\\`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold returns s wrapped as bold legacy Telegram Markdown text.
|
||||||
|
func (s Markdown) Bold() Markdown {
|
||||||
|
return "*" + s + "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Italic returns s wrapped as italic legacy Telegram Markdown text.
|
||||||
|
func (s Markdown) Italic() Markdown {
|
||||||
|
return "_" + s + "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link returns s as a legacy Telegram Markdown text link.
|
||||||
|
func (s Markdown) Link(url string) Markdown {
|
||||||
|
return "[" + s + "](" + Markdown(url) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mention returns s as a legacy Telegram Markdown user mention.
|
||||||
|
func (s Markdown) Mention(userID int64) Markdown {
|
||||||
|
return "[" + s + "](tg://user?id=" + Markdown(strconv.FormatInt(userID, 10)) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineCode returns s wrapped as inline code legacy Telegram Markdown text.
|
||||||
|
func (s Markdown) InlineCode() Markdown {
|
||||||
|
return "`" + s + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockCode returns s wrapped as a legacy Telegram Markdown code block.
|
||||||
|
func (s Markdown) BlockCode() Markdown {
|
||||||
|
return "```\n" + s + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block.
|
||||||
|
func (s Markdown) BlockCodeLanguage(lang string) Markdown {
|
||||||
|
return "```" + Markdown(lang) + "\n" + s + "\n```"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEscapeMarkdown(t *testing.T) {
|
||||||
|
got := EscapeMarkdown("a_b*c[1]`x`")
|
||||||
|
want := Markdown("a\\_b\\*c\\[1]\\`x\\`")
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("EscapeMarkdown() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownFormattingMethods(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got Markdown
|
||||||
|
want Markdown
|
||||||
|
}{
|
||||||
|
{name: "bold", got: EscapeMarkdown("text").Bold(), want: "*text*"},
|
||||||
|
{name: "italic", got: EscapeMarkdown("text").Italic(), want: "_text_"},
|
||||||
|
{name: "link", got: EscapeMarkdown("Laniakea").Link("https://example.test"), want: "[Laniakea](https://example.test)"},
|
||||||
|
{name: "mention", got: EscapeMarkdown("User").Mention(123), want: "[User](tg://user?id=123)"},
|
||||||
|
{name: "inline code", got: EscapeMarkdown("text").InlineCode(), want: "`text`"},
|
||||||
|
{name: "block code", got: EscapeMarkdown("text").BlockCode(), want: "```\ntext\n```"},
|
||||||
|
{name: "block code language", got: EscapeMarkdown("text").BlockCodeLanguage("go"), want: "```go\ntext\n```"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Fatalf("formatted Markdown = %q, want %q", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkdownV2 is an escaped Telegram MarkdownV2 fragment.
|
||||||
|
//
|
||||||
|
// Methods on MarkdownV2 compose formatting without escaping the fragment again.
|
||||||
|
type MarkdownV2 string
|
||||||
|
|
||||||
|
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
|
||||||
|
// https://core.telegram.org/bots/api#markdownv2-style
|
||||||
|
func EscapeMarkdownV2(s string) MarkdownV2 {
|
||||||
|
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
||||||
|
}
|
||||||
|
return MarkdownV2(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold returns s wrapped as bold Telegram MarkdownV2 text.
|
||||||
|
func (s MarkdownV2) Bold() MarkdownV2 {
|
||||||
|
return "*" + s + "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Italic returns s wrapped as italic Telegram MarkdownV2 text.
|
||||||
|
func (s MarkdownV2) Italic() MarkdownV2 {
|
||||||
|
return "_" + s + "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Underline returns s wrapped as underlined Telegram MarkdownV2 text.
|
||||||
|
func (s MarkdownV2) Underline() MarkdownV2 {
|
||||||
|
return "__" + s + "__"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text.
|
||||||
|
func (s MarkdownV2) Strikethrough() MarkdownV2 {
|
||||||
|
return "~" + s + "~"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text.
|
||||||
|
func (s MarkdownV2) Spoiler() MarkdownV2 {
|
||||||
|
return "||" + s + "||"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link returns s as a Telegram MarkdownV2 text link.
|
||||||
|
func (s MarkdownV2) Link(url string) MarkdownV2 {
|
||||||
|
return "[" + s + "](" + escapeMarkdownV2LinkDestination(url) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mention returns s as a Telegram MarkdownV2 user mention.
|
||||||
|
func (s MarkdownV2) Mention(userID uint64) MarkdownV2 {
|
||||||
|
return "[" + s + "](tg://user?id=" + MarkdownV2(strconv.FormatUint(userID, 10)) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji returns s as a Telegram MarkdownV2 custom emoji.
|
||||||
|
func (s MarkdownV2) Emoji(emojiID string) MarkdownV2 {
|
||||||
|
return "[" + s + "](tg://emoji?id=" + escapeMarkdownV2LinkDestination(emojiID) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time returns s as a Telegram MarkdownV2 localized timestamp.
|
||||||
|
func (s MarkdownV2) Time(unix uint64) MarkdownV2 {
|
||||||
|
return ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeFormat returns s as a Telegram MarkdownV2 localized timestamp with format.
|
||||||
|
func (s MarkdownV2) TimeFormat(unix uint64, format string) MarkdownV2 {
|
||||||
|
dest := "tg://time?unix=" + strconv.FormatUint(unix, 10) + "&format=" + format
|
||||||
|
return " + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineCode returns s wrapped as inline code Telegram MarkdownV2 text.
|
||||||
|
func (s MarkdownV2) InlineCode() MarkdownV2 {
|
||||||
|
return "`" + s + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockCode returns s wrapped as a Telegram MarkdownV2 code block.
|
||||||
|
func (s MarkdownV2) BlockCode() MarkdownV2 {
|
||||||
|
return "```\n" + s + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language.
|
||||||
|
func (s MarkdownV2) BlockCodeLanguage(lang string) MarkdownV2 {
|
||||||
|
return "```" + MarkdownV2(lang) + "\n" + s + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quote returns s as a Telegram MarkdownV2 blockquote.
|
||||||
|
func (s MarkdownV2) Quote() MarkdownV2 {
|
||||||
|
return MarkdownV2(">" + strings.ReplaceAll(string(s), "\n", "\n>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteExpandable returns s as a Telegram MarkdownV2 expandable blockquote.
|
||||||
|
func (s MarkdownV2) QuoteExpandable() MarkdownV2 {
|
||||||
|
return "**>" + s
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMarkdownV2LinkDestination(s string) MarkdownV2 {
|
||||||
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
s = strings.ReplaceAll(s, ")", "\\)")
|
||||||
|
return MarkdownV2(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEscapeMarkdownV2(t *testing.T) {
|
||||||
|
got := EscapeMarkdownV2(`a_b*c[1](x)!`)
|
||||||
|
want := MarkdownV2(`a\_b\*c\[1\]\(x\)\!`)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("EscapeMarkdownV2() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownV2ComposesWithoutDoubleEscaping(t *testing.T) {
|
||||||
|
got := EscapeMarkdownV2("a*b").Bold().Italic()
|
||||||
|
want := MarkdownV2(`_*a\*b*_`)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatted text = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownV2FormattingMethods(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got MarkdownV2
|
||||||
|
want MarkdownV2
|
||||||
|
}{
|
||||||
|
{name: "bold", got: EscapeMarkdownV2("text").Bold(), want: "*text*"},
|
||||||
|
{name: "italic", got: EscapeMarkdownV2("text").Italic(), want: "_text_"},
|
||||||
|
{name: "underline", got: EscapeMarkdownV2("text").Underline(), want: "__text__"},
|
||||||
|
{name: "strikethrough", got: EscapeMarkdownV2("text").Strikethrough(), want: "~text~"},
|
||||||
|
{name: "spoiler", got: EscapeMarkdownV2("text").Spoiler(), want: "||text||"},
|
||||||
|
{name: "inline code", got: EscapeMarkdownV2("text").InlineCode(), want: "`text`"},
|
||||||
|
{name: "block code", got: EscapeMarkdownV2("text").BlockCode(), want: "```\ntext\n```"},
|
||||||
|
{name: "block code language", got: EscapeMarkdownV2("text").BlockCodeLanguage("go"), want: "```go\ntext\n```"},
|
||||||
|
{name: "quote", got: EscapeMarkdownV2("a\nb").Quote(), want: ">a\n>b"},
|
||||||
|
{name: "expandable quote", got: EscapeMarkdownV2("text").QuoteExpandable(), want: "**>text"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Fatalf("formatted text = %q, want %q", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownV2LinkEscapesDestination(t *testing.T) {
|
||||||
|
got := EscapeMarkdownV2("Laniakea").Link(`https://example.test/a)b\c`)
|
||||||
|
want := MarkdownV2(`[Laniakea](https://example.test/a\)b\\c)`)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Link() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownV2SpecialLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got MarkdownV2
|
||||||
|
want MarkdownV2
|
||||||
|
}{
|
||||||
|
{name: "mention", got: EscapeMarkdownV2("User").Mention(123), want: "[User](tg://user?id=123)"},
|
||||||
|
{name: "emoji", got: EscapeMarkdownV2("emoji").Emoji(`12)3`), want: `[emoji](tg://emoji?id=12\)3)`},
|
||||||
|
{name: "time", got: EscapeMarkdownV2("date").Time(1772323200), want: ""},
|
||||||
|
{name: "time format", got: EscapeMarkdownV2("date").TimeFormat(1772323200, `MMM ) yyyy`), want: ` yyyy)`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Fatalf("formatted link = %q, want %q", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Package tgmd provides small helpers for Telegram Markdown text.
|
|
||||||
package tgmd
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, "&", "&")
|
|
||||||
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 (
|
const (
|
||||||
// VersionString re-exports the module version string.
|
// VersionString re-exports the module version string.
|
||||||
VersionString = utils.VersionString
|
VersionString = utils.VersionString
|
||||||
|
|||||||
Reference in New Issue
Block a user