package feishu import ( "context" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "github.com/chenhg5/cc-connect/core" lark "github.com/larksuite/oapi-sdk-go/v3" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) func TestOnMessageRecalledDispatchesCoreRecallMessage(t *testing.T) { got := make(chan *core.Message, 1) p := &Platform{ platformName: "feishu", handler: func(_ core.Platform, msg *core.Message) { got <- msg }, } messageID := "om_recalled" chatID := "oc_chat" recallTime := "1710000000000" recallType := "user" err := p.onMessageRecalled(context.Background(), &larkim.P2MessageRecalledV1{ Event: &larkim.P2MessageRecalledV1Data{ MessageId: &messageID, ChatId: &chatID, RecallTime: &recallTime, RecallType: &recallType, }, }) if err != nil { t.Fatalf("onMessageRecalled returned error: %v", err) } select { case msg := <-got: if msg.Platform != "feishu" { t.Fatalf("Platform = %q, want feishu", msg.Platform) } if msg.MessageID != messageID { t.Fatalf("MessageID = %q, want %q", msg.MessageID, messageID) } if !msg.Recalled { t.Fatal("Recalled = false, want true") } if msg.Content != "" { t.Fatalf("Content = %q, want empty recall payload", msg.Content) } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for recall message") } } func TestDispatchMessageDropsRecalledMessageBeforeHandler(t *testing.T) { called := false p := &Platform{ platformName: "feishu", handler: func(_ core.Platform, _ *core.Message) { called = true }, } p.markMessageRecalled("om_drop") p.dispatchMessage( context.Background(), "text", `{"text":"hello"}`, nil, "om_drop", "feishu:ou_user:ou_user", "", "", replyContext{messageID: "om_drop", sessionKey: "feishu:ou_user:ou_user"}, "", ) if called { t.Fatal("handler was called for a message already marked recalled") } } func TestDispatchMessageIncludesQuotedImage(t *testing.T) { const appID = "cli_quote_image" const appSecret = "secret-quote-image" const parentMessageID = "om_parent_image" const imageKey = "img_parent" imageData := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} tests := []struct { name string msgType string content string }{ { name: "text reply", msgType: "text", content: `{"text":"这是什么图"}`, }, { name: "post reply", msgType: "post", content: `{"content":[[{"tag":"text","text":"这是什么图"}]]}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := make(chan *core.Message, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "expire": 7200, "tenant_access_token": "tenant-token", }) case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID: w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "data": map[string]any{ "items": []map[string]any{ { "msg_type": "image", "parent_id": "", "sender": map[string]any{ "id": "", "sender_type": "user", }, "body": map[string]any{ "content": `{"image_key":"` + imageKey + `"}`, }, }, }, }, }) case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID+"/resources/"+imageKey: if r.URL.Query().Get("type") != "image" { t.Fatalf("resource type = %q, want image", r.URL.Query().Get("type")) } w.Header().Set("Content-Type", "image/png") if _, err := w.Write(imageData); err != nil { t.Fatalf("write image: %v", err) } default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer srv.Close() p := &Platform{ platformName: "feishu", domain: srv.URL, appID: appID, appSecret: appSecret, client: lark.NewClient(appID, appSecret, lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), handler: func(_ core.Platform, msg *core.Message) { got <- msg }, } p.dispatchMessage( context.Background(), tc.msgType, tc.content, nil, "om_child", "feishu:oc_chat:ou_user", "", "", replyContext{messageID: "om_child", sessionKey: "feishu:oc_chat:ou_user"}, parentMessageID, ) select { case msg := <-got: if msg.Content != "这是什么图" { t.Fatalf("Content = %q, want question text", msg.Content) } if !strings.Contains(msg.ExtraContent, "[image]") { t.Fatalf("ExtraContent = %q, want quoted image marker", msg.ExtraContent) } if len(msg.Images) != 1 { t.Fatalf("len(Images) = %d, want 1", len(msg.Images)) } if string(msg.Images[0].Data) != string(imageData) { t.Fatal("quoted image data did not match downloaded resource") } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for dispatched message") } }) } } func TestDispatchMessageKeepsMentionOnlyQuotedText(t *testing.T) { const appID = "cli_quote_text" const appSecret = "secret-quote-text" const parentMessageID = "om_parent_text" got := make(chan *core.Message, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "expire": 7200, "tenant_access_token": "tenant-token", }) case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID: w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "data": map[string]any{ "items": []map[string]any{ { "msg_type": "text", "parent_id": "", "sender": map[string]any{ "id": "ou_parent", "sender_type": "user", }, "body": map[string]any{ "content": `{"text":"请总结这条消息"}`, }, }, }, }, }) case strings.HasPrefix(r.URL.Path, "/open-apis/contact/v3/users/"): w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) case strings.HasPrefix(r.URL.Path, "/open-apis/im/v1/chats/"): w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{"code": 0, "msg": "success"}) default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer srv.Close() p := &Platform{ platformName: "feishu", domain: srv.URL, appID: appID, appSecret: appSecret, botOpenID: "ou_bot", client: lark.NewClient(appID, appSecret, lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), handler: func(_ core.Platform, msg *core.Message) { got <- msg }, } p.dispatchMessage( context.Background(), "text", `{"text":"@bot"}`, []*larkim.MentionEvent{ {Key: strPtr("@bot"), Id: &larkim.UserId{OpenId: strPtr("ou_bot")}, Name: strPtr("Bot")}, }, "om_child", "feishu:oc_chat:ou_user", "ou_user", "oc_chat", replyContext{messageID: "om_child", sessionKey: "feishu:oc_chat:ou_user"}, parentMessageID, ) select { case msg := <-got: if msg.Content != "" { t.Fatalf("Content = %q, want empty user text after bot mention stripping", msg.Content) } if !strings.Contains(msg.ExtraContent, "请总结这条消息") { t.Fatalf("ExtraContent = %q, want quoted text", msg.ExtraContent) } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for mention-only quoted text message") } } func TestIsMessageRecalledDetectsWithdrawnMessageFromGetAPI(t *testing.T) { const appID = "cli_recall_probe" const appSecret = "secret-recall-probe" getCalls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/open-apis/auth/v3/tenant_access_token/internal": writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "expire": 7200, "tenant_access_token": "tenant-token", }) case "/open-apis/im/v1/messages/om_withdrawn": getCalls++ writeJSON(t, w, map[string]any{ "code": 230011, "msg": "The message was withdrawn.", }) default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer srv.Close() p := &Platform{ platformName: "feishu", domain: srv.URL, appID: appID, appSecret: appSecret, client: lark.NewClient(appID, appSecret, lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), replayClient: lark.NewClient(appID, appSecret, lark.WithEnableTokenCache(false), lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), } recalled, err := p.IsMessageRecalled(context.Background(), replyContext{messageID: "om_withdrawn", chatID: "oc_chat"}) if err != nil { t.Fatalf("IsMessageRecalled() error = %v", err) } if !recalled { t.Fatal("IsMessageRecalled() = false, want true") } if getCalls != 1 { t.Fatalf("getCalls = %d, want 1", getCalls) } if !p.isMessageRecalled("om_withdrawn") { t.Fatal("withdrawn message id was not cached after detection") } } func TestIsMessageRecalledDetectsDeletedMessageItem(t *testing.T) { const appID = "cli_recall_probe" const appSecret = "secret-recall-probe" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/open-apis/auth/v3/tenant_access_token/internal": writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "expire": 7200, "tenant_access_token": "tenant-token", }) case "/open-apis/im/v1/messages/om_deleted": writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "data": map[string]any{ "items": []map[string]any{ { "message_id": "om_deleted", "deleted": true, }, }, }, }) default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer srv.Close() p := &Platform{ platformName: "feishu", domain: srv.URL, appID: appID, appSecret: appSecret, client: lark.NewClient(appID, appSecret, lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), replayClient: lark.NewClient(appID, appSecret, lark.WithEnableTokenCache(false), lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), } recalled, err := p.IsMessageRecalled(context.Background(), replyContext{messageID: "om_deleted", chatID: "oc_chat"}) if err != nil { t.Fatalf("IsMessageRecalled() error = %v", err) } if !recalled { t.Fatal("IsMessageRecalled() = false, want true for deleted message item") } if !p.isMessageRecalled("om_deleted") { t.Fatal("deleted message id was not cached after detection") } } 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] != "[这里](https://example.com)" { 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 TestExtractPostParts_AtMention(t *testing.T) { p := &Platform{botOpenID: "ou_bot"} post := &postLang{ Content: [][]postElement{ { {Tag: "text", Text: "hi "}, {Tag: "at", UserId: "ou_alice", UserName: "Alice"}, {Tag: "text", Text: " and "}, {Tag: "at", UserId: "all"}, {Tag: "text", Text: " but not "}, {Tag: "at", UserId: "ou_bot", UserName: "Bot"}, }, }, } texts, _ := p.extractPostParts("", post) joined := strings.Join(texts, "") want := "hi @Alice and @all but not " if joined != want { t.Errorf("want %q, got %q", want, joined) } } func TestExtractPostParts_Markdown(t *testing.T) { p := &Platform{} post := &postLang{ Content: [][]postElement{ {{Tag: "markdown", Text: "**bold**"}}, }, } texts, _ := p.extractPostParts("", post) if len(texts) != 1 || texts[0] != "**bold**" { 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") } } func TestParseInlineMarkdown_Link(t *testing.T) { elements := parseInlineMarkdown("visit [Google](https://google.com) now") found := false for _, el := range elements { if el["tag"] == "a" && el["text"] == "Google" && el["href"] == "https://google.com" { found = true } } if !found { t.Errorf("expected link element, got %v", elements) } } func TestParseInlineMarkdown_Italic(t *testing.T) { elements := parseInlineMarkdown("hello *world*") found := false for _, el := range elements { if styles, ok := el["style"].([]string); ok { for _, s := range styles { if s == "italic" && el["text"] == "world" { found = true } } } } if !found { t.Errorf("expected italic element, got %v", elements) } } func TestParseInlineMarkdown_Strikethrough(t *testing.T) { elements := parseInlineMarkdown("hello ~~world~~") found := false for _, el := range elements { if styles, ok := el["style"].([]string); ok { for _, s := range styles { if s == "lineThrough" && el["text"] == "world" { found = true } } } } if !found { t.Errorf("expected strikethrough element, got %v", elements) } } func TestPreprocessFeishuMarkdown_NewlineBeforeCodeFence(t *testing.T) { input := "some text```go\ncode\n```" out := preprocessFeishuMarkdown(input) if !strings.Contains(out, "text\n```go") { t.Errorf("expected newline before code fence, got %q", out) } } func TestPreprocessFeishuMarkdown_AlreadyNewline(t *testing.T) { input := "text\n```go\ncode\n```" out := preprocessFeishuMarkdown(input) if out != input { t.Errorf("should not change content that already has newlines, got %q", out) } } func TestPreprocessFeishuMarkdown_PreservesTablesAndHeadings(t *testing.T) { input := "## Title\n| A | B |\n|---|---|\n> quote" out := preprocessFeishuMarkdown(input) if !strings.Contains(out, "## Title") { t.Errorf("heading should be preserved, got %q", out) } if !strings.Contains(out, "| A | B |") { t.Errorf("table should be preserved, got %q", out) } if !strings.Contains(out, "> quote") { t.Errorf("blockquote should be preserved, got %q", out) } } func TestHasComplexMarkdown(t *testing.T) { if !hasComplexMarkdown("text\n```go\ncode\n```") { t.Error("should detect code blocks") } if !hasComplexMarkdown("| A | B |\n|---|---|") { t.Error("should detect tables") } if hasComplexMarkdown("**bold** and *italic*") { t.Error("should not detect simple markdown") } } func TestCountMarkdownTables(t *testing.T) { tests := []struct { name string input string want int }{ {"no tables", "hello world", 0}, {"one table", "| A | B |\n|---|---|\n| 1 | 2 |", 1}, {"two tables separated by text", "| A |\n|---|\n\nsome text\n\n| B |\n|---|", 2}, {"consecutive tables no gap", "| A |\n|---|\n| 1 |\n| B |\n|---|", 1}, {"six tables", "| A |\n|---|\n\nx\n\n| B |\n|---|\n\nx\n\n| C |\n|---|\n\nx\n\n| D |\n|---|\n\nx\n\n| E |\n|---|\n\nx\n\n| F |\n|---|", 6}, {"table inside code block is still counted", "```\n| A |\n```\n\n| B |\n|---|", 2}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := countMarkdownTables(tt.input) if got != tt.want { t.Errorf("countMarkdownTables() = %d, want %d", got, tt.want) } }) } } func TestBuildReplyContent_FallbackWhenManyTables(t *testing.T) { // Build content with 6 tables (exceeds the 5-table card limit). var sb strings.Builder for i := 0; i < 6; i++ { if i > 0 { sb.WriteString("\n\nsome text\n\n") } sb.WriteString("| H |\n|---|\n| V |") } content := sb.String() msgType, _ := buildReplyContent(content) if msgType == larkim.MsgTypeInteractive { t.Errorf("expected non-card message type for >5 tables, got interactive") } // With exactly 5 tables, card should still be used. sb.Reset() for i := 0; i < 5; i++ { if i > 0 { sb.WriteString("\n\nsome text\n\n") } sb.WriteString("| H |\n|---|\n| V |") } content5 := sb.String() msgType5, _ := buildReplyContent(content5) if msgType5 != larkim.MsgTypeInteractive { t.Errorf("expected interactive card for 5 tables, got %s", msgType5) } } func TestParseInlineMarkdown_BoldAndCode(t *testing.T) { elements := parseInlineMarkdown("**bold** and `code`") hasBold, hasCode := false, false for _, el := range elements { if styles, ok := el["style"].([]string); ok { for _, s := range styles { if s == "bold" && el["text"] == "bold" { hasBold = true } if s == "code" && el["text"] == "code" { hasCode = true } } } } if !hasBold || !hasCode { t.Errorf("expected bold and code, got %v", elements) } } func TestExtractPostPlainText_FlatFormat(t *testing.T) { content := `{"title":"公告","content":[[{"tag":"text","text":"第一段"}],[{"tag":"text","text":"第二段"}]]}` got := extractPostPlainText(content) if got != "公告\n第一段\n第二段" { t.Errorf("expected '公告\\n第一段\\n第二段', got %q", got) } } func TestExtractPostPlainText_LocaleWrapped(t *testing.T) { content := `{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"内容"}]]}}` got := extractPostPlainText(content) if got != "标题\n内容" { t.Errorf("expected '标题\\n内容', got %q", got) } } func TestExtractPostPlainText_NoTitle(t *testing.T) { content := `{"content":[[{"tag":"text","text":"仅内容"}]]}` got := extractPostPlainText(content) if got != "仅内容" { t.Errorf("expected '仅内容', got %q", got) } } func TestExtractPostPlainText_Empty(t *testing.T) { got := extractPostPlainText(`{}`) if got != "" { t.Errorf("expected empty string, got %q", got) } } func TestExtractPostPlainText_LinkText(t *testing.T) { content := `{"content":[[{"tag":"text","text":"hello "},{"tag":"a","text":"link","href":"http://x.com"}]]}` got := extractPostPlainText(content) if got != "hello [link](http://x.com)" { t.Errorf("expected 'hello [link](http://x.com)', got %q", got) } } func TestExtractPostPlainText_AtMention(t *testing.T) { cases := []struct { name string content string want string }{ {"named", `{"content":[[{"tag":"text","text":"hi "},{"tag":"at","user_id":"ou_x","user_name":"Alice"}]]}`, "hi @Alice"}, {"all", `{"content":[[{"tag":"at","user_id":"all"}]]}`, "@all"}, {"fallback", `{"content":[[{"tag":"at","user_id":"ou_x"}]]}`, "@user"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := extractPostPlainText(tc.content); got != tc.want { t.Errorf("want %q, got %q", tc.want, got) } }) } } func TestExtractPostPlainText_Markdown(t *testing.T) { content := `{"content":[[{"tag":"markdown","text":"**bold** and *italic*"}]]}` got := extractPostPlainText(content) if got != "**bold** and *italic*" { t.Errorf("expected markdown passthrough, got %q", got) } } func TestExtractPostPlainText_CodeBlock(t *testing.T) { content := `{"content":[[{"tag":"text","text":"see:"},{"tag":"code_block","language":"go","text":"fmt.Println()"}]]}` got := extractPostPlainText(content) // Same paragraph: inline elements are concatenated (no extra newline before the fence). want := "see:```go\nfmt.Println()\n```" if got != want { t.Errorf("expected %q, got %q", want, got) } } func strPtr(s string) *string { return &s } func TestStripMentions(t *testing.T) { tests := []struct { name string text string mentions []*larkim.MentionEvent botOpenID string expected string }{ { name: "no mentions", text: "hello", mentions: nil, botOpenID: "", expected: "hello", }, { name: "bot mention removed", text: "@_user_1 /help", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_1"), Id: &larkim.UserId{OpenId: strPtr("bot123")}, Name: strPtr("Bot")}, }, botOpenID: "bot123", expected: "/help", }, { name: "non-bot mention replaced with name", text: "assign to @_user_2", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_2"), Id: &larkim.UserId{OpenId: strPtr("user456")}, Name: strPtr("张三")}, }, botOpenID: "bot123", expected: "assign to @张三", }, { name: "bot removed and other preserved", text: "@_user_1 assign to @_user_2", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_1"), Id: &larkim.UserId{OpenId: strPtr("bot123")}, Name: strPtr("Bot")}, {Key: strPtr("@_user_2"), Id: &larkim.UserId{OpenId: strPtr("user456")}, Name: strPtr("张三")}, }, botOpenID: "bot123", expected: "assign to @张三", }, { name: "mention with nil key skipped", text: "@_user_1 hello", mentions: []*larkim.MentionEvent{ {Key: nil, Id: &larkim.UserId{OpenId: strPtr("bot123")}}, }, botOpenID: "bot123", expected: "@_user_1 hello", }, { name: "mention with no name fallback removed", text: "text @_user_3", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_3"), Id: &larkim.UserId{OpenId: strPtr("user789")}, Name: nil}, }, botOpenID: "bot123", expected: "text", }, { name: "empty botOpenID all non-named removed", text: "@_user_1 hello", mentions: []*larkim.MentionEvent{ {Key: strPtr("@_user_1"), Id: &larkim.UserId{OpenId: strPtr("someone")}}, }, botOpenID: "", expected: "hello", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := stripMentions(tt.text, tt.mentions, tt.botOpenID) if got != tt.expected { t.Errorf("stripMentions() = %q, want %q", got, tt.expected) } }) } } func TestResolveBotSenderName(t *testing.T) { p := &Platform{peerBots: map[string]string{ "cli_known": "Jeeves", "cli_other": "Ivy", }} tests := []struct { name string appID string want string }{ {"empty app id falls back to Bot", "", "Bot"}, {"known app id resolves to alias", "cli_known", "Jeeves"}, {"another known app id", "cli_other", "Ivy"}, {"unknown app id surfaces id", "cli_unknown", "Bot[cli_unknown]"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := p.resolveBotSenderName(tt.appID) if got != tt.want { t.Errorf("resolveBotSenderName(%q) = %q, want %q", tt.appID, got, tt.want) } }) } } func TestResolveBotSenderName_NilMap(t *testing.T) { p := &Platform{} if got := p.resolveBotSenderName("cli_any"); got != "Bot[cli_any]" { t.Errorf("nil peerBots: got %q, want %q", got, "Bot[cli_any]") } if got := p.resolveBotSenderName(""); got != "Bot" { t.Errorf("nil peerBots + empty id: got %q, want %q", got, "Bot") } } func TestIsAttachmentMsgType(t *testing.T) { tests := []struct { msgType string want bool }{ {"image", true}, {"file", true}, {"audio", true}, {"media", true}, {"text", false}, {"post", false}, {"sticker", false}, {"merge_forward", false}, {"", false}, } for _, tt := range tests { if got := isAttachmentMsgType(tt.msgType); got != tt.want { t.Errorf("isAttachmentMsgType(%q) = %v, want %v", tt.msgType, got, tt.want) } } } func TestMarkAndIsActiveThreadSession(t *testing.T) { const threadKey = "feishu:oc_chat:root:om_root" const directKey = "feishu:oc_chat:ou_user" t.Run("thread isolation disabled is no-op", func(t *testing.T) { p := &Platform{threadIsolation: false} p.markThreadSessionActive(threadKey) if p.isActiveThreadSession(threadKey) { t.Fatal("expected no-op when thread_isolation is off") } }) t.Run("non-thread sessionKey is ignored", func(t *testing.T) { p := &Platform{threadIsolation: true} p.markThreadSessionActive(directKey) if p.isActiveThreadSession(directKey) { t.Fatal("expected non-thread sessionKey to be ignored") } }) t.Run("thread sessionKey is recorded", func(t *testing.T) { p := &Platform{threadIsolation: true} if p.isActiveThreadSession(threadKey) { t.Fatal("thread should not be active before mark") } p.markThreadSessionActive(threadKey) if !p.isActiveThreadSession(threadKey) { t.Fatal("thread should be active after mark") } }) } // TestOnMessageThreadIsolationAdmitsAttachmentWithoutMention covers the fix // for the case where a user @mentions the bot in a thread, then drops follow-up // images into the same thread without re-mentioning. Pre-fix, those images // were silently dropped by the group @bot filter; post-fix they should pass // through and be dispatched. func TestOnMessageThreadIsolationAdmitsAttachmentWithoutMention(t *testing.T) { const appID = "cli_thread_admit" const appSecret = "secret-thread-admit" const botOpenID = "ou_bot" const userOpenID = "ou_user" const chatID = "oc_chat" const rootMsgID = "om_root" const imageKey = "img_in_thread" imageBytes := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{ "code": 0, "msg": "success", "expire": 7200, "tenant_access_token": "tenant-token", }) case strings.HasSuffix(r.URL.Path, "/resources/"+imageKey): w.Header().Set("Content-Type", "image/png") if _, err := w.Write(imageBytes); err != nil { t.Fatalf("write image: %v", err) } default: // Unknown calls (e.g. user/chat info lookups) return empty success // so dispatch can continue past optional metadata fetches. w.Header().Set("Content-Type", "application/json") writeJSON(t, w, map[string]any{"code": 0, "msg": "success", "data": map[string]any{}}) } })) defer srv.Close() received := make(chan *core.Message, 8) p := &Platform{ platformName: "feishu", domain: srv.URL, appID: appID, appSecret: appSecret, botOpenID: botOpenID, threadIsolation: true, dedup: &core.MessageDedup{}, client: lark.NewClient(appID, appSecret, lark.WithOpenBaseUrl(srv.URL), lark.WithHttpClient(srv.Client()), ), handler: func(_ core.Platform, msg *core.Message) { received <- msg }, } chatType := "group" senderType := "user" now := time.Now().UnixMilli() createTime := func() *string { s := strconv.FormatInt(now, 10) now++ return &s } buildEvent := func(msgID, msgType, content string, mentions []*larkim.MentionEvent, rootID string) *larkim.P2MessageReceiveV1 { ev := &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Sender: &larkim.EventSender{ SenderId: &larkim.UserId{OpenId: stringPtr(userOpenID)}, SenderType: &senderType, }, Message: &larkim.EventMessage{ MessageId: stringPtr(msgID), ChatId: stringPtr(chatID), ChatType: &chatType, MessageType: stringPtr(msgType), Content: stringPtr(content), CreateTime: createTime(), Mentions: mentions, }, }, } if rootID != "" { ev.Event.Message.RootId = stringPtr(rootID) } return ev } botMention := []*larkim.MentionEvent{ { Key: stringPtr("@_user_1"), Id: &larkim.UserId{OpenId: stringPtr(botOpenID)}, Name: stringPtr("claude code"), }, } // Step 1: opening message @mentions the bot — establishes the thread. if err := p.onMessage(context.Background(), buildEvent(rootMsgID, "text", `{"text":"@_user_1 看看这个"}`, botMention, "")); err != nil { t.Fatalf("onMessage(root) error = %v", err) } threadKey := "feishu:" + chatID + ":root:" + rootMsgID if !p.isActiveThreadSession(threadKey) { t.Fatalf("thread %q should be marked active after @bot text", threadKey) } select { case <-received: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for the opening @bot text to dispatch") } // Step 2: follow-up image in the same thread, no @mention — should pass through. imgContent := `{"image_key":"` + imageKey + `"}` if err := p.onMessage(context.Background(), buildEvent("om_thread_img", "image", imgContent, nil, rootMsgID)); err != nil { t.Fatalf("onMessage(image in thread) error = %v", err) } select { case msg := <-received: if msg.MessageID != "om_thread_img" { t.Fatalf("expected dispatched image om_thread_img, got %q", msg.MessageID) } if len(msg.Images) != 1 { t.Fatalf("expected dispatched image to carry 1 attachment, got %d", len(msg.Images)) } case <-time.After(2 * time.Second): t.Fatal("expected image in active thread to be dispatched without @mention") } // Step 3: image in a *different* thread without @mention — should still be dropped. if err := p.onMessage(context.Background(), buildEvent("om_other_img", "image", imgContent, nil, "om_other_root")); err != nil { t.Fatalf("onMessage(image in unrelated thread) error = %v", err) } select { case msg := <-received: t.Fatalf("image in unrelated thread should be dropped, but handler received %q", msg.MessageID) case <-time.After(300 * time.Millisecond): // expected: nothing dispatched } // Step 4: text without @mention in the active thread — should be dropped // (only attachments are admitted; otherwise unrelated thread chatter would // flood the agent). if err := p.onMessage(context.Background(), buildEvent("om_thread_text", "text", `{"text":"刚才那张图能看到吗"}`, nil, rootMsgID)); err != nil { t.Fatalf("onMessage(text in thread without mention) error = %v", err) } select { case msg := <-received: t.Fatalf("text in active thread without @mention should be dropped, got %q", msg.MessageID) case <-time.After(300 * time.Millisecond): // expected } } func extractBasePlatform(p core.Platform) *Platform { if fp, ok := p.(*Platform); ok { return fp } if ip, ok := p.(*interactivePlatform); ok { return ip.Platform } return nil } func TestNewPlatform_RequireMentionFalseAliasesGroupReplyAll(t *testing.T) { // Regression test for #1141: users set require_mention = false but feishu // reads group_reply_all. The two options must be equivalent so that // group messages without @mention are NOT silently dropped. p, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{ "app_id": "cli_test", "app_secret": "secret", "require_mention": false, }) if err != nil { t.Fatalf("newPlatform error: %v", err) } fp := extractBasePlatform(p) if fp == nil { t.Fatal("expected *Platform or *interactivePlatform") } if !fp.groupReplyAll { t.Error("require_mention=false should set groupReplyAll=true, but it is false") } } func TestNewPlatform_RequireMentionTrueDoesNotForceGroupReplyAll(t *testing.T) { // require_mention = true (the default) must NOT set groupReplyAll. p, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{ "app_id": "cli_test", "app_secret": "secret", "require_mention": true, }) if err != nil { t.Fatalf("newPlatform error: %v", err) } fp := extractBasePlatform(p) if fp == nil { t.Fatal("expected *Platform or *interactivePlatform") } if fp.groupReplyAll { t.Error("require_mention=true should leave groupReplyAll=false, but it is true") } }