diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index 81f3c69..3c43e45 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -717,22 +717,88 @@ func (h *Handler) downloadFile(fileID string) ([]byte, error) { } func (h *Handler) sendResponse(chatID int64, text string) { - if len(text) >= maxMessageLen { - h.sendAsFile(chatID, text) + // Fast path: short message. + htmlText := markdownToTelegramHTML(text) + if len(htmlText) < maxMessageLen { + h.sendHTMLWithFallback(chatID, text, htmlText) return } - htmlText := markdownToTelegramHTML(text) - msg := tgbotapi.NewMessage(chatID, htmlText) - msg.ParseMode = tgbotapi.ModeHTML - 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) + // 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, 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 + } + + // Last resort: send as file. + h.sendAsFile(chatID, text) +} + +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.Error("failed to send response", "chat_id", chatID, "err", err) + } +} + +// 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) { @@ -753,6 +819,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) 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) {