Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion notify/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (n *Notifier) Notify(ctx context.Context, alert ...*types.Alert) (bool, err
tmpl = notify.TmplHTML(n.tmpl, data, &err)
}

messageText, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
messageText, truncated := truncateMessage(tmpl(n.conf.Message), maxMessageLenRunes, n.conf.ParseMode)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -133,3 +133,10 @@ func (n *Notifier) getBotToken() (string, error) {
}
return string(n.conf.BotToken), nil
}

func truncateMessage(message string, maxMessageLenRunes int, parseMode string) (string, bool) {
if parseMode == "HTML" {
return notify.TruncateInRunesHTML(message, maxMessageLenRunes)
}
return notify.TruncateInRunes(message, maxMessageLenRunes)
}
90 changes: 90 additions & 0 deletions notify/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,96 @@ func TruncateInRunes(s string, n int) (string, bool) {
return string(r[:n-1]) + truncationMarker, true
}

// TruncateInRunesHTML truncates an HTML string to fit the given size in runes,
// ensuring all open HTML tags are properly closed after truncation.
func TruncateInRunesHTML(s string, n int) (string, bool) {
runes := []rune(s)
if len(runes) <= n {
return s, false
}

if n <= 1 {
return string(runes[:n]), true
}

const closingTagOverhead = 3 // len("</>")

var (
openTags []string
inTag bool
tagStart int
lastSafePos int
lastSafeTags []string
)

closingLen := func(tags []string) int {
total := 0
for _, tag := range tags {
total += closingTagOverhead + len([]rune(tag))
}
return total
}

markSafeCutPoint := func(pos int) {
needed := pos + 1 + closingLen(openTags) // +1 for truncation marker
if needed <= n {
lastSafePos = pos
lastSafeTags = slices.Clone(openTags)
}
}

for i, r := range runes {
switch {
case !inTag && r == '<':
inTag = true
tagStart = i

case inTag && r == '>':
inTag = false
tagContent := string(runes[tagStart+1 : i])

switch {
case strings.HasPrefix(tagContent, "/"):
closeName := strings.TrimSpace(strings.TrimPrefix(tagContent, "/"))
for j := len(openTags) - 1; j >= 0; j-- {
if openTags[j] == closeName {
openTags = append(openTags[:j], openTags[j+1:]...)
break
}
}
case strings.HasSuffix(strings.TrimSpace(tagContent), "/"):
// self-closing
default:
tagName := tagContent
if idx := strings.IndexAny(tagContent, " \t\n"); idx != -1 {
tagName = tagContent[:idx]
}
openTags = append(openTags, tagName)
}
markSafeCutPoint(i + 1)

case !inTag:
markSafeCutPoint(i + 1)
}
}

if lastSafePos == 0 {
return string(runes[:n-1]) + truncationMarker, true
}

var truncated strings.Builder
truncated.Grow(n)
truncated.WriteString(string(runes[:lastSafePos]))
truncated.WriteString(truncationMarker)
for i := len(lastSafeTags) - 1; i >= 0; i-- {
truncated.WriteString("</")
truncated.WriteString(lastSafeTags[i])
truncated.WriteRune('>')
}

return truncated.String(), true
}

// TruncateInBytes truncates a string to fit the given size in Bytes.
func TruncateInBytes(s string, n int) (string, bool) {
// First, measure the string the w/o a to-rune conversion.
Expand Down
131 changes: 131 additions & 0 deletions notify/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,137 @@ import (
"github.com/stretchr/testify/require"
)

func TestTruncateInRunesHTML(t *testing.T) {
testCases := []struct {
name string
in string
n int
out string
trunc bool
}{
{
name: "no truncation needed",
in: "<b>hello</b>",
n: 100,
out: "<b>hello</b>",
trunc: false,
},
{
name: "empty string",
in: "",
n: 10,
out: "",
trunc: false,
},
{
name: "truncate plain text with single tag",
in: "<b>hello world</b>",
n: 15,
out: "<b>hello w…</b>",
trunc: true,
},
{
name: "truncate with nested tags closes both",
in: "<b>bold <i>italic</i> text</b>",
n: 20,
out: "<b>bold <i>…</i></b>",
trunc: true,
},
{
name: "nested tags with more space",
in: "<b>bold <i>italic text</i></b>",
n: 27,
out: "<b>bold <i>italic …</i></b>",
trunc: true,
},
{
name: "tag with attributes needs room for closing",
in: `<a href="http://example.com">link text</a>`,
n: 41,
out: `<a href="http://example.com">link te…</a>`,
trunc: true,
},
{
name: "self-closing tag not added to stack",
in: "<b>hello<br/>world</b>",
n: 19,
out: "<b>hello<br/>w…</b>",
trunc: true,
},
{
name: "self-closing with space not added to stack",
in: "<b>hello<br />world</b>",
n: 20,
out: "<b>hello<br />w…</b>",
trunc: true,
},
{
name: "sequential tags no open at cut point",
in: "<b>one</b><i>two</i><u>three</u>",
n: 21,
out: "<b>one</b><i>two</i>…",
trunc: true,
},
{
name: "cut at tag boundary no open tags",
in: "<b>ab</b>cdef",
n: 11,
out: "<b>ab</b>c…",
trunc: true,
},
{
name: "very small n falls back to simple truncation",
in: "<b>text</b>",
n: 3,
out: "<b…",
trunc: true,
},
{
name: "unicode content",
in: "<b>こんにちは世界</b>",
n: 13,
out: "<b>こんにちは…</b>",
trunc: true,
},
{
name: "emoji content",
in: "<b>🔥 alert 🔥</b>",
n: 15,
out: "<b>🔥 alert…</b>",
trunc: true,
},
{
name: "deeply nested tags all closed",
in: "<b><i><u>deep text</u></i></b>",
n: 25,
out: "<b><i><u>dee…</u></i></b>",
trunc: true,
},
{
name: "mismatched closing tag preserved",
in: "<b>text</i>more</b>",
n: 17,
out: "<b>text</i>m…</b>",
trunc: true,
},
{
name: "real telegram template pattern",
in: "🔥 <b>AlertName</b> 🔥\n<b>Labels:</b>\n<b>sev</b>: <i>crit</i>",
n: 52,
out: "🔥 <b>AlertName</b> 🔥\n<b>Labels:</b>\n<b>sev</b>: …",
trunc: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out, trunc := TruncateInRunesHTML(tc.in, tc.n)
require.Equal(t, tc.out, out)
require.Equal(t, tc.trunc, trunc)
})
}
}

func TestTruncate(t *testing.T) {
type expect struct {
out string
Expand Down