From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 17:59:33 +0000 (-0300) Subject: Escape tags in the Markdown of rich replies X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/de170709599e849c6b409d247b800dc68b0c7ab7?ds=sidebyside Escape tags in the Markdown of rich replies The bot puts a backslash before each < of a reply outside inline code and fenced code blocks before it sends the reply with sendRichMessage. Rich Markdown reads a tag in prose, such as
, as raw HTML: the tag name is lost and the rest of the paragraph is in a collapsed section. With the escape, Telegram shows the tag as written, and a reply cannot carry raw HTML. Inline code and code blocks keep their text, also a code block that a split opens again, because the escape runs before the split. The split thus counts the escaped text against the length limit. A < that a backslash already makes plain stays as it is. The pipe, the dollar sign, and the equals sign are not escaped: the tables use the pipe, and the live check showed no problem with the dollar sign. The entity fallback reads the backslash as an escape, so it shows the same text as before. Other events go as plain text and do not change. Issue: d75e312 --- diff --git a/telegram.go b/telegram.go index 13196d4..13c200c 100644 --- a/telegram.go +++ b/telegram.go @@ -311,8 +311,9 @@ func (b *TelegramBot) typing(ctx context.Context) { } // post sends event to the chat. A reply goes as rich messages of its Markdown, -// split with telegramSplitRich. Every other event goes as plain text, split -// with telegramSplitMessage. +// with each < outside code escaped by telegramEscapeTags and then split with +// telegramSplitRich, so that the length limit counts the escaped text. Every +// other event goes as plain text, split with telegramSplitMessage. func (b *TelegramBot) post(ctx context.Context, event Event) { if event.Kind != "reply" { for _, text := range telegramSplitMessage(telegramText(event), telegramMessageUnits) { @@ -324,7 +325,7 @@ func (b *TelegramBot) post(ctx context.Context, event Event) { return } - for _, markdown := range telegramSplitRich(event.Text, telegramRichBytes) { + for _, markdown := range telegramSplitRich(telegramEscapeTags(event.Text), telegramRichBytes) { b.deliverRich(ctx, markdown) } } diff --git a/telegram_markdown.go b/telegram_markdown.go index 54bfbcb..16b83f2 100644 --- a/telegram_markdown.go +++ b/telegram_markdown.go @@ -68,6 +68,37 @@ func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) { return out.text.String(), out.entities } +// telegramEscapeTags puts a backslash before each < of markdown outside inline +// code and fenced code blocks, so that rich Markdown shows a tag name as text +// and reads no raw HTML. A < that a backslash already makes plain stays as it +// is. A code block that does not close runs to the end, as in Markdown. +func telegramEscapeTags(markdown string) string { + var out strings.Builder + lines := strings.Split(markdown, "\n") + for index := 0; index < len(lines); index++ { + if index > 0 { + out.WriteString("\n") + } + + line := lines[index] + _, fence := telegramCodeFence(line) + if fence == "" { + telegramEscapeLineTags(&out, line) + continue + } + + end := telegramClosingFence(lines, index, fence) + if end < 0 { + end = len(lines) - 1 + } + + out.WriteString(strings.Join(lines[index:end+1], "\n")) + index = end + } + + return out.String() +} + // telegramCodeFence returns the language of a line that opens a fenced code // block, and the fence. The fence is "" when line does not open a block. func telegramCodeFence(line string) (string, string) { @@ -225,6 +256,39 @@ func telegramHeadingText(line string) string { return rest } +// telegramEscapeLineTags writes line to out with a backslash before each < that +// is outside inline code and not already plain. A run of backticks that opens +// no inline code stays as text whole, as in Markdown. +func telegramEscapeLineTags(out *strings.Builder, line string) { + for index := 0; index < len(line); { + switch line[index] { + case '\\': + if index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 { + out.WriteString(line[index : index+2]) + index += 2 + + continue + } + case '`': + end := index + telegramBackticks(line[index:]) + span, found := telegramCodeSpan(line, index) + if found { + end = span.end + } + + out.WriteString(line[index:end]) + index = end + + continue + case '<': + out.WriteByte('\\') + } + + out.WriteByte(line[index]) + index++ + } +} + // telegramMarkSpan returns the inline mark that starts at index of line and // closes in line. It returns false when no mark starts there or the mark does // not close. diff --git a/telegram_markdown_test.go b/telegram_markdown_test.go index e2a2530..d80d575 100644 --- a/telegram_markdown_test.go +++ b/telegram_markdown_test.go @@ -239,3 +239,63 @@ func TestMarkdownEntities(t *testing.T) { }) } } + +func TestEscapeTags(t *testing.T) { + testCases := []struct { + name string + markdown string + escaped string + }{ + { + name: "tag in prose shows as text", + markdown: "Use a
tag for the rest.", + escaped: `Use a \
tag for the rest.`, + }, + { + name: "tag in inline code stays as written", + markdown: "Use `
` and `` a.", + escaped: "Use `
` and `` a.", + }, + { + name: "tag in a code block stays as written", + markdown: "

\n\n```html\n

\n```\n\n

", + escaped: "\\

\n\n```html\n

\n```\n\n\\

", + }, + { + name: "code block that does not close stays as written", + markdown: "a \n```\n

", + escaped: "a \\\n```\n
", + }, + { + name: "tag in a table cell shows as text", + markdown: "| Tag | Use |\n|-----|-----|\n|
| `
` |", + escaped: "| Tag | Use |\n|-----|-----|\n| \\
| `
` |", + }, + { + name: "autolink shows as text", + markdown: "See .", + escaped: `See \.`, + }, + { + name: "backtick run that opens no code stays as text", + markdown: "a ``b` ", + escaped: "a ``b` \\", + }, + { + name: "escaped bracket stays as written", + markdown: `a \ and \\`, + escaped: `a \ and \\\`, + }, + { + name: "dollar, pipe, and equals signs stay as written", + markdown: "$5 or $10, a || b, ==c==", + escaped: "$5 or $10, a || b, ==c==", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.escaped, telegramEscapeTags(tc.markdown)) + }) + } +} diff --git a/telegram_test.go b/telegram_test.go index 44c9c8f..cf46106 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -390,6 +390,67 @@ func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) { require.Equal(t, []string{paragraphs, table}, telegram.Sent()) } +func TestReplyTagsGoAsText(t *testing.T) { + testCases := []struct { + name string + status int + sent []string + }{ + { + name: "rich message escapes the tag outside code", + sent: []string{"a \\
tag and ``"}, + }, + { + name: "entity fallback shows the tag as written", + status: http.StatusBadRequest, + sent: []string{"a
tag and "}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + telegram := newTelegramFake(t) + telegram.fail = func(call TelegramFakeCall) (int, string) { + if call.method != "sendRichMessage" { + return 0, "" + } + + return tc.status, "Bad Request" + } + + bot, _ := telegramStartTestBot(t, telegram) + bot.post(t.Context(), Event{ + Kind: "reply", + Text: "a
tag and ``", + }) + + require.Equal(t, tc.sent, telegram.Sent()) + }) + } +} + +func TestLongReplySplitCountsTheEscapedTags(t *testing.T) { + paragraph := "A tag.\n\n" + count := telegramRichBytes / len(paragraph) + reply := strings.Repeat(paragraph, count) + require.LessOrEqual(t, len(reply), telegramRichBytes) + + telegram := newTelegramFake(t) + bot, _ := telegramStartTestBot(t, telegram) + bot.post(t.Context(), Event{ + Kind: "reply", + Text: reply, + }) + + sent := telegram.Sent() + require.Len(t, sent, 2) + require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, "")) + + for _, markdown := range sent { + require.LessOrEqual(t, len(markdown), telegramRichBytes) + } +} + func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) { telegram := newTelegramFake(t) telegram.fail = func(call TelegramFakeCall) (int, string) {