From 70805f5ce86261b4e04b41e9faebdbb40cdef05d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 09:09:18 +0000 Subject: [PATCH 1/2] Split long Telegram responses into multiple messages instead of truncating Previously, responses >= 4096 chars were sent as a .md file, and if that failed, truncated with "(response truncated)". Now long responses are split at paragraph/line boundaries into multiple messages. File upload is used as fallback only if chunk sending fails. https://claude.ai/code/session_01VtMjyBkcnrX8AZDuPj67XT --- internal/telegram/handler.go | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index 81f3c69..d28967e 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -717,11 +717,31 @@ func (h *Handler) downloadFile(fileID string) ([]byte, error) { } func (h *Handler) sendResponse(chatID int64, text string) { - if len(text) >= maxMessageLen { - h.sendAsFile(chatID, text) + if len(text) < maxMessageLen { + h.sendSingleMessage(chatID, text) return } + // Split long responses into multiple messages. + parts := splitMessage(text, maxMessageLen-100) // leave margin for safety + allOK := true + for i, part := range parts { + if !h.sendSingleMessage(chatID, part) { + h.logger.Warn("chunk send failed, falling back to file", "chunk", i+1, "of", len(parts)) + allOK = false + break + } + } + if allOK { + return + } + + // Fallback: send as file. + h.sendAsFile(chatID, text) +} + +// sendSingleMessage sends one chunk as HTML (with plain-text fallback). Returns true on success. +func (h *Handler) sendSingleMessage(chatID int64, text string) bool { htmlText := markdownToTelegramHTML(text) msg := tgbotapi.NewMessage(chatID, htmlText) msg.ParseMode = tgbotapi.ModeHTML @@ -731,8 +751,10 @@ func (h *Handler) sendResponse(chatID int64, text string) { msg.Text = text if _, err := h.bot.Send(msg); err != nil { h.logger.Error("failed to send response", "chat_id", chatID, "err", err) + return false } } + return true } func (h *Handler) sendAsFile(chatID int64, text string) { @@ -753,6 +775,43 @@ func (h *Handler) sendAsFile(chatID int64, text string) { } } +// splitMessage splits text into chunks of at most maxLen bytes, +// breaking at paragraph boundaries (\n\n), then line boundaries (\n). +func splitMessage(text string, maxLen int) []string { + if len(text) <= maxLen { + return []string{text} + } + + var parts []string + for len(text) > 0 { + if len(text) <= maxLen { + parts = append(parts, text) + break + } + + chunk := text[:maxLen] + + // Try to break at a paragraph boundary. + if idx := strings.LastIndex(chunk, "\n\n"); idx > maxLen/4 { + parts = append(parts, text[:idx]) + text = strings.TrimLeft(text[idx:], "\n") + continue + } + + // Try to break at a line boundary. + if idx := strings.LastIndex(chunk, "\n"); idx > maxLen/4 { + parts = append(parts, text[:idx]) + text = text[idx+1:] + continue + } + + // Hard break at maxLen. + parts = append(parts, chunk) + text = text[maxLen:] + } + return parts +} + // send sends a bot-generated message with MarkdownV2 (text must be pre-escaped). func (h *Handler) send(chatID int64, text string) { msg := tgbotapi.NewMessage(chatID, text) From 3da223577d953a234cc744a32db26b26cefae270 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 09:14:19 +0000 Subject: [PATCH 2/2] Fix formatting for long messages and add checkbox support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long messages were sent as files (or truncated plain text), bypassing the markdown-to-HTML converter entirely. Now: split markdown into chunks, convert each to HTML, verify each fits in Telegram's 4096 char limit (accounting for HTML tag expansion), and send as multiple messages. Also adds checkbox rendering: [ ] → ☐, [x] → ☑ in bullet lists. Fallback chain: split HTML msgs → plain text → file → truncated. https://claude.ai/code/session_01VtMjyBkcnrX8AZDuPj67XT --- internal/telegram/handler.go | 90 ++++++++++++++++++++++-------- internal/telegram/markdown.go | 13 ++++- internal/telegram/markdown_test.go | 54 ++++++++++++++++++ 3 files changed, 133 insertions(+), 24 deletions(-) diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index d28967e..3c43e45 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -717,44 +717,88 @@ func (h *Handler) downloadFile(fileID string) ([]byte, error) { } func (h *Handler) sendResponse(chatID int64, text string) { - if len(text) < maxMessageLen { - h.sendSingleMessage(chatID, text) + // Fast path: short message. + htmlText := markdownToTelegramHTML(text) + if len(htmlText) < maxMessageLen { + h.sendHTMLWithFallback(chatID, text, htmlText) return } - // Split long responses into multiple messages. - parts := splitMessage(text, maxMessageLen-100) // leave margin for safety + // Split markdown into chunks, convert each to HTML. + // Use conservative limit — HTML tags expand the text ~30%. + mdChunks := splitMessage(text, maxMessageLen*2/3) + var htmlChunks []htmlChunk + for _, md := range mdChunks { + htm := markdownToTelegramHTML(md) + if len(htm) < maxMessageLen { + htmlChunks = append(htmlChunks, htmlChunk{raw: md, html: htm}) + } else { + // HTML still too long — re-split the markdown piece more aggressively. + subMD := splitMessage(md, maxMessageLen/3) + for _, sm := range subMD { + sh := markdownToTelegramHTML(sm) + htmlChunks = append(htmlChunks, htmlChunk{raw: sm, html: sh}) + } + } + } + allOK := true - for i, part := range parts { - if !h.sendSingleMessage(chatID, part) { - h.logger.Warn("chunk send failed, falling back to file", "chunk", i+1, "of", len(parts)) - allOK = false - break + for i, ch := range htmlChunks { + if len(ch.html) < maxMessageLen { + if !h.sendHTMLMsg(chatID, ch.html) { + // Retry as plain text. + if !h.sendPlainMsg(chatID, ch.raw) { + h.logger.Warn("chunk send failed", "chunk", i+1, "of", len(htmlChunks)) + allOK = false + break + } + } + } else { + // Still too long after aggressive split — send plain, truncated. + if !h.sendPlainMsg(chatID, ch.raw[:min(len(ch.raw), maxMessageLen-50)]) { + allOK = false + break + } } } if allOK { return } - // Fallback: send as file. + // Last resort: send as file. h.sendAsFile(chatID, text) } -// sendSingleMessage sends one chunk as HTML (with plain-text fallback). Returns true on success. -func (h *Handler) sendSingleMessage(chatID int64, text string) bool { - htmlText := markdownToTelegramHTML(text) - msg := tgbotapi.NewMessage(chatID, htmlText) - msg.ParseMode = tgbotapi.ModeHTML +type htmlChunk struct { + raw string + html string +} + +// sendHTMLWithFallback sends a single HTML message, falling back to plain text. +func (h *Handler) sendHTMLWithFallback(chatID int64, rawText, htmlText string) { + if h.sendHTMLMsg(chatID, htmlText) { + return + } + h.logger.Warn("html send failed, retrying as plain text") + msg := tgbotapi.NewMessage(chatID, rawText) if _, err := h.bot.Send(msg); err != nil { - h.logger.Warn("html send failed, retrying as plain text", "err", err) - msg.ParseMode = "" - msg.Text = text - if _, err := h.bot.Send(msg); err != nil { - h.logger.Error("failed to send response", "chat_id", chatID, "err", err) - return false - } + h.logger.Error("failed to send response", "chat_id", chatID, "err", err) } - return true +} + +// sendHTMLMsg sends an HTML message. Returns true on success. +func (h *Handler) sendHTMLMsg(chatID int64, htmlText string) bool { + msg := tgbotapi.NewMessage(chatID, htmlText) + msg.ParseMode = tgbotapi.ModeHTML + _, err := h.bot.Send(msg) + return err == nil +} + +// sendPlainMsg sends a plain-text message. Returns true on success. +func (h *Handler) sendPlainMsg(chatID int64, text string) bool { + msg := tgbotapi.NewMessage(chatID, text) + _, err := h.bot.Send(msg) + return err == nil } func (h *Handler) sendAsFile(chatID int64, text string) { diff --git a/internal/telegram/markdown.go b/internal/telegram/markdown.go index 8a8f736..4e41612 100644 --- a/internal/telegram/markdown.go +++ b/internal/telegram/markdown.go @@ -19,6 +19,8 @@ var ( reHeader = regexp.MustCompile(`^#{1,6}\s+(.+)$`) reBulletItem = regexp.MustCompile(`^(\s*)[-*]\s+(.+)$`) reNumItem = regexp.MustCompile(`^(\s*\d+\.)\s+(.+)$`) + reCheckbox = regexp.MustCompile(`^\[[ ]\]\s*`) + reCheckboxDone = regexp.MustCompile(`^\[[xX]\]\s*`) rePlaceholder = regexp.MustCompile(`\x00(\d+)\x00`) ) @@ -64,7 +66,16 @@ func processLine(line string) string { } if m := reBulletItem.FindStringSubmatch(line); m != nil { indent := strings.Repeat(" ", len(m[1])/2) - return indent + "• " + processInline(m[2]) + rest := m[2] + prefix := "• " + if reCheckboxDone.MatchString(rest) { + rest = reCheckboxDone.ReplaceAllString(rest, "") + prefix = "☑ " + } else if reCheckbox.MatchString(rest) { + rest = reCheckbox.ReplaceAllString(rest, "") + prefix = "☐ " + } + return indent + prefix + processInline(rest) } if m := reNumItem.FindStringSubmatch(line); m != nil { return html.EscapeString(m[1]) + " " + processInline(m[2]) diff --git a/internal/telegram/markdown_test.go b/internal/telegram/markdown_test.go index 7605ede..d3f6dd5 100644 --- a/internal/telegram/markdown_test.go +++ b/internal/telegram/markdown_test.go @@ -109,6 +109,24 @@ func TestMarkdown_BulletListMultiline(t *testing.T) { assertMD(t, input, want) } +// --- Checkboxes --- + +func TestMarkdown_CheckboxUnchecked(t *testing.T) { + assertMD(t, "- [ ] task", "☐ task") +} + +func TestMarkdown_CheckboxChecked(t *testing.T) { + assertMD(t, "- [x] done", "☑ done") +} + +func TestMarkdown_CheckboxCheckedUpper(t *testing.T) { + assertMD(t, "* [X] done", "☑ done") +} + +func TestMarkdown_CheckboxWithBold(t *testing.T) { + assertMD(t, "* [ ] **Паспорт** (загран)", "☐ Паспорт (загран)") +} + // --- HTML escaping --- func TestMarkdown_HTMLInText(t *testing.T) { @@ -151,6 +169,42 @@ func TestMarkdown_MultilineDocument(t *testing.T) { } } +// --- splitMessage --- + +func TestSplitMessage_Short(t *testing.T) { + parts := splitMessage("hello", 100) + if len(parts) != 1 || parts[0] != "hello" { + t.Errorf("unexpected: %v", parts) + } +} + +func TestSplitMessage_ParagraphBoundary(t *testing.T) { + text := "first paragraph\n\nsecond paragraph\n\nthird paragraph" + parts := splitMessage(text, 30) + if len(parts) < 2 { + t.Errorf("expected multiple parts, got %d: %v", len(parts), parts) + } + // Verify no part exceeds maxLen + for i, p := range parts { + if len(p) > 30 { + t.Errorf("part %d exceeds maxLen: %d chars", i, len(p)) + } + } +} + +func TestSplitMessage_LineBoundary(t *testing.T) { + text := "line one\nline two\nline three\nline four" + parts := splitMessage(text, 20) + if len(parts) < 2 { + t.Errorf("expected multiple parts, got %d: %v", len(parts), parts) + } + for i, p := range parts { + if len(p) > 20 { + t.Errorf("part %d exceeds maxLen: %d chars", i, len(p)) + } + } +} + // --- helpers --- func assertMD(t *testing.T, input, want string) {