feat(telegram): implement robust message splitting and dynamic HTML retry logic (#236)

* feat(telegram): implement robust message splitting and dynamic HTML retry logic

* fix(telegram): fix sendHTML error chain regression and add split depth limit

- Re-check err.Error() in thread-not-found handler instead of stale errStr,
  restoring the original chained fallback behavior
- Add maxSplitDepth (5) to prevent unbounded recursion when Telegram
  repeatedly rejects split chunks
- Rename misleading test case to reflect actual monolithic fallback behavior

---------

Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
badgerbeesandviettranx authored and GitHub committed 2026-03-17 13:01:20 +07:00
1 parent 9429a7c844
commit 1c4dce0ccf
3 files changed
+168 -16

No files matched your search

+30 -3
View File
@@ -365,17 +365,44 @@ func chunkHTML(text string, maxLen int) []string {
break
}
// Find best split point within maxLen
// Strategy: search backwards for best natural breakpoint within maxLen.
cutAt := maxLen
// Prefer paragraph boundary
// 1. Look for preferred boundaries: paragraph, then newline, then space.
if idx := strings.LastIndex(remaining[:cutAt], "\n\n"); idx > 0 {
cutAt = idx + 1 // include first newline
cutAt = idx + 2
} else if idx := strings.LastIndex(remaining[:cutAt], "\n"); idx > 0 {
cutAt = idx + 1
} else if idx := strings.LastIndex(remaining[:cutAt], " "); idx > 0 {
cutAt = idx + 1
}
// 2. Safety: ensure we don't cut in the middle of an HTML tag or entity.
// Tag check: find last '<' and see if it was closed before cutAt.
if lastOpen := strings.LastIndex(remaining[:cutAt], "<"); lastOpen != -1 {
lastClose := strings.LastIndex(remaining[:cutAt], ">")
if lastOpen > lastClose {
// We're inside a tag (e.g. "<a hre"). Move cutAt back to start of tag.
// This ensures the tag remains whole in the next chunk.
cutAt = lastOpen
}
}
// Entity check: find last '&' and see if it was closed before cutAt.
if lastOpen := strings.LastIndex(remaining[:cutAt], "&"); lastOpen != -1 {
lastClose := strings.LastIndex(remaining[:cutAt], ";")
if lastOpen > lastClose {
// Inside an entity (e.g. "&am"). Move cutAt back to start of entity.
cutAt = lastOpen
}
}
// 3. Fallback for monolithic blocks: if boundaries or safety moved cutAt to 0,
// force progress by using maxLen anyway. This avoids infinite loops.
if cutAt <= 0 {
cutAt = maxLen
}
chunks = append(chunks, strings.TrimRight(remaining[:cutAt], " \n"))
remaining = strings.TrimLeft(remaining[cutAt:], " \n")
}
+69
View File
@@ -61,3 +61,72 @@ func TestRenderTableAsCode_Vietnamese(t *testing.T) {
}
}
}
func TestChunkHTML(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want []string
}{
{
name: "natural boundary",
input: "hello world",
maxLen: 6,
want: []string{"hello", "world"},
},
{
name: "tag exceeds maxLen uses fallback",
input: "hello <a href='url'>link</a> world",
maxLen: 12,
want: []string{"hello", "<a href='url", "'>link</a>", "world"},
},
{
name: "avoid mid-tag split safe",
input: "hello <a href='url'>link</a> world",
maxLen: 25,
// "hello " (6) -> remaining "<a href='url'>link</a> world" (28)
// maxLen 25. remaining[:25] is "<a href='url'>link</a> wo"
// lastOpen is at "</a" (18). lastClose at "</a>" (21).
// lastOpen < lastClose (18 < 21). No cut change from safety.
// lastSpace is at index 22 (" world"). cutAt=23.
want: []string{"hello", "<a href='url'>link</a>", "world"},
},
{
name: "avoid mid-entity split",
input: "hello &amp; world",
maxLen: 9,
// "hello " (6) -> then "&amp; world"
// At second chunk: remaining="&amp; world", maxLen=9
// remaining[:9] is "&amp; wor"
// lastSpace is at index 5. cutAt=6.
want: []string{"hello", "&amp;", "world"},
},
{
name: "monolithic fallback",
input: "monolithicblock",
maxLen: 5,
want: []string{"monol", "ithic", "block"},
},
{
name: "paragraph preferred",
input: "para1\n\npara2\nline3",
maxLen: 10,
want: []string{"para1", "para2", "line3"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := chunkHTML(tt.input, tt.maxLen)
if len(got) != len(tt.want) {
t.Fatalf("chunkHTML() returned %d chunks, want %d\ngot: %q\nwant: %q", len(got), len(tt.want), got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("chunk[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
+69 -13
View File
@@ -22,12 +22,14 @@ var (
parseErrRe = regexp.MustCompile(`(?i)can't parse entities|parse entities|find end of the entity`)
messageNotModifiedRe = regexp.MustCompile(`(?i)message is not modified`)
threadNotFoundRe = regexp.MustCompile(`(?i)message thread not found`)
messageTooLongRe = regexp.MustCompile(`(?i)message is too long|entities too long`)
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
)
const (
sendMaxRetries = 3
sendRetryDelay = 2 * time.Second
maxSplitDepth = 5 // max recursion depth for "message too long" splitting
photoSizeThreshold = 5 * 1024 * 1024 // 5 MB — images larger than this are sent as documents to avoid Telegram compression
)
@@ -269,8 +271,12 @@ func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.Ou
// sendHTML sends a single HTML message, falling back to plain text if Telegram rejects the HTML.
// replyTo and threadID are optional (0 = omit). General topic (1) is handled by resolveThreadIDForSend.
func (c *Channel) sendHTML(ctx context.Context, chatID int64, html string, replyTo, threadID int) error {
tgMsg := tu.Message(tu.ID(chatID), html)
func (c *Channel) sendHTML(ctx context.Context, chatID int64, htmlContent string, replyTo, threadID int) error {
return c.sendHTMLWithDepth(ctx, chatID, htmlContent, replyTo, threadID, 0)
}
func (c *Channel) sendHTMLWithDepth(ctx context.Context, chatID int64, htmlContent string, replyTo, threadID, depth int) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
// TS ref: buildTelegramThreadParams() — General topic (1) must be omitted.
@@ -288,17 +294,67 @@ func (c *Channel) sendHTML(ctx context.Context, chatID int64, html string, reply
_, e := c.bot.SendMessage(ctx, tgMsg)
return e
})
if err != nil && parseErrRe.MatchString(err.Error()) {
slog.Warn("HTML parse failed, falling back to plain text", "error", err)
tgMsg.ParseMode = ""
tgMsg.Text = stripHTML(tgMsg.Text)
_, err = c.bot.SendMessage(ctx, tgMsg)
}
// TS ref: withTelegramThreadFallback — retry without thread ID when topic is deleted.
if err != nil && tgMsg.MessageThreadID != 0 && threadNotFoundRe.MatchString(err.Error()) {
slog.Warn("thread not found, retrying without message_thread_id", "thread_id", tgMsg.MessageThreadID)
tgMsg.MessageThreadID = 0
_, err = c.bot.SendMessage(ctx, tgMsg)
if err != nil {
errStr := err.Error()
// Case 1: Message too long. Split into smaller chunks and send individually.
if messageTooLongRe.MatchString(errStr) {
if depth >= maxSplitDepth {
return fmt.Errorf("max split depth (%d) exceeded: %w", maxSplitDepth, err)
}
slog.Warn("Telegram rejected message as too long, splitting further", "len", len(htmlContent), "depth", depth)
newMaxLen := len(htmlContent) / 2
if newMaxLen < 100 {
return err // too small to split meaningfully
}
innerChunks := chunkHTML(htmlContent, newMaxLen)
for i, chunk := range innerChunks {
r := 0
if i == 0 {
r = replyTo
}
if sendErr := c.sendHTMLWithDepth(ctx, chatID, chunk, r, threadID, depth+1); sendErr != nil {
return sendErr
}
}
return nil
}
// Case 2: Parse error. Fallback to plain text.
if parseErrRe.MatchString(errStr) {
slog.Warn("HTML parse failed, falling back to plain text", "error", err)
tgMsg.ParseMode = ""
tgMsg.Text = stripHTML(htmlContent)
_, err = c.bot.SendMessage(ctx, tgMsg)
// If plain text is STILL too long, split it.
if err != nil && messageTooLongRe.MatchString(err.Error()) {
slog.Warn("Plain text fallback too long, splitting further", "len", len(tgMsg.Text))
innerChunks := chunkPlainText(tgMsg.Text, 4000) // use default safe limit
for i, chunk := range innerChunks {
msg := tu.Message(tu.ID(chatID), chunk)
msg.ReplyParameters = tgMsg.ReplyParameters
if i > 0 {
msg.ReplyParameters = nil
}
msg.MessageThreadID = tgMsg.MessageThreadID
_, err = c.bot.SendMessage(ctx, msg)
if err != nil {
return err
}
}
return nil
}
}
// Case 3: Thread not found. Re-check err (may have changed after Case 2 fallback).
if err != nil && tgMsg.MessageThreadID != 0 && threadNotFoundRe.MatchString(err.Error()) {
slog.Warn("thread not found, retrying without message_thread_id", "thread_id", tgMsg.MessageThreadID)
tgMsg.MessageThreadID = 0
_, err = c.bot.SendMessage(ctx, tgMsg)
}
}
return err
}