mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
feat(engine): enhance session switching and markdown conversion
- Improved `/switch` command to support session names and better matching logic - Refactored markdown-to-HTML conversion with proper tag nesting and quote escaping - Added detailed logging for streaming preview operations - Fixed HTML tag validation in markdown tests - Updated Telegram platform to handle HTML parsing errors gracefully generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
914198e597
commit
e40d033c3f
5 files changed
+231
-42
No files matched your search
+57
-17
@@ -991,8 +991,9 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
|
||||
|
||||
// If streaming preview was active, try to finalize in-place
|
||||
if sp.finish(fullResponse) {
|
||||
slog.Debug("stream preview: finalized in-place", "response_len", len(fullResponse))
|
||||
slog.Debug("EventResult: finalized via stream preview", "response_len", len(fullResponse))
|
||||
} else {
|
||||
slog.Debug("EventResult: sending via p.Send (preview inactive or failed)", "response_len", len(fullResponse), "chunks", len(splitMessage(fullResponse, maxPlatformMessageLen)))
|
||||
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
|
||||
if err := p.Send(e.ctx, replyCtx, chunk); err != nil {
|
||||
slog.Error("failed to send reply", "error", err)
|
||||
@@ -1283,10 +1284,10 @@ func (e *Engine) cmdList(p Platform, msg *Message) {
|
||||
|
||||
func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) {
|
||||
if len(args) == 0 {
|
||||
e.reply(p, msg.ReplyCtx, "Usage: /switch <number or id_prefix>")
|
||||
e.reply(p, msg.ReplyCtx, "Usage: /switch <number | id_prefix | name>")
|
||||
return
|
||||
}
|
||||
prefix := strings.TrimSpace(args[0])
|
||||
query := strings.TrimSpace(strings.Join(args, " "))
|
||||
|
||||
slog.Info("cmdSwitch: listing agent sessions", "session_key", msg.SessionKey)
|
||||
agentSessions, err := e.agent.ListSessions(e.ctx)
|
||||
@@ -1295,21 +1296,9 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
var matched *AgentSessionInfo
|
||||
|
||||
// Try numeric index first (1-based, matching /list output)
|
||||
if idx, err := strconv.Atoi(prefix); err == nil && idx >= 1 && idx <= len(agentSessions) {
|
||||
matched = &agentSessions[idx-1]
|
||||
} else {
|
||||
for i := range agentSessions {
|
||||
if strings.HasPrefix(agentSessions[i].ID, prefix) {
|
||||
matched = &agentSessions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
matched := e.matchSession(agentSessions, query)
|
||||
if matched == nil {
|
||||
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching %q", prefix))
|
||||
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching %q", query))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1335,6 +1324,57 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) {
|
||||
fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", displayName, shortID, matched.MessageCount))
|
||||
}
|
||||
|
||||
// matchSession resolves a user query to an agent session. Priority:
|
||||
// 1. Numeric index (1-based, matching /list output)
|
||||
// 2. Exact custom name match (case-insensitive)
|
||||
// 3. Session ID prefix match
|
||||
// 4. Custom name prefix match (case-insensitive)
|
||||
// 5. Summary substring match (case-insensitive)
|
||||
func (e *Engine) matchSession(sessions []AgentSessionInfo, query string) *AgentSessionInfo {
|
||||
if len(sessions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Numeric index
|
||||
if idx, err := strconv.Atoi(query); err == nil && idx >= 1 && idx <= len(sessions) {
|
||||
return &sessions[idx-1]
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
// 2. Exact custom name match
|
||||
for i := range sessions {
|
||||
name := e.sessions.GetSessionName(sessions[i].ID)
|
||||
if name != "" && strings.ToLower(name) == queryLower {
|
||||
return &sessions[i]
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Session ID prefix match
|
||||
for i := range sessions {
|
||||
if strings.HasPrefix(sessions[i].ID, query) {
|
||||
return &sessions[i]
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Custom name prefix match
|
||||
for i := range sessions {
|
||||
name := e.sessions.GetSessionName(sessions[i].ID)
|
||||
if name != "" && strings.HasPrefix(strings.ToLower(name), queryLower) {
|
||||
return &sessions[i]
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Summary substring match
|
||||
for i := range sessions {
|
||||
if sessions[i].Summary != "" && strings.Contains(strings.ToLower(sessions[i].Summary), queryLower) {
|
||||
return &sessions[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cmdName(p Platform, msg *Message, args []string) {
|
||||
if len(args) == 0 {
|
||||
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNameUsage))
|
||||
|
||||
+33
-13
@@ -93,10 +93,10 @@ var (
|
||||
|
||||
// convertInlineHTML converts inline Markdown formatting to Telegram-compatible HTML.
|
||||
//
|
||||
// Strategy (matches Claude-to-IM): extract code/links into placeholders first,
|
||||
// then HTML-escape ALL remaining text, then apply bold/italic/strike regex on
|
||||
// the escaped text (their markers **, *, ~~ don't contain HTML special chars),
|
||||
// then restore placeholders.
|
||||
// Each formatting pass (bold, strikethrough) protects its output as placeholders
|
||||
// so that subsequent passes (italic) cannot match across HTML tag boundaries.
|
||||
// Without this, input like `**bold *text***` would produce crossed tags
|
||||
// `<b>bold <i>text</b></i>` which Telegram rejects.
|
||||
func convertInlineHTML(s string) string {
|
||||
type placeholder struct {
|
||||
key string
|
||||
@@ -128,15 +128,25 @@ func convertInlineHTML(s string) string {
|
||||
})
|
||||
|
||||
// 3. HTML-escape the entire remaining text.
|
||||
// Placeholders (\x00PH...\x00) contain no HTML special chars so they survive.
|
||||
// Markdown markers **, *, ~~, __ also survive since they aren't <, >, or &.
|
||||
s = escapeHTML(s)
|
||||
|
||||
// 4. Apply formatting on the escaped text.
|
||||
s = reBoldAstHTML.ReplaceAllString(s, "<b>$1</b>")
|
||||
s = reBoldUndHTML.ReplaceAllString(s, "<b>$1</b>")
|
||||
s = reStrikeHTML.ReplaceAllString(s, "<s>$1</s>")
|
||||
// 4. Bold → placeholder (so italic regex can't cross bold boundaries)
|
||||
s = reBoldAstHTML.ReplaceAllStringFunc(s, func(m string) string {
|
||||
inner := m[2 : len(m)-2]
|
||||
return nextPH("<b>" + inner + "</b>")
|
||||
})
|
||||
s = reBoldUndHTML.ReplaceAllStringFunc(s, func(m string) string {
|
||||
inner := m[2 : len(m)-2]
|
||||
return nextPH("<b>" + inner + "</b>")
|
||||
})
|
||||
|
||||
// 5. Strikethrough → placeholder
|
||||
s = reStrikeHTML.ReplaceAllStringFunc(s, func(m string) string {
|
||||
inner := m[2 : len(m)-2]
|
||||
return nextPH("<s>" + inner + "</s>")
|
||||
})
|
||||
|
||||
// 6. Italic (applied last, on text with bold/strike already protected)
|
||||
s = reItalicAstHTML.ReplaceAllStringFunc(s, func(m string) string {
|
||||
idx := strings.Index(m, "*")
|
||||
if idx < 0 {
|
||||
@@ -149,9 +159,18 @@ func convertInlineHTML(s string) string {
|
||||
return m[:idx] + "<i>" + m[idx+1:lastIdx] + "</i>" + m[lastIdx+1:]
|
||||
})
|
||||
|
||||
// 5. Restore placeholders
|
||||
for _, ph := range phs {
|
||||
s = strings.Replace(s, ph.key, ph.html, 1)
|
||||
// 7. Restore all placeholders (may be nested, so iterate until stable)
|
||||
for i := 0; i < 3; i++ {
|
||||
changed := false
|
||||
for _, ph := range phs {
|
||||
if strings.Contains(s, ph.key) {
|
||||
s = strings.Replace(s, ph.key, ph.html, 1)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
@@ -161,6 +180,7 @@ func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -89,6 +90,29 @@ func TestMarkdownToTelegramHTML_LinkWithAmpersand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_LinkWithQuotesInURL(t *testing.T) {
|
||||
out := MarkdownToTelegramHTML(`visit [book](https://example.com/q="test")`)
|
||||
if strings.Contains(out, `href="https://example.com/q="`) {
|
||||
t.Errorf("unescaped quote in href attribute, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `"`) {
|
||||
t.Errorf("expected escaped quote in URL, got %q", out)
|
||||
}
|
||||
if err := validateHTMLNesting(out); err != nil {
|
||||
t.Errorf("invalid HTML: %v, got %q", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_EscapesQuotesInText(t *testing.T) {
|
||||
out := MarkdownToTelegramHTML(`He said "hello" world`)
|
||||
if strings.Contains(out, `"hello"`) {
|
||||
t.Errorf("quotes in text should be escaped, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `"hello"`) {
|
||||
t.Errorf("expected " in output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_CodeBlockEscapesHTML(t *testing.T) {
|
||||
md := "```\nif a < b && c > d {\n}\n```"
|
||||
out := MarkdownToTelegramHTML(md)
|
||||
@@ -117,6 +141,69 @@ func TestMarkdownToTelegramHTML_MixedFormattingWithSpecialChars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_NoCrossedTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"bold then italic", "**bold *text***"},
|
||||
{"italic around bold", "*italic **bold** more*"},
|
||||
{"heading with bold", "## **important** heading"},
|
||||
{"heading with italic", "## *weather* report"},
|
||||
{"mixed line", "**北京** *晴天* 25°C"},
|
||||
{"triple star", "***bold italic***"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := MarkdownToTelegramHTML(tt.input)
|
||||
// Check no crossed tags: every <b> must close before enclosing </i> etc.
|
||||
// Simple check: no </b> inside an <i> block or vice versa
|
||||
if err := validateHTMLNesting(out); err != nil {
|
||||
t.Errorf("crossed tags in output %q: %v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validateHTMLNesting(html string) error {
|
||||
var stack []string
|
||||
i := 0
|
||||
for i < len(html) {
|
||||
if html[i] != '<' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
end := strings.Index(html[i:], ">")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
tag := html[i+1 : i+end]
|
||||
i += end + 1
|
||||
if strings.HasPrefix(tag, "/") {
|
||||
closing := tag[1:]
|
||||
// strip attributes
|
||||
if sp := strings.IndexByte(closing, ' '); sp > 0 {
|
||||
closing = closing[:sp]
|
||||
}
|
||||
if len(stack) == 0 {
|
||||
return fmt.Errorf("unexpected closing tag </%s>", closing)
|
||||
}
|
||||
top := stack[len(stack)-1]
|
||||
if top != closing {
|
||||
return fmt.Errorf("expected </%s>, found </%s>", top, closing)
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
} else {
|
||||
name := tag
|
||||
if sp := strings.IndexByte(name, ' '); sp > 0 {
|
||||
name = name[:sp]
|
||||
}
|
||||
stack = append(stack, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSplitMessageCodeFenceAware_Short(t *testing.T) {
|
||||
chunks := SplitMessageCodeFenceAware("hello", 100)
|
||||
if len(chunks) != 1 || chunks[0] != "hello" {
|
||||
|
||||
+18
-9
@@ -152,6 +152,7 @@ func (sp *streamPreview) flushLocked(text string) {
|
||||
|
||||
updater, ok := sp.platform.(MessageUpdater)
|
||||
if !ok {
|
||||
slog.Debug("stream preview: platform does not support UpdateMessage, degrading")
|
||||
sp.degraded = true
|
||||
return
|
||||
}
|
||||
@@ -159,6 +160,7 @@ func (sp *streamPreview) flushLocked(text string) {
|
||||
if sp.previewMsgID == nil {
|
||||
// First preview: try to send a new preview message
|
||||
if starter, ok := sp.platform.(PreviewStarter); ok {
|
||||
slog.Debug("stream preview: sending first preview via SendPreviewStart", "text_len", len(text))
|
||||
handle, err := starter.SendPreviewStart(sp.ctx, sp.replyCtx, text)
|
||||
if err != nil {
|
||||
slog.Debug("stream preview: start failed, degrading", "error", err)
|
||||
@@ -167,14 +169,11 @@ func (sp *streamPreview) flushLocked(text string) {
|
||||
}
|
||||
sp.previewMsgID = handle
|
||||
} else {
|
||||
// Platform supports UpdateMessage but not PreviewStarter;
|
||||
// use Send to create initial message, then update in-place
|
||||
if err := sp.platform.Send(sp.ctx, sp.replyCtx, text); err != nil {
|
||||
slog.Debug("stream preview: initial send failed", "error", err)
|
||||
sp.degraded = true
|
||||
return
|
||||
}
|
||||
// For platforms without PreviewStarter, replyCtx itself serves as the handle
|
||||
sp.previewMsgID = sp.replyCtx
|
||||
}
|
||||
sp.lastSentText = text
|
||||
@@ -183,6 +182,7 @@ func (sp *streamPreview) flushLocked(text string) {
|
||||
}
|
||||
|
||||
// Update existing preview message
|
||||
slog.Debug("stream preview: updating via UpdateMessage", "text_len", len(text))
|
||||
if err := updater.UpdateMessage(sp.ctx, sp.previewMsgID, text); err != nil {
|
||||
slog.Debug("stream preview: update failed, degrading", "error", err)
|
||||
sp.degraded = true
|
||||
@@ -235,24 +235,28 @@ func (sp *streamPreview) finish(finalText string) bool {
|
||||
}
|
||||
|
||||
if sp.previewMsgID == nil || sp.degraded {
|
||||
slog.Debug("stream preview finish: no active preview", "hasHandle", sp.previewMsgID != nil, "degraded", sp.degraded)
|
||||
return false
|
||||
}
|
||||
|
||||
// If platform wants to delete the preview and send fresh, let it
|
||||
if cleaner, ok := sp.platform.(PreviewCleaner); ok {
|
||||
slog.Debug("stream preview finish: deleting preview (PreviewCleaner)")
|
||||
_ = cleaner.DeletePreviewMessage(sp.ctx, sp.previewMsgID)
|
||||
return false
|
||||
}
|
||||
|
||||
updater, ok := sp.platform.(MessageUpdater)
|
||||
if !ok {
|
||||
slog.Debug("stream preview finish: no MessageUpdater")
|
||||
return false
|
||||
}
|
||||
|
||||
// For very long responses, we may need chunked sending instead
|
||||
maxChars := sp.cfg.MaxChars
|
||||
if maxChars > 0 && len([]rune(finalText)) > maxChars {
|
||||
// Preview content stays as-is; fall back to chunked send for the full response
|
||||
slog.Debug("stream preview finish: text exceeds maxChars, falling back to chunked send",
|
||||
"text_runes", len([]rune(finalText)), "maxChars", maxChars)
|
||||
if sp.lastSentText != "" {
|
||||
_ = updater.UpdateMessage(sp.ctx, sp.previewMsgID, sp.lastSentText)
|
||||
}
|
||||
@@ -260,17 +264,22 @@ func (sp *streamPreview) finish(finalText string) bool {
|
||||
}
|
||||
|
||||
if finalText == "" {
|
||||
// Empty final text (error path) — nothing to update
|
||||
if sp.lastSentText != "" {
|
||||
_ = updater.UpdateMessage(sp.ctx, sp.previewMsgID, sp.lastSentText)
|
||||
}
|
||||
slog.Debug("stream preview finish: empty final text")
|
||||
return false
|
||||
}
|
||||
|
||||
// Always call UpdateMessage for the final text, even if the raw text matches
|
||||
// what was last sent. The preview may have been sent as plain text (via
|
||||
// SendPreviewStart), while UpdateMessage applies platform-specific formatting
|
||||
// (e.g., Markdown→HTML for Telegram). Platform implementations should treat
|
||||
// "message is not modified" as success to avoid duplicate sends.
|
||||
slog.Debug("stream preview finish: sending final UpdateMessage",
|
||||
"text_len", len(finalText), "lastSent_len", len(sp.lastSentText), "same", finalText == sp.lastSentText)
|
||||
if err := updater.UpdateMessage(sp.ctx, sp.previewMsgID, finalText); err != nil {
|
||||
slog.Debug("stream preview: final update failed", "error", err)
|
||||
slog.Debug("stream preview finish: final update FAILED", "error", err)
|
||||
return false
|
||||
}
|
||||
slog.Debug("stream preview finish: success via UpdateMessage")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -496,14 +496,47 @@ func (p *Platform) UpdateMessage(ctx context.Context, previewHandle any, content
|
||||
if !ok {
|
||||
return fmt.Errorf("telegram: invalid preview handle type %T", previewHandle)
|
||||
}
|
||||
edit := tgbotapi.NewEditMessageText(h.chatID, h.messageID, content)
|
||||
_, err := p.bot.Send(edit)
|
||||
if err != nil {
|
||||
|
||||
html := core.MarkdownToTelegramHTML(content)
|
||||
slog.Debug("telegram: UpdateMessage",
|
||||
"content_len", len(content), "html_len", len(html),
|
||||
"content_prefix", truncateForLog(content, 80),
|
||||
"html_prefix", truncateForLog(html, 80))
|
||||
|
||||
edit := tgbotapi.NewEditMessageText(h.chatID, h.messageID, html)
|
||||
edit.ParseMode = tgbotapi.ModeHTML
|
||||
|
||||
if _, err := p.bot.Send(edit); err != nil {
|
||||
errMsg := err.Error()
|
||||
slog.Debug("telegram: UpdateMessage HTML failed", "error", errMsg)
|
||||
if strings.Contains(errMsg, "not modified") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(errMsg, "can't parse") {
|
||||
slog.Debug("telegram: UpdateMessage falling back to plain text", "full_html", html)
|
||||
edit.Text = content
|
||||
edit.ParseMode = ""
|
||||
if _, err2 := p.bot.Send(edit); err2 != nil {
|
||||
if strings.Contains(err2.Error(), "not modified") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("telegram: edit message: %w", err2)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("telegram: edit message: %w", err)
|
||||
}
|
||||
slog.Debug("telegram: UpdateMessage HTML success")
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateForLog(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// StartTyping sends a "typing…" chat action and repeats every 5 seconds
|
||||
// until the returned stop function is called.
|
||||
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
|
||||
|
||||
Reference in new issue
Block a user