diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index f360c75ef..7dc3f3198 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "time" "github.com/bwmarrin/discordgo" @@ -106,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks + chunks := utils.SplitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks for _, chunk := range chunks { if err := c.sendChunk(ctx, channelID, chunk); err != nil { @@ -117,132 +116,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } -// splitMessage splits long messages into chunks, preserving code block integrity -// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks -func splitMessage(content string, limit int) []string { - var messages []string - - for len(content) > 0 { - if len(content) <= limit { - messages = append(messages, content) - break - } - - msgEnd := limit - - // Find natural split point within the limit - msgEnd = findLastNewline(content[:limit], 200) - if msgEnd <= 0 { - msgEnd = findLastSpace(content[:limit], 100) - } - if msgEnd <= 0 { - msgEnd = limit - } - - // Check if this would end with an incomplete code block - candidate := content[:msgEnd] - unclosedIdx := findLastUnclosedCodeBlock(candidate) - - if unclosedIdx >= 0 { - // Message would end with incomplete code block - // Try to extend to include the closing ``` (with some buffer) - extendedLimit := limit + 500 // Allow 500 char buffer for code blocks - if len(content) > extendedLimit { - closingIdx := findNextClosingCodeBlock(content, msgEnd) - if closingIdx > 0 && closingIdx <= extendedLimit { - // Extend to include the closing ``` - msgEnd = closingIdx - } else { - // Can't find closing, split before the code block - msgEnd = findLastNewline(content[:unclosedIdx], 200) - if msgEnd <= 0 { - msgEnd = findLastSpace(content[:unclosedIdx], 100) - } - if msgEnd <= 0 { - msgEnd = unclosedIdx - } - } - } else { - // Remaining content fits within extended limit - msgEnd = len(content) - } - } - - if msgEnd <= 0 { - msgEnd = limit - } - - messages = append(messages, content[:msgEnd]) - content = strings.TrimSpace(content[msgEnd:]) - } - - return messages -} - -// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` -// Returns the position of the opening ``` or -1 if all code blocks are complete -func findLastUnclosedCodeBlock(text string) int { - count := 0 - lastOpenIdx := -1 - - for i := 0; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - if count == 0 { - lastOpenIdx = i - } - count++ - i += 2 - } - } - - // If odd number of ``` markers, last one is unclosed - if count%2 == 1 { - return lastOpenIdx - } - return -1 -} - -// findNextClosingCodeBlock finds the next closing ``` starting from a position -// Returns the position after the closing ``` or -1 if not found -func findNextClosingCodeBlock(text string, startIdx int) int { - for i := startIdx; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - return i + 3 - } - } - return -1 -} - -// findLastNewline finds the last newline character within the last N characters -// Returns the position of the newline or -1 if not found -func findLastNewline(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == '\n' { - return i - } - } - return -1 -} - -// findLastSpace finds the last space character within the last N characters -// Returns the position of the space or -1 if not found -func findLastSpace(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == ' ' || s[i] == '\t' { - return i - } - } - return -1 -} - func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // 使用传入的 ctx 进行超时控制 sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 24b82b557..27175acc1 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/string.go b/pkg/utils/string.go index 0d9837cb9..d70ccc6bf 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,5 +1,7 @@ package utils +import "strings" + // Truncate returns a truncated version of s with at most maxLen runes. // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. @@ -14,3 +16,151 @@ func Truncate(s string, maxLen int) string { } return string(runes[:maxLen-3]) + "..." } + +// SplitMessage splits long messages into chunks, preserving code block integrity where possible. +// Logic is inspired by the Discord channel implementation and is channel-agnostic. +// This allocates a slice to hold all chunks; for streaming use SplitMessageIter. +func SplitMessage(content string, limit int) []string { + var messages []string + + _ = SplitMessageIter(content, limit, 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, limit int, cb func(chunk string) error) error { + content = strings.TrimSpace(content) + for len(content) > 0 { + if len(content) <= limit { + if content != "" { + if err := cb(content); err != nil { + return err + } + } + break + } + + msgEnd := limit + + // Find natural split point within the limit + msgEnd = findLastNewline(content[:limit], 200) + if msgEnd <= 0 { + msgEnd = findLastSpace(content[:limit], 100) + } + if msgEnd <= 0 { + msgEnd = limit + } + + // Check if this would end with an incomplete code block + candidate := content[:msgEnd] + unclosedIdx := findLastUnclosedCodeBlock(candidate) + + if unclosedIdx >= 0 { + // Message would end with incomplete code block + // Try to extend to include the closing ``` (with some buffer) + extendedLimit := limit + 500 // Allow buffer for code blocks + if len(content) > extendedLimit { + closingIdx := findNextClosingCodeBlock(content, msgEnd) + if closingIdx > 0 && closingIdx <= extendedLimit { + // Extend to include the closing ``` + msgEnd = closingIdx + } else { + // Can't find closing, split before the code block + msgEnd = findLastNewline(content[:unclosedIdx], 200) + if msgEnd <= 0 { + msgEnd = findLastSpace(content[:unclosedIdx], 100) + } + if msgEnd <= 0 { + msgEnd = unclosedIdx + } + } + } else { + // Remaining content fits within extended limit + msgEnd = len(content) + } + } + + if msgEnd <= 0 { + msgEnd = limit + } + + chunk := strings.TrimSpace(content[:msgEnd]) + if chunk != "" { + if err := cb(chunk); err != nil { + return err + } + } + content = strings.TrimSpace(content[msgEnd:]) + } + + return nil +} + +// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```. +// Returns the position of the opening ``` or -1 if all code blocks are complete. +func findLastUnclosedCodeBlock(text string) int { + count := 0 + lastOpenIdx := -1 + + for i := 0; i < len(text); i++ { + if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + if count == 0 { + lastOpenIdx = i + } + count++ + i += 2 + } + } + + // If odd number of ``` markers, last one is unclosed + if count%2 == 1 { + return lastOpenIdx + } + return -1 +} + +// findNextClosingCodeBlock finds the next closing ``` starting from a position. +// Returns the position after the closing ``` or -1 if not found. +func findNextClosingCodeBlock(text string, startIdx int) int { + for i := startIdx; i < len(text); i++ { + if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + return i + 3 + } + } + return -1 +} + +// findLastNewline finds the last newline character within the last N characters. +// Returns the position of the newline or -1 if not found. +func findLastNewline(s string, searchWindow int) int { + searchStart := len(s) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(s) - 1; i >= searchStart; i-- { + if s[i] == '\n' { + return i + } + } + return -1 +} + +// findLastSpace finds the last space character within the last N characters. +// Returns the position of the space or -1 if not found. +func findLastSpace(s string, searchWindow int) int { + searchStart := len(s) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(s) - 1; i >= searchStart; i-- { + if s[i] == ' ' || s[i] == '\t' { + return i + } + } + return -1 +}