diff --git a/backend/app/notify/prune_html.go b/backend/app/notify/prune_html.go new file mode 100644 index 00000000..b2ebb7fa --- /dev/null +++ b/backend/app/notify/prune_html.go @@ -0,0 +1,77 @@ +package notify + +import ( + "fmt" + "strings" + + "golang.org/x/net/html" +) + +// pruneHTML prunes string keeping HTML closing tags. +// maxLength applies to visible text only, not HTML tags. +func pruneHTML(htmlText string, maxLength int) string { + var result strings.Builder + var endTokens []string + visibleLen := 0 + + suffix := "..." + suffixLen := len(suffix) + + tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) + for { + if tokenizer.Next() == html.ErrorToken { + return result.String() + } + token := tokenizer.Token() + + switch token.Type { + case html.CommentToken, html.DoctypeToken: + continue + + case html.StartTagToken: + endTokens = append([]string{fmt.Sprintf("", token.Data)}, endTokens...) + result.WriteString(token.String()) + + case html.EndTagToken: + if len(endTokens) > 0 { + endTokens = endTokens[1:] + } + result.WriteString(token.String()) + + case html.SelfClosingTagToken: + result.WriteString(token.String()) + + case html.TextToken: + text := token.String() + if visibleLen+len(text)+suffixLen > maxLength { + remaining := maxLength - visibleLen - suffixLen + text = pruneStringToWord(text, remaining) + result.WriteString(text) + result.WriteString(suffix) + for _, endTag := range endTokens { + result.WriteString(endTag) + } + return result.String() + } + visibleLen += len(text) + result.WriteString(text) + } + } +} + +// pruneStringToWord prunes string to specified length respecting word boundaries +func pruneStringToWord(text string, maxLength int) string { + if maxLength <= 0 { + return "" + } + if len(text) <= maxLength { + return text + } + + // find last space at or before maxLength to cut at word boundary + lastSpace := strings.LastIndex(text[:maxLength+1], " ") + if lastSpace <= 0 { + return "" + } + return text[:lastSpace] +} diff --git a/backend/app/notify/prune_html_test.go b/backend/app/notify/prune_html_test.go new file mode 100644 index 00000000..4ec2f6ab --- /dev/null +++ b/backend/app/notify/prune_html_test.go @@ -0,0 +1,47 @@ +package notify + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPruneHTML(t *testing.T) { + tests := []struct { + name string + html string + maxLength int + expected string + }{ + {"within limit", "

Hello

", 20, "

Hello

"}, + {"exceeds limit", "

Hello world, this is a long text

", 15, "

Hello world,...

"}, + {"nested tags", "

Hello world

More text

", 20, "

Hello world

More...

"}, + {"html comment stripped", "

Hello

", 20, "

Hello

"}, + {"self-closing tag", "

Hello
World

", 8, "

Hello
...

"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, pruneHTML(tt.html, tt.maxLength)) + }) + } +} + +func TestPruneStringToWord(t *testing.T) { + tests := []struct { + name string + text string + maxLength int + expected string + }{ + {"within limit", "hello world", 15, "hello world"}, + {"cut at word boundary", "hello world and more", 11, "hello world"}, + {"zero length", "hello", 0, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, pruneStringToWord(tt.text, tt.maxLength)) + }) + } +} diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 472b5c80..924478bd 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/go-multierror" ) +const commentTextLengthLimit = 100 + // TelegramParams contain settings for telegram notifications type TelegramParams struct { AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) @@ -85,10 +87,10 @@ func (t *Telegram) buildMessage(req Request) string { msg += fmt.Sprintf(" -> %s", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name)) } - msg += fmt.Sprintf("\n\n%s", ntf.TelegramSupportedHTML(req.Comment.Text)) + msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), commentTextLengthLimit)) if req.Comment.ParentID != "" { - msg += fmt.Sprintf("\n\n\"%s\"", ntf.TelegramSupportedHTML(req.parent.Text)) + msg += fmt.Sprintf("\n\n\"%s\"", pruneHTML(ntf.TelegramSupportedHTML(req.parent.Text), commentTextLengthLimit)) } if req.Comment.PostTitle != "" { diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 4518e156..dc832915 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -53,6 +53,15 @@ some text HelloWorld`, res) + + // prune string keeping HTML closing tags + c = store.Comment{ + Text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + } + res = tb.buildMessage(Request{Comment: c}) + assert.Equal(t, ` + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut...`, res) } func TestTelegram_SendVerification(t *testing.T) {