Add chunking on Telegram Msgs
This commit is contained in:
parent
048cd08e41
commit
8e060701c9
3 changed files with 226 additions and 147 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
placeholderID = id
|
||||||
|
hasPlaceholder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkIndex := 0
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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
|
editMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
if _, err := c.bot.EditMessageText(ctx, editMsg); err == nil {
|
||||||
|
chunkIndex++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Fallback to new message if edit fails
|
|
||||||
|
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 := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if utf8.RuneCountInString(tgMsg.Text) > telegramMessageLimit {
|
||||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
// As an extra safeguard, truncate if HTML expansion unexpectedly exceeds Telegram's hard limit.
|
||||||
"error": err.Error(),
|
runes := []rune(tgMsg.Text)
|
||||||
})
|
if len(runes) > telegramMessageLimit {
|
||||||
tgMsg.ParseMode = ""
|
tgMsg.Text = string(runes[:telegramMessageLimit])
|
||||||
_, err = c.bot.SendMessage(ctx, tgMsg)
|
}
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue