]> git.giacomolli.uk - onechat/commitdiff
Convert reply Markdown to Telegram entities
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 14:37:49 +0000 (11:37 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 14:37:49 +0000 (11:37 -0300)
A converter on the standard library turns the Markdown of a model reply
into plain text and Telegram message entities. It knows bold, italic,
strikethrough, inline code, fenced code blocks with an optional
language, links, and ATX headings, which become bold. Lists, quotes and
tables stay as their text. With entities, the text needs no escaping, so
a mark that does not close only stays as text, and it cannot make
Telegram refuse the message.

Inline marks do not cross a line break, so one open mark cannot change
the rest of a reply. Underscores inside a word stay as text, so
snake_case names do not turn italic, and an asterisk that a space
follows, as in a list item, opens nothing. Code holds no entity. The
offsets and lengths count UTF-16 code units, as Telegram does.

Issue: 3309f9b

botapi.go
markdown.go [new file with mode: 0644]
markdown_test.go [new file with mode: 0644]

index d02aff04b539507225a0ab858822abb98fd38adf..57bf9dba8111e163f99bda5a78470dafd594016e 100644 (file)
--- a/botapi.go
+++ b/botapi.go
@@ -87,6 +87,17 @@ type botChat struct {
        Type string `json:"type"`
 }
 
        Type string `json:"type"`
 }
 
+// botEntity is a message entity of sendMessage: a part of the text with a
+// format, such as bold or a link. Offset and Length count UTF-16 code units.
+// URL is the target of a text_link, and Language the language of a pre.
+type botEntity struct {
+       Type     string `json:"type"`
+       Offset   int    `json:"offset"`
+       Length   int    `json:"length"`
+       URL      string `json:"url,omitempty"`
+       Language string `json:"language,omitempty"`
+}
+
 // botUser is the sender of a message.
 type botUser struct {
        ID int64 `json:"id"`
 // botUser is the sender of a message.
 type botUser struct {
        ID int64 `json:"id"`
diff --git a/markdown.go b/markdown.go
new file mode 100644 (file)
index 0000000..48e9ace
--- /dev/null
@@ -0,0 +1,397 @@
+package main
+
+import (
+       "slices"
+       "strings"
+       "unicode"
+       "unicode/utf8"
+)
+
+// markdownPunctuation holds the ASCII characters that a backslash makes plain.
+const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+
+// markdownText is the plain text of a Markdown reply and its Telegram entities,
+// while the conversion builds it. Units counts the UTF-16 code units of text,
+// the unit of the entity offsets and lengths.
+type markdownText struct {
+       text     strings.Builder
+       units    int
+       entities []botEntity
+}
+
+// markdownSpan is an inline mark of a line: the entity type, the content inside
+// the marks, the index after the closing mark, and the URL of a link.
+type markdownSpan struct {
+       kind    string
+       content string
+       end     int
+       url     string
+}
+
+// markdownEntities converts the Markdown of a reply to plain text and Telegram
+// message entities. It knows bold (** and __), italic (* and _),
+// strikethrough (~~), inline code, fenced code blocks with an optional
+// language, links [text](url), and ATX headings, which become bold. Lists,
+// quotes, and tables stay as their text. A mark that does not close stays as
+// text with no entity, and code holds no entity.
+//
+// Inline marks do not cross a line break, so a mark that does not close cannot
+// change the rest of the reply.
+func markdownEntities(markdown string) (string, []botEntity) {
+       var out markdownText
+       lines := strings.Split(markdown, "\n")
+       for index := 0; index < len(lines); index++ {
+               if index > 0 {
+                       out.add("\n")
+               }
+
+               line := lines[index]
+               language, fence := codeFence(line)
+               end := closingFence(lines, index, fence)
+               heading := headingText(line)
+               switch {
+               case end > index:
+                       start := out.units
+                       out.add(strings.Join(lines[index+1:end], "\n"))
+                       out.entity(botEntity{
+                               Type:     "pre",
+                               Offset:   start,
+                               Length:   out.units - start,
+                               Language: language,
+                       })
+
+                       index = end
+               case heading != "":
+                       start := out.units
+                       out.inline(heading)
+                       out.entity(botEntity{
+                               Type:   "bold",
+                               Offset: start,
+                               Length: out.units - start,
+                       })
+               default:
+                       out.inline(line)
+               }
+       }
+
+       slices.SortStableFunc(out.entities, func(a, b botEntity) int {
+               if a.Offset != b.Offset {
+                       return a.Offset - b.Offset
+               }
+
+               return b.Length - a.Length
+       })
+
+       return out.text.String(), out.entities
+}
+
+// add appends text.
+func (m *markdownText) add(text string) {
+       m.text.WriteString(text)
+       m.units += utf16Units(text)
+}
+
+// entity keeps entity, unless it is empty, because Telegram refuses an entity
+// of length 0.
+func (m *markdownText) entity(entity botEntity) {
+       if entity.Length > 0 {
+               m.entities = append(m.entities, entity)
+       }
+}
+
+// inline appends one line of Markdown with its inline marks as entities. A
+// backslash before an ASCII punctuation character makes the character plain
+// text.
+func (m *markdownText) inline(line string) {
+       plain := 0
+       index := 0
+       for index < len(line) {
+               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
+                       m.add(line[plain:index])
+                       plain = index + 1
+                       index += 2
+
+                       continue
+               }
+
+               span, found := markSpan(line, index)
+               if !found {
+                       index++
+                       continue
+               }
+
+               m.add(line[plain:index])
+               start := m.units
+               if span.kind == "code" {
+                       m.add(span.content)
+               } else {
+                       m.inline(span.content)
+               }
+
+               m.entity(botEntity{
+                       Type:   span.kind,
+                       Offset: start,
+                       Length: m.units - start,
+                       URL:    span.url,
+               })
+
+               index = span.end
+               plain = span.end
+       }
+
+       m.add(line[plain:])
+}
+
+// codeFence 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 codeFence(line string) (string, string) {
+       trimmed := strings.TrimLeft(line, " ")
+       if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
+               return "", ""
+       }
+
+       run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
+       info := strings.TrimSpace(trimmed[run:])
+       if strings.Contains(info, "`") {
+               return "", ""
+       }
+
+       language, _, _ := strings.Cut(info, " ")
+
+       return language, trimmed[:run]
+}
+
+// closingFence returns the index of the line after index that closes the code
+// block of fence, or -1 when fence is "" or no line closes it.
+func closingFence(lines []string, index int, fence string) int {
+       if fence == "" {
+               return -1
+       }
+
+       for end := index + 1; end < len(lines); end++ {
+               trimmed := strings.TrimSpace(lines[end])
+               if strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" {
+                       return end
+               }
+       }
+
+       return -1
+}
+
+// headingText returns the text of an ATX heading line without its marks, or ""
+// when line is not a heading or has no text.
+func headingText(line string) string {
+       trimmed := strings.TrimLeft(line, " ")
+       if len(line)-len(trimmed) > 3 {
+               return ""
+       }
+
+       level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
+       if level == 0 || level > 6 {
+               return ""
+       }
+
+       rest := trimmed[level:]
+       if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
+               return ""
+       }
+
+       rest = strings.TrimSpace(rest)
+       closed := strings.TrimRight(rest, "#")
+       if closed == "" || strings.HasSuffix(closed, " ") {
+               rest = strings.TrimSpace(closed)
+       }
+
+       return rest
+}
+
+// utf16Units returns the number of UTF-16 code units of text.
+func utf16Units(text string) int {
+       units := 0
+       for _, char := range text {
+               units++
+               if char > 0xFFFF {
+                       units++
+               }
+       }
+
+       return units
+}
+
+// markSpan 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.
+func markSpan(line string, index int) (markdownSpan, bool) {
+       switch line[index] {
+       case '`':
+               return codeSpan(line, index)
+       case '[':
+               return linkSpan(line, index)
+       case '~':
+               return delimited(line, index, "~~", "strikethrough")
+       case '*', '_':
+               double := line[index : index+1]
+               double += double
+               span, found := delimited(line, index, double, "bold")
+               if found {
+                       return span, true
+               }
+
+               return delimited(line, index, line[index:index+1], "italic")
+       default:
+               return markdownSpan{}, false
+       }
+}
+
+// codeSpan returns the inline code that starts at index: a run of backticks,
+// the code, and a run of the same length. One space on each side of the code
+// is left out, as in Markdown.
+func codeSpan(line string, index int) (markdownSpan, bool) {
+       run := backticks(line[index:])
+       for search := index + run; search < len(line); {
+               next := strings.IndexByte(line[search:], '`')
+               if next < 0 {
+                       break
+               }
+
+               next += search
+               length := backticks(line[next:])
+               if length != run {
+                       search = next + length
+                       continue
+               }
+
+               content := line[index+run : next]
+               if strings.TrimSpace(content) == "" {
+                       break
+               }
+
+               if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
+                       content = content[1 : len(content)-1]
+               }
+
+               return markdownSpan{
+                       kind:    "code",
+                       content: content,
+                       end:     next + run,
+               }, true
+       }
+
+       return markdownSpan{}, false
+}
+
+// linkSpan returns the link [text](url) that starts at index. The text can
+// hold brackets in pairs, and the URL holds no space.
+func linkSpan(line string, index int) (markdownSpan, bool) {
+       depth := 0
+       for closing := index; closing < len(line); closing++ {
+               switch line[closing] {
+               case '\\':
+                       closing++
+               case '[':
+                       depth++
+               case ']':
+                       depth--
+               }
+
+               if depth > 0 {
+                       continue
+               }
+
+               rest := line[closing+1:]
+               if closing == index+1 || !strings.HasPrefix(rest, "(") {
+                       return markdownSpan{}, false
+               }
+
+               url, _, found := strings.Cut(rest[1:], ")")
+               if !found || url == "" || strings.ContainsAny(url, " \t") {
+                       return markdownSpan{}, false
+               }
+
+               return markdownSpan{
+                       kind:    "text_link",
+                       content: line[index+1 : closing],
+                       end:     closing + 2 + len(url) + 1,
+                       url:     url,
+               }, true
+       }
+
+       return markdownSpan{}, false
+}
+
+// delimited returns the span of kind between the mark at index and the same
+// mark later in line. An opening mark is followed by a character that is not a
+// space, and a closing mark follows one. A single mark is not a part of a
+// double one. A mark of underscores does not open or close inside a word, so
+// snake_case stays as text. A code span inside the content is skipped whole.
+func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
+       char := mark[0]
+       after := index + len(mark)
+       if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
+               return markdownSpan{}, false
+       }
+
+       if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
+               return markdownSpan{}, false
+       }
+
+       if char == '_' && index > 0 && wordCharBefore(line[:index]) {
+               return markdownSpan{}, false
+       }
+
+       for closing := after + 1; closing < len(line); closing++ {
+               switch {
+               case line[closing] == '\\':
+                       closing++
+                       continue
+               case line[closing] == '`':
+                       span, found := codeSpan(line, closing)
+                       if found {
+                               closing = span.end - 1
+                       }
+
+                       continue
+               case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
+                       continue
+               }
+
+               end := closing + len(mark)
+               if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
+                       continue
+               }
+
+               if char == '_' && wordChar(line[end:]) {
+                       continue
+               }
+
+               return markdownSpan{
+                       kind:    kind,
+                       content: line[after:closing],
+                       end:     end,
+               }, true
+       }
+
+       return markdownSpan{}, false
+}
+
+// backticks returns the length of the run of backticks at the start of text.
+func backticks(text string) int {
+       return len(text) - len(strings.TrimLeft(text, "`"))
+}
+
+// isSpace tells if char is a space, a tab, or a line break.
+func isSpace(char byte) bool {
+       return char == ' ' || char == '\t' || char == '\n' || char == '\r'
+}
+
+// wordCharBefore tells if the character that ends text is a letter or a digit.
+func wordCharBefore(text string) bool {
+       char, _ := utf8.DecodeLastRuneInString(text)
+       return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
+}
+
+// wordChar tells if the character that starts text is a letter or a digit. An
+// underscore next to one is a part of a word, such as in snake_case.
+func wordChar(text string) bool {
+       char, _ := utf8.DecodeRuneInString(text)
+       return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
+}
diff --git a/markdown_test.go b/markdown_test.go
new file mode 100644 (file)
index 0000000..cca7db0
--- /dev/null
@@ -0,0 +1,241 @@
+package main
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestMarkdownEntities(t *testing.T) {
+       testCases := []struct {
+               name     string
+               markdown string
+               text     string
+               entities []botEntity
+       }{
+               {
+                       name:     "double asterisks give bold",
+                       markdown: "a **b** c",
+                       text:     "a b c",
+                       entities: []botEntity{
+                               {
+                                       Type:   "bold",
+                                       Offset: 2,
+                                       Length: 1,
+                               },
+                       },
+               },
+               {
+                       name:     "double underscores give bold",
+                       markdown: "__bold__",
+                       text:     "bold",
+                       entities: []botEntity{
+                               {
+                                       Type:   "bold",
+                                       Offset: 0,
+                                       Length: 4,
+                               },
+                       },
+               },
+               {
+                       name:     "single asterisk gives italic",
+                       markdown: "an *odd* word",
+                       text:     "an odd word",
+                       entities: []botEntity{
+                               {
+                                       Type:   "italic",
+                                       Offset: 3,
+                                       Length: 3,
+                               },
+                       },
+               },
+               {
+                       name:     "single underscore gives italic",
+                       markdown: "an _odd_ word",
+                       text:     "an odd word",
+                       entities: []botEntity{
+                               {
+                                       Type:   "italic",
+                                       Offset: 3,
+                                       Length: 3,
+                               },
+                       },
+               },
+               {
+                       name:     "double tildes give strikethrough",
+                       markdown: "~~old~~ new",
+                       text:     "old new",
+                       entities: []botEntity{
+                               {
+                                       Type:   "strikethrough",
+                                       Offset: 0,
+                                       Length: 3,
+                               },
+                       },
+               },
+               {
+                       name:     "backticks give inline code with no entity inside",
+                       markdown: "run `go **test**` now",
+                       text:     "run go **test** now",
+                       entities: []botEntity{
+                               {
+                                       Type:   "code",
+                                       Offset: 4,
+                                       Length: 11,
+                               },
+                       },
+               },
+               {
+                       name:     "double backticks hold a backtick",
+                       markdown: "``a ` b``",
+                       text:     "a ` b",
+                       entities: []botEntity{
+                               {
+                                       Type:   "code",
+                                       Offset: 0,
+                                       Length: 5,
+                               },
+                       },
+               },
+               {
+                       name:     "link gives a text link",
+                       markdown: "see [the docs](https://example.com/a_b) here",
+                       text:     "see the docs here",
+                       entities: []botEntity{
+                               {
+                                       Type:   "text_link",
+                                       Offset: 4,
+                                       Length: 8,
+                                       URL:    "https://example.com/a_b",
+                               },
+                       },
+               },
+               {
+                       name:     "bold inside a link nests",
+                       markdown: "[a **b** c](https://example.com)",
+                       text:     "a b c",
+                       entities: []botEntity{
+                               {
+                                       Type:   "text_link",
+                                       Offset: 0,
+                                       Length: 5,
+                                       URL:    "https://example.com",
+                               },
+                               {
+                                       Type:   "bold",
+                                       Offset: 2,
+                                       Length: 1,
+                               },
+                       },
+               },
+               {
+                       name:     "code block with a language gives pre with the language",
+                       markdown: "Commit:\n```text\nFix the **bug**\n\nBody\n```\nDone.",
+                       text:     "Commit:\nFix the **bug**\n\nBody\nDone.",
+                       entities: []botEntity{
+                               {
+                                       Type:     "pre",
+                                       Offset:   8,
+                                       Length:   21,
+                                       Language: "text",
+                               },
+                       },
+               },
+               {
+                       name:     "code block without a language gives pre",
+                       markdown: "```\nls -l\n```",
+                       text:     "ls -l",
+                       entities: []botEntity{
+                               {
+                                       Type:   "pre",
+                                       Offset: 0,
+                                       Length: 5,
+                               },
+                       },
+               },
+               {
+                       name:     "heading becomes bold with its inline marks",
+                       markdown: "## The `plan`\nnext",
+                       text:     "The plan\nnext",
+                       entities: []botEntity{
+                               {
+                                       Type:   "bold",
+                                       Offset: 0,
+                                       Length: 8,
+                               },
+                               {
+                                       Type:   "code",
+                                       Offset: 4,
+                                       Length: 4,
+                               },
+                       },
+               },
+               {
+                       name:     "list, quote and table stay as text",
+                       markdown: "- one\n* two\n1. three\n> quote\n| a | b |\n|---|---|",
+                       text:     "- one\n* two\n1. three\n> quote\n| a | b |\n|---|---|",
+               },
+               {
+                       name:     "asterisk of a list item with italic later stays",
+                       markdown: "* item with *stress*",
+                       text:     "* item with stress",
+                       entities: []botEntity{
+                               {
+                                       Type:   "italic",
+                                       Offset: 12,
+                                       Length: 6,
+                               },
+                       },
+               },
+               {
+                       name:     "unclosed marks stay as text",
+                       markdown: "**open, `tick, [link](, ~~gone and *star",
+                       text:     "**open, `tick, [link](, ~~gone and *star",
+               },
+               {
+                       name:     "unclosed code fence stays as text",
+                       markdown: "```go\nfmt.Println()",
+                       text:     "```go\nfmt.Println()",
+               },
+               {
+                       name:     "snake case words stay as text",
+                       markdown: "set snake_case_words and max_tokens_count",
+                       text:     "set snake_case_words and max_tokens_count",
+               },
+               {
+                       name:     "multiplication stays as text",
+                       markdown: "2 * 3 * 4",
+                       text:     "2 * 3 * 4",
+               },
+               {
+                       name:     "mark does not cross a line break",
+                       markdown: "**a\nb**",
+                       text:     "**a\nb**",
+               },
+               {
+                       name:     "backslash makes a mark plain",
+                       markdown: `\*not italic\* and C:\Users`,
+                       text:     `*not italic* and C:\Users`,
+               },
+               {
+                       name:     "character outside the BMP moves the offset by two",
+                       markdown: "\U0001F600 **b**",
+                       text:     "\U0001F600 b",
+                       entities: []botEntity{
+                               {
+                                       Type:   "bold",
+                                       Offset: 3,
+                                       Length: 1,
+                               },
+                       },
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       text, entities := markdownEntities(tc.markdown)
+                       require.Equal(t, tc.text, text)
+                       require.Equal(t, tc.entities, entities)
+               })
+       }
+}