From 7c76dfea4bcf5d3687d8fe5daf8a7dcf4bf67801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Fri, 7 Aug 2026 15:30:09 +0800 Subject: [PATCH] fix(chat): fail closed for scoped search and card updates --- internal/helpers/chat.go | 348 +++++++++++++++++- internal/helpers/chat_card_update_test.go | 101 +++++ internal/helpers/chat_message_search_test.go | 289 ++++++++++++++- internal/shortcut/chat/chat_message.go | 66 +++- .../shortcut/chat/coverage_completion_test.go | 4 +- internal/shortcut/chat/gap_fill_test.go | 99 +++++ internal/shortcut/chatmsg/card_update.go | 176 +++++++++ internal/shortcut/chatmsg/card_update_test.go | 79 ++++ internal/shortcut/chatmsg/chatmsg.go | 1 + internal/shortcut/chatmsg/search.go | 192 ++++++++++ internal/shortcut/chatmsg/search_test.go | 75 ++++ .../smart/compatibility_coverage_test.go | 5 +- .../smart/natural_target_resolution_test.go | 26 +- internal/shortcut/smart/search_msg.go | 199 +++++----- .../smart/search_msg_execution_test.go | 165 ++++++++- 15 files changed, 1683 insertions(+), 142 deletions(-) create mode 100644 internal/helpers/chat_card_update_test.go create mode 100644 internal/shortcut/chatmsg/card_update.go create mode 100644 internal/shortcut/chatmsg/card_update_test.go create mode 100644 internal/shortcut/chatmsg/search.go create mode 100644 internal/shortcut/chatmsg/search_test.go diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 7db040a9..de7d8984 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -6,6 +6,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -18,6 +19,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" @@ -116,6 +118,249 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string return v } +const maxConversationScopedSearchPages = 40 + +func runConversationScopedMessageSearch( + cmd *cobra.Command, + serverID, toolName, scopeParam string, + toolArgs map[string]any, + conversationIDs []string, +) error { + conversationIDs = uniqueNonEmptyStrings(conversationIDs) + if len(conversationIDs) == 0 { + return callMCPToolOnServer(serverID, toolName, toolArgs) + } + if commandDryRun(cmd) { + return writeConversationScopedSearchPreview(cmd, serverID, toolName, scopeParam, toolArgs, conversationIDs) + } + if err := validateNativeSearchConversationScope(conversationIDs); err != nil { + return err + } + + // The downstream search currently treats invalid CID filters as absent and + // does not reliably return group-scoped hits. Keep every other filter, scan + // the global result stream with a hard page bound, and apply the validated + // conversation set locally. + scanArgs := cloneStringAnyMap(toolArgs) + delete(scanArgs, scopeParam) + resultLimit := positiveSearchLimit(scanArgs["limit"], 100) + cursor := cleanSearchCursor(scanArgs["cursor"]) + messages := make([]map[string]any, 0, resultLimit) + seenMessageIDs := map[string]struct{}{} + pagesFetched := 0 + hasMore := false + var nextCursor any + + for pagesFetched < maxConversationScopedSearchPages && len(messages) < resultLimit { + remaining := resultLimit - len(messages) + scanArgs["limit"] = remaining + scanArgs["cursor"] = cursor + text, err := CallMCPToolTextOnServer(serverID, toolName, scanArgs) + if err != nil { + return err + } + var data map[string]any + if err := unmarshalJSONUseNumber(text, &data); err != nil { + return apperrors.NewInternal( + fmt.Sprintf("解析 %s 返回失败: %v", toolName, err), + apperrors.WithReason("search_response_invalid"), + ) + } + pagesFetched++ + + pageMessages := chatmsg.SearchItems(data) + pageMessages, unverifiableMessageIDs := chatmsg.FilterConversationScope(pageMessages, conversationIDs) + if len(unverifiableMessageIDs) > 0 { + return nativeSearchScopeUnverifiedError(conversationIDs, unverifiableMessageIDs) + } + for _, message := range pageMessages { + messageID := strings.TrimSpace(fmt.Sprint(chatmsg.MessageID(message))) + if messageID != "" && messageID != "" { + if _, exists := seenMessageIDs[messageID]; exists { + continue + } + seenMessageIDs[messageID] = struct{}{} + } + messages = append(messages, message) + } + + page := chatmsg.Pagination(data) + hasMoreValue, paginationKnown := page["hasMore"].(bool) + if !paginationKnown { + return apperrors.NewAPI( + "搜索服务未返回可靠的 hasMore,无法安全完成会话范围过滤", + apperrors.WithReason("search_conversation_scope_pagination_unknown"), + apperrors.WithRetryable(false), + ) + } + hasMore = hasMoreValue + nextCursor = page["nextCursor"] + if !hasMore { + break + } + next := cleanSearchCursor(nextCursor) + if next == "" || next == cursor { + return apperrors.NewAPI( + "搜索服务声称仍有更多结果,但 nextCursor 缺失或未前进", + apperrors.WithReason("search_conversation_scope_cursor_stalled"), + apperrors.WithRetryable(false), + ) + } + if len(messages) >= resultLimit { + break + } + cursor = next + } + + result := map[string]any{ + "conversationMessagesList": chatmsg.GroupSearchMessages(messages), + "hasMore": hasMore, + "complete": !hasMore, + "pagesFetched": pagesFetched, + } + if hasMore && nextCursor != nil && cleanSearchCursor(nextCursor) != "" { + result["nextCursor"] = nextCursor + } + payload := map[string]any{ + "result": result, + "scope": map[string]any{ + "requestedConversationIds": append([]string(nil), conversationIDs...), + "targetsValidated": true, + "filterApplied": true, + "filterMode": "client", + "resultsWithinScope": true, + "sourceComplete": !hasMore, + }, + } + return writeCommandPayload(cmd, payload) +} + +func writeConversationScopedSearchPreview( + cmd *cobra.Command, + serverID, toolName, scopeParam string, + toolArgs map[string]any, + conversationIDs []string, +) error { + plan := make([]map[string]any, 0, len(conversationIDs)+2) + for _, conversationID := range conversationIDs { + plan = append(plan, map[string]any{ + "stage": "validate-conversation", + "product": "chat", + "tool": "get_conversation_info", + "arguments": map[string]any{ + "openConversationId": conversationID, + }, + }) + } + scanArgs := cloneStringAnyMap(toolArgs) + delete(scanArgs, scopeParam) + plan = append(plan, + map[string]any{ + "stage": "search-global", + "product": serverID, + "tool": toolName, + "arguments": scanArgs, + "pageLimit": maxConversationScopedSearchPages, + }, + map[string]any{ + "stage": "filter-conversation-scope", + "requestedConversationIds": append([]string(nil), conversationIDs...), + "failClosed": true, + }, + ) + return writeCommandPayload(cmd, map[string]any{ + "dry_run": true, + "executed": false, + "plan": plan, + }) +} + +func validateNativeSearchConversationScope(conversationIDs []string) error { + for _, conversationID := range conversationIDs { + _, err := CallMCPToolTextOnServer("chat", "get_conversation_info", map[string]any{ + "openConversationId": conversationID, + }) + if err == nil { + continue + } + var cliErr *CLIError + if !errors.As(err, &cliErr) || (cliErr.Code != CodeMCPToolError && cliErr.Code != CodeResourceNotFound) { + return err + } + return apperrors.NewValidation( + fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID), + apperrors.WithReason("search_conversation_scope_invalid"), + apperrors.WithDetails(map[string]any{"conversationId": conversationID}), + apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"), + apperrors.WithCause(err), + ) + } + return nil +} + +func nativeSearchScopeUnverifiedError(conversationIDs, messageIDs []string) error { + return apperrors.NewAPI( + "搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出", + apperrors.WithReason("search_conversation_scope_unverified"), + apperrors.WithDetails(map[string]any{ + "requestedConversationIds": conversationIDs, + "unverifiableMessageIds": messageIDs, + }), + apperrors.WithRetryable(false), + apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"), + ) +} + +func uniqueNonEmptyStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func positiveSearchLimit(value any, fallback int) int { + switch typed := value.(type) { + case int: + if typed > 0 { + return typed + } + case int64: + if typed > 0 { + return int(typed) + } + case json.Number: + if parsed, err := strconv.Atoi(typed.String()); err == nil && parsed > 0 { + return parsed + } + case float64: + if typed > 0 { + return int(typed) + } + } + return fallback +} + +func cleanSearchCursor(value any) string { + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || strings.EqualFold(text, "null") { + return "" + } + return text +} + func runChatGroupSearch(cmd *cobra.Command, args []string) error { keyword := flagOrFallback(cmd, "query", "keyword", "name", "group") if len(args) == 1 { @@ -1242,6 +1487,35 @@ func unmarshalJSONUseNumber(text string, v any) error { return dec.Decode(v) } +func nativeCardUpdateVerificationError(bizID string, verifyErr error) error { + reason := "streaming_card_update_unverified" + message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败" + hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果" + switch { + case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied): + reason = "streaming_card_update_not_applied" + message = "服务端明确表示流式卡片没有被更新" + hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换" + case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift): + reason = "streaming_card_update_biz_id_mismatch" + message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新" + hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射" + } + return apperrors.NewAPI( + message, + apperrors.WithOperation("update_streaming_card"), + apperrors.WithServerKey("im"), + apperrors.WithOrigin("client_postcondition"), + apperrors.WithFailureStage("verify_update_result"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + apperrors.WithHint(hint), + apperrors.WithDetails(map[string]any{"bizId": bizID}), + apperrors.WithCause(verifyErr), + ) +} + func firstStringField(data map[string]any, keys ...string) string { for _, key := range keys { if value, ok := data[key]; ok { @@ -3066,7 +3340,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, + Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再有界扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor @@ -3102,7 +3376,14 @@ func newChatCommand() *cobra.Command { if groupID != "" { toolArgs["openConversationId"] = groupID } - return callMCPTool("search_messages_by_keyword", toolArgs) + return runConversationScopedMessageSearch( + cmd, + "chat", + "search_messages_by_keyword", + "openConversationId", + toolArgs, + []string{groupID}, + ) }, } DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{ @@ -3144,7 +3425,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchAdvancedCmd := &cobra.Command{ Use: "search-advanced", Short: "多维度搜索消息", - Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。`, + Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。显式指定会话时,CLI 会先验证 CID,再有界扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。所有参数均为可选,至少指定一个搜索条件。`, Example: ` dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --user --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --users , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" @@ -3191,6 +3472,7 @@ func newChatCommand() *cobra.Command { // conversation-ids / groups / group -> openConversationIds convIds := "" + var conversationIDs []string if v, _ := cmd.Flags().GetString("conversation-ids"); v != "" { convIds = v } else if v, _ := cmd.Flags().GetString("groups"); v != "" { @@ -3199,14 +3481,13 @@ func newChatCommand() *cobra.Command { convIds = v } if convIds != "" { - var ids []string for _, s := range strings.Split(convIds, ",") { if t := strings.TrimSpace(s); t != "" { - ids = append(ids, t) + conversationIDs = append(conversationIDs, t) } } - if len(ids) > 0 { - toolArgs["openConversationIds"] = ids + if len(conversationIDs) > 0 { + toolArgs["openConversationIds"] = conversationIDs } } @@ -3250,7 +3531,14 @@ func newChatCommand() *cobra.Command { toolArgs["limit"] = v } - return callMCPToolOnServer("im", "search_messages", toolArgs) + return runConversationScopedMessageSearch( + cmd, + "im", + "search_messages", + "openConversationIds", + toolArgs, + conversationIDs, + ) }, } DeclareLeafMetadata(chatMessageSearchAdvancedCmd, LeafSpec{ @@ -5183,12 +5471,48 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 if !cmd.Flags().Changed("flow-status") { return fmt.Errorf("flag --flow-status is required") } + bizID, err := chatmsg.NormalizeCardBizID(mustGetFlag(cmd, "biz-id")) + if err != nil { + return err + } flowStatus, _ := cmd.Flags().GetInt("flow-status") - return callMCPToolOnServer("im", "update_streaming_card", map[string]any{ - "bizId": mustGetFlag(cmd, "biz-id"), + if flowStatus < 1 || flowStatus > 5 { + return fmt.Errorf("--flow-status 必须在 1-5 之间") + } + params := map[string]any{ + "bizId": bizID, "msgContent": mustGetFlag(cmd, "content"), "flowStatus": flowStatus, - }) + } + if commandDryRun(cmd) { + return writeCommandPayload(cmd, map[string]any{ + "dry_run": true, + "executed": false, + "verified": false, + "action": map[string]any{ + "product": "im", + "tool": "update_streaming_card", + "arguments": params, + }, + }) + } + text, err := CallMCPToolTextOnServer("im", "update_streaming_card", params) + if err != nil { + return err + } + var response map[string]any + if strings.TrimSpace(text) == "" { + response = map[string]any{} + } else if err := unmarshalJSONUseNumber(text, &response); err != nil { + return apperrors.NewInternal( + fmt.Sprintf("解析 update_streaming_card 返回失败: %v", err), + apperrors.WithReason("streaming_card_update_response_invalid"), + ) + } + if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, response); err != nil { + return nativeCardUpdateVerificationError(bizID, err) + } + return writeCommandPayload(cmd, response) }, } DeclareLeafMetadata(chatMessageUpdateCardCmd, LeafSpec{ @@ -5217,7 +5541,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Examples: []string{"dws chat message update-card --biz-id --content \"处理完成\" --flow-status 2"}, }, Parameters: []contract.ParamDecl{ + {Name: "biz-id", Property: "bizId"}, {Name: "content", Property: "msgContent"}, + {Name: "flow-status", Property: "flowStatus"}, }, }, }) diff --git a/internal/helpers/chat_card_update_test.go b/internal/helpers/chat_card_update_test.go new file mode 100644 index 00000000..3c7bc472 --- /dev/null +++ b/internal/helpers/chat_card_update_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "errors" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + installScriptedCaller(t, caller) + root := newChatCommand() + root.SilenceErrors = true + root.SilenceUsage = true + if root.PersistentFlags().Lookup("dry-run") == nil { + root.PersistentFlags().Bool("dry-run", false, "preview without executing") + } + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) { + t.Run("explicit evidence succeeds", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-1","updated":true}}`}}} + err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "biz-1", + "--content", "完成", + "--flow-status", "3", + ) + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" { + t.Fatalf("call = count:%d server:%q tool:%q", caller.calls, caller.server, caller.tool) + } + if caller.args["bizId"] != "biz-1" { + t.Fatalf("args = %#v", caller.args) + } + }) + + t.Run("generic success is unverified", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}} + err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "not-a-real-card", + "--content", "完成", + "--flow-status", "3", + ) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" { + t.Fatalf("error = %#v, want streaming_card_update_unverified", err) + } + }) + + t.Run("invalid arguments make no call", func(t *testing.T) { + for _, args := range [][]string{ + {"message", "update-card", "--biz-id", "", "--content", "完成", "--flow-status", "3"}, + {"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"}, + } { + caller := &scriptedToolCaller{} + if err := runNativeCardUpdate(t, caller, args...); err == nil { + t.Fatalf("args %v unexpectedly succeeded", args) + } + if caller.calls != 0 { + t.Fatalf("args %v made %d calls", args, caller.calls) + } + } + }) + + t.Run("dry run publishes unverified plan without write", func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "biz-preview", + "--content", "完成", + "--flow-status", "3", + "--dry-run", + ) + if err != nil { + t.Fatal(err) + } + if caller.calls != 0 { + t.Fatalf("dry-run made %d calls", caller.calls) + } + }) +} diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 3d559b51..6000bb5f 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -16,6 +16,7 @@ package helpers import ( "context" "encoding/json" + "errors" "io" "os" "reflect" @@ -23,8 +24,10 @@ import ( "testing" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" ) type chatMessageSearchCall struct { @@ -34,12 +37,36 @@ type chatMessageSearchCall struct { } type chatMessageSearchCaller struct { - calls []chatMessageSearchCall + calls []chatMessageSearchCall + searchResponse string + searchResponses []string + searchCalls int + failPreflight bool + preflightError error } func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args}) - return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil + text := `{}` + if toolName == "get_conversation_info" { + if c.preflightError != nil { + return nil, c.preflightError + } + if c.failPreflight { + return nil, errors.New("conversation not found") + } + text = `{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}` + } + if toolName == "search_messages_by_keyword" || toolName == "search_messages" { + text = `{"result":{"messages":[],"hasMore":false}}` + if c.searchCalls < len(c.searchResponses) { + text = c.searchResponses[c.searchCalls] + } else if c.searchResponse != "" { + text = c.searchResponse + } + c.searchCalls++ + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } func (*chatMessageSearchCaller) Format() string { return "json" } @@ -72,6 +99,7 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { productID string toolName string wantToolArg map[string]any + preflight []string }{ { name: "keyword search", @@ -79,13 +107,13 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { productID: "chat", toolName: "search_messages_by_keyword", wantToolArg: map[string]any{ - "keyword": "categoryName", - "openConversationId": "cid-1", - "startTime": startTime.UnixMilli(), - "endTime": endTime.UnixMilli(), - "limit": 100, - "cursor": "0", + "keyword": "categoryName", + "startTime": startTime.UnixMilli(), + "endTime": endTime.UnixMilli(), + "limit": 100, + "cursor": "0", }, + preflight: []string{"cid-1"}, }, { name: "advanced search", @@ -93,16 +121,16 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { productID: "im", toolName: "search_messages", wantToolArg: map[string]any{ - "keyword": "categoryName", - "openConversationIds": []string{"cid-1", "cid-2"}, - "messageType": "text", - "onlyRobotMessages": true, - "searchConvType": "group", - "startTime": startTime.UnixMilli(), - "endTime": endTime.UnixMilli(), - "limit": 100, - "cursor": "0", + "keyword": "categoryName", + "messageType": "text", + "onlyRobotMessages": true, + "searchConvType": "group", + "startTime": startTime.UnixMilli(), + "endTime": endTime.UnixMilli(), + "limit": 100, + "cursor": "0", }, + preflight: []string{"cid-1", "cid-2"}, }, } @@ -116,14 +144,21 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { cmd := newChatCommand() cmd.SilenceErrors = true cmd.SilenceUsage = true + cmd.SetOut(io.Discard) cmd.SetArgs(tt.args) if err := cmd.Execute(); err != nil { t.Fatalf("chat search returned error: %v", err) } - if len(caller.calls) != 1 { - t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + if len(caller.calls) != len(tt.preflight)+1 { + t.Fatalf("tool calls = %#v", caller.calls) } - call := caller.calls[0] + for index, conversationID := range tt.preflight { + call := caller.calls[index] + if call.productID != "chat" || call.toolName != "get_conversation_info" || call.args["openConversationId"] != conversationID { + t.Fatalf("preflight[%d] = %#v", index, call) + } + } + call := caller.calls[len(caller.calls)-1] if call.productID != tt.productID || call.toolName != tt.toolName { t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName) } @@ -134,6 +169,220 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { } } +func executeNativeScopedSearch(t *testing.T, caller *chatMessageSearchCaller, args ...string) (map[string]any, error) { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + deps.Out.w = io.Discard + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + var output strings.Builder + cmd.SetOut(&output) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal([]byte(output.String()), &payload); err != nil { + return nil, err + } + return payload, nil +} + +func TestNativeScopedSearchFiltersGlobalResultsForBothEntries(t *testing.T) { + start := "2026-07-09T00:00:00+08:00" + end := "2026-07-11T00:00:00+08:00" + for _, tt := range []struct { + name string + args []string + tool string + scopeParam string + }{ + { + name: "keyword search", + args: []string{"message", "search", "--query", "周报", "--group", "cid-target", "--start", start, "--end", end}, + tool: "search_messages_by_keyword", + scopeParam: "openConversationId", + }, + { + name: "advanced search", + args: []string{"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target"}, + tool: "search_messages", + scopeParam: "openConversationIds", + }, + } { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{ + "result": { + "conversationMessagesList": [ + {"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]}, + {"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]} + ], + "hasMore": false + } + }`} + payload, err := executeNativeScopedSearch(t, caller, tt.args...) + if err != nil { + t.Fatal(err) + } + result, _ := payload["result"].(map[string]any) + groups, _ := result["conversationMessagesList"].([]any) + if len(groups) != 1 { + t.Fatalf("result = %#v", result) + } + group, _ := groups[0].(map[string]any) + if group["openConversationId"] != "cid-target" { + t.Fatalf("group = %#v", group) + } + scope, _ := payload["scope"].(map[string]any) + if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["filterMode"] != "client" { + t.Fatalf("scope = %#v", scope) + } + searchCall := caller.calls[len(caller.calls)-1] + if searchCall.toolName != tt.tool { + t.Fatalf("search call = %#v", searchCall) + } + if _, exists := searchCall.args[tt.scopeParam]; exists { + t.Fatalf("global fallback unexpectedly forwarded %s: %#v", tt.scopeParam, searchCall.args) + } + }) + } +} + +func TestNativeScopedSearchInvalidCIDStopsBeforeSearch(t *testing.T) { + caller := &chatMessageSearchCaller{failPreflight: true} + _, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-invalid") + if err == nil { + t.Fatal("invalid CID unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" { + t.Fatalf("error = %#v", err) + } + if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" { + t.Fatalf("calls = %#v", caller.calls) + } +} + +func TestNativeScopedSearchPreservesPreflightAuthError(t *testing.T) { + want := &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录"} + caller := &chatMessageSearchCaller{preflightError: want} + _, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target") + if err == nil { + t.Fatal("auth failure unexpectedly succeeded") + } + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthNotConfigured { + t.Fatalf("error = %#v", err) + } +} + +func TestNativeScopedSearchScansUntilTargetConversationAppears(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponses: []string{ + `{"result":{"conversationMessagesList":[{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}],"hasMore":true,"nextCursor":"c2"}}`, + `{"result":{"conversationMessagesList":[{"openConversationId":"cid-target","messages":[{"openMessageId":"m-target"}]}],"hasMore":false}}`, + }} + payload, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target") + if err != nil { + t.Fatal(err) + } + result, _ := payload["result"].(map[string]any) + if result["pagesFetched"] != float64(2) || result["complete"] != true { + t.Fatalf("result = %#v", result) + } + groups, _ := result["conversationMessagesList"].([]any) + if len(groups) != 1 { + t.Fatalf("groups = %#v", groups) + } + searchCalls := make([]chatMessageSearchCall, 0, 2) + for _, call := range caller.calls { + if call.toolName == "search_messages" { + searchCalls = append(searchCalls, call) + } + } + if len(searchCalls) != 2 || searchCalls[1].args["cursor"] != "c2" { + t.Fatalf("search calls = %#v", searchCalls) + } +} + +func TestNativeScopedSearchMissingConversationIdentityFailsClosed(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":false}}`} + _, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target") + if err == nil { + t.Fatal("unverifiable scoped result unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" { + t.Fatalf("error = %#v", err) + } +} + +func TestNativeScopedSearchValidEmptyResultIsComplete(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`} + payload, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-empty") + if err != nil { + t.Fatal(err) + } + result, _ := payload["result"].(map[string]any) + if result["complete"] != true || result["hasMore"] != false { + t.Fatalf("result = %#v", result) + } + scope, _ := payload["scope"].(map[string]any) + if scope["targetsValidated"] != true || scope["sourceComplete"] != true { + t.Fatalf("scope = %#v", scope) + } +} + +func TestNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(t *testing.T) { + caller := &chatMessageSearchCaller{} + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + cmd := &cobra.Command{Use: "search"} + cmd.Flags().Bool("dry-run", true, "") + var output strings.Builder + cmd.SetOut(&output) + err := runConversationScopedMessageSearch( + cmd, + "im", + "search_messages", + "openConversationIds", + map[string]any{ + "keyword": "周报", + "openConversationIds": []string{"cid-target"}, + "limit": 100, + "cursor": "0", + }, + []string{"cid-target"}, + ) + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run made calls: %#v", caller.calls) + } + var payload map[string]any + if err := json.Unmarshal([]byte(output.String()), &payload); err != nil { + t.Fatal(err) + } + plan, _ := payload["plan"].([]any) + if payload["dry_run"] != true || payload["executed"] != false || len(plan) != 3 { + t.Fatalf("payload = %#v", payload) + } + searchStage, _ := plan[1].(map[string]any) + arguments, _ := searchStage["arguments"].(map[string]any) + if _, exists := arguments["openConversationIds"]; exists { + t.Fatalf("dry-run global search still carries scope: %#v", searchStage) + } +} + type chatChangedContractCaller struct { calls []chatMessageSearchCall resolveUsers bool diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 352d454f..92891d0b 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -15,6 +15,7 @@ package chat import ( "encoding/json" + "errors" "fmt" "path/filepath" "sort" @@ -1362,6 +1363,9 @@ var MessagesSendCard = shortcut.Shortcut{ if err != nil { return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err) } + if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil { + return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err)) + } return rt.Output(map[string]any{ "contractVersion": currentCardWorkflowContract.Version, "ok": true, @@ -1472,20 +1476,76 @@ var MessagesUpdateCard = shortcut.Shortcut{ }, Tips: []string{`dws chat +messages-update-card --biz-id --content "内容" --flow-status 3`}, Validate: func(rt *shortcut.RuntimeContext) error { + if _, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")); err != nil { + return err + } if !validCardFlowStatus(rt.Int("flow-status")) { return fmt.Errorf("--flow-status 必须在 1-5 之间") } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("update_streaming_card", map[string]any{ - "bizId": rt.Str("biz-id"), + bizID, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")) + if err != nil { + return err + } + params := map[string]any{ + "bizId": bizID, "msgContent": rt.Str("content"), "flowStatus": rt.Int("flow-status"), - }) + } + if rt.DryRun() { + return rt.Output(map[string]any{ + "dry_run": true, + "executed": false, + "verified": false, + "action": map[string]any{ + "product": "im", + "tool": "update_streaming_card", + "arguments": params, + }, + }) + } + updated, err := rt.CallMCPWriteData("im", "update_streaming_card", params) + if err != nil { + return err + } + if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil { + return cardUpdateVerificationError(bizID, err) + } + return rt.Output(updated) }, } +func cardUpdateVerificationError(bizID string, verifyErr error) error { + reason := "streaming_card_update_unverified" + message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败" + hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果" + switch { + case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied): + reason = "streaming_card_update_not_applied" + message = "服务端明确表示流式卡片没有被更新" + hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换" + case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift): + reason = "streaming_card_update_biz_id_mismatch" + message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新" + hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射" + } + return apperrors.NewAPI( + message, + apperrors.WithOperation("update_streaming_card"), + apperrors.WithServerKey("im"), + apperrors.WithOrigin("client_postcondition"), + apperrors.WithFailureStage("verify_update_result"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + apperrors.WithHint(hint), + apperrors.WithDetails(map[string]any{"bizId": bizID}), + apperrors.WithCause(verifyErr), + ) +} + // MessagesResourceURL gets a message resource download url (get_resource_download_url, im). var MessagesResourceURL = shortcut.Shortcut{ Service: "chat", diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index b19af0de..fa521bcd 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -360,7 +360,9 @@ func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) { }) } - helpers.InitDeps(&larkAlignmentCaller{}) + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"result":{"updated":true}}`, + }}) root := newPlatformCoverageRoot() root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"}) if err := root.Execute(); err != nil { diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index d2c618b9..55b12f11 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -793,6 +793,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test }, wantError: "biz-preserved", }, + { + name: "unverified update preserves id", + fake: &larkAlignmentCaller{responses: map[string]string{ + "im/create_and_send_card": `{"bizId":"biz-unverified"}`, + "im/update_streaming_card": `{"success":true,"errorCode":null}`, + }}, + wantError: "biz-unverified", + }, } { t.Run(tc.name, func(t *testing.T) { helpers.InitDeps(tc.fake) @@ -825,6 +833,97 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test } } +func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) { + t.Run("generic success is unverified", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"success":true,"errorCode":null}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "中文乱串", + "--content", "完成", + "--flow-status", "3", + "--yes", + }) + err := root.Execute() + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" { + t.Fatalf("error = %#v, want streaming_card_update_unverified", err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + + t.Run("explicit update evidence succeeds", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"result":{"bizId":"biz-verified","updated":true}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "biz-verified", + "--content", "完成", + "--flow-status", "3", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + + t.Run("placeholder fails before write", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "", + "--content", "完成", + "--flow-status", "3", + "--yes", + }) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "占位符") { + t.Fatalf("error = %v, want placeholder validation", err) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid placeholder made calls: %#v", fake.calls) + } + }) + + t.Run("dry run only publishes plan", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "biz-preview", + "--content", "完成", + "--flow-status", "3", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Fatalf("dry-run made calls: %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["executed"] != false || payload["verified"] != false { + t.Fatalf("dry-run payload = %#v", payload) + } + }) +} + func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) { for _, tc := range []struct { value any diff --git a/internal/shortcut/chatmsg/card_update.go b/internal/shortcut/chatmsg/card_update.go new file mode 100644 index 00000000..5649c772 --- /dev/null +++ b/internal/shortcut/chatmsg/card_update.go @@ -0,0 +1,176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "unicode" +) + +var ( + ErrCardUpdateNotApplied = errors.New("streaming card update was not applied") + ErrCardUpdateUnverified = errors.New("streaming card update could not be verified") + ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId") +) + +// NormalizeCardBizID performs only format-independent checks. bizId is an +// opaque server-issued identifier; a stricter character or prefix contract +// must not be invented by the CLI without an authoritative API declaration. +func NormalizeCardBizID(raw string) (string, error) { + bizID := strings.TrimSpace(raw) + if bizID == "" { + return "", fmt.Errorf("--biz-id 不能为空") + } + for _, r := range bizID { + if unicode.IsControl(r) || unicode.IsSpace(r) { + return "", fmt.Errorf("--biz-id 必须是 send-card 返回的单个不透明 ID,不能包含空白或控制字符") + } + } + if isCardBizIDPlaceholder(bizID) { + return "", fmt.Errorf("--biz-id 仍是占位符 %q;请传入 send-card 实际返回的 bizId", bizID) + } + return bizID, nil +} + +func isCardBizIDPlaceholder(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "bizid", "biz-id", "your-biz-id", "your_biz_id", + "", "", "", + "{bizid}", "{biz-id}", "${bizid}", "${biz-id}": + return true + default: + return false + } +} + +// VerifyStreamingCardUpdate requires affirmative evidence that the requested +// write took effect. A transport-level {success:true,errorCode:null} only says +// that the RPC returned normally and is deliberately not accepted as proof of +// a card update. +func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) { + requestedBizID = strings.TrimSpace(requestedBizID) + observation := cardUpdateObservation{bizIDs: map[string]struct{}{}} + observeCardUpdate(response, &observation) + + for responseBizID := range observation.bizIDs { + if requestedBizID != "" && responseBizID != requestedBizID { + return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID) + } + } + if observation.positiveEvidence != "" && observation.negativeEvidence != "" { + return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence) + } + if observation.positiveEvidence != "" { + return observation.positiveEvidence, nil + } + if observation.negativeEvidence != "" { + return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence) + } + return "", ErrCardUpdateUnverified +} + +type cardUpdateObservation struct { + bizIDs map[string]struct{} + positiveEvidence string + negativeEvidence string +} + +func observeCardUpdate(value any, observation *cardUpdateObservation) { + switch typed := value.(type) { + case map[string]any: + observeCardUpdateMap(typed, observation) + case []any: + for _, child := range typed { + observeCardUpdate(child, observation) + } + case bool: + if typed { + setPositiveCardUpdateEvidence(observation, "result=true") + } else { + setNegativeCardUpdateEvidence(observation, "result=false") + } + } +} + +func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) { + for _, key := range []string{"bizId", "bizID", "biz_id"} { + if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" { + observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{} + } + } + + for _, key := range []string{"updated", "applied"} { + if applied, ok := value[key].(bool); ok { + if applied { + setPositiveCardUpdateEvidence(observation, key+"=true") + } else { + setNegativeCardUpdateEvidence(observation, key+"=false") + } + } + } + for _, key := range []string{"affectedCount", "updatedCount", "modifiedCount"} { + if count, ok := cardUpdateCount(value[key]); ok { + if count > 0 { + setPositiveCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count)) + } else if count == 0 { + setNegativeCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count)) + } + } + } + + // Only documented response envelopes are traversed. This prevents an + // unrelated extension field containing "updated":true from proving the + // business write. + for _, key := range []string{"result", "data", "response", "card"} { + if child, exists := value[key]; exists { + observeCardUpdate(child, observation) + } + } +} + +func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) { + if observation.positiveEvidence == "" { + observation.positiveEvidence = evidence + } +} + +func setNegativeCardUpdateEvidence(observation *cardUpdateObservation, evidence string) { + if observation.negativeEvidence == "" { + observation.negativeEvidence = evidence + } +} + +func cardUpdateCount(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case float32: + return int64(typed), float32(int64(typed)) == typed + case float64: + return int64(typed), float64(int64(typed)) == typed + case json.Number: + count, err := typed.Int64() + return count, err == nil + default: + return 0, false + } +} diff --git a/internal/shortcut/chatmsg/card_update_test.go b/internal/shortcut/chatmsg/card_update_test.go new file mode 100644 index 00000000..0a425d08 --- /dev/null +++ b/internal/shortcut/chatmsg/card_update_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "errors" + "testing" +) + +func TestCrossPlatformCoverageNormalizeCardBizID(t *testing.T) { + for _, test := range []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "opaque id", raw: " card-token-1 ", want: "card-token-1"}, + {name: "opaque unicode remains server owned", raw: "中文乱串", want: "中文乱串"}, + {name: "empty", raw: " ", wantErr: true}, + {name: "placeholder", raw: "", wantErr: true}, + {name: "internal space", raw: "card token", wantErr: true}, + {name: "control", raw: "card\ntoken", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := NormalizeCardBizID(test.raw) + if test.wantErr { + if err == nil { + t.Fatalf("NormalizeCardBizID(%q) unexpectedly succeeded", test.raw) + } + return + } + if err != nil || got != test.want { + t.Fatalf("NormalizeCardBizID(%q) = %q, %v; want %q", test.raw, got, err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) { + for _, test := range []struct { + name string + response map[string]any + wantProof string + wantErrIs error + }{ + {name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"}, + {name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"}, + {name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"}, + {name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"}, + {name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified}, + {name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied}, + {name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift}, + {name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified}, + } { + t.Run(test.name, func(t *testing.T) { + proof, err := VerifyStreamingCardUpdate("biz-1", test.response) + if test.wantErrIs != nil { + if !errors.Is(err, test.wantErrIs) { + t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs) + } + return + } + if err != nil || proof != test.wantProof { + t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof) + } + }) + } +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index d2d6ee04..152558ba 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{ "failedCount", "failures", "partial", + "scope", "resourceDownloads", }, } diff --git a/internal/shortcut/chatmsg/search.go b/internal/shortcut/chatmsg/search.go new file mode 100644 index 00000000..a57de031 --- /dev/null +++ b/internal/shortcut/chatmsg/search.go @@ -0,0 +1,192 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "fmt" + "strings" +) + +// SearchItems locates and flattens the message list returned by the two +// DingTalk message-search interfaces. Grouped search responses carry the +// conversation identity on the group rather than each message, so the +// flattener copies that identity onto every returned message before callers +// perform scope checks. +func SearchItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + for _, root := range []map[string]any{data, childMap(data, "result")} { + if root == nil { + continue + } + if groups, ok := root["conversationMessagesList"].([]any); ok { + return flattenSearchGroups(groups) + } + } + keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return searchMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, innerKey := range []string{"list", "messages", "messageList", "items", "data", "records"} { + if arr, ok := inner[innerKey].([]any); ok { + return searchMaps(arr) + } + } + } + } + return nil +} + +func childMap(data map[string]any, key string) map[string]any { + if value, ok := data[key].(map[string]any); ok { + return value + } + return nil +} + +func flattenSearchGroups(groups []any) []map[string]any { + out := make([]map[string]any, 0) + for _, rawGroup := range groups { + group, ok := rawGroup.(map[string]any) + if !ok { + continue + } + messages, ok := group["messages"].([]any) + if !ok { + continue + } + conversationID := cleanSearchScalar(group["openConversationId"]) + conversationTitle := cleanSearchScalar(group["title"]) + singleChat, hasSingleChat := group["singleChat"] + for _, rawMessage := range messages { + message, ok := rawMessage.(map[string]any) + if !ok { + continue + } + item := make(map[string]any, len(message)+3) + for key, value := range message { + item[key] = value + } + if _, exists := item["openConversationId"]; !exists && conversationID != "" { + item["openConversationId"] = conversationID + } + if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" { + item["conversationTitle"] = conversationTitle + } + if _, exists := item["singleChat"]; !exists && hasSingleChat { + item["singleChat"] = singleChat + } + out = append(out, item) + } + } + return out +} + +func searchMaps(items []any) []map[string]any { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if message, ok := item.(map[string]any); ok { + out = append(out, message) + } + } + return out +} + +// FilterConversationScope keeps only messages belonging to the explicitly +// requested conversations. A message without a conversation identity is +// reported as unverifiable rather than treated as in-scope. +func FilterConversationScope(messages []map[string]any, conversationIDs []string) (matched []map[string]any, unverifiableMessageIDs []string) { + requested := make(map[string]struct{}, len(conversationIDs)) + for _, conversationID := range conversationIDs { + if value := strings.TrimSpace(conversationID); value != "" { + requested[value] = struct{}{} + } + } + matched = make([]map[string]any, 0, len(messages)) + for _, message := range messages { + conversationID := cleanSearchScalar(ConversationID(message)) + if conversationID == "" { + messageID := cleanSearchScalar(MessageID(message)) + if messageID == "" { + messageID = "" + } + unverifiableMessageIDs = append(unverifiableMessageIDs, messageID) + continue + } + if _, ok := requested[conversationID]; ok { + matched = append(matched, message) + } + } + return matched, uniqueStrings(unverifiableMessageIDs) +} + +// GroupSearchMessages restores the established typed search envelope after a +// client-side scoped scan. Group order follows first occurrence in the search +// result, and each message retains its original fields. +func GroupSearchMessages(messages []map[string]any) []map[string]any { + groups := make([]map[string]any, 0) + index := make(map[string]int) + for _, message := range messages { + conversationID := cleanSearchScalar(ConversationID(message)) + if conversationID == "" { + continue + } + groupIndex, ok := index[conversationID] + if !ok { + group := map[string]any{ + "openConversationId": conversationID, + "messages": []map[string]any{}, + } + if title := cleanSearchScalar(message["conversationTitle"]); title != "" { + group["title"] = title + } + if singleChat, exists := message["singleChat"]; exists { + group["singleChat"] = singleChat + } + groups = append(groups, group) + groupIndex = len(groups) - 1 + index[conversationID] = groupIndex + } + groupMessages := groups[groupIndex]["messages"].([]map[string]any) + groups[groupIndex]["messages"] = append(groupMessages, message) + } + return groups +} + +func cleanSearchScalar(value any) string { + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" || strings.EqualFold(text, "null") { + return "" + } + return text +} + +func uniqueStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/internal/shortcut/chatmsg/search_test.go b/internal/shortcut/chatmsg/search_test.go new file mode 100644 index 00000000..28856bfb --- /dev/null +++ b/internal/shortcut/chatmsg/search_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "reflect" + "testing" +) + +func TestSearchItemsCarriesGroupedConversationIdentity(t *testing.T) { + data := map[string]any{ + "result": map[string]any{ + "conversationMessagesList": []any{ + map[string]any{ + "openConversationId": "cid-group", + "title": "项目群", + "singleChat": false, + "messages": []any{ + map[string]any{"openMessageId": "m1", "content": "hello"}, + }, + }, + }, + }, + } + + items := SearchItems(data) + if len(items) != 1 || items[0]["openConversationId"] != "cid-group" || + items[0]["conversationTitle"] != "项目群" || items[0]["singleChat"] != false { + t.Fatalf("items = %#v", items) + } +} + +func TestFilterConversationScopeDropsOtherConversationsAndRejectsMissingIdentity(t *testing.T) { + messages := []map[string]any{ + {"openMessageId": "m1", "openConversationId": "cid-target"}, + {"openMessageId": "m2", "openConversationId": "cid-other"}, + {"openMessageId": "m3"}, + } + + matched, missing := FilterConversationScope(messages, []string{"cid-target"}) + if len(matched) != 1 || matched[0]["openMessageId"] != "m1" { + t.Fatalf("matched = %#v", matched) + } + if !reflect.DeepEqual(missing, []string{"m3"}) { + t.Fatalf("missing = %#v", missing) + } +} + +func TestGroupSearchMessagesPreservesFirstSeenOrder(t *testing.T) { + messages := []map[string]any{ + {"openMessageId": "m1", "openConversationId": "cid-b", "conversationTitle": "B"}, + {"openMessageId": "m2", "openConversationId": "cid-a", "conversationTitle": "A"}, + {"openMessageId": "m3", "openConversationId": "cid-b", "conversationTitle": "B"}, + } + + groups := GroupSearchMessages(messages) + if len(groups) != 2 || groups[0]["openConversationId"] != "cid-b" || groups[1]["openConversationId"] != "cid-a" { + t.Fatalf("groups = %#v", groups) + } + groupMessages, _ := groups[0]["messages"].([]map[string]any) + if len(groupMessages) != 2 || groupMessages[1]["openMessageId"] != "m3" { + t.Fatalf("group messages = %#v", groupMessages) + } +} diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 175eab7f..fcd5c1c6 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -65,6 +65,8 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin text = `{"result":{"userId":"u1"}}` case "im/search_groups": text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}` + case "im/search_messages": + text = `{"result":{"messages":[],"hasMore":false}}` } return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } @@ -393,7 +395,8 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--no-enrich", "--yes"}, wantProduct: "im", wantTool: "search_messages", - wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"}, + wantArgs: map[string]any{"keyword": "树莓派"}, + wantAbsent: []string{"openConversationIds"}, }, } diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go index 7e3419b0..63dbf332 100644 --- a/internal/shortcut/smart/natural_target_resolution_test.go +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -157,15 +157,18 @@ func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch( if err := root.Execute(); err != nil { t.Fatal(err) } - if len(fake.calls) != 3 { - t.Fatalf("calls = %#v, want chat resolve + user resolve + search", fake.calls) + if len(fake.calls) != 4 { + t.Fatalf("calls = %#v, want chat resolve + user resolve + scope validation + search", fake.calls) } - search := fake.calls[2] + if preflight := fake.calls[2]; preflight.product != "chat" || preflight.tool != "get_conversation_info" || preflight.args["openConversationId"] != "cid-1" { + t.Fatalf("scope preflight = %#v", preflight) + } + search := fake.calls[3] if search.product != "im" || search.tool != "search_messages" { t.Fatalf("search = %#v", search) } - if got, want := search.args["openConversationIds"], []string{"cid-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("openConversationIds = %#v, want %#v", got, want) + if _, exists := search.args["openConversationIds"]; exists { + t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", search.args) } if got, want := search.args["senderOpenDingTakIds"], []string{"D1"}; !reflect.DeepEqual(got, want) { t.Fatalf("senderOpenDingTakIds = %#v, want %#v", got, want) @@ -185,14 +188,17 @@ func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) if err := root.Execute(); err != nil { t.Fatal(err) } - if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" { + if len(fake.calls) != 2 || fake.calls[0].tool != "get_conversation_info" || fake.calls[1].tool != "search_messages" { t.Fatalf("calls = %#v", fake.calls) } - if got, want := fake.calls[0].args["openConversationIds"], []string{"cid-fixture-chat-0002"}; !reflect.DeepEqual(got, want) { - t.Fatalf("openConversationIds = %#v, want %#v", got, want) + if fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0002" { + t.Fatalf("scope preflight = %#v", fake.calls[0]) } - if fake.calls[0].args["keyword"] != "评测" { - t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"]) + if _, exists := fake.calls[1].args["openConversationIds"]; exists { + t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", fake.calls[1].args) + } + if fake.calls[1].args["keyword"] != "评测" { + t.Fatalf("keyword = %#v", fake.calls[1].args["keyword"]) } } diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index f9ffae27..faea73c5 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -14,6 +14,7 @@ package smart import ( + "errors" "fmt" "strconv" "strings" @@ -23,6 +24,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" @@ -38,8 +40,9 @@ var SearchMsg = shortcut.Shortcut{ Service: "chat", Command: "+search-msg", Product: "im", - Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化", + Description: "按稳定 ID 和内容等条件跨会话搜索消息,可校验会话范围、全量翻页并批量富化", Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。" + + "显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" + "--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + "--download-resources 使用安全本地路径、默认不覆盖和原子落盘。", Risk: shortcut.RiskRead, @@ -55,15 +58,15 @@ var SearchMsg = shortcut.Shortcut{ CLIPath: "chat +search-msg", PrimaryCLIPath: "chat +search-msg", }, - Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化", + Description: "按稳定 ID 和内容等条件跨会话搜索消息,可校验会话范围、全量翻页并批量富化", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.", }, Selection: contract.SelectionSpec{ - AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化", - UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"}, + AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可校验会话范围、全量翻页并批量富化", + UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;显式会话范围会先验证 CID,再有界扫描并在本地精确过滤。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"}, AvoidWhen: []string{"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"}, Examples: []string{ "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all", @@ -100,7 +103,7 @@ var SearchMsg = shortcut.Shortcut{ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"}, {Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 lark-cli 对齐别名"}, {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动连续拉取所有游标页"}, - {Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"}, + {Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 或显式会话范围本地扫描的最大页数(1-40)", Default: "20"}, {Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"}, }, chatshortcut.MessageResourceDownloadFlags()...), @@ -138,9 +141,21 @@ var SearchMsg = shortcut.Shortcut{ if err != nil { return err } + requestedConversationIDs, _ := params["openConversationIds"].([]string) + scopedSearch := len(requestedConversationIDs) > 0 + if scopedSearch { + if err := validateSearchConversationScope(rt, requestedConversationIDs); err != nil { + return err + } + // The downstream search currently drops invalid CID filters and does + // not return group-scoped hits reliably. Scan the same filtered global + // stream and apply the already-validated CID set locally instead. + delete(params, "openConversationIds") + } pageLimit := 1 - if rt.Bool("page-all") { + scanAllPages := rt.Bool("page-all") || scopedSearch + if scanAllPages { pageLimit = rt.Int("page-limit") } cursor := rt.StrFirst("page-token", "cursor") @@ -169,7 +184,15 @@ var SearchMsg = shortcut.Shortcut{ break } pagesFetched++ - for _, message := range searchMsgItems(data) { + pageMessages := searchMsgItems(data) + if scopedSearch { + var unverifiableMessageIDs []string + pageMessages, unverifiableMessageIDs = chatmsg.FilterConversationScope(pageMessages, requestedConversationIDs) + if len(unverifiableMessageIDs) > 0 { + return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs) + } + } + for _, message := range pageMessages { messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) if messageID != "" && messageID != "" { if seen[messageID] { @@ -197,7 +220,7 @@ var SearchMsg = shortcut.Shortcut{ } } hasMore = hasMoreValue - if !rt.Bool("page-all") || !hasMore { + if !scanAllPages || !hasMore { complete = !hasMore break } @@ -211,7 +234,7 @@ var SearchMsg = shortcut.Shortcut{ } cursor = nextCursor } - if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit { + if scanAllPages && hasMore && pagesFetched == pageLimit { failures = append(failures, map[string]any{ "stage": "search-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多结果", pageLimit), @@ -228,6 +251,16 @@ var SearchMsg = shortcut.Shortcut{ complete = false } } + if scopedSearch { + validatedMessages, unverifiableMessageIDs := chatmsg.FilterConversationScope(messages, requestedConversationIDs) + if len(unverifiableMessageIDs) > 0 { + return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs) + } + if len(validatedMessages) != len(messages) { + return searchScopeViolationError(requestedConversationIDs, messages) + } + messages = validatedMessages + } results := make([]map[string]any, 0, len(messages)) for _, m := range messages { @@ -246,6 +279,9 @@ var SearchMsg = shortcut.Shortcut{ "failedCount": len(failures), "failures": failures, } + if scopedSearch { + payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore) + } if hasMore && nextCursor != "" && nextCursor != "" { payload["nextCursor"] = nextCursor } @@ -402,6 +438,75 @@ func uniqueSearchStrings(values []string) []string { return out } +func validateSearchConversationScope(rt *shortcut.RuntimeContext, conversationIDs []string) error { + for _, conversationID := range conversationIDs { + _, err := rt.CallMCPData("chat", "get_conversation_info", map[string]any{ + "openConversationId": conversationID, + }) + if err == nil { + continue + } + var cliErr *helpers.CLIError + if !errors.As(err, &cliErr) || (cliErr.Code != helpers.CodeMCPToolError && cliErr.Code != helpers.CodeResourceNotFound) { + return err + } + return apperrors.NewValidation( + fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID), + apperrors.WithReason("search_conversation_scope_invalid"), + apperrors.WithDetails(map[string]any{ + "conversationId": conversationID, + }), + apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"), + apperrors.WithCause(err), + ) + } + return nil +} + +func searchScopeUnverifiedError(conversationIDs, messageIDs []string) error { + return apperrors.NewAPI( + "搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出", + apperrors.WithReason("search_conversation_scope_unverified"), + apperrors.WithDetails(map[string]any{ + "requestedConversationIds": conversationIDs, + "unverifiableMessageIds": messageIDs, + }), + apperrors.WithRetryable(false), + apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"), + ) +} + +func searchScopeViolationError(conversationIDs []string, messages []map[string]any) error { + observed := make([]string, 0, len(messages)) + for _, message := range messages { + conversationID := strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(message))) + if conversationID == "" || conversationID == "" { + continue + } + observed = append(observed, conversationID) + } + return apperrors.NewAPI( + "消息富化结果超出请求的会话范围;已停止输出", + apperrors.WithReason("search_conversation_scope_violation"), + apperrors.WithDetails(map[string]any{ + "requestedConversationIds": conversationIDs, + "observedConversationIds": uniqueSearchStrings(observed), + }), + apperrors.WithRetryable(false), + ) +} + +func searchScopePayload(conversationIDs []string, sourceComplete bool) map[string]any { + return map[string]any{ + "requestedConversationIds": append([]string(nil), conversationIDs...), + "targetsValidated": true, + "filterApplied": true, + "filterMode": "client", + "resultsWithinScope": true, + "sourceComplete": sourceComplete, + } +} + func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) { detailsByID := map[string]map[string]any{} failures := make([]map[string]any, 0) @@ -475,31 +580,7 @@ func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any // response, probing common container keys at the top level and nested under // "result". Returns nil when no list is found. func searchMsgItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} { - if root == nil { - continue - } - if groups, ok := root["conversationMessagesList"].([]any); ok { - return searchMsgFlattenGroups(groups) - } - } - keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return searchMsgToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} { - if arr, ok := inner[k2].([]any); ok { - return searchMsgToMaps(arr) - } - } - } - } - return nil + return chatmsg.SearchItems(data) } func searchMsgChildMap(data map[string]any, key string) map[string]any { @@ -509,56 +590,6 @@ func searchMsgChildMap(data map[string]any, key string) map[string]any { return nil } -func searchMsgFlattenGroups(groups []any) []map[string]any { - out := make([]map[string]any, 0) - for _, rawGroup := range groups { - group, ok := rawGroup.(map[string]any) - if !ok { - continue - } - messages, ok := group["messages"].([]any) - if !ok { - continue - } - conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"])) - conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"])) - singleChat, hasSingleChat := group["singleChat"] - for _, rawMessage := range messages { - message, ok := rawMessage.(map[string]any) - if !ok { - continue - } - item := make(map[string]any, len(message)+3) - for key, value := range message { - item[key] = value - } - if _, exists := item["openConversationId"]; !exists && - conversationID != "" && conversationID != "" { - item["openConversationId"] = conversationID - } - if _, exists := item["conversationTitle"]; !exists && - conversationTitle != "" && conversationTitle != "" { - item["conversationTitle"] = conversationTitle - } - if _, exists := item["singleChat"]; !exists && hasSingleChat { - item["singleChat"] = singleChat - } - out = append(out, item) - } - } - return out -} - -func searchMsgToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - // searchMsgProject reshapes one matched message into {sender, time, text, // messageId}, running text through the shared chatmsg cleaning (card/auto-reply // JSON → readable, ciphertext → marker) and recursively expanding any forwarded diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index e7a66b6f..7e331c79 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -12,6 +12,7 @@ import ( "reflect" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -22,29 +23,44 @@ type searchMsgExecutionCaller struct { failEnrichment bool omitPagination bool omitMgetItem bool + failPreflight bool + searchResponse string + wrongMgetScope bool } func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args}) + if product == "chat" && tool == "get_conversation_info" { + if f.failPreflight { + return nil, errors.New("conversation not found") + } + return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil + } if product != "im" { return nil, errors.New("unexpected product") } switch tool { case "search_messages": + if f.searchResponse != "" { + return searchMsgToolResult(f.searchResponse), nil + } if f.omitPagination { - return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}]}}`), nil + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}]}}`), nil } if args["cursor"] == "c2" { if f.failSecondPage { return nil, errors.New("second page unavailable") } - return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil } - return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil case "list_messages_by_ids": if f.failEnrichment { return nil, errors.New("mget unavailable") } + if f.wrongMgetScope { + return searchMsgToolResult(`{"result":[{"openMessageId":"m1","openConversationId":"cid-other","content":"detail-1"}]}`), nil + } if f.omitMgetItem { return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil } @@ -69,19 +85,27 @@ func searchMsgToolResult(text string) *edition.ToolResult { func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...string) map[string]any { t.Helper() + payload, err := executeSearchMsgResult(caller, args...) + if err != nil { + t.Fatal(err) + } + return payload +} + +func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (map[string]any, error) { helpers.InitDeps(caller) root := newPlatformCoverageRoot() var output bytes.Buffer root.SetOut(&output) root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, args...)) if err := root.Execute(); err != nil { - t.Fatal(err) + return nil, err } var payload map[string]any if err := json.Unmarshal(output.Bytes(), &payload); err != nil { - t.Fatalf("decode output: %v\n%s", err, output.String()) + return nil, err } - return payload + return payload, nil } func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { @@ -103,15 +127,18 @@ func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { "--page-limit", "3", ) - if len(caller.calls) != 3 { + if len(caller.calls) != 5 { t.Fatalf("calls = %#v", caller.calls) } - first := caller.calls[0] + if caller.calls[0].tool != "get_conversation_info" || caller.calls[0].args["openConversationId"] != "cid-1" || + caller.calls[1].tool != "get_conversation_info" || caller.calls[1].args["openConversationId"] != "cid-2" { + t.Fatalf("scope preflight calls = %#v", caller.calls[:2]) + } + first := caller.calls[2] if first.product != "im" || first.tool != "search_messages" { t.Fatalf("first call = %#v", first) } for key, want := range map[string]any{ - "openConversationIds": []string{"cid-1", "cid-2"}, "senderUserIds": []string{"42"}, "senderOpenDingTakIds": []string{"Dsender"}, "atUserIds": []string{"43"}, @@ -124,10 +151,13 @@ func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { t.Errorf("%s = %#v, want %#v", key, first.args[key], want) } } - if caller.calls[1].args["cursor"] != "c2" { - t.Fatalf("second cursor = %#v", caller.calls[1].args["cursor"]) + if _, exists := first.args["openConversationIds"]; exists { + t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", first.args) } - if ids := caller.calls[2].args["openMsgIds"]; !reflect.DeepEqual(ids, []string{"m1", "m2"}) { + if caller.calls[3].args["cursor"] != "c2" { + t.Fatalf("second cursor = %#v", caller.calls[3].args["cursor"]) + } + if ids := caller.calls[4].args["openMsgIds"]; !reflect.DeepEqual(ids, []string{"m1", "m2"}) { t.Fatalf("mget ids = %#v", ids) } if payload["complete"] != true || payload["count"] != float64(2) || @@ -140,6 +170,10 @@ func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { if firstMessage["text"] != "detail-1" { t.Fatalf("enriched message = %#v", firstMessage) } + scope, _ := payload["scope"].(map[string]any) + if scope["targetsValidated"] != true || scope["filterMode"] != "client" || scope["resultsWithinScope"] != true { + t.Fatalf("scope = %#v", scope) + } } func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) { @@ -199,3 +233,110 @@ func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) { t.Fatalf("failure = %#v", failure) } } + +func TestSearchMsgScopedFallbackFiltersGlobalResults(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{ + "result": { + "conversationMessagesList": [ + {"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]}, + {"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]} + ], + "hasMore": false + } + }`} + payload := executeSearchMsg(t, caller, "--group", "cid-target", "--query", "目标", "--no-enrich") + if payload["count"] != float64(1) || payload["complete"] != true { + t.Fatalf("payload = %#v", payload) + } + messages, _ := payload["messages"].([]any) + message, _ := messages[0].(map[string]any) + if message["conversationId"] != "cid-target" || message["messageId"] != "m-target" { + t.Fatalf("messages = %#v", messages) + } + if len(caller.calls) != 2 || caller.calls[0].tool != "get_conversation_info" || caller.calls[1].tool != "search_messages" { + t.Fatalf("calls = %#v", caller.calls) + } + if _, exists := caller.calls[1].args["openConversationIds"]; exists { + t.Fatalf("global fallback unexpectedly forwarded scope: %#v", caller.calls[1].args) + } +} + +func TestSearchMsgScopedValidEmptyResultIsComplete(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`} + payload := executeSearchMsg(t, caller, "--group", "cid-empty", "--query", "不存在", "--no-enrich") + if payload["count"] != float64(0) || payload["complete"] != true { + t.Fatalf("payload = %#v", payload) + } + scope, _ := payload["scope"].(map[string]any) + if scope["targetsValidated"] != true || scope["sourceComplete"] != true { + t.Fatalf("scope = %#v", scope) + } +} + +func TestSearchMsgScopedEmptyPartialScanCannotClaimComplete(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{ + "result": { + "conversationMessagesList": [ + {"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]} + ], + "hasMore": true, + "nextCursor": "c2" + } + }`} + payload := executeSearchMsg(t, caller, + "--group", "cid-target", "--query", "周报", "--no-enrich", "--page-limit", "1") + if payload["count"] != float64(0) || payload["complete"] != false || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + scope, _ := payload["scope"].(map[string]any) + if scope["sourceComplete"] != false { + t.Fatalf("scope = %#v", scope) + } + failures, _ := payload["failures"].([]any) + failure, _ := failures[0].(map[string]any) + if failure["stage"] != "search-page-limit" { + t.Fatalf("failure = %#v", failure) + } +} + +func TestSearchMsgInvalidCIDStopsBeforeGlobalSearch(t *testing.T) { + caller := &searchMsgExecutionCaller{failPreflight: true} + _, err := executeSearchMsgResult(caller, "--group", "cid-invalid", "--query", "周报", "--no-enrich") + if err == nil { + t.Fatal("invalid CID unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" { + t.Fatalf("error = %#v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "get_conversation_info" { + t.Fatalf("calls = %#v", caller.calls) + } +} + +func TestSearchMsgMissingConversationIdentityFailsClosed(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1","content":"unknown"}],"hasMore":false}}`} + _, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报", "--no-enrich") + if err == nil { + t.Fatal("unverifiable scoped result unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" { + t.Fatalf("error = %#v", err) + } +} + +func TestSearchMsgEnrichmentCannotMoveMessageOutsideScope(t *testing.T) { + caller := &searchMsgExecutionCaller{ + searchResponse: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target","content":"sparse"}],"hasMore":false}}`, + wrongMgetScope: true, + } + _, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报") + if err == nil { + t.Fatal("out-of-scope enrichment unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_violation" { + t.Fatalf("error = %#v", err) + } +}