fix(chat): fail closed for scoped search and card updates

This commit is contained in:
克谨
2026-08-07 15:30:09 +08:00
parent 6035d43899
commit 7c76dfea4b
15 changed files with 1683 additions and 142 deletions
+337 -11
View File
@@ -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 != "<nil>" {
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 == "<nil>" || 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 <openconversation_id> --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 <nextCursor>
@@ -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 <userId> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --users <userId1>,<userId2> --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 <bizId> --content \"处理完成\" --flow-status 2"},
},
Parameters: []contract.ParamDecl{
{Name: "biz-id", Property: "bizId"},
{Name: "content", Property: "msgContent"},
{Name: "flow-status", Property: "flowStatus"},
},
},
})
+101
View File
@@ -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", "<bizId>", "--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)
}
})
}
+269 -20
View File
@@ -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
+63 -3
View File
@@ -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 <bizId> --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",
@@ -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 {
+99
View File
@@ -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", "<bizId>",
"--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
+176
View File
@@ -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>", "<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
}
}
@@ -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: "<bizId>", 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)
}
})
}
}
+1
View File
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
"failedCount",
"failures",
"partial",
"scope",
"resourceDownloads",
},
}
+192
View File
@@ -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 = "<unknown>"
}
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 == "<nil>" || 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
}
+75
View File
@@ -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)
}
}
@@ -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"},
},
}
@@ -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"])
}
}
+115 -84
View File
@@ -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 <openDingTalkId> --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 != "<nil>" {
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 != "<nil>" {
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 == "<nil>" {
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 != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
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
@@ -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)
}
}