From ca53a6c27a9482cc173e79e55ca120ab9acad65d Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 11 Jun 2026 11:02:05 +0800 Subject: [PATCH 1/2] feat(telegram): render markdown messages --- go.mod | 2 + go.sum | 4 ++ internal/telegram/telegram.go | 64 ++++++++++++++------------- internal/telegram/telegram_test.go | 70 ++++++++++++++++++++++++------ 4 files changed, 95 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index f7abd0d5..e48947a7 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( require ( github.com/charmbracelet/x/ansi v0.11.6 + github.com/eekstunt/telegramify-markdown-go v0.2.0 github.com/gorilla/websocket v1.5.3 github.com/tta-lab/codex-server-go v0.1.0 golang.org/x/term v0.42.0 @@ -78,6 +79,7 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.8.2 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.mau.fi/util v0.9.6 // indirect diff --git a/go.sum b/go.sum index f7c87a34..4248d53c 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eekstunt/telegramify-markdown-go v0.2.0 h1:12NErifwcZWTbNySPAX/cS4OZTcYGDSL5610jJJfzlU= +github.com/eekstunt/telegramify-markdown-go v0.2.0/go.mod h1:lpEJ5GTV0/b0Yl93yrmLm+Hbn/963FZKptAjsr+F9Vk= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -165,6 +167,8 @@ github.com/tta-lab/codex-server-go v0.1.0 h1:HrZ3PvwKS1ecLlNQnQ6hBE/ss3q0eA4UWY/ github.com/tta-lab/codex-server-go v0.1.0/go.mod h1:t+1tE2M7bM6g74JUUbN/eDMqaoE3xHt/K4ZIaeiDWew= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go index 7b7f6778..9ae8f919 100644 --- a/internal/telegram/telegram.go +++ b/internal/telegram/telegram.go @@ -10,6 +10,7 @@ import ( "strings" "time" + tgmd "github.com/eekstunt/telegramify-markdown-go" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" ) @@ -25,38 +26,38 @@ func ParseChatID(s string) (int64, error) { const maxMessageLen = 4096 -// splitMessage splits text into chunks that fit within Telegram's 4096-rune limit. -// Splits at natural boundaries: paragraph breaks > newlines > spaces > hard cut. -func splitMessage(text string) []string { - runes := []rune(text) - if len(runes) <= maxMessageLen { - return []string{text} - } - - var parts []string - for len(runes) > 0 { - if len(runes) <= maxMessageLen { - parts = append(parts, string(runes)) - break - } +type messageChunk struct { + Text string + Entities []models.MessageEntity +} - chunk := string(runes[:maxMessageLen]) - cutAt := maxMessageLen // in runes - if i := strings.LastIndex(chunk, "\n\n"); i > 0 { - cutAt = len([]rune(chunk[:i])) - } else if i := strings.LastIndex(chunk, "\n"); i > 0 { - cutAt = len([]rune(chunk[:i])) - } else if i := strings.LastIndex(chunk, " "); i > 0 { - cutAt = len([]rune(chunk[:i])) - } +func renderMessageChunks(text string) []messageChunk { + messages := tgmd.ConvertAndSplit(text, tgmd.WithMaxMessageLen(maxMessageLen)) + chunks := make([]messageChunk, 0, len(messages)) + for _, msg := range messages { + chunks = append(chunks, messageChunk{ + Text: msg.Text, + Entities: telegramMessageEntities(msg.Entities), + }) + } + return chunks +} - part := strings.TrimRight(string(runes[:cutAt]), " \n") - if part != "" { - parts = append(parts, part) +func telegramMessageEntities(entities []tgmd.Entity) []models.MessageEntity { + if len(entities) == 0 { + return nil + } + out := make([]models.MessageEntity, len(entities)) + for i, e := range entities { + out[i] = models.MessageEntity{ + Type: models.MessageEntityType(e.Type), + Offset: e.Offset, + Length: e.Length, + URL: e.URL, + Language: e.Language, } - runes = []rune(strings.TrimLeft(string(runes[cutAt:]), " \n")) } - return parts + return out } // SendMessage sends a text message to a chat via the Telegram Bot API. @@ -76,14 +77,15 @@ func SendMessage(botToken, chatID, text string) error { return err } - chunks := splitMessage(text) + chunks := renderMessageChunks(text) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(len(chunks)+1)*15*time.Second) defer cancel() for i, chunk := range chunks { if _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: id, - Text: chunk, + ChatID: id, + Text: chunk.Text, + Entities: chunk.Entities, }); err != nil { return fmt.Errorf("telegram send (chunk %d/%d): %w", i+1, len(chunks), err) } diff --git a/internal/telegram/telegram_test.go b/internal/telegram/telegram_test.go index 54452abf..67b48297 100644 --- a/internal/telegram/telegram_test.go +++ b/internal/telegram/telegram_test.go @@ -3,11 +3,13 @@ package telegram import ( "strings" "testing" + + "github.com/go-telegram/bot/models" ) func TestSplitMessage_Short(t *testing.T) { text := "hello world" - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) != 1 || parts[0] != text { t.Errorf("expected single chunk %q, got %v", text, parts) } @@ -15,7 +17,7 @@ func TestSplitMessage_Short(t *testing.T) { func TestSplitMessage_Exact4096(t *testing.T) { text := strings.Repeat("a", 4096) - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) != 1 { t.Errorf("expected 1 chunk, got %d", len(parts)) } @@ -24,13 +26,13 @@ func TestSplitMessage_Exact4096(t *testing.T) { func TestSplitMessage_ParagraphBreak(t *testing.T) { half := strings.Repeat("a", 2500) text := half + "\n\n" + half - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) != 2 { t.Errorf("expected 2 chunks, got %d", len(parts)) } for _, p := range parts { - if strings.HasPrefix(p, "\n") || strings.HasSuffix(p, "\n") { - t.Errorf("chunk has leading/trailing newline: %q", p) + if len([]rune(p)) > maxMessageLen { + t.Errorf("chunk too long: %d chars", len([]rune(p))) } } } @@ -38,7 +40,7 @@ func TestSplitMessage_ParagraphBreak(t *testing.T) { func TestSplitMessage_SingleNewline(t *testing.T) { line := strings.Repeat("a", 100) + "\n" text := strings.Repeat(line, 50) // 5050 chars - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) < 2 { t.Errorf("expected at least 2 chunks, got %d", len(parts)) } @@ -52,7 +54,7 @@ func TestSplitMessage_SingleNewline(t *testing.T) { func TestSplitMessage_Space(t *testing.T) { word := strings.Repeat("a", 50) + " " text := strings.Repeat(word, 98) // ~4998 chars, no newlines - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) < 2 { t.Errorf("expected at least 2 chunks, got %d", len(parts)) } @@ -65,7 +67,7 @@ func TestSplitMessage_Space(t *testing.T) { func TestSplitMessage_MultiChunk(t *testing.T) { text := strings.Repeat("x\n", 4001) // 8002 chars - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) < 2 { t.Errorf("expected multiple chunks, got %d", len(parts)) } @@ -79,7 +81,7 @@ func TestSplitMessage_MultiChunk(t *testing.T) { func TestSplitMessage_HardCut(t *testing.T) { const textLen = 5000 text := strings.Repeat("a", textLen) - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) != 2 { t.Errorf("expected 2 chunks, got %d", len(parts)) } @@ -93,7 +95,7 @@ func TestSplitMessage_HardCut(t *testing.T) { } func TestSplitMessage_Empty(t *testing.T) { - parts := splitMessage("") + parts := renderMessageTexts("") if len(parts) != 1 { t.Errorf("expected 1 part for empty string, got %d", len(parts)) } @@ -101,10 +103,10 @@ func TestSplitMessage_Empty(t *testing.T) { func TestSplitMessage_AllWhitespaceOverLimit(t *testing.T) { text := strings.Repeat("\n", 5000) - parts := splitMessage(text) + parts := renderMessageTexts(text) // verifies no panic or infinite loop; all whitespace trims to nothing so - // splitMessage returns an empty slice. SendMessage's TrimSpace guard prevents - // this input from ever reaching splitMessage in practice. + // rendering returns an empty slice. SendMessage's TrimSpace guard prevents + // this input from ever reaching renderMessageChunks in practice. _ = parts } @@ -112,7 +114,7 @@ func TestSplitMessage_NonASCII(t *testing.T) { // Each emoji is 4 bytes — 4096 runes = 16384 bytes. // Byte-based slicing would corrupt at char boundaries; rune-based is safe. text := strings.Repeat("😀", 5000) - parts := splitMessage(text) + parts := renderMessageTexts(text) if len(parts) < 2 { t.Errorf("expected at least 2 chunks, got %d", len(parts)) } @@ -123,6 +125,46 @@ func TestSplitMessage_NonASCII(t *testing.T) { } } +func renderMessageTexts(text string) []string { + chunks := renderMessageChunks(text) + parts := make([]string, 0, len(chunks)) + for _, chunk := range chunks { + parts = append(parts, chunk.Text) + } + return parts +} + +func TestRenderMessageChunks_MarkdownEntities(t *testing.T) { + chunks := renderMessageChunks("hello **world**") + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + if chunks[0].Text != "hello world" { + t.Fatalf("text = %q, want %q", chunks[0].Text, "hello world") + } + if len(chunks[0].Entities) != 1 { + t.Fatalf("expected 1 entity, got %d", len(chunks[0].Entities)) + } + if chunks[0].Entities[0].Type != models.MessageEntityTypeBold { + t.Errorf("entity type = %q, want %q", chunks[0].Entities[0].Type, models.MessageEntityTypeBold) + } + if chunks[0].Entities[0].Offset != 6 || chunks[0].Entities[0].Length != 5 { + t.Errorf("entity span = (%d, %d), want (6, 5)", chunks[0].Entities[0].Offset, chunks[0].Entities[0].Length) + } +} + +func TestRenderMessageChunks_SplitsMarkdown(t *testing.T) { + chunks := renderMessageChunks(strings.Repeat("**bold** ", 1000)) + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks, got %d", len(chunks)) + } + for _, chunk := range chunks { + if len([]rune(chunk.Text)) > maxMessageLen { + t.Errorf("chunk exceeds max length: %d", len([]rune(chunk.Text))) + } + } +} + func TestToolEmoji(t *testing.T) { tests := []struct { tool string From 29d28fb4f26719c6a51665cb96da0567a5cf27c8 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 11 Jun 2026 11:40:03 +0800 Subject: [PATCH 2/2] chore(telegram): clean up render naming --- internal/daemon/routing.go | 2 +- internal/telegram/telegram_test.go | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/daemon/routing.go b/internal/daemon/routing.go index 9d7fd6ed..e4db1a7d 100644 --- a/internal/daemon/routing.go +++ b/internal/daemon/routing.go @@ -142,7 +142,7 @@ func persistMsg(msgSvc *message.Service, p message.CreateParams) { // applyOverflow truncates oversize message bodies and writes the full content // to the overflow directory. Only applied to agent/worker (tmux) channels — // human channels (Telegram) pass through untouched since Telegram already -// handles chunking via splitMessage(). +// handles message chunking. func applyOverflow(msg string) string { dir := filepath.Join(config.ResolveDataDir(), "files", "default") return overflow.Write(msg, overflow.DefaultThreshold, dir) diff --git a/internal/telegram/telegram_test.go b/internal/telegram/telegram_test.go index 67b48297..7ba8d3f7 100644 --- a/internal/telegram/telegram_test.go +++ b/internal/telegram/telegram_test.go @@ -7,7 +7,7 @@ import ( "github.com/go-telegram/bot/models" ) -func TestSplitMessage_Short(t *testing.T) { +func TestRenderMessageChunks_Short(t *testing.T) { text := "hello world" parts := renderMessageTexts(text) if len(parts) != 1 || parts[0] != text { @@ -15,7 +15,7 @@ func TestSplitMessage_Short(t *testing.T) { } } -func TestSplitMessage_Exact4096(t *testing.T) { +func TestRenderMessageChunks_Exact4096(t *testing.T) { text := strings.Repeat("a", 4096) parts := renderMessageTexts(text) if len(parts) != 1 { @@ -23,7 +23,7 @@ func TestSplitMessage_Exact4096(t *testing.T) { } } -func TestSplitMessage_ParagraphBreak(t *testing.T) { +func TestRenderMessageChunks_ParagraphBreak(t *testing.T) { half := strings.Repeat("a", 2500) text := half + "\n\n" + half parts := renderMessageTexts(text) @@ -37,7 +37,7 @@ func TestSplitMessage_ParagraphBreak(t *testing.T) { } } -func TestSplitMessage_SingleNewline(t *testing.T) { +func TestRenderMessageChunks_SingleNewline(t *testing.T) { line := strings.Repeat("a", 100) + "\n" text := strings.Repeat(line, 50) // 5050 chars parts := renderMessageTexts(text) @@ -51,7 +51,7 @@ func TestSplitMessage_SingleNewline(t *testing.T) { } } -func TestSplitMessage_Space(t *testing.T) { +func TestRenderMessageChunks_Space(t *testing.T) { word := strings.Repeat("a", 50) + " " text := strings.Repeat(word, 98) // ~4998 chars, no newlines parts := renderMessageTexts(text) @@ -65,7 +65,7 @@ func TestSplitMessage_Space(t *testing.T) { } } -func TestSplitMessage_MultiChunk(t *testing.T) { +func TestRenderMessageChunks_MultiChunk(t *testing.T) { text := strings.Repeat("x\n", 4001) // 8002 chars parts := renderMessageTexts(text) if len(parts) < 2 { @@ -78,7 +78,7 @@ func TestSplitMessage_MultiChunk(t *testing.T) { } } -func TestSplitMessage_HardCut(t *testing.T) { +func TestRenderMessageChunks_HardCut(t *testing.T) { const textLen = 5000 text := strings.Repeat("a", textLen) parts := renderMessageTexts(text) @@ -94,14 +94,14 @@ func TestSplitMessage_HardCut(t *testing.T) { } } -func TestSplitMessage_Empty(t *testing.T) { +func TestRenderMessageChunks_Empty(t *testing.T) { parts := renderMessageTexts("") if len(parts) != 1 { t.Errorf("expected 1 part for empty string, got %d", len(parts)) } } -func TestSplitMessage_AllWhitespaceOverLimit(t *testing.T) { +func TestRenderMessageChunks_AllWhitespaceOverLimit(t *testing.T) { text := strings.Repeat("\n", 5000) parts := renderMessageTexts(text) // verifies no panic or infinite loop; all whitespace trims to nothing so @@ -110,7 +110,7 @@ func TestSplitMessage_AllWhitespaceOverLimit(t *testing.T) { _ = parts } -func TestSplitMessage_NonASCII(t *testing.T) { +func TestRenderMessageChunks_NonASCII(t *testing.T) { // Each emoji is 4 bytes — 4096 runes = 16384 bytes. // Byte-based slicing would corrupt at char boundaries; rune-based is safe. text := strings.Repeat("😀", 5000)