Skip to content
Merged
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
125 changes: 114 additions & 11 deletions internal/telegram/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hard break in splitMessage may split multi-byte UTF-8 characters

Low Severity

The hard-break fallback in splitMessage slices at a raw byte offset (text[:maxLen]), which can split a multi-byte UTF-8 character in half. This produces invalid UTF-8 in the resulting chunk, potentially causing garbled text in the Telegram message or an API rejection. The test suite already uses Cyrillic text, indicating multi-byte input is expected. Long paragraphs without newlines in CJK or other non-ASCII languages could trigger this path.

Fix in Cursor Fix in Web

}
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)
Expand Down
13 changes: 12 additions & 1 deletion internal/telegram/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
)

Expand Down Expand Up @@ -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])
Expand Down
54 changes: 54 additions & 0 deletions internal/telegram/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "* [ ] **Паспорт** (загран)", "☐ <b>Паспорт</b> (загран)")
}

// --- HTML escaping ---

func TestMarkdown_HTMLInText(t *testing.T) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading