mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
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:
1 parent
9429a7c844
commit
1c4dce0ccf
3 files changed
+168
-16
No files matched your search
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 & 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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user