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