From 1c4dce0ccf92d7733b3f6800dfab067c27a7bdaa Mon Sep 17 00:00:00 2001 From: badgerbees <93577481+badgerbees@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:01:20 +0700 Subject: [PATCH] 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 --- internal/channels/telegram/format.go | 33 ++++++++- internal/channels/telegram/format_test.go | 69 +++++++++++++++++++ internal/channels/telegram/send.go | 82 +++++++++++++++++++---- 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/internal/channels/telegram/format.go b/internal/channels/telegram/format.go index 2be7df09..65ad4803 100644 --- a/internal/channels/telegram/format.go +++ b/internal/channels/telegram/format.go @@ -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. " 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") } diff --git a/internal/channels/telegram/format_test.go b/internal/channels/telegram/format_test.go index 4952358d..72014a72 100644 --- a/internal/channels/telegram/format_test.go +++ b/internal/channels/telegram/format_test.go @@ -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 link world", + maxLen: 12, + want: []string{"hello", "link", "world"}, + }, + { + name: "avoid mid-tag split safe", + input: "hello link world", + maxLen: 25, + // "hello " (6) -> remaining "link world" (28) + // maxLen 25. remaining[:25] is "link wo" + // lastOpen is at "" (21). + // lastOpen < lastClose (18 < 21). No cut change from safety. + // lastSpace is at index 22 (" world"). cutAt=23. + want: []string{"hello", "link", "world"}, + }, + { + name: "avoid mid-entity split", + input: "hello & world", + maxLen: 9, + // "hello " (6) -> then "& world" + // At second chunk: remaining="& world", maxLen=9 + // remaining[:9] is "& wor" + // lastSpace is at index 5. cutAt=6. + want: []string{"hello", "&", "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]) + } + } + }) + } +} diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index e1d19e63..0e4a1503 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -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 }