]> git.giacomolli.uk - onechat/blob - telegram_markdown.go
Add local context to chat operation failures
[onechat] / telegram_markdown.go
1 package main
2
3 import (
4 "slices"
5 "strings"
6 )
7
8 var (
9 // telegramTagEscaper writes the characters that rich Markdown reads as
10 // HTML as HTML entities.
11 telegramTagEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;")
12
13 // telegramTagUnescaper reverses telegramTagEscaper.
14 telegramTagUnescaper = strings.NewReplacer("&amp;", "&", "&lt;", "<")
15 )
16
17 // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
18 // entities, while the conversion builds it. Units counts the UTF-16 code units
19 // of text, the unit of the entity offsets and lengths.
20 type TelegramMarkdownText struct {
21 text strings.Builder
22 units int
23 entities []TelegramEntity
24 }
25
26 // inline appends one line of Markdown with its inline marks as entities. A
27 // backslash before an ASCII punctuation character makes the character plain
28 // text.
29 func (m *TelegramMarkdownText) inline(line string) {
30 plain := 0
31 index := 0
32 for index < len(line) {
33 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
34 m.add(line[plain:index])
35
36 plain = index + 1
37 index += 2
38
39 continue
40 }
41
42 span, found := markdownMarkSpan(line, index)
43 if !found {
44 index++
45 continue
46 }
47
48 m.add(line[plain:index])
49
50 start := m.units
51 if span.kind == "code" {
52 m.add(span.content)
53 } else {
54 m.inline(span.content)
55 }
56
57 m.entity(TelegramEntity{
58 Type: span.kind,
59 Offset: start,
60 Length: m.units - start,
61 URL: span.url,
62 })
63
64 index = span.end
65 plain = span.end
66 }
67
68 m.add(line[plain:])
69 }
70
71 // add appends text.
72 func (m *TelegramMarkdownText) add(text string) {
73 m.text.WriteString(text)
74
75 m.units += telegramUTF16Units(text)
76 }
77
78 // entity keeps entity, unless it is empty, because Telegram refuses an entity
79 // of length 0.
80 func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
81 if entity.Length > 0 {
82 m.entities = append(m.entities, entity)
83 }
84 }
85
86 // telegramUTF16Units returns the number of UTF-16 code units of text.
87 func telegramUTF16Units(text string) int {
88 units := 0
89 for _, char := range text {
90 units++
91 if char > 0xFFFF {
92 units++
93 }
94 }
95
96 return units
97 }
98
99 // telegramMarkdownEntities converts the Markdown of a reply to plain text and
100 // Telegram message entities. It knows bold (** and __), italic (* and _),
101 // strikethrough (~~), inline code, fenced code blocks with an optional
102 // language, links [text](url), and ATX headings, which become bold. Lists,
103 // quotes, and tables stay as their text. A mark that does not close stays as
104 // text with no entity, and code holds no entity.
105 //
106 // Inline marks do not cross a line break, so a mark that does not close cannot
107 // change the rest of the reply.
108 func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
109 var out TelegramMarkdownText
110
111 lines := strings.Split(markdown, "\n")
112 for index := 0; index < len(lines); index++ {
113 if index > 0 {
114 out.add("\n")
115 }
116
117 line := lines[index]
118 language, fence := markdownCodeFence(line)
119 end := markdownClosingFence(lines, index, fence)
120 heading := markdownHeadingText(line)
121 switch {
122 case end > index:
123 start := out.units
124 out.add(strings.Join(lines[index+1:end], "\n"))
125 out.entity(TelegramEntity{
126 Type: "pre",
127 Offset: start,
128 Length: out.units - start,
129 Language: language,
130 })
131
132 index = end
133 case heading != "":
134 start := out.units
135 out.inline(heading)
136 out.entity(TelegramEntity{
137 Type: "bold",
138 Offset: start,
139 Length: out.units - start,
140 })
141 default:
142 out.inline(line)
143 }
144 }
145
146 slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
147 if a.Offset != b.Offset {
148 return a.Offset - b.Offset
149 }
150
151 return b.Length - a.Length
152 })
153
154 return out.text.String(), out.entities
155 }
156
157 // telegramEscapeTags writes each & of markdown outside inline code and fenced
158 // code blocks as &amp; and each < as &lt;. Rich Markdown reads raw HTML and
159 // HTML entities in prose, and it does not read a backslash before < as an
160 // escape. With the entities, it shows a tag name and an entity that the model
161 // wrote in prose as text.
162 func telegramEscapeTags(markdown string) string {
163 return telegramRewriteProse(markdown, telegramTagEscaper.Replace)
164 }
165
166 // telegramRewriteProse returns markdown with rewrite applied to each part of a
167 // line outside inline code and fenced code blocks. A code block that does not
168 // close runs to the end, as in Markdown.
169 func telegramRewriteProse(markdown string, rewrite func(string) string) string {
170 var out strings.Builder
171
172 lines := strings.Split(markdown, "\n")
173 for index := 0; index < len(lines); index++ {
174 if index > 0 {
175 out.WriteString("\n")
176 }
177
178 line := lines[index]
179 _, fence := markdownCodeFence(line)
180 if fence == "" {
181 telegramRewriteLineProse(&out, line, rewrite)
182
183 continue
184 }
185
186 end := markdownClosingFence(lines, index, fence)
187 if end < 0 {
188 end = len(lines) - 1
189 }
190
191 out.WriteString(strings.Join(lines[index:end+1], "\n"))
192
193 index = end
194 }
195
196 return out.String()
197 }
198
199 // telegramRewriteLineProse writes line to out with rewrite applied to each part
200 // outside inline code. A backtick after a backslash opens no code. A run of
201 // backticks that opens no inline code stays as text whole, as in Markdown.
202 func telegramRewriteLineProse(out *strings.Builder, line string, rewrite func(string) string) {
203 prose := 0
204 for index := 0; index < len(line); {
205 switch line[index] {
206 case '\\':
207 if index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
208 index += 2
209 continue
210 }
211 case '`':
212 end := index + markdownBackticks(line[index:])
213 span, found := markdownCodeSpan(line, index)
214 if found {
215 end = span.end
216 }
217
218 out.WriteString(rewrite(line[prose:index]))
219 out.WriteString(line[index:end])
220
221 index = end
222 prose = end
223
224 continue
225 }
226
227 index++
228 }
229
230 out.WriteString(rewrite(line[prose:]))
231 }
232
233 // telegramUnescapeTags gives back the Markdown that telegramEscapeTags escaped.
234 // It is exact, because after the escape each & outside code starts &amp; or
235 // &lt;. It also works on a part of telegramSplitRich, because a part has the
236 // same code as the text that it comes from.
237 func telegramUnescapeTags(markdown string) string {
238 return telegramRewriteProse(markdown, telegramTagUnescaper.Replace)
239 }