Skip to content

Commit 653d524

Browse files
committed
fix(telegram): preserve markdown paragraph spacing
Purpose of the change: - Preserve markdown paragraph spacing when converting messages for Telegram Markdown and HTML targets. How behavior was before: - Paragraph blocks and hard line breaks were both normalized through single-line break handling. - The markdown renderer's newline after generated <br> nodes could combine with explicit block breaks. Why that was a problem: - Blank lines appeared to move in Telegram messages, matching the spacing regression reported in #48. - Consecutive metadata lines could gain extra blank lines while real paragraph gaps disappeared. What the new change accomplishes: - Keeps paragraph boundaries as paragraph breaks while preserving single hard line breaks as one newline. - Adds regression coverage for the exact issue sample across Telegram Markdown v1, MarkdownV2, and HTML output. How it works: - Introduces paragraph-aware block handling for Telegram renderers. - Trims parser-inserted text-node newlines that immediately follow <br> elements.
1 parent cf5e556 commit 653d524

2 files changed

Lines changed: 164 additions & 11 deletions

File tree

internal/notify/telegram_format_convert.go

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ var telegramHTMLBlockTags = map[string]struct{}{
2222
"pre": {},
2323
}
2424

25+
var telegramHTMLParagraphTags = map[string]struct{}{
26+
"blockquote": {},
27+
"div": {},
28+
"h1": {},
29+
"h2": {},
30+
"h3": {},
31+
"h4": {},
32+
"h5": {},
33+
"h6": {},
34+
"p": {},
35+
}
36+
2537
func convertTelegramMessageFormat(content, inputFormat, outputFormat, markdownVersion string) (string, error) {
2638
input := normalizeNotifyFormat(inputFormat)
2739
output := normalizeNotifyFormat(outputFormat)
@@ -81,10 +93,11 @@ func telegramHTMLFromHTML(content string) string {
8193

8294
func renderTelegramHTMLNode(out *strings.Builder, node *nethtml.Node, inCode bool) {
8395
if node.Type == nethtml.TextNode {
84-
if !inCode && strings.TrimSpace(node.Data) == "" && strings.ContainsAny(node.Data, "\r\n") {
96+
data := telegramTextNodeData(node)
97+
if !inCode && strings.TrimSpace(data) == "" && strings.ContainsAny(data, "\r\n") {
8598
return
8699
}
87-
out.WriteString(html.EscapeString(node.Data))
100+
out.WriteString(html.EscapeString(data))
88101
return
89102
}
90103
if node.Type != nethtml.ElementNode {
@@ -93,8 +106,12 @@ func renderTelegramHTMLNode(out *strings.Builder, node *nethtml.Node, inCode boo
93106
}
94107

95108
tag := strings.ToLower(node.Data)
96-
if _, ok := telegramHTMLBlockTags[tag]; ok && out.Len() > 0 {
97-
ensureLineBreak(out)
109+
if out.Len() > 0 {
110+
if _, ok := telegramHTMLParagraphTags[tag]; ok {
111+
ensureParagraphBreak(out)
112+
} else if _, ok := telegramHTMLBlockTags[tag]; ok {
113+
ensureLineBreak(out)
114+
}
98115
}
99116

100117
switch tag {
@@ -150,7 +167,9 @@ func renderTelegramHTMLNode(out *strings.Builder, node *nethtml.Node, inCode boo
150167
renderTelegramHTMLChildren(out, node, inCode)
151168
}
152169

153-
if _, ok := telegramHTMLBlockTags[tag]; ok {
170+
if _, ok := telegramHTMLParagraphTags[tag]; ok {
171+
ensureParagraphBreak(out)
172+
} else if _, ok := telegramHTMLBlockTags[tag]; ok {
154173
ensureLineBreak(out)
155174
}
156175
}
@@ -177,14 +196,15 @@ func telegramMarkdownFromHTML(content, markdownMode string) string {
177196

178197
func renderTelegramMarkdownNode(out *strings.Builder, node *nethtml.Node, markdownMode string, inCode bool) {
179198
if node.Type == nethtml.TextNode {
199+
data := telegramTextNodeData(node)
180200
if inCode {
181-
out.WriteString(escapeTelegramMarkdownCodeText(node.Data, markdownMode))
201+
out.WriteString(escapeTelegramMarkdownCodeText(data, markdownMode))
182202
return
183203
}
184-
if strings.TrimSpace(node.Data) == "" && strings.ContainsAny(node.Data, "\r\n") {
204+
if strings.TrimSpace(data) == "" && strings.ContainsAny(data, "\r\n") {
185205
return
186206
}
187-
out.WriteString(escapeTelegramMarkdownText(node.Data, markdownMode))
207+
out.WriteString(escapeTelegramMarkdownText(data, markdownMode))
188208
return
189209
}
190210
if node.Type != nethtml.ElementNode {
@@ -193,8 +213,12 @@ func renderTelegramMarkdownNode(out *strings.Builder, node *nethtml.Node, markdo
193213
}
194214

195215
tag := strings.ToLower(node.Data)
196-
if _, ok := telegramHTMLBlockTags[tag]; ok && out.Len() > 0 {
197-
ensureLineBreak(out)
216+
if out.Len() > 0 {
217+
if _, ok := telegramHTMLParagraphTags[tag]; ok {
218+
ensureParagraphBreak(out)
219+
} else if _, ok := telegramHTMLBlockTags[tag]; ok {
220+
ensureLineBreak(out)
221+
}
198222
}
199223

200224
switch tag {
@@ -254,7 +278,9 @@ func renderTelegramMarkdownNode(out *strings.Builder, node *nethtml.Node, markdo
254278
renderTelegramMarkdownChildren(out, node, markdownMode, inCode)
255279
}
256280

257-
if _, ok := telegramHTMLBlockTags[tag]; ok {
281+
if _, ok := telegramHTMLParagraphTags[tag]; ok {
282+
ensureParagraphBreak(out)
283+
} else if _, ok := telegramHTMLBlockTags[tag]; ok {
258284
ensureLineBreak(out)
259285
}
260286
}
@@ -332,6 +358,61 @@ func htmlAttr(node *nethtml.Node, key string) string {
332358
return ""
333359
}
334360

361+
// telegramTextNodeData returns node text while trimming leading CR/LF only after
362+
// a preceding br element, as reported by previousElementTag, to compensate for
363+
// markdown parsers that emit a formatting newline after <br>. Other whitespace
364+
// is preserved in non-br contexts.
365+
func telegramTextNodeData(node *nethtml.Node) string {
366+
if node == nil {
367+
return ""
368+
}
369+
data := node.Data
370+
if previousElementTag(node) == "br" {
371+
data = strings.TrimLeft(data, "\r\n")
372+
}
373+
return data
374+
}
375+
376+
// previousElementTag walks previous siblings to find the nearest non-empty
377+
// element node. It skips whitespace-only text nodes because HTML parsers often
378+
// expose formatting whitespace as siblings, returns the lowercase tag name of
379+
// the first previous element node found, and returns an empty string for nil
380+
// input or when only non-whitespace text/other nodes are found.
381+
func previousElementTag(node *nethtml.Node) string {
382+
if node == nil {
383+
return ""
384+
}
385+
for sibling := node.PrevSibling; sibling != nil; sibling = sibling.PrevSibling {
386+
if sibling.Type == nethtml.TextNode && strings.TrimSpace(sibling.Data) == "" {
387+
continue
388+
}
389+
if sibling.Type == nethtml.ElementNode {
390+
return strings.ToLower(sibling.Data)
391+
}
392+
return ""
393+
}
394+
return ""
395+
}
396+
397+
// ensureParagraphBreak mutates the provided *strings.Builder in-place so it
398+
// ends with one paragraph break. It no-ops for empty builders or existing
399+
// double-newline endings, appends one newline after a single trailing newline,
400+
// and appends two newlines otherwise, making it safe to call multiple times.
401+
func ensureParagraphBreak(out *strings.Builder) {
402+
if out.Len() == 0 {
403+
return
404+
}
405+
value := out.String()
406+
switch {
407+
case strings.HasSuffix(value, "\n\n"):
408+
return
409+
case strings.HasSuffix(value, "\n"):
410+
out.WriteByte('\n')
411+
default:
412+
out.WriteString("\n\n")
413+
}
414+
}
415+
335416
func ensureLineBreak(out *strings.Builder) {
336417
if out.Len() == 0 {
337418
return

internal/notify/telegram_format_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,78 @@ func TestTelegramMarkdownV2PreservesLinks(t *testing.T) {
185185
}
186186
}
187187

188+
func TestTelegramPreservesMarkdownParagraphSpacing(t *testing.T) {
189+
body := strings.Join([]string{
190+
"**--- Header ---**",
191+
"",
192+
"Hostname: **server**",
193+
"Date/Time: **Today**",
194+
"Uptime: **TESTER**",
195+
"",
196+
"_Beware of a tall blond man with one black shoe._",
197+
}, "\n")
198+
199+
cases := []struct {
200+
name string
201+
rawURL string
202+
expected string
203+
}{
204+
{
205+
name: "markdown v1",
206+
rawURL: "tgram://123456:abcdef/7890/?format=markdown&mdv=v1",
207+
expected: strings.Join([]string{
208+
"*— Header —*",
209+
"",
210+
"Hostname: *server*",
211+
"Date/Time: *Today*",
212+
"Uptime: *TESTER*",
213+
"",
214+
"_Beware of a tall blond man with one black shoe._",
215+
}, "\n"),
216+
},
217+
{
218+
name: "markdown v2",
219+
rawURL: "tgram://123456:abcdef/7890/?format=markdown&mdv=v2",
220+
expected: strings.Join([]string{
221+
"*— Header —*",
222+
"",
223+
"Hostname: *server*",
224+
"Date/Time: *Today*",
225+
"Uptime: *TESTER*",
226+
"",
227+
"_Beware of a tall blond man with one black shoe\\._",
228+
}, "\n"),
229+
},
230+
{
231+
name: "html",
232+
rawURL: "tgram://123456:abcdef/7890/?format=html",
233+
expected: strings.Join([]string{
234+
"<b>— Header —</b>",
235+
"",
236+
"Hostname: <b>server</b>",
237+
"Date/Time: <b>Today</b>",
238+
"Uptime: <b>TESTER</b>",
239+
"",
240+
"<i>Beware of a tall blond man with one black shoe.</i>",
241+
}, "\n"),
242+
},
243+
}
244+
245+
for _, tc := range cases {
246+
t.Run(tc.name, func(t *testing.T) {
247+
payload := captureTelegramPayload(t, tc.rawURL, body, "", "markdown")
248+
249+
text, ok := payload["text"].(string)
250+
if !ok {
251+
t.Fatalf("expected text payload, got %#v", payload["text"])
252+
}
253+
if text != tc.expected {
254+
t.Fatalf("expected Telegram body\n%q\ngot\n%q", tc.expected, text)
255+
}
256+
})
257+
}
258+
}
259+
188260
func TestTelegramTextFormatUsesHTMLParseMode(t *testing.T) {
189261
assertTelegramFormatParity(t, "tgram://123456:abcdef/7890/?format=text", "<b>plain</b>", "Title", "text")
190262
}

0 commit comments

Comments
 (0)