Skip to content

Commit a2f3bc4

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 a2f3bc4

2 files changed

Lines changed: 145 additions & 11 deletions

File tree

internal/notify/telegram_format_convert.go

Lines changed: 73 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,42 @@ func htmlAttr(node *nethtml.Node, key string) string {
332358
return ""
333359
}
334360

361+
func telegramTextNodeData(node *nethtml.Node) string {
362+
data := node.Data
363+
if previousElementTag(node) == "br" {
364+
data = strings.TrimLeft(data, "\r\n")
365+
}
366+
return data
367+
}
368+
369+
func previousElementTag(node *nethtml.Node) string {
370+
for sibling := node.PrevSibling; sibling != nil; sibling = sibling.PrevSibling {
371+
if sibling.Type == nethtml.TextNode && strings.TrimSpace(sibling.Data) == "" {
372+
continue
373+
}
374+
if sibling.Type == nethtml.ElementNode {
375+
return strings.ToLower(sibling.Data)
376+
}
377+
return ""
378+
}
379+
return ""
380+
}
381+
382+
func ensureParagraphBreak(out *strings.Builder) {
383+
if out.Len() == 0 {
384+
return
385+
}
386+
value := out.String()
387+
switch {
388+
case strings.HasSuffix(value, "\n\n"):
389+
return
390+
case strings.HasSuffix(value, "\n"):
391+
out.WriteByte('\n')
392+
default:
393+
out.WriteString("\n\n")
394+
}
395+
}
396+
335397
func ensureLineBreak(out *strings.Builder) {
336398
if out.Len() == 0 {
337399
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)