From 7e1fc2434c9bf02d2604509a75d3c38fd4989867 Mon Sep 17 00:00:00 2001 From: shenghui kevin Date: Wed, 4 Mar 2026 01:48:18 -0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A3=9E=E4=B9=A6=20post=20=E5=AF=8C?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=B6=88=E6=81=AF=E6=94=AF=E6=8C=81=20&=20?= =?UTF-8?q?=E6=9C=AA=E8=AF=86=E5=88=AB=20slash=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E7=BB=99=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 飞书发图片或富文本时 msgType 为 post,之前直接忽略了,现在解析其中的文字和图片 2. cc-connect 不认识的 /xxx 命令之前直接报 Unknown command,现在透传给 agent 处理 --- core/engine.go | 15 +++-- platform/feishu/feishu.go | 77 ++++++++++++++++++++++++ platform/feishu/feishu_test.go | 103 +++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 platform/feishu/feishu_test.go diff --git a/core/engine.go b/core/engine.go index f5cd959..a21453b 100644 --- a/core/engine.go +++ b/core/engine.go @@ -283,8 +283,10 @@ func (e *Engine) handleMessage(p Platform, msg *Message) { } if len(msg.Images) == 0 && strings.HasPrefix(content, "/") { - e.handleCommand(p, msg, content) - return + if e.handleCommand(p, msg, content) { + return + } + // unrecognized slash command — fall through to agent } // Permission responses bypass the session lock @@ -720,7 +722,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess // Command handling // ────────────────────────────────────────────────────────────── -func (e *Engine) handleCommand(p Platform, msg *Message, raw string) { +func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { parts := strings.Fields(raw) cmd := strings.ToLower(parts[0]) args := parts[1:] @@ -774,14 +776,15 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) { cmdName := strings.TrimPrefix(cmd, "/") if custom, ok := e.commands.Resolve(cmdName); ok { e.executeCustomCommand(p, msg, custom, args) - return + return true } if skill := e.skills.Resolve(cmdName); skill != nil { e.executeSkill(p, msg, skill, args) - return + return true } - e.reply(p, msg.ReplyCtx, fmt.Sprintf("Unknown command: %s\nType /help for available commands.", cmd)) + return false // unrecognized, let caller forward to agent } + return true } func (e *Engine) cmdNew(p Platform, msg *Message, args []string) { diff --git a/platform/feishu/feishu.go b/platform/feishu/feishu.go index b6e4f8e..cdda582 100644 --- a/platform/feishu/feishu.go +++ b/platform/feishu/feishu.go @@ -227,6 +227,19 @@ func (p *Platform) onMessage(event *larkim.P2MessageReceiveV1) error { ReplyCtx: rctx, }) + case "post": + textParts, images := p.parsePostContent(messageID, *msg.Content) + text := strings.Join(textParts, "\n") + if text == "" && len(images) == 0 { + return nil + } + p.handler(p, &core.Message{ + SessionKey: sessionKey, Platform: "feishu", + UserID: userID, UserName: userName, + Content: text, Images: images, + ReplyCtx: rctx, + }) + default: slog.Debug("feishu: ignoring unsupported message type", "type", msgType) } @@ -442,3 +455,67 @@ func (p *Platform) Stop() error { } return nil } + +type postElement struct { + Tag string `json:"tag"` + Text string `json:"text,omitempty"` + ImageKey string `json:"image_key,omitempty"` + Href string `json:"href,omitempty"` +} + +type postLang struct { + Title string `json:"title"` + Content [][]postElement `json:"content"` +} + +// parsePostContent handles both formats of feishu post content: +// 1. {"title":"...", "content":[[...]]} (receive event) +// 2. {"zh_cn":{"title":"...", "content":[[...]]}} (some SDK versions) +func (p *Platform) parsePostContent(messageID, raw string) ([]string, []core.ImageAttachment) { + // try flat format first + var flat postLang + if err := json.Unmarshal([]byte(raw), &flat); err == nil && flat.Content != nil { + return p.extractPostParts(messageID, &flat) + } + // try language-keyed format + var langMap map[string]postLang + if err := json.Unmarshal([]byte(raw), &langMap); err == nil { + for _, lang := range langMap { + return p.extractPostParts(messageID, &lang) + } + } + slog.Error("feishu: failed to parse post content", "raw", raw) + return nil, nil +} + +func (p *Platform) extractPostParts(messageID string, post *postLang) ([]string, []core.ImageAttachment) { + var textParts []string + var images []core.ImageAttachment + if post.Title != "" { + textParts = append(textParts, post.Title) + } + for _, line := range post.Content { + for _, elem := range line { + switch elem.Tag { + case "text": + if elem.Text != "" { + textParts = append(textParts, elem.Text) + } + case "a": + if elem.Text != "" { + textParts = append(textParts, elem.Text) + } + case "img": + if elem.ImageKey != "" { + imgData, mimeType, err := p.downloadImage(messageID, elem.ImageKey) + if err != nil { + slog.Error("feishu: download post image failed", "error", err, "key", elem.ImageKey) + continue + } + images = append(images, core.ImageAttachment{MimeType: mimeType, Data: imgData}) + } + } + } + } + return textParts, images +} diff --git a/platform/feishu/feishu_test.go b/platform/feishu/feishu_test.go new file mode 100644 index 0000000..3aea9ad --- /dev/null +++ b/platform/feishu/feishu_test.go @@ -0,0 +1,103 @@ +package feishu + +import ( + "testing" +) + +func TestExtractPostParts_TextOnly(t *testing.T) { + p := &Platform{} + post := &postLang{ + Title: "标题", + Content: [][]postElement{ + { + {Tag: "text", Text: "第一行"}, + {Tag: "text", Text: "接着"}, + }, + { + {Tag: "text", Text: "第二行"}, + }, + }, + } + texts, images := p.extractPostParts("", post) + if len(texts) != 4 { + t.Fatalf("expected 4 text parts, got %d", len(texts)) + } + if texts[0] != "标题" { + t.Errorf("expected title '标题', got %q", texts[0]) + } + if texts[1] != "第一行" { + t.Errorf("expected '第一行', got %q", texts[1]) + } + if len(images) != 0 { + t.Errorf("expected 0 images, got %d", len(images)) + } +} + +func TestExtractPostParts_WithLink(t *testing.T) { + p := &Platform{} + post := &postLang{ + Content: [][]postElement{ + { + {Tag: "text", Text: "点击 "}, + {Tag: "a", Text: "这里", Href: "https://example.com"}, + }, + }, + } + texts, _ := p.extractPostParts("", post) + if len(texts) != 2 { + t.Fatalf("expected 2 text parts, got %d", len(texts)) + } + if texts[0] != "点击 " || texts[1] != "这里" { + t.Errorf("unexpected texts: %v", texts) + } +} + +func TestExtractPostParts_EmptyContent(t *testing.T) { + p := &Platform{} + post := &postLang{} + texts, images := p.extractPostParts("", post) + if len(texts) != 0 || len(images) != 0 { + t.Errorf("expected empty results, got texts=%d images=%d", len(texts), len(images)) + } +} + +func TestExtractPostParts_NoTitle(t *testing.T) { + p := &Platform{} + post := &postLang{ + Content: [][]postElement{ + { + {Tag: "text", Text: "只有正文"}, + }, + }, + } + texts, _ := p.extractPostParts("", post) + if len(texts) != 1 || texts[0] != "只有正文" { + t.Errorf("unexpected texts: %v", texts) + } +} + +func TestParsePostContent_FlatFormat(t *testing.T) { + p := &Platform{} + raw := `{"title":"test","content":[[{"tag":"text","text":"hello"}]]}` + texts, _ := p.parsePostContent("", raw) + if len(texts) != 2 || texts[0] != "test" || texts[1] != "hello" { + t.Errorf("unexpected result: %v", texts) + } +} + +func TestParsePostContent_LangKeyedFormat(t *testing.T) { + p := &Platform{} + raw := `{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"内容"}]]}}` + texts, _ := p.parsePostContent("", raw) + if len(texts) != 2 || texts[0] != "标题" || texts[1] != "内容" { + t.Errorf("unexpected result: %v", texts) + } +} + +func TestParsePostContent_InvalidJSON(t *testing.T) { + p := &Platform{} + texts, images := p.parsePostContent("", "not json") + if texts != nil || images != nil { + t.Errorf("expected nil results for invalid json") + } +}