Skip to content

Commit 943e0aa

Browse files
committed
feat: add original message formatting helpers with tests
1 parent ec97c6a commit 943e0aa

2 files changed

Lines changed: 626 additions & 0 deletions

File tree

formatting.go

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
package botapi
2+
3+
import (
4+
"html"
5+
"slices"
6+
"strconv"
7+
"strings"
8+
"unicode"
9+
"unicode/utf16"
10+
)
11+
12+
var mdMap = map[MessageEntityType]string{
13+
EntityBold: "*",
14+
EntityItalic: "_",
15+
EntityCode: "`",
16+
EntityPre: "```",
17+
}
18+
19+
var mdV2Map = map[MessageEntityType]string{
20+
EntityBold: "*",
21+
EntityItalic: "_",
22+
EntityCode: "`",
23+
EntityPre: "```",
24+
EntityUnderline: "__",
25+
EntityStrikethrough: "~",
26+
EntitySpoiler: "||",
27+
EntityBlockquote: ">",
28+
EntityExpandableBlockquote: "**>",
29+
}
30+
31+
var htmlMap = map[MessageEntityType]string{
32+
EntityBold: "b",
33+
EntityItalic: "i",
34+
EntityCode: "code",
35+
EntityPre: "pre",
36+
EntityUnderline: "u",
37+
EntityStrikethrough: "s",
38+
EntitySpoiler: "span class=\"tg-spoiler\"",
39+
EntityBlockquote: "blockquote",
40+
EntityExpandableBlockquote: "blockquote expandable",
41+
}
42+
43+
// TextAndEntities gets message or caption text and entities
44+
func (m *Message) TextAndEntities() (string, []MessageEntity) {
45+
if m.Text != "" {
46+
return m.Text, m.Entities
47+
}
48+
return m.Caption, m.CaptionEntities
49+
}
50+
51+
// OriginalMD gets the original markdown formatting of a message text.
52+
func (m *Message) OriginalMD() string {
53+
return getOrigMsgMD(utf16.Encode([]rune(m.Text)), m.Entities)
54+
}
55+
56+
// OriginalMDV2 gets the original markdownV2 formatting of a message text.
57+
func (m *Message) OriginalMDV2() string {
58+
return getOrigMsgMDV2(utf16.Encode([]rune(m.Text)), m.Entities)
59+
}
60+
61+
// OriginalHTML gets the original HTML formatting of a message text.
62+
func (m *Message) OriginalHTML() string {
63+
return getOrigMsgHTML(utf16.Encode([]rune(m.Text)), m.Entities)
64+
}
65+
66+
// OriginalCaptionMD gets the original markdown formatting of a message caption.
67+
func (m *Message) OriginalCaptionMD() string {
68+
return getOrigMsgMD(utf16.Encode([]rune(m.Caption)), m.CaptionEntities)
69+
}
70+
71+
// OriginalCaptionMDV2 gets the original markdownV2 formatting of a message caption.
72+
func (m *Message) OriginalCaptionMDV2() string {
73+
return getOrigMsgMDV2(utf16.Encode([]rune(m.Caption)), m.CaptionEntities)
74+
}
75+
76+
// OriginalCaptionHTML gets the original HTML formatting of a message caption.
77+
func (m *Message) OriginalCaptionHTML() string {
78+
return getOrigMsgHTML(utf16.Encode([]rune(m.Caption)), m.CaptionEntities)
79+
}
80+
81+
// OriginalTextMD gets the original markdown formatting of a message text or caption.
82+
func (m *Message) OriginalTextMD() string {
83+
text, ents := m.TextAndEntities()
84+
return getOrigMsgMD(utf16.Encode([]rune(text)), ents)
85+
}
86+
87+
// OriginalTextMDV2 gets the original markdownV2 formatting of a message text or caption.
88+
func (m *Message) OriginalTextMDV2() string {
89+
text, ents := m.TextAndEntities()
90+
return getOrigMsgMDV2(utf16.Encode([]rune(text)), ents)
91+
}
92+
93+
// OriginalTextHTML gets the original HTML formatting of a message text caption.
94+
func (m *Message) OriginalTextHTML() string {
95+
text, ents := m.TextAndEntities()
96+
return getOrigMsgHTML(utf16.Encode([]rune(text)), ents)
97+
}
98+
99+
// Does not support nesting. only look at upper entities.
100+
func getOrigMsgMD(utf16Data []uint16, ents []MessageEntity) string {
101+
out := strings.Builder{}
102+
prev := 0
103+
104+
for _, ent := range getUpperEntities(ents) {
105+
newPrev := ent.Offset + ent.Length
106+
prevText := string(utf16.Decode(utf16Data[prev:ent.Offset]))
107+
108+
text := utf16.Decode(utf16Data[ent.Offset:newPrev])
109+
pre, cleanCntnt, post := splitEdgeWhitespace(string(text), ent)
110+
cleanCntntRune := []rune(cleanCntnt)
111+
112+
switch ent.Type {
113+
case EntityBold, EntityItalic, EntityCode:
114+
out.WriteString(prevText + pre + mdMap[ent.Type] + escapeContainedMDV1(cleanCntntRune, []rune(mdMap[ent.Type])) + mdMap[ent.Type] + post)
115+
case EntityPre:
116+
if ent.Language == "" {
117+
out.WriteString(prevText + pre + mdMap[ent.Type] +
118+
escapeContainedMDV1(cleanCntntRune, []rune(mdMap[ent.Type])) + mdMap[ent.Type] + post)
119+
} else {
120+
out.WriteString(prevText + pre + mdMap[ent.Type] +
121+
ent.Language + "\n" + escapeContainedMDV1(cleanCntntRune, []rune(mdMap[ent.Type])) + mdMap[ent.Type] + post)
122+
}
123+
case EntityTextMention:
124+
out.WriteString(prevText + pre + "[" + escapeContainedMDV1(cleanCntntRune, []rune("[]()")) + "](tg://user?id=" +
125+
strconv.FormatInt(ent.User.ID, 10) + ")" + post)
126+
case EntityTextLink:
127+
out.WriteString(prevText + pre + "[" + escapeContainedMDV1(cleanCntntRune, []rune("[]()")) + "](" + ent.URL + ")" + post)
128+
default:
129+
continue
130+
}
131+
132+
prev = newPrev
133+
}
134+
135+
out.WriteString(string(utf16.Decode(utf16Data[prev:])))
136+
137+
return out.String()
138+
}
139+
140+
func getOrigMsgHTML(utf16Data []uint16, ents []MessageEntity) string {
141+
if len(ents) == 0 {
142+
return html.EscapeString(string(utf16.Decode(utf16Data)))
143+
}
144+
145+
bd := strings.Builder{}
146+
prev := 0
147+
148+
for _, e := range getUpperEntities(ents) {
149+
data, end := fillNestedHTML(utf16Data, e, prev, getChildEntities(e, ents))
150+
bd.WriteString(data)
151+
152+
prev = end
153+
}
154+
155+
bd.WriteString(html.EscapeString(string(utf16.Decode(utf16Data[prev:]))))
156+
157+
return bd.String()
158+
}
159+
160+
func getOrigMsgMDV2(utf16Data []uint16, ents []MessageEntity) (origMsg string) {
161+
if len(ents) == 0 {
162+
return string(utf16.Decode(utf16Data))
163+
}
164+
165+
bd := strings.Builder{}
166+
prev := 0
167+
168+
for _, e := range getUpperEntities(ents) {
169+
data, end := fillNestedMarkdownV2(utf16Data, e, prev, getChildEntities(e, ents))
170+
bd.WriteString(data)
171+
172+
prev = end
173+
}
174+
175+
bd.WriteString(string(utf16.Decode(utf16Data[prev:])))
176+
177+
return bd.String()
178+
}
179+
180+
func fillNestedHTML(data []uint16, ent MessageEntity, start int, entities []MessageEntity) (finalHTML string, entEnd int) {
181+
entEnd = ent.Offset + ent.Length
182+
if len(entities) == 0 || entEnd < entities[0].Offset {
183+
// no nesting; just return straight away and move to next.
184+
return writeFinalHTML(data, ent, start, html.EscapeString(string(utf16.Decode(data[ent.Offset:entEnd])))), entEnd
185+
}
186+
187+
subPrev := ent.Offset
188+
subEnd := ent.Offset
189+
bd := strings.Builder{}
190+
191+
for _, e := range getUpperEntities(entities) {
192+
if e.Offset < subEnd || e == ent {
193+
continue
194+
}
195+
196+
if e.Offset >= entEnd {
197+
break
198+
}
199+
200+
out, end := fillNestedHTML(data, e, subPrev, getChildEntities(e, entities))
201+
bd.WriteString(out)
202+
203+
subPrev = end
204+
}
205+
206+
bd.WriteString(html.EscapeString(string(utf16.Decode(data[subPrev:entEnd]))))
207+
208+
return writeFinalHTML(data, ent, start, bd.String()), entEnd
209+
}
210+
211+
func fillNestedMarkdownV2(
212+
data []uint16,
213+
ent MessageEntity,
214+
start int,
215+
entities []MessageEntity,
216+
) (finalMD string, entEnd int) {
217+
entEnd = ent.Offset + ent.Length
218+
if len(entities) == 0 || entEnd < entities[0].Offset {
219+
// no nesting; just return straight away and move to next.
220+
return writeFinalMarkdownV2(data, ent, start, string(utf16.Decode(data[ent.Offset:entEnd]))), entEnd
221+
}
222+
223+
subPrev := ent.Offset
224+
subEnd := ent.Offset
225+
bd := strings.Builder{}
226+
227+
for _, e := range getUpperEntities(entities) {
228+
if e.Offset < subEnd || e == ent {
229+
continue
230+
}
231+
232+
if e.Offset >= entEnd {
233+
break
234+
}
235+
236+
out, end := fillNestedMarkdownV2(data, e, subPrev, getChildEntities(e, entities))
237+
bd.WriteString(out)
238+
239+
subPrev = end
240+
}
241+
242+
bd.WriteString(string(utf16.Decode(data[subPrev:entEnd])))
243+
244+
return writeFinalMarkdownV2(data, ent, start, bd.String()), entEnd
245+
}
246+
247+
func writeFinalHTML(data []uint16, ent MessageEntity, start int, cntnt string) string {
248+
prevText := html.EscapeString(string(utf16.Decode(data[start:ent.Offset])))
249+
switch ent.Type {
250+
case EntityBold, EntityItalic, EntityCode, EntityUnderline, EntityStrikethrough, EntitySpoiler:
251+
return prevText + "<" + htmlMap[ent.Type] + ">" + cntnt + "</" + closeHTMLTag(htmlMap[ent.Type]) + ">"
252+
case EntityPre:
253+
if ent.Language == "" {
254+
return prevText + "<pre>" + cntnt + "</pre>"
255+
}
256+
257+
return prevText + `<pre><code class="` + ent.Language + `">` + cntnt + "</code></pre>"
258+
case EntityCustomEmoji:
259+
return prevText + `<tg-emoji emoji-id="` + ent.CustomEmojiID + `">` + cntnt + "</tg-emoji>"
260+
case EntityDateTime:
261+
if ent.DateTimeFormat != "" {
262+
return prevText + `<tg-time unix="` + strconv.Itoa(ent.UnixTime) + `" format="` + ent.DateTimeFormat + `">` + cntnt + "</tg-time>"
263+
}
264+
265+
return prevText + `<tg-time unix="` + strconv.Itoa(ent.UnixTime) + `">` + cntnt + "</tg-time>"
266+
case EntityTextMention:
267+
return prevText + `<a href="tg://user?id=` + strconv.FormatInt(ent.User.ID, 10) + `">` + cntnt + "</a>"
268+
case EntityTextLink:
269+
return prevText + `<a href="` + ent.URL + `">` + cntnt + "</a>"
270+
case EntityBlockquote:
271+
return prevText + `<blockquote>` + cntnt + "</blockquote>"
272+
case EntityExpandableBlockquote:
273+
return prevText + `<blockquote expandable>` + cntnt + "</blockquote>"
274+
default:
275+
return prevText + cntnt
276+
}
277+
}
278+
279+
// closeHTMLTag makes sure to generate the correct HTML closing tag for a given opening tag.
280+
func closeHTMLTag(s string) string {
281+
if !strings.HasPrefix(s, "span") {
282+
return s
283+
}
284+
285+
return "span"
286+
}
287+
288+
func writeFinalMarkdownV2(data []uint16, ent MessageEntity, start int, cntnt string) string {
289+
prevText := string(utf16.Decode(data[start:ent.Offset]))
290+
pre, cleanCntnt, post := splitEdgeWhitespace(cntnt, ent)
291+
292+
switch ent.Type {
293+
case EntityBold, EntityItalic, EntityCode, EntityUnderline, EntityStrikethrough, EntitySpoiler:
294+
return prevText + pre + mdV2Map[ent.Type] + cleanCntnt + mdV2Map[ent.Type] + post
295+
case EntityPre:
296+
if ent.Language == "" {
297+
return prevText + pre + "```\n" + cleanCntnt + "```" + post
298+
}
299+
300+
return prevText + pre + "```" + ent.Language + "\n" + cleanCntnt + "```" + post
301+
case EntityCustomEmoji:
302+
return prevText + pre + "![" + cleanCntnt + "](tg://emoji?id=" + ent.CustomEmojiID + ")" + post
303+
case EntityDateTime:
304+
if ent.DateTimeFormat != "" {
305+
return prevText + pre + "![" + cleanCntnt + "](tg://time?unix=" +
306+
strconv.Itoa(ent.UnixTime) + "&format=" + ent.DateTimeFormat + ")" + post
307+
}
308+
309+
return prevText + pre + "![" + cleanCntnt + "](tg://time?unix=" + strconv.Itoa(ent.UnixTime) + ")" + post
310+
case EntityTextMention:
311+
return prevText + pre + "[" + cleanCntnt + "](tg://user?id=" + strconv.FormatInt(ent.User.ID, 10) + ")" + post
312+
case EntityTextLink:
313+
return prevText + pre + "[" + cleanCntnt + "](" + ent.URL + ")" + post
314+
case EntityBlockquote:
315+
return prevText + pre + ">" + strings.Join(strings.Split(cleanCntnt, "\n"), "\n>") + post
316+
case EntityExpandableBlockquote:
317+
return prevText + pre + "**>" + strings.Join(strings.Split(cleanCntnt, "\n"), "\n>") + "||" + post
318+
default:
319+
return prevText + cntnt
320+
}
321+
}
322+
323+
func getUpperEntities(ents []MessageEntity) []MessageEntity {
324+
prev := 0
325+
uppers := make([]MessageEntity, 0, len(ents))
326+
327+
for _, e := range ents {
328+
if e.Offset < prev {
329+
continue
330+
}
331+
332+
uppers = append(uppers, e)
333+
prev = e.Offset + e.Length
334+
}
335+
336+
return uppers
337+
}
338+
339+
func getChildEntities(ent MessageEntity, ents []MessageEntity) []MessageEntity {
340+
end := ent.Offset + ent.Length
341+
children := make([]MessageEntity, 0, len(ents))
342+
343+
for _, e := range ents {
344+
if e.Offset < ent.Offset || e == ent {
345+
continue
346+
}
347+
348+
if e.Offset >= end {
349+
break
350+
}
351+
352+
children = append(children, e)
353+
}
354+
355+
return children
356+
}
357+
358+
func splitEdgeWhitespace(text string, ent MessageEntity) (pre, cntnt, post string) {
359+
keepNewLines := ent.Type == EntityPre
360+
361+
bd := strings.Builder{}
362+
rText := []rune(text)
363+
364+
for i := 0; i < len(rText) && unicode.IsSpace(rText[i]) && (!keepNewLines || rText[i] != '\n'); i++ {
365+
bd.WriteRune(rText[i])
366+
}
367+
368+
pre = bd.String()
369+
370+
text = strings.TrimPrefix(text, pre)
371+
372+
bd.Reset()
373+
374+
for i := len(rText) - 1; i >= 0 && unicode.IsSpace(rText[i]); i-- {
375+
bd.WriteRune(rText[i])
376+
}
377+
378+
post = bd.String()
379+
380+
return pre, strings.TrimSuffix(text, post), post
381+
}
382+
383+
func escapeContainedMDV1(data, mdType []rune) string {
384+
out := strings.Builder{}
385+
386+
for _, x := range data {
387+
if slices.Contains(mdType, x) {
388+
out.WriteRune('\\')
389+
}
390+
391+
out.WriteRune(x)
392+
}
393+
394+
return out.String()
395+
}

0 commit comments

Comments
 (0)