Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 75 additions & 19 deletions pkg/channels/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"

th "github.com/mymmrac/telego/telegohandler"

Expand All @@ -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
Expand Down Expand Up @@ -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
Comment on lines +196 to +203
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err is referenced outside the scope where it’s declared: if _, err := c.bot.EditMessageText(...); ... defines err only inside the if, but logger.WarnCF(..., {"error": err.Error()}) uses it after the block. This will not compile. Capture the error into a variable (e.g., editErr := ...) before logging, or restructure the if to keep the error in scope.

Suggested change
}
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
} else {
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
}

Copilot uses AI. Check for mistakes.
}

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 {
Expand Down
43 changes: 38 additions & 5 deletions pkg/utils/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 ```
Expand Down