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("%s>", 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: "",
+ maxLength: 30,
+ expected: "",
+ },
+ {
+ name: "nested tags exceeding limit",
+ html: "",
+ 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) {