diff --git a/backend/app/notify/prune_html.go b/backend/app/notify/prune_html.go new file mode 100644 index 0000000000..98ba556230 --- /dev/null +++ b/backend/app/notify/prune_html.go @@ -0,0 +1,120 @@ +package notify + +import ( + "fmt" + "strings" + + "golang.org/x/net/html" +) + +type stringArr struct { + data []string + len int +} + +// Push adds element to the end +func (s *stringArr) Push(v string) { + s.data = append(s.data, v) + s.len += len(v) +} + +// Pop removes element from end and returns it +func (s *stringArr) Pop() string { + l := len(s.data) + newData, v := s.data[:l-1], s.data[l-1] + s.data = newData + s.len -= len(v) + return v +} + +// Unshift adds element to the start +func (s *stringArr) Unshift(v string) { + s.data = append([]string{v}, s.data...) + s.len += len(v) +} + +// Shift removes element from start and returns it +func (s *stringArr) Shift() string { + v, newData := s.data[0], s.data[1:] + s.data = newData + s.len -= len(v) + return v +} + +// String returns all strings concatenated +func (s stringArr) String() string { + return strings.Join(s.data, "") +} + +// Len returns total length of all strings concatenated +func (s stringArr) Len() int { + return s.len +} + +// pruneHTML prunes string keeping HTML closing tags +func pruneHTML(htmlText string, maxLength int) string { + result := stringArr{} + endTokens := stringArr{} + + 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: + // skip tokens without content + continue + + case html.StartTagToken: + // + // len(token) * 2 + len("<>") + totalLenToAppend := len(token.Data)*2 + 5 + + lengthAfterChange := result.Len() + totalLenToAppend + endTokens.Len() + suffixLen + + if lengthAfterChange > maxLength { + return result.String() + suffix + endTokens.String() + } + + endTokens.Unshift(fmt.Sprintf("", token.Data)) + + case html.EndTagToken: + endTokens.Shift() + + case html.TextToken, html.SelfClosingTagToken: + lengthAfterChange := result.Len() + len(token.String()) + endTokens.Len() + suffixLen + + if lengthAfterChange > maxLength { + text := pruneStringToWord(token.String(), maxLength-result.Len()-endTokens.Len()-suffixLen) + return result.String() + text + suffix + endTokens.String() + } + } + + result.Push((token.String())) + } +} + +// pruneStringToWord prunes string to specified length respecting words +func pruneStringToWord(text string, maxLength int) string { + if maxLength <= 0 { + return "" + } + + result := "" + + arr := strings.Split(text, " ") + for _, s := range arr { + if len(result)+len(s) > maxLength { + return strings.TrimRight(result, " ") + } + result += s + " " + } + + return text +} diff --git a/backend/app/notify/prune_html_test.go b/backend/app/notify/prune_html_test.go new file mode 100644 index 0000000000..29b09ca270 --- /dev/null +++ b/backend/app/notify/prune_html_test.go @@ -0,0 +1,310 @@ +package notify + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringArr_Push(t *testing.T) { + tests := []struct { + name string + initial []string + push string + expected []string + expLen int + }{ + { + name: "push to empty array", + initial: []string{}, + push: "hello", + expected: []string{"hello"}, + expLen: 5, + }, + { + name: "push to non-empty array", + initial: []string{"hello"}, + push: "world", + expected: []string{"hello", "world"}, + expLen: 10, + }, + { + name: "push empty string", + initial: []string{"hello"}, + push: "", + expected: []string{"hello", ""}, + expLen: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + s.Push(tt.push) + + assert.Equal(t, tt.expected, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_Pop(t *testing.T) { + tests := []struct { + name string + initial []string + expectedPop string + remaining []string + expLen int + }{ + { + name: "pop from array with multiple elements", + initial: []string{"hello", "world"}, + expectedPop: "world", + remaining: []string{"hello"}, + expLen: 5, + }, + { + name: "pop from array with one element", + initial: []string{"hello"}, + expectedPop: "hello", + remaining: []string{}, + expLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + popped := s.Pop() + + assert.Equal(t, tt.expectedPop, popped) + assert.Equal(t, tt.remaining, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_Unshift(t *testing.T) { + tests := []struct { + name string + initial []string + unshift string + expected []string + expLen int + }{ + { + name: "unshift to empty array", + initial: []string{}, + unshift: "hello", + expected: []string{"hello"}, + expLen: 5, + }, + { + name: "unshift to non-empty array", + initial: []string{"world"}, + unshift: "hello", + expected: []string{"hello", "world"}, + expLen: 10, + }, + { + name: "unshift empty string", + initial: []string{"world"}, + unshift: "", + expected: []string{"", "world"}, + expLen: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + s.Unshift(tt.unshift) + + assert.Equal(t, tt.expected, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_Shift(t *testing.T) { + tests := []struct { + name string + initial []string + expectedShift string + remaining []string + expLen int + }{ + { + name: "shift from array with multiple elements", + initial: []string{"hello", "world"}, + expectedShift: "hello", + remaining: []string{"world"}, + expLen: 5, + }, + { + name: "shift from array with one element", + initial: []string{"hello"}, + expectedShift: "hello", + remaining: []string{}, + expLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + shifted := s.Shift() + + assert.Equal(t, tt.expectedShift, shifted) + assert.Equal(t, tt.remaining, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_String(t *testing.T) { + tests := []struct { + name string + data []string + expected string + }{ + { + name: "empty array", + data: []string{}, + expected: "", + }, + { + name: "single element", + data: []string{"hello"}, + expected: "hello", + }, + { + name: "multiple elements", + data: []string{"hello", " ", "world"}, + expected: "hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.data} + assert.Equal(t, tt.expected, s.String()) + }) + } +} + +func TestPruneHTML(t *testing.T) { + tests := []struct { + name string + html string + maxLength int + expected string + }{ + { + name: "simple text within limit", + html: "

Hello

", + maxLength: 20, + expected: "

Hello

", + }, + { + name: "text exceeding limit", + html: "

Hello world, this is a long text

", + maxLength: 15, + expected: "

Hello...

", + }, + { + name: "nested tags within limit", + html: "

Hello

", + maxLength: 30, + expected: "

Hello

", + }, + { + name: "nested tags exceeding limit", + html: "

Hello world

More text

", + maxLength: 20, + expected: "
...
", + }, + { + name: "with comment", + html: "

Hello

", + maxLength: 20, + expected: "

Hello

", + }, + { + name: "self-closing tag", + html: "

Hello
World

", + maxLength: 20, + expected: "

Hello
...

", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pruneHTML(tt.html, tt.maxLength) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPruneStringToWord(t *testing.T) { + tests := []struct { + name string + text string + maxLength int + expected string + }{ + { + name: "within limit", + text: "hello world", + maxLength: 15, + expected: "hello world", + }, + { + name: "exact limit", + text: "hello world", + maxLength: 11, + expected: "hello world", + }, + { + name: "cut at word boundary", + text: "hello world and more", + maxLength: 11, + expected: "hello world", + }, + { + name: "zero length", + text: "hello", + maxLength: 0, + expected: "", + }, + { + name: "negative length", + text: "hello", + maxLength: -1, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pruneStringToWord(tt.text, tt.maxLength) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 472b5c80f7..796520ca93 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,7 +87,7 @@ 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)) diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 4518e156f1..3439a4a9bd 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...`, res) } func TestTelegram_SendVerification(t *testing.T) {