diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 24b82b557b..27175acc1d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" th "github.com/mymmrac/telego/telegohandler" @@ -24,6 +25,13 @@ import ( "github.com/sipeed/picoclaw/pkg/voice" ) +const ( + // Telegram has a limit of 4096 characters per message. + // Use a conservative limit on the original content to account for HTML markup expansion. + telegramMessageLimit = 4096 + telegramSafeContentLength = 3000 +) + type TelegramChannel struct { *BaseChannel bot *telego.Bot @@ -157,33 +165,81 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err c.stopThinking.Delete(msg.ChatID) } - htmlContent := markdownToTelegramHTML(msg.Content) + var ( + placeholderID int + hasPlaceholder bool + ) - // Try to edit placeholder + // Try to use placeholder (thinking...) message for the first chunk if pID, ok := c.placeholders.Load(msg.ChatID); ok { c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil + if id, ok := pID.(int); ok { + placeholderID = id + hasPlaceholder = true } - // Fallback to new message if edit fails } - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML + chunkIndex := 0 - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ - "error": err.Error(), - }) - tgMsg.ParseMode = "" - _, err = c.bot.SendMessage(ctx, tgMsg) - return err - } + // Split long messages to stay under Telegram limits and avoid delivery failures. + sendErr := utils.SplitMessageIter(msg.Content, telegramSafeContentLength, func(chunk string) error { + htmlContent := markdownToTelegramHTML(chunk) - return nil + // First chunk: try to edit the existing placeholder message + if hasPlaceholder && chunkIndex == 0 { + editMsg := tu.EditMessageText(tu.ID(chatID), placeholderID, htmlContent) + editMsg.ParseMode = telego.ModeHTML + + if _, err := c.bot.EditMessageText(ctx, editMsg); err == nil { + chunkIndex++ + return nil + } + + logger.WarnCF("telegram", "Failed to edit placeholder message, sending new message instead", map[string]interface{}{ + "error": err.Error(), + }) + + // If edit fails, fall back to sending a new message for this and subsequent chunks + hasPlaceholder = false + } + + tgMsg := tu.Message(tu.ID(chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + + if utf8.RuneCountInString(tgMsg.Text) > telegramMessageLimit { + // As an extra safeguard, truncate if HTML expansion unexpectedly exceeds Telegram's hard limit. + runes := []rune(tgMsg.Text) + if len(runes) > telegramMessageLimit { + tgMsg.Text = string(runes[:telegramMessageLimit]) + } + } + + if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "Failed to send HTML message, falling back to plain text", map[string]interface{}{ + "error": err.Error(), + }) + + // Fallback to plain text using the original chunk content + tgMsg.ParseMode = "" + tgMsg.Text = chunk + + // Final safety: hard truncate plain text if still too long + if utf8.RuneCountInString(tgMsg.Text) > telegramMessageLimit { + runes := []rune(tgMsg.Text) + if len(runes) > telegramMessageLimit { + tgMsg.Text = string(runes[:telegramMessageLimit]) + } + } + + if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + return err + } + } + chunkIndex++ + return nil + }) + + return sendErr } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 1d05950d9f..2dbb288c51 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -12,6 +12,21 @@ import ( func SplitMessage(content string, maxLen int) []string { var messages []string + _ = SplitMessageIter(content, maxLen, func(chunk string) error { + messages = append(messages, chunk) + return nil + }) + + return messages +} + +// SplitMessageIter splits content into chunks and calls cb for each chunk. +// This avoids allocating a slice to hold all chunks and is more memory-efficient for very large messages. +func SplitMessageIter(content string, maxLen int, cb func(chunk string) error) error { + if maxLen <= 0 { + return nil + } + // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible codeBlockBuffer := maxLen / 10 if codeBlockBuffer < 50 { @@ -21,9 +36,14 @@ func SplitMessage(content string, maxLen int) []string { codeBlockBuffer = maxLen / 2 } + content = strings.TrimSpace(content) for len(content) > 0 { if len(content) <= maxLen { - messages = append(messages, content) + if content != "" { + if err := cb(content); err != nil { + return err + } + } break } @@ -75,7 +95,11 @@ func SplitMessage(content string, maxLen int) []string { } else { msgEnd = innerLimit } - messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") + + chunk := strings.TrimRight(content[:msgEnd], " \t\n\r") + "\n```" + if err := cb(chunk); err != nil { + return err + } content = strings.TrimSpace(header + "\n" + content[msgEnd:]) continue } @@ -93,7 +117,11 @@ func SplitMessage(content string, maxLen int) []string { msgEnd = unclosedIdx } else { msgEnd = maxLen - 5 - messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") + + chunk := strings.TrimRight(content[:msgEnd], " \t\n\r") + "\n```" + if err := cb(chunk); err != nil { + return err + } content = strings.TrimSpace(header + "\n" + content[msgEnd:]) continue } @@ -106,11 +134,16 @@ func SplitMessage(content string, maxLen int) []string { msgEnd = effectiveLimit } - messages = append(messages, content[:msgEnd]) + chunk := strings.TrimSpace(content[:msgEnd]) + if chunk != "" { + if err := cb(chunk); err != nil { + return err + } + } content = strings.TrimSpace(content[msgEnd:]) } - return messages + return nil } // findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```