From 1beffe3f257c619d2d55108f4fb00a1d53a491cb Mon Sep 17 00:00:00 2001 From: cg33 Date: Tue, 24 Mar 2026 14:16:04 +0800 Subject: [PATCH] fix(telegram): UTF-8-safe command menu descriptions (fixes #119) (#301) * fix(codex): avoid closing events channel before readLoop exits Close() previously closed cs.events after an 8s wait even when wg.Wait had not finished, while readLoop could still send in flushPendingAsText (turn.completed), causing "panic: send on closed channel" after /stop then /new. Defer channel close until readLoop completes. Fixes https://github.com/chenhg5/cc-connect/issues/281 * fix(telegram): UTF-8-safe bot command descriptions and table rows (#119) - Truncate BotCommand.Description by runes (Telegram 256-char limit); byte slicing broke CJK and caused API errors. - Make truncateForLog rune-aware for debug prefixes. - Parse markdown table rows without byte-slicing the outer pipes. Fixes #119 Made-with: Cursor --------- Co-authored-by: Claude --- agent/codex/session.go | 46 +++++++++++++++++++----------- core/markdown_html.go | 30 ++++++++++--------- platform/telegram/telegram.go | 29 +++++++++++++++---- platform/telegram/telegram_test.go | 34 ++++++++++++++++++++++ 4 files changed, 103 insertions(+), 36 deletions(-) diff --git a/agent/codex/session.go b/agent/codex/session.go index b22ea4a..ecd95fa 100644 --- a/agent/codex/session.go +++ b/agent/codex/session.go @@ -24,18 +24,18 @@ import ( // codexSession manages a multi-turn Codex conversation. // First Send() uses `codex exec`, subsequent ones use `codex exec resume `. type codexSession struct { - workDir string - model string - effort string - mode string - extraEnv []string - events chan core.Event - threadID atomic.Value // stores string — Codex thread_id - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - alive atomic.Bool - closeOnce sync.Once + workDir string + model string + effort string + mode string + extraEnv []string + events chan core.Event + threadID atomic.Value // stores string — Codex thread_id + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + closeOnce sync.Once pendingMsgs []string // buffered agent_message texts awaiting classification } @@ -535,13 +535,25 @@ func (cs *codexSession) Close() error { }() select { case <-done: + // readLoop has exited; safe to close the events channel. + cs.closeOnce.Do(func() { + close(cs.events) + }) + return nil case <-time.After(8 * time.Second): - slog.Warn("codexSession: close timed out, abandoning wg.Wait") + // Do not close(cs.events) here: readLoop may still be in handleEvent + // (e.g. turn.completed -> flushPendingAsText) and would panic on send. + // See https://github.com/chenhg5/cc-connect/issues/281 + slog.Warn("codexSession: close timed out, deferring events channel close until readLoop exits", + "wait", 8*time.Second) + go func() { + <-done + cs.closeOnce.Do(func() { + close(cs.events) + }) + }() + return nil } - cs.closeOnce.Do(func() { - close(cs.events) - }) - return nil } // extractItemText extracts text from an item's array field (e.g. "summary" or "content"). diff --git a/core/markdown_html.go b/core/markdown_html.go index 9a4ddd2..7ba8e3a 100644 --- a/core/markdown_html.go +++ b/core/markdown_html.go @@ -69,7 +69,11 @@ func MarkdownToSimpleHTML(md string) string { if reTableSep.MatchString(tl) { b.WriteString("——————————") } else { - inner := tl[1 : len(tl)-1] + tlTrim := strings.TrimSpace(tl) + inner := tlTrim + if strings.HasPrefix(tlTrim, "|") && strings.HasSuffix(tlTrim, "|") && len(tlTrim) >= 2 { + inner = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(tlTrim, "|"), "|")) + } cells := strings.Split(inner, "|") for k := range cells { cells[k] = strings.TrimSpace(cells[k]) @@ -193,18 +197,18 @@ func MarkdownToSimpleHTML(md string) string { } var ( - reInlineCodeHTML = regexp.MustCompile("`([^`]+)`") - reBoldItalicHTML = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`) - reBoldAstHTML = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUndHTML = regexp.MustCompile(`__(.+?)__`) - reItalicAstHTML = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`) - reStrikeHTML = regexp.MustCompile(`~~(.+?)~~`) - reLinkHTML = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reWikilinkHTML = regexp.MustCompile(`\[\[([^\]|]+)\|([^\]]+)\]\]|\[\[([^\]]+)\]\]`) - reUnorderedList = regexp.MustCompile(`^(\s*)[-*]\s+(.*)$`) - reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+(.*)$`) - reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`) - reCallout = regexp.MustCompile(`^\[!(\w+)\]\s*(.*)$`) + reInlineCodeHTML = regexp.MustCompile("`([^`]+)`") + reBoldItalicHTML = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`) + reBoldAstHTML = regexp.MustCompile(`\*\*(.+?)\*\*`) + reBoldUndHTML = regexp.MustCompile(`__(.+?)__`) + reItalicAstHTML = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`) + reStrikeHTML = regexp.MustCompile(`~~(.+?)~~`) + reLinkHTML = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reWikilinkHTML = regexp.MustCompile(`\[\[([^\]|]+)\|([^\]]+)\]\]|\[\[([^\]]+)\]\]`) + reUnorderedList = regexp.MustCompile(`^(\s*)[-*]\s+(.*)$`) + reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+(.*)$`) + reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`) + reCallout = regexp.MustCompile(`^\[!(\w+)\]\s*(.*)$`) ) // convertInlineHTML converts inline Markdown formatting to Telegram-compatible HTML. diff --git a/platform/telegram/telegram.go b/platform/telegram/telegram.go index 786e29a..db369db 100644 --- a/platform/telegram/telegram.go +++ b/platform/telegram/telegram.go @@ -13,6 +13,7 @@ import ( "sync" "time" "unicode/utf16" + "unicode/utf8" "github.com/chenhg5/cc-connect/core" @@ -1232,10 +1233,29 @@ func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) { } func truncateForLog(s string, maxLen int) string { - if len(s) <= maxLen { + if maxLen <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= maxLen { return s } - return s[:maxLen] + "..." + r := []rune(s) + if len(r) <= maxLen { + return s + } + return string(r[:maxLen]) + "..." +} + +// truncateTelegramBotDescription enforces Telegram's 256-character limit for +// BotCommand.Description. Byte slicing breaks UTF-8 for CJK text and triggers +// "text must be encoded in UTF-8" from the API (#119). +func truncateTelegramBotDescription(s string) string { + const max = 256 + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:253]) + "..." } func (p *Platform) Stop() error { @@ -1276,10 +1296,7 @@ func (p *Platform) RegisterCommands(commands []core.BotCommandInfo) error { continue } seen[cmd] = true - desc := c.Description - if len(desc) > 256 { - desc = desc[:253] + "..." - } + desc := truncateTelegramBotDescription(c.Description) tgCommands = append(tgCommands, tgbotapi.BotCommand{ Command: cmd, Description: desc, diff --git a/platform/telegram/telegram_test.go b/platform/telegram/telegram_test.go index 192d4df..e97d03c 100644 --- a/platform/telegram/telegram_test.go +++ b/platform/telegram/telegram_test.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "testing" "time" + "unicode/utf8" "github.com/chenhg5/cc-connect/core" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -760,6 +761,39 @@ func TestSendAudioReturnsConversionErrorForWAV(t *testing.T) { } } +func TestTruncateTelegramBotDescription_UTF8Safe(t *testing.T) { + t.Parallel() + cjk := strings.Repeat("你", 200) + out := truncateTelegramBotDescription(cjk) + if !utf8.ValidString(out) { + t.Fatal("invalid UTF-8 from CJK description") + } + if got, max := utf8.RuneCountInString(out), 256; got > max { + t.Fatalf("rune count %d > %d", got, max) + } + + long := strings.Repeat("b", 260) + out2 := truncateTelegramBotDescription(long) + if want := 256; utf8.RuneCountInString(out2) != want { + t.Fatalf("ascii truncation: got %d runes want %d", utf8.RuneCountInString(out2), want) + } + if !utf8.ValidString(out2) { + t.Fatal("invalid UTF-8 after ascii truncation") + } +} + +func TestTruncateForLog_UTF8Safe(t *testing.T) { + t.Parallel() + s := strings.Repeat("世", 50) // 50 runes + out := truncateForLog(s, 10) + if !utf8.ValidString(out) { + t.Fatal("invalid UTF-8") + } + if utf8.RuneCountInString(out) != 13 { // 10 + "..." + t.Fatalf("got %d runes", utf8.RuneCountInString(out)) + } +} + func newTelegramTestPlatform(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Platform { t.Helper()