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
}