Skip to content

Commit 3da2235

Browse files
committed
Fix formatting for long messages and add checkbox support
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
1 parent 70805f5 commit 3da2235

3 files changed

Lines changed: 133 additions & 24 deletions

File tree

internal/telegram/handler.go

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -717,44 +717,88 @@ func (h *Handler) downloadFile(fileID string) ([]byte, error) {
717717
}
718718

719719
func (h *Handler) sendResponse(chatID int64, text string) {
720-
if len(text) < maxMessageLen {
721-
h.sendSingleMessage(chatID, text)
720+
// Fast path: short message.
721+
htmlText := markdownToTelegramHTML(text)
722+
if len(htmlText) < maxMessageLen {
723+
h.sendHTMLWithFallback(chatID, text, htmlText)
722724
return
723725
}
724726

725-
// Split long responses into multiple messages.
726-
parts := splitMessage(text, maxMessageLen-100) // leave margin for safety
727+
// Split markdown into chunks, convert each to HTML.
728+
// Use conservative limit — HTML tags expand the text ~30%.
729+
mdChunks := splitMessage(text, maxMessageLen*2/3)
730+
var htmlChunks []htmlChunk
731+
for _, md := range mdChunks {
732+
htm := markdownToTelegramHTML(md)
733+
if len(htm) < maxMessageLen {
734+
htmlChunks = append(htmlChunks, htmlChunk{raw: md, html: htm})
735+
} else {
736+
// HTML still too long — re-split the markdown piece more aggressively.
737+
subMD := splitMessage(md, maxMessageLen/3)
738+
for _, sm := range subMD {
739+
sh := markdownToTelegramHTML(sm)
740+
htmlChunks = append(htmlChunks, htmlChunk{raw: sm, html: sh})
741+
}
742+
}
743+
}
744+
727745
allOK := true
728-
for i, part := range parts {
729-
if !h.sendSingleMessage(chatID, part) {
730-
h.logger.Warn("chunk send failed, falling back to file", "chunk", i+1, "of", len(parts))
731-
allOK = false
732-
break
746+
for i, ch := range htmlChunks {
747+
if len(ch.html) < maxMessageLen {
748+
if !h.sendHTMLMsg(chatID, ch.html) {
749+
// Retry as plain text.
750+
if !h.sendPlainMsg(chatID, ch.raw) {
751+
h.logger.Warn("chunk send failed", "chunk", i+1, "of", len(htmlChunks))
752+
allOK = false
753+
break
754+
}
755+
}
756+
} else {
757+
// Still too long after aggressive split — send plain, truncated.
758+
if !h.sendPlainMsg(chatID, ch.raw[:min(len(ch.raw), maxMessageLen-50)]) {
759+
allOK = false
760+
break
761+
}
733762
}
734763
}
735764
if allOK {
736765
return
737766
}
738767

739-
// Fallback: send as file.
768+
// Last resort: send as file.
740769
h.sendAsFile(chatID, text)
741770
}
742771

743-
// sendSingleMessage sends one chunk as HTML (with plain-text fallback). Returns true on success.
744-
func (h *Handler) sendSingleMessage(chatID int64, text string) bool {
745-
htmlText := markdownToTelegramHTML(text)
746-
msg := tgbotapi.NewMessage(chatID, htmlText)
747-
msg.ParseMode = tgbotapi.ModeHTML
772+
type htmlChunk struct {
773+
raw string
774+
html string
775+
}
776+
777+
// sendHTMLWithFallback sends a single HTML message, falling back to plain text.
778+
func (h *Handler) sendHTMLWithFallback(chatID int64, rawText, htmlText string) {
779+
if h.sendHTMLMsg(chatID, htmlText) {
780+
return
781+
}
782+
h.logger.Warn("html send failed, retrying as plain text")
783+
msg := tgbotapi.NewMessage(chatID, rawText)
748784
if _, err := h.bot.Send(msg); err != nil {
749-
h.logger.Warn("html send failed, retrying as plain text", "err", err)
750-
msg.ParseMode = ""
751-
msg.Text = text
752-
if _, err := h.bot.Send(msg); err != nil {
753-
h.logger.Error("failed to send response", "chat_id", chatID, "err", err)
754-
return false
755-
}
785+
h.logger.Error("failed to send response", "chat_id", chatID, "err", err)
756786
}
757-
return true
787+
}
788+
789+
// sendHTMLMsg sends an HTML message. Returns true on success.
790+
func (h *Handler) sendHTMLMsg(chatID int64, htmlText string) bool {
791+
msg := tgbotapi.NewMessage(chatID, htmlText)
792+
msg.ParseMode = tgbotapi.ModeHTML
793+
_, err := h.bot.Send(msg)
794+
return err == nil
795+
}
796+
797+
// sendPlainMsg sends a plain-text message. Returns true on success.
798+
func (h *Handler) sendPlainMsg(chatID int64, text string) bool {
799+
msg := tgbotapi.NewMessage(chatID, text)
800+
_, err := h.bot.Send(msg)
801+
return err == nil
758802
}
759803

760804
func (h *Handler) sendAsFile(chatID int64, text string) {

internal/telegram/markdown.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ var (
1919
reHeader = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
2020
reBulletItem = regexp.MustCompile(`^(\s*)[-*]\s+(.+)$`)
2121
reNumItem = regexp.MustCompile(`^(\s*\d+\.)\s+(.+)$`)
22+
reCheckbox = regexp.MustCompile(`^\[[ ]\]\s*`)
23+
reCheckboxDone = regexp.MustCompile(`^\[[xX]\]\s*`)
2224
rePlaceholder = regexp.MustCompile(`\x00(\d+)\x00`)
2325
)
2426

@@ -64,7 +66,16 @@ func processLine(line string) string {
6466
}
6567
if m := reBulletItem.FindStringSubmatch(line); m != nil {
6668
indent := strings.Repeat(" ", len(m[1])/2)
67-
return indent + "• " + processInline(m[2])
69+
rest := m[2]
70+
prefix := "• "
71+
if reCheckboxDone.MatchString(rest) {
72+
rest = reCheckboxDone.ReplaceAllString(rest, "")
73+
prefix = "☑ "
74+
} else if reCheckbox.MatchString(rest) {
75+
rest = reCheckbox.ReplaceAllString(rest, "")
76+
prefix = "☐ "
77+
}
78+
return indent + prefix + processInline(rest)
6879
}
6980
if m := reNumItem.FindStringSubmatch(line); m != nil {
7081
return html.EscapeString(m[1]) + " " + processInline(m[2])

internal/telegram/markdown_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,24 @@ func TestMarkdown_BulletListMultiline(t *testing.T) {
109109
assertMD(t, input, want)
110110
}
111111

112+
// --- Checkboxes ---
113+
114+
func TestMarkdown_CheckboxUnchecked(t *testing.T) {
115+
assertMD(t, "- [ ] task", "☐ task")
116+
}
117+
118+
func TestMarkdown_CheckboxChecked(t *testing.T) {
119+
assertMD(t, "- [x] done", "☑ done")
120+
}
121+
122+
func TestMarkdown_CheckboxCheckedUpper(t *testing.T) {
123+
assertMD(t, "* [X] done", "☑ done")
124+
}
125+
126+
func TestMarkdown_CheckboxWithBold(t *testing.T) {
127+
assertMD(t, "* [ ] **Паспорт** (загран)", "☐ <b>Паспорт</b> (загран)")
128+
}
129+
112130
// --- HTML escaping ---
113131

114132
func TestMarkdown_HTMLInText(t *testing.T) {
@@ -151,6 +169,42 @@ func TestMarkdown_MultilineDocument(t *testing.T) {
151169
}
152170
}
153171

172+
// --- splitMessage ---
173+
174+
func TestSplitMessage_Short(t *testing.T) {
175+
parts := splitMessage("hello", 100)
176+
if len(parts) != 1 || parts[0] != "hello" {
177+
t.Errorf("unexpected: %v", parts)
178+
}
179+
}
180+
181+
func TestSplitMessage_ParagraphBoundary(t *testing.T) {
182+
text := "first paragraph\n\nsecond paragraph\n\nthird paragraph"
183+
parts := splitMessage(text, 30)
184+
if len(parts) < 2 {
185+
t.Errorf("expected multiple parts, got %d: %v", len(parts), parts)
186+
}
187+
// Verify no part exceeds maxLen
188+
for i, p := range parts {
189+
if len(p) > 30 {
190+
t.Errorf("part %d exceeds maxLen: %d chars", i, len(p))
191+
}
192+
}
193+
}
194+
195+
func TestSplitMessage_LineBoundary(t *testing.T) {
196+
text := "line one\nline two\nline three\nline four"
197+
parts := splitMessage(text, 20)
198+
if len(parts) < 2 {
199+
t.Errorf("expected multiple parts, got %d: %v", len(parts), parts)
200+
}
201+
for i, p := range parts {
202+
if len(p) > 20 {
203+
t.Errorf("part %d exceeds maxLen: %d chars", i, len(p))
204+
}
205+
}
206+
}
207+
154208
// --- helpers ---
155209

156210
func assertMD(t *testing.T, input, want string) {

0 commit comments

Comments
 (0)