]> git.giacomolli.uk - onechat/commitdiff
Escape tags in the Markdown of rich replies
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 17:59:33 +0000 (14:59 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 17:59:33 +0000 (14:59 -0300)
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 <details>, 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

telegram.go
telegram_markdown.go
telegram_markdown_test.go
telegram_test.go

index 13196d4a547781602d63e4b0460117f0f55d8366..13c200c8481a1c47c6cb1497a7e9f84a4a869a62 100644 (file)
@@ -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,
 }
 
 // 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) {
 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
        }
 
                return
        }
 
-       for _, markdown := range telegramSplitRich(event.Text, telegramRichBytes) {
+       for _, markdown := range telegramSplitRich(telegramEscapeTags(event.Text), telegramRichBytes) {
                b.deliverRich(ctx, markdown)
        }
 }
                b.deliverRich(ctx, markdown)
        }
 }
index 54bfbcb36031565e89fa67621ceaf2f78e480d92..16b83f2f851d81bddd19353a1af8e6c43dfb6c97 100644 (file)
@@ -68,6 +68,37 @@ func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
        return out.text.String(), out.entities
 }
 
        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) {
 // 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
 }
 
        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.
 // 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.
index e2a253056e251b8877c6972c229d7b808f85061e..d80d57547de8fe24a86d07af40b4cf518d463daf 100644 (file)
@@ -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 <details> tag for the rest.",
+                       escaped:  `Use a \<details> tag for the rest.`,
+               },
+               {
+                       name:     "tag in inline code stays as written",
+                       markdown: "Use `<details>` and `` a<b ``, not <b>.",
+                       escaped:  "Use `<details>` and `` a<b ``, not \\<b>.",
+               },
+               {
+                       name:     "tag in a code block stays as written",
+                       markdown: "<p>\n\n```html\n<details>\n```\n\n<p>",
+                       escaped:  "\\<p>\n\n```html\n<details>\n```\n\n\\<p>",
+               },
+               {
+                       name:     "code block that does not close stays as written",
+                       markdown: "a <b>\n```\n<details>",
+                       escaped:  "a \\<b>\n```\n<details>",
+               },
+               {
+                       name:     "tag in a table cell shows as text",
+                       markdown: "| Tag | Use |\n|-----|-----|\n| <br> | `<br>` |",
+                       escaped:  "| Tag | Use |\n|-----|-----|\n| \\<br> | `<br>` |",
+               },
+               {
+                       name:     "autolink shows as text",
+                       markdown: "See <https://example.com>.",
+                       escaped:  `See \<https://example.com>.`,
+               },
+               {
+                       name:     "backtick run that opens no code stays as text",
+                       markdown: "a ``b` <c>",
+                       escaped:  "a ``b` \\<c>",
+               },
+               {
+                       name:     "escaped bracket stays as written",
+                       markdown: `a \<b> and \\<c>`,
+                       escaped:  `a \<b> and \\\<c>`,
+               },
+               {
+                       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))
+               })
+       }
+}
index 44c9c8fe16de2bb285b58c976947a0be583e8a43..cf46106874d3d4d641e6a663158216f93e45dfb4 100644 (file)
@@ -390,6 +390,67 @@ func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
        require.Equal(t, []string{paragraphs, table}, telegram.Sent())
 }
 
        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 \\<details> tag and `<b>`"},
+               },
+               {
+                       name:   "entity fallback shows the tag as written",
+                       status: http.StatusBadRequest,
+                       sent:   []string{"a <details> tag and <b>"},
+               },
+       }
+
+       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 <details> tag and `<b>`",
+                       })
+
+                       require.Equal(t, tc.sent, telegram.Sent())
+               })
+       }
+}
+
+func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
+       paragraph := "A <b> 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) {
 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
        telegram := newTelegramFake(t)
        telegram.fail = func(call TelegramFakeCall) (int, string) {