fix(chat): fail closed for scoped search and card updates
This commit is contained in:
+337
-11
@@ -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"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
|
||||
"failedCount",
|
||||
"failures",
|
||||
"partial",
|
||||
"scope",
|
||||
"resourceDownloads",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user