]> git.giacomolli.uk - onechat/blob - telegram_markdown.go
Write the log rules in the Go skill
[onechat] / telegram_markdown.go
1 package main
2
3 import (
4         "slices"
5         "strings"
6         "unicode"
7         "unicode/utf8"
8 )
9
10 // telegramMarkdownPunctuation holds the ASCII characters that a backslash makes
11 // plain.
12 const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
13
14 var (
15         // telegramTagEscaper writes the characters that rich Markdown reads as HTML
16         // as HTML entities.
17         telegramTagEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;")
18
19         // telegramTagUnescaper reverses telegramTagEscaper.
20         telegramTagUnescaper = strings.NewReplacer("&amp;", "&", "&lt;", "<")
21 )
22
23 // telegramMarkdownEntities converts the Markdown of a reply to plain text and
24 // Telegram message entities. It knows bold (** and __), italic (* and _),
25 // strikethrough (~~), inline code, fenced code blocks with an optional
26 // language, links [text](url), and ATX headings, which become bold. Lists,
27 // quotes, and tables stay as their text. A mark that does not close stays as
28 // text with no entity, and code holds no entity.
29 //
30 // Inline marks do not cross a line break, so a mark that does not close cannot
31 // change the rest of the reply.
32 func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
33         var out TelegramMarkdownText
34
35         lines := strings.Split(markdown, "\n")
36         for index := 0; index < len(lines); index++ {
37                 if index > 0 {
38                         out.add("\n")
39                 }
40
41                 line := lines[index]
42                 language, fence := telegramCodeFence(line)
43                 end := telegramClosingFence(lines, index, fence)
44                 heading := telegramHeadingText(line)
45                 switch {
46                 case end > index:
47                         start := out.units
48                         out.add(strings.Join(lines[index+1:end], "\n"))
49                         out.entity(TelegramEntity{
50                                 Type:     "pre",
51                                 Offset:   start,
52                                 Length:   out.units - start,
53                                 Language: language,
54                         })
55
56                         index = end
57                 case heading != "":
58                         start := out.units
59                         out.inline(heading)
60                         out.entity(TelegramEntity{
61                                 Type:   "bold",
62                                 Offset: start,
63                                 Length: out.units - start,
64                         })
65                 default:
66                         out.inline(line)
67                 }
68         }
69
70         slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
71                 if a.Offset != b.Offset {
72                         return a.Offset - b.Offset
73                 }
74
75                 return b.Length - a.Length
76         })
77
78         return out.text.String(), out.entities
79 }
80
81 // telegramEscapeTags writes each & of markdown outside inline code and fenced
82 // code blocks as &amp; and each < as &lt;. Rich Markdown reads raw HTML and
83 // HTML entities in prose, and it does not read a backslash before < as an
84 // escape. With the entities, it shows a tag name and an entity that the model
85 // wrote in prose as text.
86 func telegramEscapeTags(markdown string) string {
87         return telegramRewriteProse(markdown, telegramTagEscaper.Replace)
88 }
89
90 // telegramUnescapeTags gives back the Markdown that telegramEscapeTags escaped.
91 // It is exact, because after the escape each & outside code starts &amp; or
92 // &lt;. It also works on a part of telegramSplitRich, because a part has the
93 // same code as the text that it comes from.
94 func telegramUnescapeTags(markdown string) string {
95         return telegramRewriteProse(markdown, telegramTagUnescaper.Replace)
96 }
97
98 // telegramCodeFence returns the language of a line that opens a fenced code
99 // block, and the fence. The fence is "" when line does not open a block.
100 func telegramCodeFence(line string) (string, string) {
101         trimmed := strings.TrimLeft(line, " ")
102         if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
103                 return "", ""
104         }
105
106         run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
107         info := strings.TrimSpace(trimmed[run:])
108         if strings.Contains(info, "`") {
109                 return "", ""
110         }
111
112         language, _, _ := strings.Cut(info, " ")
113
114         return language, trimmed[:run]
115 }
116
117 // telegramClosesFence tells if line closes the code block of fence.
118 func telegramClosesFence(line, fence string) bool {
119         trimmed := strings.TrimSpace(line)
120         return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
121 }
122
123 // telegramUTF16Units returns the number of UTF-16 code units of text.
124 func telegramUTF16Units(text string) int {
125         units := 0
126         for _, char := range text {
127                 units++
128                 if char > 0xFFFF {
129                         units++
130                 }
131         }
132
133         return units
134 }
135
136 // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
137 // entities, while the conversion builds it. Units counts the UTF-16 code units
138 // of text, the unit of the entity offsets and lengths.
139 type TelegramMarkdownText struct {
140         text     strings.Builder
141         units    int
142         entities []TelegramEntity
143 }
144
145 // add appends text.
146 func (m *TelegramMarkdownText) add(text string) {
147         m.text.WriteString(text)
148
149         m.units += telegramUTF16Units(text)
150 }
151
152 // entity keeps entity, unless it is empty, because Telegram refuses an entity
153 // of length 0.
154 func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
155         if entity.Length > 0 {
156                 m.entities = append(m.entities, entity)
157         }
158 }
159
160 // inline appends one line of Markdown with its inline marks as entities. A
161 // backslash before an ASCII punctuation character makes the character plain
162 // text.
163 func (m *TelegramMarkdownText) inline(line string) {
164         plain := 0
165         index := 0
166         for index < len(line) {
167                 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
168                         m.add(line[plain:index])
169
170                         plain = index + 1
171                         index += 2
172
173                         continue
174                 }
175
176                 span, found := telegramMarkSpan(line, index)
177                 if !found {
178                         index++
179                         continue
180                 }
181
182                 m.add(line[plain:index])
183
184                 start := m.units
185                 if span.kind == "code" {
186                         m.add(span.content)
187                 } else {
188                         m.inline(span.content)
189                 }
190
191                 m.entity(TelegramEntity{
192                         Type:   span.kind,
193                         Offset: start,
194                         Length: m.units - start,
195                         URL:    span.url,
196                 })
197
198                 index = span.end
199                 plain = span.end
200         }
201
202         m.add(line[plain:])
203 }
204
205 // TelegramMarkdownSpan is an inline mark of a line: the entity type, the
206 // content inside the marks, the index after the closing mark, and the URL of a
207 // link.
208 type TelegramMarkdownSpan struct {
209         kind    string
210         content string
211         end     int
212         url     string
213 }
214
215 // telegramClosingFence returns the index of the line after index that closes
216 // the code block of fence, or -1 when fence is "" or no line closes it.
217 func telegramClosingFence(lines []string, index int, fence string) int {
218         if fence == "" {
219                 return -1
220         }
221
222         for end := index + 1; end < len(lines); end++ {
223                 if telegramClosesFence(lines[end], fence) {
224                         return end
225                 }
226         }
227
228         return -1
229 }
230
231 // telegramHeadingText returns the text of an ATX heading line without its
232 // marks, or "" when line is not a heading or has no text.
233 func telegramHeadingText(line string) string {
234         trimmed := strings.TrimLeft(line, " ")
235         if len(line)-len(trimmed) > 3 {
236                 return ""
237         }
238
239         level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
240         if level == 0 || level > 6 {
241                 return ""
242         }
243
244         rest := trimmed[level:]
245         if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
246                 return ""
247         }
248
249         rest = strings.TrimSpace(rest)
250         closed := strings.TrimRight(rest, "#")
251         if closed == "" || strings.HasSuffix(closed, " ") {
252                 rest = strings.TrimSpace(closed)
253         }
254
255         return rest
256 }
257
258 // telegramRewriteProse returns markdown with rewrite applied to each part of a
259 // line outside inline code and fenced code blocks. A code block that does not
260 // close runs to the end, as in Markdown.
261 func telegramRewriteProse(markdown string, rewrite func(string) string) string {
262         var out strings.Builder
263
264         lines := strings.Split(markdown, "\n")
265         for index := 0; index < len(lines); index++ {
266                 if index > 0 {
267                         out.WriteString("\n")
268                 }
269
270                 line := lines[index]
271                 _, fence := telegramCodeFence(line)
272                 if fence == "" {
273                         telegramRewriteLineProse(&out, line, rewrite)
274
275                         continue
276                 }
277
278                 end := telegramClosingFence(lines, index, fence)
279                 if end < 0 {
280                         end = len(lines) - 1
281                 }
282
283                 out.WriteString(strings.Join(lines[index:end+1], "\n"))
284
285                 index = end
286         }
287
288         return out.String()
289 }
290
291 // telegramRewriteLineProse writes line to out with rewrite applied to each part
292 // outside inline code. A backtick after a backslash opens no code. A run of
293 // backticks that opens no inline code stays as text whole, as in Markdown.
294 func telegramRewriteLineProse(out *strings.Builder, line string, rewrite func(string) string) {
295         prose := 0
296         for index := 0; index < len(line); {
297                 switch line[index] {
298                 case '\\':
299                         if index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
300                                 index += 2
301                                 continue
302                         }
303                 case '`':
304                         end := index + telegramBackticks(line[index:])
305                         span, found := telegramCodeSpan(line, index)
306                         if found {
307                                 end = span.end
308                         }
309
310                         out.WriteString(rewrite(line[prose:index]))
311                         out.WriteString(line[index:end])
312
313                         index = end
314                         prose = end
315
316                         continue
317                 }
318
319                 index++
320         }
321
322         out.WriteString(rewrite(line[prose:]))
323 }
324
325 // telegramMarkSpan returns the inline mark that starts at index of line and
326 // closes in line. It returns false when no mark starts there or the mark does
327 // not close.
328 func telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
329         switch line[index] {
330         case '`':
331                 return telegramCodeSpan(line, index)
332         case '[':
333                 return telegramLinkSpan(line, index)
334         case '~':
335                 return telegramDelimited(line, index, "~~", "strikethrough")
336         case '*', '_':
337                 double := line[index : index+1]
338                 double += double
339                 span, found := telegramDelimited(line, index, double, "bold")
340                 if found {
341                         return span, true
342                 }
343
344                 return telegramDelimited(line, index, line[index:index+1], "italic")
345         default:
346                 return TelegramMarkdownSpan{}, false
347         }
348 }
349
350 // telegramCodeSpan returns the inline code that starts at index: a run of
351 // backticks, the code, and a run of the same length. One space on each side of
352 // the code is left out, as in Markdown.
353 func telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) {
354         run := telegramBackticks(line[index:])
355         for search := index + run; search < len(line); {
356                 next := strings.IndexByte(line[search:], '`')
357                 if next < 0 {
358                         break
359                 }
360
361                 next += search
362                 length := telegramBackticks(line[next:])
363                 if length != run {
364                         search = next + length
365                         continue
366                 }
367
368                 content := line[index+run : next]
369                 if strings.TrimSpace(content) == "" {
370                         break
371                 }
372
373                 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
374                         content = content[1 : len(content)-1]
375                 }
376
377                 return TelegramMarkdownSpan{
378                         kind:    "code",
379                         content: content,
380                         end:     next + run,
381                 }, true
382         }
383
384         return TelegramMarkdownSpan{}, false
385 }
386
387 // telegramLinkSpan returns the link [text](url) that starts at index. The text
388 // can hold brackets in pairs, and the URL holds no space.
389 func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
390         depth := 0
391         for closing := index; closing < len(line); closing++ {
392                 switch line[closing] {
393                 case '\\':
394                         closing++
395                 case '[':
396                         depth++
397                 case ']':
398                         depth--
399                 }
400
401                 if depth > 0 {
402                         continue
403                 }
404
405                 rest := line[closing+1:]
406                 if closing == index+1 || !strings.HasPrefix(rest, "(") {
407                         return TelegramMarkdownSpan{}, false
408                 }
409
410                 url, _, found := strings.Cut(rest[1:], ")")
411                 if !found || url == "" || strings.ContainsAny(url, " \t") {
412                         return TelegramMarkdownSpan{}, false
413                 }
414
415                 return TelegramMarkdownSpan{
416                         kind:    "text_link",
417                         content: line[index+1 : closing],
418                         end:     closing + 2 + len(url) + 1,
419                         url:     url,
420                 }, true
421         }
422
423         return TelegramMarkdownSpan{}, false
424 }
425
426 // telegramDelimited returns the span of kind between the mark at index and the
427 // same mark later in line. An opening mark is followed by a character that is
428 // not a space, and a closing mark follows one. A single mark is not a part of a
429 // double one. A mark of underscores does not open or close inside a word, so
430 // snake_case stays as text. A code span inside the content is skipped whole.
431 func telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) {
432         char := mark[0]
433         after := index + len(mark)
434         if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) {
435                 return TelegramMarkdownSpan{}, false
436         }
437
438         if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
439                 return TelegramMarkdownSpan{}, false
440         }
441
442         if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) {
443                 return TelegramMarkdownSpan{}, false
444         }
445
446         for closing := after + 1; closing < len(line); closing++ {
447                 switch {
448                 case line[closing] == '\\':
449                         closing++
450                         continue
451                 case line[closing] == '`':
452                         span, found := telegramCodeSpan(line, closing)
453                         if found {
454                                 closing = span.end - 1
455                         }
456
457                         continue
458                 case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(line[closing-1]):
459                         continue
460                 }
461
462                 end := closing + len(mark)
463                 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
464                         continue
465                 }
466
467                 if char == '_' && telegramWordChar(line[end:]) {
468                         continue
469                 }
470
471                 return TelegramMarkdownSpan{
472                         kind:    kind,
473                         content: line[after:closing],
474                         end:     end,
475                 }, true
476         }
477
478         return TelegramMarkdownSpan{}, false
479 }
480
481 // telegramBackticks returns the length of the run of backticks at the start of
482 // text.
483 func telegramBackticks(text string) int {
484         return len(text) - len(strings.TrimLeft(text, "`"))
485 }
486
487 // telegramIsSpace tells if char is a space, a tab, or a line break.
488 func telegramIsSpace(char byte) bool {
489         return char == ' ' || char == '\t' || char == '\n' || char == '\r'
490 }
491
492 // telegramWordCharBefore tells if the character that ends text is a letter or a
493 // digit.
494 func telegramWordCharBefore(text string) bool {
495         char, _ := utf8.DecodeLastRuneInString(text)
496         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
497 }
498
499 // telegramWordChar tells if the character that starts text is a letter or a
500 // digit. An underscore next to one is a part of a word, such as in snake_case.
501 func telegramWordChar(text string) bool {
502         char, _ := utf8.DecodeRuneInString(text)
503         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
504 }