Add chunking on Telegram Msgs

This commit is contained in:
harshbansal7 2026-02-19 17:27:36 +05:30
parent 048cd08e41
commit 8e060701c9
3 changed files with 226 additions and 147 deletions

View file

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@ -106,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil 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 { for _, chunk := range chunks {
if err := c.sendChunk(ctx, channelID, chunk); err != nil { 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 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 { func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
// 使用传入的 ctx 进行超时控制 // 使用传入的 ctx 进行超时控制
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
th "github.com/mymmrac/telego/telegohandler" th "github.com/mymmrac/telego/telegohandler"
@ -24,6 +25,13 @@ import (
"github.com/sipeed/picoclaw/pkg/voice" "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 { type TelegramChannel struct {
*BaseChannel *BaseChannel
bot *telego.Bot bot *telego.Bot
@ -157,33 +165,81 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
c.stopThinking.Delete(msg.ChatID) 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 { if pID, ok := c.placeholders.Load(msg.ChatID); ok {
c.placeholders.Delete(msg.ChatID) c.placeholders.Delete(msg.ChatID)
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) if id, ok := pID.(int); ok {
editMsg.ParseMode = telego.ModeHTML placeholderID = id
hasPlaceholder = true
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
return nil
} }
// Fallback to new message if edit fails
} }
tgMsg := tu.Message(tu.ID(chatID), htmlContent) chunkIndex := 0
tgMsg.ParseMode = telego.ModeHTML
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { // Split long messages to stay under Telegram limits and avoid delivery failures.
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ sendErr := utils.SplitMessageIter(msg.Content, telegramSafeContentLength, func(chunk string) error {
"error": err.Error(), htmlContent := markdownToTelegramHTML(chunk)
})
tgMsg.ParseMode = ""
_, err = c.bot.SendMessage(ctx, tgMsg)
return err
}
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 { func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {

View file

@ -1,5 +1,7 @@
package utils package utils
import "strings"
// Truncate returns a truncated version of s with at most maxLen runes. // Truncate returns a truncated version of s with at most maxLen runes.
// Handles multi-byte Unicode characters properly. // Handles multi-byte Unicode characters properly.
// If the string is truncated, "..." is appended to indicate truncation. // 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]) + "..." 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
}