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 <noreply@anthropic.com>
This commit is contained in:
cg33andClaude authored and GitHub committed 2026-03-24 14:16:04 +08:00
1 parent 235e54db25
commit 1beffe3f25
4 files changed
+103 -36

No files matched your search

+29 -17
View File
@@ -24,18 +24,18 @@ import (
// codexSession manages a multi-turn Codex conversation.
// First Send() uses `codex exec`, subsequent ones use `codex exec resume <threadID>`.
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").
+17 -13
View File
@@ -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.
+23 -6
View File
@@ -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,
+34
View File
@@ -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()