diff --git a/.github/eval-allowlist.txt b/.github/eval-allowlist.txt new file mode 100644 index 00000000..8cde985f --- /dev/null +++ b/.github/eval-allowlist.txt @@ -0,0 +1,61 @@ +# /eval 自助触发允许名单 +# +# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测; +# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。 +# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。 +# +# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。 + +aftersss +notable-open +EdgarWang0925 +ayunya +yutongshe +qingyang1014 +caiTriumph +xlb1130 +Anonymity-0 +FuShu-Yang +guimingyue +AlwaysLee +TaoJikun +zengyoulingzyl-stack +liyuan333 +huangyoo +lifeihong +nitonitori +cywan1998 +gangwn +junlonghuo2 +aqruan +Freda0909 +ShawnWhite777 +PeterGuy326 +abucraft +pengzhihan47-star +rainyak8 +gongrongyun +huangyuanzhuo-coder +ybcstudy +bigqy +liwang-ai +meng93 +wxianfeng +Patrick-Star-CN +rossluo28-hz +dxy704330469 +gtezg30062 +Neige-Premaire +zhuoyu20 +avicii-chen +typefield +Haofeng0705 +Huwenjiao +liuzeyang +maoqxxmm +FloralTide +lingyun9833 +dxb121 +C0922 +xiaoji121 +H3java diff --git a/.github/workflows/eval-dispatch.yml b/.github/workflows/eval-dispatch.yml new file mode 100644 index 00000000..70df32e4 --- /dev/null +++ b/.github/workflows/eval-dispatch.yml @@ -0,0 +1,139 @@ +name: PR Eval Dispatch + +# `/eval [sha=] [cases=]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。 +# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。 +# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证 +# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻 +# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以 +# FETCH_HEAD 校验兜底派发后的变更。 +# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支 +# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。 +# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。 + +on: + issue_comment: + types: + - created + +permissions: {} + +concurrency: + group: eval-dispatch-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + dispatch: + name: Dispatch internal evaluation + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/eval') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: read + steps: + - name: Check out default branch tooling + uses: actions/checkout@v4 + + - name: Verify commenter dispatch authorization + env: + GH_TOKEN: ${{ github.token }} + COMMENTER: ${{ github.event.comment.user.login }} + PR_AUTHOR: ${{ github.event.issue.user.login }} + EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt + run: | + # 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed + permission_json="$(curl --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}' + printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission + + - name: Parse /eval command + id: parse + continue-on-error: true + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: python3 scripts/ci/eval_comment_parse.py + + - name: Reply usage on parse failure + if: steps.parse.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + PARSE_ERROR: ${{ steps.parse.outputs.error }} + run: | + body="❌ /eval 命令解析失败:${PARSE_ERROR}" + jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + --data @- \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null + exit 1 + + - name: Verify reviewed PR head + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + EXPECTED_PR_NUMBER: ${{ github.event.issue.number }} + REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }} + COMMENTER: ${{ github.event.comment.user.login }} + run: | + pr_json="$(curl --fail --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + printf '%s' "$pr_json" \ + | python3 scripts/ci/eval_dispatch_guard.py head \ + >> "$GITHUB_OUTPUT" + + - name: Trigger internal evaluation pipeline + env: + EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }} + EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PRODUCTS: ${{ steps.parse.outputs.products }} + CASES_REF: ${{ steps.parse.outputs.cases_ref }} + run: | + if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then + echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2 + exit 1 + fi + jq -n \ + --arg pr "$PR_NUMBER" \ + --arg sha "$PR_HEAD_SHA" \ + --arg products "$PRODUCTS" \ + --arg cases "$CASES_REF" \ + '{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \ + | curl --fail --silent --show-error \ + -X POST \ + -H "private-token: ${EVAL_TRIGGER_TOKEN}" \ + -H "Content-Type: application/json" \ + --data @- \ + "$EVAL_TRIGGER_URL" + echo "Internal evaluation dispatched." + + - name: Acknowledge on PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PRODUCTS: ${{ steps.parse.outputs.products }} + CASES_REF: ${{ steps.parse.outputs.cases_ref }} + run: | + cases_note="" + if [ -n "$CASES_REF" ]; then + cases_note=",用例版本 \`${CASES_REF}\`" + fi + body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。" + jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + --data @- \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null diff --git a/internal/app/chat_message_receipt_action_contract_test.go b/internal/app/chat_message_receipt_action_contract_test.go new file mode 100644 index 00000000..b3caedd6 --- /dev/null +++ b/internal/app/chat_message_receipt_action_contract_test.go @@ -0,0 +1,84 @@ +// 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. + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" +) + +func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) { + testCases := []struct { + name string + payload map[string]any + }{ + { + name: "send receipt awaiting status", + payload: chatmsg.ProjectMessageSendReceipt(map[string]any{ + "openTaskId": "task-pending", + }), + }, + { + name: "send receipt ready for message actions", + payload: chatmsg.ProjectMessageSendReceipt(map[string]any{ + "openTaskId": "task-ready", + "openMessageId": "message-ready", + "openConversationId": "conversation-ready", + }), + }, + { + name: "send status awaiting message reference", + payload: chatmsg.ProjectMessageSendStatus(map[string]any{ + "status": "PENDING", + }, "task-pending"), + }, + { + name: "send status ready for message actions", + payload: chatmsg.ProjectMessageSendStatus(map[string]any{ + "openTaskId": "task-ready", + "openMessageId": "message-ready", + "openConversationId": "conversation-ready", + "status": "SUCCESS", + }, "task-ready"), + }, + } + + root := NewRootCommand() + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + actions, ok := testCase.payload["nextActions"].([]map[string]any) + if !ok || len(actions) == 0 { + t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"]) + } + + for index, action := range actions { + cliPath, ok := action["cliPath"].(string) + if !ok || strings.TrimSpace(cliPath) == "" { + t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"]) + } + + command, remaining, err := root.Find(strings.Fields(cliPath)) + if err != nil { + t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err) + } + if command == nil || len(remaining) != 0 || !command.Runnable() { + t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable()) + } + + arguments, ok := action["arguments"].(map[string]any) + if !ok { + t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"]) + } + for name := range arguments { + if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil { + t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath) + } + } + } + }) + } +} diff --git a/internal/app/schema_safety_contract_test.go b/internal/app/schema_safety_contract_test.go index 43b0dbc4..2ceffe07 100644 --- a/internal/app/schema_safety_contract_test.go +++ b/internal/app/schema_safety_contract_test.go @@ -26,6 +26,14 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) { wants := []finalSchemaSafetyWant{ {canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared}, {canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + // Card update intentionally layers confirmation: the atomic typed command + // preserves its original contract, while the Agent-facing shortcut owns + // the outer confirmation boundary. + {canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, {canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared}, {canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared}, {canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, diff --git a/internal/app/server_failure_classifier.go b/internal/app/server_failure_classifier.go index 6ab9d8ea..978a06c1 100644 --- a/internal/app/server_failure_classifier.go +++ b/internal/app/server_failure_classifier.go @@ -81,6 +81,7 @@ func newServerFailureAPIError( apperrors.WithReason(fallbackReason), apperrors.WithServerKey(serverKey), apperrors.WithHint(fallbackHint), + apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"), apperrors.WithServerDiag(diag), } if classified, ok := classifyServerFailure(message, diag); ok { diff --git a/internal/app/server_failure_classifier_test.go b/internal/app/server_failure_classifier_test.go index 489ecad0..df1a104d 100644 --- a/internal/app/server_failure_classifier_test.go +++ b/internal/app/server_failure_classifier_test.go @@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil { t.Fatalf("unexpected fallback classification: %#v", typed) } + if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") { + t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions) + } } func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) { diff --git a/internal/errors/coverage_edges_test.go b/internal/errors/coverage_edges_test.go index 49f0fb9d..bf64d5b8 100644 --- a/internal/errors/coverage_edges_test.go +++ b/internal/errors/coverage_edges_test.go @@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) { t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err) } out.Reset() - if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") { + if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") { t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err) } out.Reset() diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 0467dd54..84491c52 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -19,6 +19,7 @@ import ( stderrors "errors" "fmt" "io" + "net/url" "strings" "time" @@ -514,7 +515,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { lines = append(lines, tui.Cyan("Hint: "+friendlyHint)) } if actionURL != "" { - lines = append(lines, tui.White("Action: 开启地址: "+actionURL)) + lines = append(lines, tui.White("Action: 处理入口: "+actionURL)) } } @@ -594,7 +595,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { func serverGuidance(diag ServerDiagnostics) (string, string) { friendlyHint := strings.TrimSpace(diag.FriendlyHint) - actionURL := strings.TrimSpace(diag.ActionURL) + actionURL := safeServerActionURL(diag.ActionURL) if friendlyHint == "" || actionURL == "" { switch diag.ServerErrorCode { case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": @@ -615,6 +616,19 @@ func ServerGuidance(diag ServerDiagnostics) (string, string) { return serverGuidance(diag) } +func safeServerActionURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || + parsed.Hostname() == "" || parsed.User != nil { + return "" + } + return parsed.String() +} + func category(err error) string { var typed *Error if stderrors.As(err, &typed) { diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 7b7c77bc..092f2c84 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -336,12 +336,14 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) { if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") { t.Fatalf("expected server guidance in output, got %q", got) } - if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") { + if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") { t.Fatalf("expected server action URL in output, got %q", got) } } func TestCrossPlatformCoverageServerGuidanceAdapter(t *testing.T) { + t.Parallel() + hint, action := ServerGuidance(ServerDiagnostics{ FriendlyHint: "follow the recovery action", ActionURL: "https://example.test/recover", @@ -351,6 +353,35 @@ func TestCrossPlatformCoverageServerGuidanceAdapter(t *testing.T) { } } +func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) { + t.Parallel() + for _, actionURL := range []string{ + "http://example.test/help", + "javascript:alert(1)", + "https://user:secret@example.test/help", + "not a url", + } { + var human strings.Builder + err := NewAPI("server error", WithServerDiag(ServerDiagnostics{ + FriendlyHint: "保留 Trace ID 后排查", + ActionURL: actionURL, + })) + if printErr := PrintHuman(&human, err); printErr != nil { + t.Fatal(printErr) + } + if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") { + t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String()) + } + var jsonOutput strings.Builder + if printErr := PrintJSON(&jsonOutput, err); printErr != nil { + t.Fatal(printErr) + } + if strings.Contains(jsonOutput.String(), `"action_url"`) { + t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String()) + } + } +} + func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) { t.Parallel() diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index bd9a013a..3f84eb0d 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -6,6 +6,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -18,6 +19,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" @@ -119,6 +121,256 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string return v } +const maxConversationScopedSearchPages = 40 + +func runConversationScopedPagedMessageSearch( + cmd *cobra.Command, + cfg PagedMCPCommandConfig, + scopeParam string, + conversationIDs []string, +) error { + conversationIDs = uniqueNonEmptyStrings(conversationIDs) + if len(conversationIDs) == 0 { + return RunPagedMCPCommand(cmd, cfg) + } + toolArgs, err := cfg.BuildArgs(cmd) + if err != nil { + return err + } + return runConversationScopedMessageSearch( + cmd, + cfg.ServerID, + cfg.ToolName, + scopeParam, + toolArgs, + conversationIDs, + ) +} + +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) + } + opts, err := readPagedCommandOptions(cmd) + if err != nil { + return err + } + if commandDryRun(cmd) { + return writeConversationScopedSearchPreview(cmd, serverID, toolName, scopeParam, toolArgs, conversationIDs, opts) + } + 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) + pageSize := positiveSearchLimit(scanArgs["limit"], 100) + resultLimit := pageSize + pageLimit := maxConversationScopedSearchPages + if opts.pageAll { + resultLimit = opts.maxItems + pageLimit = opts.pageLimit + } + cursor := cleanSearchCursor(scanArgs["cursor"]) + capacity := resultLimit + if capacity <= 0 { + capacity = pageSize + } + messages := make([]map[string]any, 0, capacity) + seenMessageIDs := map[string]struct{}{} + pagesFetched := 0 + hasMore := false + truncatedWithinPage := false + var nextCursor any + + for pagesFetched < pageLimit && (resultLimit == 0 || len(messages) < resultLimit) { + requestLimit := pageSize + if !opts.pageAll { + requestLimit = resultLimit - len(messages) + } + scanArgs["limit"] = requestLimit + 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 index, message := range pageMessages { + messageID := strings.TrimSpace(fmt.Sprint(chatmsg.MessageID(message))) + if messageID != "" && messageID != "" { + if _, exists := seenMessageIDs[messageID]; exists { + continue + } + seenMessageIDs[messageID] = struct{}{} + } + messages = append(messages, message) + if resultLimit > 0 && len(messages) == resultLimit { + truncatedWithinPage = index < len(pageMessages)-1 + break + } + } + + 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 || truncatedWithinPage + nextCursor = page["nextCursor"] + if !hasMore { + break + } + if truncatedWithinPage { + break + } + next := cleanSearchCursor(nextCursor) + if next == "" || next == cursor { + return apperrors.NewAPI( + "搜索服务声称仍有更多结果,但 nextCursor 缺失或未前进", + apperrors.WithReason("search_conversation_scope_cursor_stalled"), + apperrors.WithRetryable(false), + ) + } + if resultLimit > 0 && len(messages) >= resultLimit { + break + } + cursor = next + if opts.pageAll && opts.delayMS > 0 { + if err := sleepPagedCommandDelay(cmd.Context(), time.Duration(opts.delayMS)*time.Millisecond); err != nil { + return err + } + } + } + + 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, + }, + } + if opts.pageAll { + paging := map[string]any{ + "truncated": hasMore, + "hasMore": hasMore, + "lastCursor": nextCursor, + "pages": pagesFetched, + "total": len(messages), + } + if truncatedWithinPage { + paging["truncatedWithinPage"] = true + paging["resumeCursorReliable"] = false + } + payload["paging"] = paging + } + return writeCommandPayload(cmd, payload) +} + +func writeConversationScopedSearchPreview( + cmd *cobra.Command, + serverID, toolName, scopeParam string, + toolArgs map[string]any, + conversationIDs []string, + opts pagedCommandOptions, +) 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) + pageLimit := maxConversationScopedSearchPages + maxItems := positiveSearchLimit(scanArgs["limit"], 100) + pageDelay := 0 + if opts.pageAll { + pageLimit = opts.pageLimit + maxItems = opts.maxItems + pageDelay = opts.delayMS + } + plan = append(plan, + map[string]any{ + "stage": "search-global", + "product": serverID, + "tool": toolName, + "arguments": scanArgs, + "pageAll": opts.pageAll, + "pageLimit": pageLimit, + "maxItems": maxItems, + "pageDelay": pageDelay, + }, + 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 + } + return NormalizeSearchConversationScopeError(conversationID, err) + } + return nil +} + func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { if err := validateRequiredFlags(cmd, "start", "end"); err != nil { return nil, err @@ -320,6 +572,69 @@ func appendChatAdvancedFilters(cmd *cobra.Command, toolArgs map[string]any) erro return nil } +func nativeSearchScopeUnverifiedError(conversationIDs, messageIDs []string) error { + return apperrors.NewAPI( + "搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出", + apperrors.WithReason("search_conversation_scope_unverified"), + apperrors.WithDetails(map[string]any{ + "requestedConversationIds": conversationIDs, + "unverifiableMessageIds": messageIDs, + }), + apperrors.WithRetryable(false), + apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"), + ) +} + +func uniqueNonEmptyStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func positiveSearchLimit(value any, fallback int) int { + switch typed := value.(type) { + case int: + if typed > 0 { + return typed + } + case int64: + if typed > 0 { + return int(typed) + } + case json.Number: + if parsed, err := strconv.Atoi(typed.String()); err == nil && parsed > 0 { + return parsed + } + case float64: + if typed > 0 { + return int(typed) + } + } + return fallback +} + +func cleanSearchCursor(value any) string { + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || strings.EqualFold(text, "null") { + return "" + } + return text +} + func chatMessageListFavoritesArgs(cmd *cobra.Command) (map[string]any, error) { cursor, _ := cmd.Flags().GetInt64("cursor") if cursor < 0 { @@ -1514,6 +1829,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 { @@ -2549,7 +2893,7 @@ func newChatCommand() *cobra.Command { }, Selection: contract.SelectionSpec{ AgentSummary: "以当前用户身份发送群聊或单聊消息", - UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时"}, + UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"}, AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"}, Examples: []string{"dws chat message send --group \"项目已更新\""}, }, @@ -3281,15 +3625,20 @@ 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 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0 - # 查询群 ID: dws chat search --query "群名"`, + # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatConversationMessagesConfig( - "search_messages_by_keyword", chatMessageSearchArgs)) + groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") + return runConversationScopedPagedMessageSearch( + cmd, + pagedChatConversationMessagesConfig("search_messages_by_keyword", chatMessageSearchArgs), + "openConversationId", + []string{groupID}, + ) }, } DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{ @@ -3334,7 +3683,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchAdvancedCmd := &cobra.Command{ Use: "search-advanced", Short: "多维度搜索消息", - Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。所有参数均为可选,至少指定一个搜索条件。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --user --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" dws chat message search-advanced --users , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" @@ -3345,10 +3694,15 @@ func newChatCommand() *cobra.Command { dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --page-limit 20 --max-items 500 # 查询群 ID: dws chat search --query "群名" # 查询单聊会话 ID: dws chat conversation-info --user - # 查询人员: dws contact user search --keyword "姓名" --format json`, + # 查询人员: dws contact user search --keyword "姓名" --format json`, RunE: func(cmd *cobra.Command, args []string) error { - return RunPagedMCPCommand(cmd, pagedChatConversationMessagesOnServerConfig( - "im", "search_messages", chatMessageSearchAdvancedArgs)) + conversationIDs := parseCSVValues(flagOrFallback(cmd, "conversation-ids", "groups", "group")) + return runConversationScopedPagedMessageSearch( + cmd, + pagedChatConversationMessagesOnServerConfig("im", "search_messages", chatMessageSearchAdvancedArgs), + "openConversationIds", + conversationIDs, + ) }, } DeclareLeafMetadata(chatMessageSearchAdvancedCmd, LeafSpec{ @@ -3400,14 +3754,16 @@ func newChatCommand() *cobra.Command { // ── query-send-status:查询消息发送状态(走 IM MCP)────── chatMessageQuerySendStatusCmd := &cobra.Command{ - Use: "query-send-status", - Short: "查询消息发送状态", + Use: "query-send-status", + Aliases: []string{"send-status"}, + Short: "查询消息发送状态", Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。 发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为 -chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。`, +chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 +同一组 ID 也可继续用于 chat message read-status(消息参数为 --message-id);openTaskId 本身不是消息 ID。`, Example: ` dws chat message query-send-status --open-task-id - # openTaskId 由 dws chat message send 返回`, + dws chat message recall --conversation-id --msg-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-task-id"); err != nil { return err @@ -3429,6 +3785,7 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` CanonicalPath: "chat.query_message_send_status", CLIPath: "chat message query-send-status", PrimaryCLIPath: "chat message query-send-status", + Aliases: []string{"chat message send-status"}, }, Description: "查询异步消息发送任务的状态", Interface: &contract.InterfaceSpec{ @@ -3438,8 +3795,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。` }, Selection: contract.SelectionSpec{ AgentSummary: "查询异步消息发送任务的状态", - UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果时"}, - AvoidWhen: []string{"没有 openTaskId 或只需查消息内容时不要使用"}, + UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果,或后续 edit/recall/read-status 需要先取得 openMessageId 和 openConversationId 时"}, + AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"}, Examples: []string{"dws chat message query-send-status --open-task-id "}, }, }, @@ -5300,17 +5657,57 @@ 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{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", + // The typed command is the atomic MCP surface and intentionally + // preserves its original no-extra-confirmation contract. The + // Agent-facing +messages-update-card shortcut owns the higher-level + // confirmation boundary. Confirmation: "not_required", Idempotency: "unknown", }, Contract: LeafContract{ @@ -5329,12 +5726,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, Selection: contract.SelectionSpec{ AgentSummary: "更新已发送流式卡片的内容和状态", - UseWhen: []string{"已有 bizId 并需要追加内容或结束流式输出时"}, - AvoidWhen: []string{"创建新卡片时使用 chat message send-card"}, + UseWhen: []string{"需要直接调用底层原子更新,并由调用方自行管理确认与更新节奏时"}, + AvoidWhen: []string{"面向 Agent 的默认快速通道使用 chat +messages-update-card;创建新卡片时使用 chat message send-card"}, Examples: []string{"dws chat message update-card --biz-id --content \"处理完成\" --flow-status 2"}, }, Parameters: []contract.ParamDecl{ + {Name: "biz-id", Property: "bizId"}, {Name: "content", Property: "msgContent"}, + {Name: "flow-status", Property: "flowStatus"}, }, }, }) diff --git a/internal/helpers/chat_card_update_test.go b/internal/helpers/chat_card_update_test.go new file mode 100644 index 00000000..58644967 --- /dev/null +++ b/internal/helpers/chat_card_update_test.go @@ -0,0 +1,163 @@ +// 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" + "reflect" + "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") + } + if root.PersistentFlags().Lookup("yes") == nil { + root.PersistentFlags().Bool("yes", false, "skip confirmation") + } + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) { + t.Run("atomic command preserves no-extra-confirmation contract", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-confirm","updated":true}}`}}} + if err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "biz-confirm", + "--content", "原子更新", + "--flow-status", "3", + ); err != nil { + t.Fatal(err) + } + wantArgs := map[string]any{ + "bizId": "biz-confirm", + "msgContent": "原子更新", + "flowStatus": 3, + } + if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" || !reflect.DeepEqual(caller.args, wantArgs) { + t.Fatalf("atomic call = count:%d server:%q tool:%q args:%#v", caller.calls, caller.server, caller.tool, caller.args) + } + }) + + 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("lower write error is preserved", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("write unavailable")}}} + err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "biz-1", + "--content", "完成", + "--flow-status", "3", + ) + if err == nil { + t.Fatal("lower write error was ignored") + } + }) + + for _, test := range []struct { + name string + response string + wantReason string + }{ + {name: "empty response", response: "", wantReason: "streaming_card_update_unverified"}, + {name: "invalid response", response: "{", wantReason: "streaming_card_update_response_invalid"}, + {name: "not applied", response: `{"result":{"updated":false}}`, wantReason: "streaming_card_update_not_applied"}, + {name: "biz id drift", response: `{"result":{"bizId":"biz-other","updated":true}}`, wantReason: "streaming_card_update_biz_id_mismatch"}, + } { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: test.response}}} + err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "biz-1", + "--content", "完成", + "--flow-status", "3", + ) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != test.wantReason { + t.Fatalf("error = %#v, want reason %q", err, test.wantReason) + } + }) + } + + t.Run("invalid arguments make no call", func(t *testing.T) { + for _, args := range [][]string{ + {"message", "update-card", "--biz-id", "", "--content", "完成", "--flow-status", "3"}, + {"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"}, + } { + caller := &scriptedToolCaller{} + if err := runNativeCardUpdate(t, caller, args...); err == nil { + t.Fatalf("args %v unexpectedly succeeded", args) + } + if caller.calls != 0 { + t.Fatalf("args %v made %d calls", args, caller.calls) + } + } + }) + + t.Run("dry run publishes unverified plan without write", func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runNativeCardUpdate(t, caller, + "message", "update-card", + "--biz-id", "biz-preview", + "--content", "完成", + "--flow-status", "3", + "--dry-run", + ) + if err != nil { + t.Fatal(err) + } + if caller.calls != 0 { + t.Fatalf("dry-run made %d calls", caller.calls) + } + }) +} diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index c9c04d76..21f67753 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str root.SilenceUsage = true root.SetOut(io.Discard) root.SetErr(io.Discard) - root.SetArgs(args) + root.SetArgs(append(append([]string(nil), args...), "--yes")) return root.ExecuteContext(context.Background()) } diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 3d559b51..a41711e2 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -16,6 +16,7 @@ package helpers import ( "context" "encoding/json" + "errors" "io" "os" "reflect" @@ -23,8 +24,10 @@ import ( "testing" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" ) type chatMessageSearchCall struct { @@ -34,12 +37,41 @@ type chatMessageSearchCall struct { } type chatMessageSearchCaller struct { - calls []chatMessageSearchCall + calls []chatMessageSearchCall + searchResponse string + searchResponses []string + searchCalls int + searchError error + 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" { + if c.searchError != nil { + c.searchCalls++ + return nil, c.searchError + } + 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 +104,7 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { productID string toolName string wantToolArg map[string]any + preflight []string }{ { name: "keyword search", @@ -79,13 +112,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 +126,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 +149,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 +174,507 @@ 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 TestCrossPlatformCoverageNativeScopedSearchPreservesAmbiguousMCPToolErrors(t *testing.T) { + for _, test := range []struct { + name string + want *CLIError + }{ + { + name: "rate limited", + want: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`, + }, + }, + { + name: "permission denied", + want: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := &chatMessageSearchCaller{preflightError: test.want} + _, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target") + if err != test.want { + t.Fatalf("error = %#v, want original %#v", err, test.want) + } + if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + } +} + +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 TestCrossPlatformCoverageNativeScopedSearchPageAllOptions(t *testing.T) { + t.Run("page limit preserves continuation", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{ + "result": { + "conversationMessagesList": [ + {"openConversationId":"cid-target","messages":[{"openMessageId":"m1"}]} + ], + "hasMore": true, + "nextCursor": "c2" + } + }`} + payload, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", + "--page-all", "--page-limit", "1", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + paging, _ := payload["paging"].(map[string]any) + if paging["pages"] != float64(1) || paging["total"] != float64(1) || paging["truncated"] != true { + t.Fatalf("paging = %#v", paging) + } + if caller.searchCalls != 1 { + t.Fatalf("search calls = %d, want 1", caller.searchCalls) + } + }) + + t.Run("max items truncates within filtered page", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{ + "result": { + "conversationMessagesList": [ + {"openConversationId":"cid-target","messages":[ + {"openMessageId":"m1"}, + {"openMessageId":"m2"} + ]} + ], + "hasMore": false + } + }`} + payload, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", + "--page-all", "--max-items", "1", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + result, _ := payload["result"].(map[string]any) + groups, _ := result["conversationMessagesList"].([]any) + group, _ := groups[0].(map[string]any) + messages, _ := group["messages"].([]any) + if len(messages) != 1 { + t.Fatalf("messages = %#v", messages) + } + paging, _ := payload["paging"].(map[string]any) + if paging["total"] != float64(1) || paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false { + t.Fatalf("paging = %#v", paging) + } + }) +} + +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 TestCrossPlatformCoverageNativeScopedSearchFailureAndPaginationBranches(t *testing.T) { + t.Run("empty scope uses the native search call", func(t *testing.T) { + caller := &chatMessageSearchCaller{} + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := &cobra.Command{Use: "search"} + err := runConversationScopedMessageSearch( + cmd, + "im", + "search_messages", + "openConversationIds", + map[string]any{"keyword": "周报"}, + []string{"", " "}, + ) + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].toolName != "search_messages" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + t.Run("invalid page options fail before preflight", func(t *testing.T) { + caller := &chatMessageSearchCaller{} + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + + cmd := &cobra.Command{Use: "search"} + AddPagedMCPFlags(cmd) + if err := cmd.Flags().Set("page-all", "true"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("page-limit", "0"); err != nil { + t.Fatal(err) + } + err := runConversationScopedMessageSearch( + cmd, + "im", + "search_messages", + "openConversationIds", + map[string]any{"keyword": "周报"}, + []string{"cid-target"}, + ) + if err == nil || !strings.Contains(err.Error(), "--page-limit must be between 1 and 500") { + t.Fatalf("error = %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid paging made calls: %#v", caller.calls) + } + }) + + t.Run("cancelled context interrupts page delay", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{ + "result": { + "messages": [{"openMessageId":"m1","openConversationId":"cid-target"}], + "hasMore": true, + "nextCursor": "c2" + } + }`} + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := &cobra.Command{Use: "search"} + AddPagedMCPFlags(cmd) + if err := cmd.Flags().Set("page-all", "true"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("page-delay", "60000"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd.SetContext(ctx) + err := runConversationScopedMessageSearch( + cmd, + "im", + "search_messages", + "openConversationIds", + map[string]any{"keyword": "周报", "limit": 100, "cursor": "0"}, + []string{"cid-target"}, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } + if caller.searchCalls != 1 { + t.Fatalf("search calls = %d, want 1", caller.searchCalls) + } + }) + + t.Run("lower search error", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchError: errors.New("search unavailable")} + _, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target") + if err == nil { + t.Fatal("lower search error was ignored") + } + }) + + t.Run("stalled cursor fails closed", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{ + "result": { + "messages": [{"openMessageId":"m1","openConversationId":"cid-target"}], + "hasMore": true + } + }`} + _, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_cursor_stalled" { + t.Fatalf("error = %#v", err) + } + }) + + t.Run("result limit preserves continuation", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponse: `{ + "result": { + "messages": [{"openMessageId":"m1","openConversationId":"cid-target"}], + "hasMore": true, + "nextCursor": "c2" + } + }`} + payload, err := executeNativeScopedSearch(t, caller, + "message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", "--limit", "1") + if err != nil { + t.Fatal(err) + } + result, _ := payload["result"].(map[string]any) + if result["complete"] != false || result["hasMore"] != true || result["nextCursor"] != "c2" { + t.Fatalf("result = %#v", result) + } + }) + + t.Run("duplicate message ids are removed across pages", func(t *testing.T) { + caller := &chatMessageSearchCaller{searchResponses: []string{ + `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"}],"hasMore":true,"nextCursor":"c2"}}`, + `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"},{"openMessageId":"m2","openConversationId":"cid-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) + groups, _ := result["conversationMessagesList"].([]any) + group, _ := groups[0].(map[string]any) + messages, _ := group["messages"].([]any) + if len(messages) != 2 { + t.Fatalf("deduplicated messages = %#v", messages) + } + }) + + if got := uniqueNonEmptyStrings([]string{" cid ", "", "cid"}); !reflect.DeepEqual(got, []string{"cid"}) { + t.Fatalf("uniqueNonEmptyStrings = %#v", got) + } + for _, test := range []struct { + value any + want int + }{ + {value: int64(7), want: 7}, + {value: json.Number("8"), want: 8}, + {value: float64(9), want: 9}, + {value: int64(0), want: 11}, + } { + if got := positiveSearchLimit(test.value, 11); got != test.want { + t.Errorf("positiveSearchLimit(%#v) = %d, want %d", test.value, got, test.want) + } + } + if cleanSearchCursor(nil) != "" || cleanSearchCursor(" null ") != "" || cleanSearchCursor(" c2 ") != "c2" { + t.Fatal("cleanSearchCursor did not normalize sentinel values") + } +} + +func TestCrossPlatformCoverageNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(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, "") + AddPagedMCPFlags(cmd) + for name, value := range map[string]string{ + "page-all": "true", + "page-limit": "7", + "max-items": "9", + "page-delay": "11", + } { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + 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) + } + if searchStage["pageAll"] != true || + searchStage["pageLimit"] != float64(7) || + searchStage["maxItems"] != float64(9) || + searchStage["pageDelay"] != float64(11) { + t.Fatalf("dry-run paging = %#v", searchStage) + } +} + type chatChangedContractCaller struct { calls []chatMessageSearchCall resolveUsers bool @@ -160,9 +701,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, InitDeps(caller) deps.Out.w = io.Discard cmd := newChatCommand() + if cmd.PersistentFlags().Lookup("yes") == nil { + cmd.PersistentFlags().Bool("yes", false, "skip confirmation") + } cmd.SilenceErrors = true cmd.SilenceUsage = true - cmd.SetArgs(args) + cmd.SetArgs(append(append([]string(nil), args...), "--yes")) return cmd.Execute() } diff --git a/internal/helpers/chat_search_scope_error.go b/internal/helpers/chat_search_scope_error.go new file mode 100644 index 00000000..9dc09ec1 --- /dev/null +++ b/internal/helpers/chat_search_scope_error.go @@ -0,0 +1,123 @@ +// 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 ( + "encoding/json" + "errors" + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// NormalizeSearchConversationScopeError maps only errors that prove the +// requested conversation does not exist or that openConversationId itself is +// invalid. Unknown MCP tool failures must remain unchanged: the legacy +// CodeMCPToolError bucket also carries permission, throttling, and transient +// backend failures, none of which proves that the caller supplied a bad CID. +func NormalizeSearchConversationScopeError(conversationID string, err error) error { + if err == nil || !isDefinitiveInvalidSearchConversationError(err) { + return err + } + return apperrors.NewValidation( + fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID), + apperrors.WithReason("search_conversation_scope_invalid"), + apperrors.WithDetails(map[string]any{"conversationId": conversationID}), + apperrors.WithRetryable(false), + apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"), + apperrors.WithCause(err), + ) +} + +func isDefinitiveInvalidSearchConversationError(err error) bool { + var cliErr *CLIError + if errors.As(err, &cliErr) { + switch cliErr.Code { + case CodeResourceNotFound, CodeInvalidParam: + return true + } + code, message := searchConversationErrorFacts(cliErr.Message) + if isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message) { + return true + } + } + + // Transport errors can survive below a legacy CLIError in the cause chain. + // Inspect their structured diagnostics, but require conversation-specific + // evidence before treating a generic PARAM_ERROR as an invalid CID. + var appErr *apperrors.Error + if errors.As(err, &appErr) { + code := strings.TrimSpace(appErr.ServerDiag.ServerErrorCode) + message := strings.Join([]string{ + appErr.Message, + appErr.ServerDiag.TechnicalDetail, + appErr.Reason, + appErr.FailureStage, + }, " ") + return isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message) + } + return false +} + +func searchConversationErrorFacts(raw string) (string, string) { + var body map[string]any + if json.Unmarshal([]byte(raw), &body) != nil { + return "", raw + } + code := firstSearchConversationErrorString(body, "errorCode", "error_code", "code") + message := firstSearchConversationErrorString(body, "errorMsg", "error_msg", "message", "error") + return code, message +} + +func firstSearchConversationErrorString(body map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := body[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func isExplicitInvalidConversationCode(code string) bool { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "INVALID_OPEN_CONVERSATION_ID", "OPEN_CONVERSATION_NOT_FOUND", "CONVERSATION_NOT_FOUND": + return true + default: + return false + } +} + +func isConversationParameterError(code, message string) bool { + normalizedCode := strings.ToUpper(strings.TrimSpace(code)) + if normalizedCode != "PARAM_ERROR" && normalizedCode != "PARAMETER_ERROR" && normalizedCode != "INVALID_ARGUMENT" { + return false + } + normalizedMessage := strings.ToLower(strings.TrimSpace(message)) + mentionsConversationID := strings.Contains(normalizedMessage, "openconversationid") || + strings.Contains(normalizedMessage, "open conversation id") || + strings.Contains(normalizedMessage, "conversation id") || + strings.Contains(normalizedMessage, "cid") + if !mentionsConversationID { + return false + } + for _, marker := range []string{ + "invalid", "illegal", "malformed", "required", "missing", "not found", "不存在", "无效", "非法", "缺少", "必填", + } { + if strings.Contains(normalizedMessage, marker) { + return true + } + } + return false +} diff --git a/internal/helpers/chat_search_scope_error_test.go b/internal/helpers/chat_search_scope_error_test.go new file mode 100644 index 00000000..e8942f6b --- /dev/null +++ b/internal/helpers/chat_search_scope_error_test.go @@ -0,0 +1,123 @@ +// 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 TestCrossPlatformCoverageNormalizeSearchConversationScopeError(t *testing.T) { + if got := NormalizeSearchConversationScopeError("cid", nil); got != nil { + t.Fatalf("nil error normalized to %#v", got) + } + + invalidCases := []struct { + name string + err error + }{ + { + name: "classified resource not found", + err: &CLIError{Code: CodeResourceNotFound, Message: "conversation not found"}, + }, + { + name: "explicit conversation error code", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"INVALID_OPEN_CONVERSATION_ID","errorMsg":"invalid conversation"}`, + }, + }, + { + name: "conversation specific parameter error", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId is invalid"}`, + }, + }, + { + name: "transport diagnostic proves invalid conversation", + err: apperrors.NewAPI( + "conversation validation failed", + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "INVALID_OPEN_CONVERSATION_ID", + }), + ), + }, + } + for _, test := range invalidCases { + t.Run(test.name, func(t *testing.T) { + got := NormalizeSearchConversationScopeError("cid-invalid", test.err) + var typed *apperrors.Error + if !errors.As(got, &typed) || typed.Reason != "search_conversation_scope_invalid" { + t.Fatalf("normalized error = %#v", got) + } + if !typed.RetryableSet || typed.Retryable { + t.Fatalf("retryable = (%t, set=%t), want false and set", typed.Retryable, typed.RetryableSet) + } + if typed.Details["conversationId"] != "cid-invalid" || !errors.Is(got, test.err) { + t.Fatalf("normalized error lost details or cause: %#v", typed) + } + }) + } + + preservedCases := []struct { + name string + err error + }{ + { + name: "rate limit", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down","retryable":true}`, + }, + }, + { + name: "permission denied", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`, + }, + }, + { + name: "generic parameter error without CID evidence", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"未找到指定工具"}`, + }, + }, + { + name: "structured error without recognized facts", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"retryable":true}`, + }, + }, + { + name: "parameter error mentions CID without invalid evidence", + err: &CLIError{ + Code: CodeMCPToolError, + Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId could not be processed"}`, + }, + }, + } + for _, test := range preservedCases { + t.Run(test.name, func(t *testing.T) { + if got := NormalizeSearchConversationScopeError("cid-target", test.err); got != test.err { + t.Fatalf("error = %#v, want original %#v", got, test.err) + } + }) + } +} diff --git a/internal/helpers/chat_user_settings_test.go b/internal/helpers/chat_user_settings_test.go index 69107d78..cab0db84 100644 --- a/internal/helpers/chat_user_settings_test.go +++ b/internal/helpers/chat_user_settings_test.go @@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) { caller = &guardedMutationCaller{} err = executeGuardedMutationCommand(t, caller, newChatCommand, "message", "send", "--group", "cid1", "--msg-type", "location", - "--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1") + "--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes") if err != nil { t.Fatal(err) } @@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) { caller = &guardedMutationCaller{} err = executeGuardedMutationCommand(t, caller, newChatCommand, - "message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123") + "message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes") if err != nil { t.Fatal(err) } diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 1dcef8bd..abd78a70 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -1893,7 +1893,9 @@ func devAppCommandResult(result executor.Result) output.CommandResult { } else if meta != nil { successOptions = append(successOptions, output.WithMeta(meta)) return output.Success(devAppDataWithoutPagination(data), successOptions...) - } else if devAppToolRequiresPagination(result.Invocation.Tool) { + } else if devAppToolRequiresPagination(result.Invocation.Tool) && !result.Invocation.DryRun { + // A dry-run payload is a completed local invocation preview, not a + // server list response. Only real responses must prove pagination. return output.Failure(&output.ErrorInfo{ Type: "api", Subtype: "pagination_inconsistent", diff --git a/internal/helpers/devapp_shared_result_test.go b/internal/helpers/devapp_shared_result_test.go index cdc93f77..00f15216 100644 --- a/internal/helpers/devapp_shared_result_test.go +++ b/internal/helpers/devapp_shared_result_test.go @@ -148,6 +148,19 @@ func TestDevAppSharedResultMapperClassifiesServiceOutcomes(t *testing.T) { t.Fatalf("missing pagination envelope=%+v", env) } }) + + t.Run("dry run preview needs no server pagination "+tool, func(t *testing.T) { + result := DevAppCommandResultFromPayload(tool, map[string]any{ + "invocation": map[string]any{"tool": tool}, + }, true) + env, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if env.Outcome != output.OutcomeSuccess || !env.DryRun || env.Meta != nil { + t.Fatalf("dry-run preview envelope=%+v", env) + } + }) } t.Run("partial", func(t *testing.T) { diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 5a1f7576..b74761cd 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -15,6 +15,7 @@ package chat import ( "encoding/json" + "errors" "fmt" "math" "path/filepath" @@ -1081,6 +1082,10 @@ func DownloadMessageResources( continue } preferredName := resourceDownloadPreferredName(data) + if preferredName == "" { + preferredName, _ = resource["name"].(string) + preferredName = strings.TrimSpace(preferredName) + } filename := resourceDownloadFilename(resourceURL, preferredName) filename = disambiguateResourceDownloadFilename(filename, downloadedNames) output := filepath.Join(outputDir, filename) @@ -1203,9 +1208,10 @@ func uniqueShortcutStrings(values []string) []string { var MessagesQuerySendStatus = shortcut.Shortcut{ Service: "chat", Command: "+messages-query-send-status", + Aliases: []string{"+messages-send-status"}, Product: "im", - Description: "查询消息发送状态", - Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。", + Description: "查询消息投递状态并衔接后续消息操作", + Intent: "当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -1218,17 +1224,18 @@ var MessagesQuerySendStatus = shortcut.Shortcut{ CanonicalPath: "chat.shortcut_messages_query_send_status", CLIPath: "chat +messages-query-send-status", PrimaryCLIPath: "chat +messages-query-send-status", + Aliases: []string{"chat +messages-send-status"}, }, - Description: "查询消息发送状态", + Description: "查询消息投递状态并衔接后续消息操作", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "查询消息发送状态", - UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AgentSummary: "查询消息投递状态并衔接后续消息操作", + UseWhen: []string{"当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。"}, + AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"}, Examples: []string{"dws chat +messages-query-send-status --open-task-id "}, }, }, @@ -1237,7 +1244,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{ }, Tips: []string{`dws chat +messages-query-send-status --open-task-id `}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")}) + taskID := rt.Str("open-task-id") + data, err := rt.CallMCPData("im", "query_message_send_status", map[string]any{"openTaskId": taskID}) + if err != nil { + return err + } + return rt.Output(chatmsg.ProjectMessageSendStatus(data, taskID)) }, } @@ -1541,7 +1553,28 @@ var MessagesSendCard = shortcut.Shortcut{ } content := rt.Str("content") if content == "" { - return rt.CallMCP("create_and_send_card", params) + if rt.DryRun() { + return rt.Output(map[string]any{ + "contractVersion": chatmsg.StreamingCardContractVersion, + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "actionCount": 1, + "actions": []map[string]any{{ + "tool": "create_and_send_card", + "arguments": params, + }}, + }) + } + created, err := rt.CallMCPWriteData("im", "create_and_send_card", params) + if err != nil { + return err + } + bizID := findCardBizID(created) + if bizID == "" { + return cardCreateMissingBizIDError(created) + } + return rt.Output(chatmsg.ProjectStreamingCardReceipt(created, bizID)) } status := rt.Int("flow-status") if rt.DryRun() { @@ -1592,14 +1625,14 @@ var MessagesSendCard = shortcut.Shortcut{ if err != nil { return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err) } - return rt.Output(map[string]any{ - "contractVersion": currentCardWorkflowContract.Version, - "ok": true, - "bizId": bizID, - "flowStatus": status, - "created": created, - "updated": updated, - }) + if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil { + return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err)) + } + payload := chatmsg.ProjectStreamingCardReceipt(created, bizID) + payload["bizId"] = bizID + payload["flowStatus"] = status + payload["updated"] = updated + return rt.Output(payload) }, } @@ -1694,7 +1727,7 @@ var MessagesUpdateCard = shortcut.Shortcut{ Selection: contract.SelectionSpec{ AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)", UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AvoidWhen: []string{"需要底层原始响应、未公开参数,或由调用方自行管理确认与更新节奏时,改用 chat message update-card"}, Examples: []string{"dws chat +messages-update-card --biz-id --content \"内容\" --flow-status 3"}, }, }, @@ -1708,20 +1741,91 @@ var MessagesUpdateCard = shortcut.Shortcut{ }, Tips: []string{`dws chat +messages-update-card --biz-id --content "内容" --flow-status 3`}, Validate: func(rt *shortcut.RuntimeContext) error { + if _, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")); err != nil { + return err + } if !validCardFlowStatus(rt.Int("flow-status")) { return fmt.Errorf("--flow-status 必须在 1-5 之间") } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("update_streaming_card", map[string]any{ - "bizId": rt.Str("biz-id"), + // Validate has already normalized and rejected empty, placeholder, and + // whitespace-containing values before Execute is entered. + bizID, _ := chatmsg.NormalizeCardBizID(rt.Str("biz-id")) + 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 + } + proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated) + if err != nil { + return cardUpdateVerificationError(bizID, err) + } + return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof)) }, } +func cardCreateMissingBizIDError(created map[string]any) error { + return apperrors.NewAPI( + "卡片可能已经创建,但服务端未返回后续更新所需的 bizId;CLI 无法确认卡片工作流可继续", + apperrors.WithOperation("create_and_send_card"), + apperrors.WithServerKey("im"), + apperrors.WithOrigin("client_postcondition"), + apperrors.WithFailureStage("verify_card_reference"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason("streaming_card_reference_missing"), + apperrors.WithHint("不要盲目重试创建;请保留 trace_id 并推动服务端返回 bizId、openMessageId 和 openConversationId"), + apperrors.WithDetails(map[string]any{"created": created}), + ) +} + +func cardUpdateVerificationError(bizID string, verifyErr error) error { + reason := "streaming_card_update_unverified" + message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败" + hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果" + switch { + case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied): + reason = "streaming_card_update_not_applied" + message = "服务端明确表示流式卡片没有被更新" + hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换" + case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift): + reason = "streaming_card_update_biz_id_mismatch" + message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新" + hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射" + } + return apperrors.NewAPI( + message, + apperrors.WithOperation("update_streaming_card"), + apperrors.WithServerKey("im"), + apperrors.WithOrigin("client_postcondition"), + apperrors.WithFailureStage("verify_update_result"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + apperrors.WithHint(hint), + apperrors.WithDetails(map[string]any{"bizId": bizID}), + apperrors.WithCause(verifyErr), + ) +} + // MessagesResourceURL gets a message resource download url (get_resource_download_url, im). var MessagesResourceURL = shortcut.Shortcut{ Service: "chat", diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 7edecd4e..98d5cb1a 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -360,7 +360,9 @@ func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) { }) } - helpers.InitDeps(&larkAlignmentCaller{}) + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"result":{"updated":true}}`, + }}) root := newPlatformCoverageRoot() root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"}) if err := root.Execute(); err != nil { diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index a118157a..e8040232 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -20,6 +20,7 @@ import ( 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" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) type chatOutputErrorWriter struct { @@ -124,6 +125,159 @@ func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeB } } +func TestCrossPlatformCoverageMessagesSendStatusAliasPublishesWorkflowReceipt(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/query_message_send_status": `{"result":{"status":"SUCCESS","openTaskId":"task-1","openMessageId":"msg-1","openConversationId":"cid-1"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+messages-send-status", "--open-task-id", "task-1"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "query_message_send_status" || fake.calls[0].args["openTaskId"] != "task-1" { + t.Fatalf("calls = %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["contractVersion"] != chatmsg.MessageSendStatusContractVersion || payload["readyForMessageActions"] != true { + t.Fatalf("payload = %#v", payload) + } + ref, _ := payload["messageRef"].(map[string]any) + if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" { + t.Fatalf("messageRef = %#v", ref) + } +} + +func TestCrossPlatformCoverageMessageWorkflowFailureAndPreviewBranches(t *testing.T) { + t.Run("send status lower error", func(t *testing.T) { + fake := &larkAlignmentCaller{failProductTool: "im/query_message_send_status"} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-query-send-status", "--open-task-id", "task-1"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("create only dry run", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-send-card", "--group", "cid", "--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["actionCount"] != float64(1) || payload["executed"] != false { + t.Fatalf("payload = %#v", payload) + } + }) + + for _, test := range []struct { + name string + fake *larkAlignmentCaller + wantError string + }{ + { + name: "create only lower error", + fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"}, + wantError: "fixture lower call failed", + }, + { + name: "create only missing biz id", + fake: &larkAlignmentCaller{responses: map[string]string{ + "im/create_and_send_card": `{"result":{"created":true}}`, + }}, + wantError: "未返回后续更新所需的 bizId", + }, + } { + t.Run(test.name, func(t *testing.T) { + helpers.InitDeps(test.fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-send-card", "--group", "cid", "--yes"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want substring %q", err, test.wantError) + } + }) + } + + t.Run("update card lower error", func(t *testing.T) { + fake := &larkAlignmentCaller{failProductTool: "im/update_streaming_card"} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "biz-1", "--content", "完成", "--flow-status", "3", "--yes", + }) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") { + t.Fatalf("error = %v", err) + } + }) + + createErr := cardCreateMissingBizIDError(map[string]any{"created": true}) + var typed *apperrors.Error + if !errors.As(createErr, &typed) || typed.Reason != "streaming_card_reference_missing" { + t.Fatalf("create error = %#v", createErr) + } + for _, test := range []struct { + cause error + wantReason string + }{ + {cause: chatmsg.ErrCardUpdateNotApplied, wantReason: "streaming_card_update_not_applied"}, + {cause: chatmsg.ErrCardUpdateBizIDDrift, wantReason: "streaming_card_update_biz_id_mismatch"}, + } { + typed = nil + err := cardUpdateVerificationError("biz-1", test.cause) + if !errors.As(err, &typed) || typed.Reason != test.wantReason { + t.Errorf("cardUpdateVerificationError(%v) = %#v", test.cause, err) + } + } +} + +func TestCrossPlatformCoverageMessagesSendPublishesStatusQueryReceipt(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "chat/send_personal_message": `{"result":{"openTaskId":"task-send-1"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "user", "--chat-id", "cid-1", + "--text", "hello", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + receipt, _ := payload["sendReceipt"].(map[string]any) + if receipt["contractVersion"] != chatmsg.MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-send-1" { + t.Fatalf("sendReceipt = %#v", receipt) + } + actions, _ := receipt["nextActions"].([]any) + if len(actions) != 1 { + t.Fatalf("nextActions = %#v", actions) + } +} + func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) @@ -895,6 +1049,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) @@ -929,6 +1091,120 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test } } +func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) { + t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetIn(strings.NewReader("")) + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "biz-confirm", + "--content", "高层更新", + "--flow-status", "3", + }) + err := root.Execute() + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "confirmation_required" { + t.Fatalf("error = %#v, want confirmation_required", err) + } + if len(fake.calls) != 0 { + t.Fatalf("unconfirmed shortcut reached MCP: %#v", fake.calls) + } + }) + + t.Run("generic success is unverified", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"success":true,"errorCode":null}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "中文乱串", + "--content", "完成", + "--flow-status", "3", + "--yes", + }) + err := root.Execute() + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" { + t.Fatalf("error = %#v, want streaming_card_update_unverified", err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + + t.Run("explicit update evidence succeeds", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/update_streaming_card": `{"result":{"bizId":"biz-verified","updated":true}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "biz-verified", + "--content", "完成", + "--flow-status", "3", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + + t.Run("placeholder fails before write", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "", + "--content", "完成", + "--flow-status", "3", + "--yes", + }) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "占位符") { + t.Fatalf("error = %v, want placeholder validation", err) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid placeholder made calls: %#v", fake.calls) + } + }) + + t.Run("dry run only publishes plan", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-update-card", + "--biz-id", "biz-preview", + "--content", "完成", + "--flow-status", "3", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Fatalf("dry-run made calls: %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["executed"] != false || payload["verified"] != false { + t.Fatalf("dry-run payload = %#v", payload) + } + }) +} + func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) { for _, tc := range []struct { value any @@ -1090,6 +1366,9 @@ func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesNa fake.calls[0].args["fileId"] != "drive-file" { t.Fatalf("drive call = %#v", fake.calls) } + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque"}}`, + }}) var ledger map[string]any shortcut.Register(shortcut.Shortcut{ diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index 91d91752..5dbf9f19 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -75,6 +75,8 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, text = string(payload) case "im/create_group_conversation": text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}` + case "im/create_and_send_card": + text = `{"result":{"bizId":"biz-created"}}` case "im/list_messages_by_ids": text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}` case "im/list_conversations_by_category": diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 6c52f203..9991d147 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -19,6 +19,7 @@ import ( 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" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) @@ -372,12 +373,16 @@ func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool strin if err != nil { return err } - return rt.Output(map[string]any{ + payload := map[string]any{ "ok": true, "identity": messagesSendIdentity(rt), "tool": tool, "result": data, - }) + } + if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" { + payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data) + } + return rt.Output(payload) } func messagesSendIdentity(rt *shortcut.RuntimeContext) string { @@ -612,7 +617,7 @@ func executeMessagesSendUserFile( if err != nil { return err } - return rt.Output(map[string]any{ + payload := map[string]any{ "ok": true, "identity": "user", "tool": "send_personal_message", @@ -624,7 +629,9 @@ func executeMessagesSendUserFile( "sizeBytes": meta.FileSize, }, "result": data, - }) + } + payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data) + return rt.Output(payload) } func addMessagesSendUserTarget(params map[string]any, group, openID string) { diff --git a/internal/shortcut/chatmsg/card_ref.go b/internal/shortcut/chatmsg/card_ref.go new file mode 100644 index 00000000..03dcbfba --- /dev/null +++ b/internal/shortcut/chatmsg/card_ref.go @@ -0,0 +1,65 @@ +// 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. + +package chatmsg + +import "strings" + +// StreamingCardContractVersion identifies the additive card receipt emitted by +// high-level card shortcuts. +const StreamingCardContractVersion = "im.streaming-card.v1" + +// ProjectStreamingCardReceipt publishes every server-returned identifier in a +// single cardRef. referencePairAvailable means this response contained both +// the update identifier and the visible message identifiers; it does not claim +// that older messages can be resolved without server-side mapping support. +func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[string]any { + bizID = strings.TrimSpace(bizID) + messageID := firstSendStatusString(created, "openMessageId", "messageId", "msgId") + conversationID := firstSendStatusString(created, "openConversationId", "conversationId", "openCid") + cardRef := map[string]any{} + if bizID != "" { + cardRef["bizId"] = bizID + } + if messageID != "" { + cardRef["openMessageId"] = messageID + } + if conversationID != "" { + cardRef["openConversationId"] = conversationID + } + pairAvailable := bizID != "" && messageID != "" && conversationID != "" + payload := map[string]any{ + "contractVersion": StreamingCardContractVersion, + "ok": true, + "cardRef": cardRef, + "referencePairAvailable": pairAvailable, + "created": created, + "nextActions": []map[string]any{}, + } + if bizID != "" { + payload["nextActions"] = []map[string]any{{ + "cliPath": "chat +messages-update-card", + "arguments": map[string]any{ + "biz-id": bizID, + }, + "requiredArguments": []string{"content", "flow-status"}, + "ready": false, + }} + } + if !pairAvailable { + payload["capabilityGap"] = "服务端尚未同时返回 bizId、openMessageId 和 openConversationId;CLI 只能保留本次响应,不能据此承诺从历史消息反向恢复 bizId" + } + return payload +} + +// ProjectStreamingCardUpdate preserves the lower response while making the +// verified target explicit for downstream consumers. +func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any { + payload := cloneSendStatusMap(updated) + payload["contractVersion"] = StreamingCardContractVersion + payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)} + payload["verified"] = true + payload["verificationEvidence"] = proof + return payload +} diff --git a/internal/shortcut/chatmsg/card_ref_test.go b/internal/shortcut/chatmsg/card_ref_test.go new file mode 100644 index 00000000..baa65687 --- /dev/null +++ b/internal/shortcut/chatmsg/card_ref_test.go @@ -0,0 +1,42 @@ +// 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. + +package chatmsg + +import "testing" + +func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) { + complete := ProjectStreamingCardReceipt(map[string]any{ + "result": map[string]any{ + "bizId": "biz-1", + "openMessageId": "msg-1", + "openConversationId": "cid-1", + }, + }, "biz-1") + if complete["contractVersion"] != StreamingCardContractVersion || complete["referencePairAvailable"] != true { + t.Fatalf("complete receipt = %#v", complete) + } + ref, _ := complete["cardRef"].(map[string]any) + if ref["bizId"] != "biz-1" || ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" { + t.Fatalf("cardRef = %#v", ref) + } + if _, exists := complete["capabilityGap"]; exists { + t.Fatalf("complete receipt has capability gap: %#v", complete) + } + + partial := ProjectStreamingCardReceipt(map[string]any{"result": map[string]any{"bizId": "biz-2"}}, "biz-2") + if partial["referencePairAvailable"] != false || partial["capabilityGap"] == "" { + t.Fatalf("partial receipt = %#v", partial) + } +} + +func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) { + payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true") + if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" { + t.Fatalf("payload = %#v", payload) + } + if _, exists := payload["result"]; !exists { + t.Fatal("lower response was not preserved") + } +} diff --git a/internal/shortcut/chatmsg/card_update.go b/internal/shortcut/chatmsg/card_update.go new file mode 100644 index 00000000..5649c772 --- /dev/null +++ b/internal/shortcut/chatmsg/card_update.go @@ -0,0 +1,176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "unicode" +) + +var ( + ErrCardUpdateNotApplied = errors.New("streaming card update was not applied") + ErrCardUpdateUnverified = errors.New("streaming card update could not be verified") + ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId") +) + +// NormalizeCardBizID performs only format-independent checks. bizId is an +// opaque server-issued identifier; a stricter character or prefix contract +// must not be invented by the CLI without an authoritative API declaration. +func NormalizeCardBizID(raw string) (string, error) { + bizID := strings.TrimSpace(raw) + if bizID == "" { + return "", fmt.Errorf("--biz-id 不能为空") + } + for _, r := range bizID { + if unicode.IsControl(r) || unicode.IsSpace(r) { + return "", fmt.Errorf("--biz-id 必须是 send-card 返回的单个不透明 ID,不能包含空白或控制字符") + } + } + if isCardBizIDPlaceholder(bizID) { + return "", fmt.Errorf("--biz-id 仍是占位符 %q;请传入 send-card 实际返回的 bizId", bizID) + } + return bizID, nil +} + +func isCardBizIDPlaceholder(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "bizid", "biz-id", "your-biz-id", "your_biz_id", + "", "", "", + "{bizid}", "{biz-id}", "${bizid}", "${biz-id}": + return true + default: + return false + } +} + +// VerifyStreamingCardUpdate requires affirmative evidence that the requested +// write took effect. A transport-level {success:true,errorCode:null} only says +// that the RPC returned normally and is deliberately not accepted as proof of +// a card update. +func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) { + requestedBizID = strings.TrimSpace(requestedBizID) + observation := cardUpdateObservation{bizIDs: map[string]struct{}{}} + observeCardUpdate(response, &observation) + + for responseBizID := range observation.bizIDs { + if requestedBizID != "" && responseBizID != requestedBizID { + return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID) + } + } + if observation.positiveEvidence != "" && observation.negativeEvidence != "" { + return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence) + } + if observation.positiveEvidence != "" { + return observation.positiveEvidence, nil + } + if observation.negativeEvidence != "" { + return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence) + } + return "", ErrCardUpdateUnverified +} + +type cardUpdateObservation struct { + bizIDs map[string]struct{} + positiveEvidence string + negativeEvidence string +} + +func observeCardUpdate(value any, observation *cardUpdateObservation) { + switch typed := value.(type) { + case map[string]any: + observeCardUpdateMap(typed, observation) + case []any: + for _, child := range typed { + observeCardUpdate(child, observation) + } + case bool: + if typed { + setPositiveCardUpdateEvidence(observation, "result=true") + } else { + setNegativeCardUpdateEvidence(observation, "result=false") + } + } +} + +func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) { + for _, key := range []string{"bizId", "bizID", "biz_id"} { + if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" { + observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{} + } + } + + for _, key := range []string{"updated", "applied"} { + if applied, ok := value[key].(bool); ok { + if applied { + setPositiveCardUpdateEvidence(observation, key+"=true") + } else { + setNegativeCardUpdateEvidence(observation, key+"=false") + } + } + } + for _, key := range []string{"affectedCount", "updatedCount", "modifiedCount"} { + if count, ok := cardUpdateCount(value[key]); ok { + if count > 0 { + setPositiveCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count)) + } else if count == 0 { + setNegativeCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count)) + } + } + } + + // Only documented response envelopes are traversed. This prevents an + // unrelated extension field containing "updated":true from proving the + // business write. + for _, key := range []string{"result", "data", "response", "card"} { + if child, exists := value[key]; exists { + observeCardUpdate(child, observation) + } + } +} + +func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) { + if observation.positiveEvidence == "" { + observation.positiveEvidence = evidence + } +} + +func setNegativeCardUpdateEvidence(observation *cardUpdateObservation, evidence string) { + if observation.negativeEvidence == "" { + observation.negativeEvidence = evidence + } +} + +func cardUpdateCount(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case float32: + return int64(typed), float32(int64(typed)) == typed + case float64: + return int64(typed), float64(int64(typed)) == typed + case json.Number: + count, err := typed.Int64() + return count, err == nil + default: + return 0, false + } +} diff --git a/internal/shortcut/chatmsg/card_update_test.go b/internal/shortcut/chatmsg/card_update_test.go new file mode 100644 index 00000000..27f48fb5 --- /dev/null +++ b/internal/shortcut/chatmsg/card_update_test.go @@ -0,0 +1,107 @@ +// 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" + "testing" +) + +func TestCrossPlatformCoverageNormalizeCardBizID(t *testing.T) { + for _, test := range []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "opaque id", raw: " card-token-1 ", want: "card-token-1"}, + {name: "opaque unicode remains server owned", raw: "中文乱串", want: "中文乱串"}, + {name: "empty", raw: " ", wantErr: true}, + {name: "placeholder", raw: "", wantErr: true}, + {name: "internal space", raw: "card token", wantErr: true}, + {name: "control", raw: "card\ntoken", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := NormalizeCardBizID(test.raw) + if test.wantErr { + if err == nil { + t.Fatalf("NormalizeCardBizID(%q) unexpectedly succeeded", test.raw) + } + return + } + if err != nil || got != test.want { + t.Fatalf("NormalizeCardBizID(%q) = %q, %v; want %q", test.raw, got, err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) { + for _, test := range []struct { + name string + response map[string]any + wantProof string + wantErrIs error + }{ + {name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"}, + {name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"}, + {name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"}, + {name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied}, + {name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"}, + {name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified}, + {name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied}, + {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) + } + }) + } +} + +func TestCrossPlatformCoverageCardUpdateCountScalarVariants(t *testing.T) { + for _, test := range []struct { + value any + want int64 + ok bool + }{ + {value: int(1), want: 1, ok: true}, + {value: int32(2), want: 2, ok: true}, + {value: int64(3), want: 3, ok: true}, + {value: float32(4), want: 4, ok: true}, + {value: float32(4.5), want: 4, ok: false}, + {value: float64(5), want: 5, ok: true}, + {value: float64(5.5), want: 5, ok: false}, + {value: json.Number("6"), want: 6, ok: true}, + {value: json.Number("6.5"), ok: false}, + {value: "7", ok: false}, + } { + got, ok := cardUpdateCount(test.value) + if got != test.want || ok != test.ok { + t.Errorf("cardUpdateCount(%#v) = (%d, %v), want (%d, %v)", test.value, got, ok, test.want, test.ok) + } + } +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index ed186909..e90a547e 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -91,6 +91,7 @@ var messageResultContractV1 = MessageResultContract{ "failedCount", "failures", "partial", + "scope", "resourceDownloads", }, } @@ -283,9 +284,10 @@ func SenderType(m map[string]any) any { // legacy aliases such as time or msgType, but the underlying identity, // context, reaction, quote, forward, and resource semantics come from here. func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any { + ownedResources := Resources(m) row := map[string]any{ "sender": Sender(m), - "text": Text(m), + "text": projectedResourceText(m, ownedResources), "createTime": CreateTime(m), } if value := MessageID(m); value != nil { @@ -357,7 +359,8 @@ func QuotedMessage(m map[string]any) map[string]any { if value := Sender(quoted); value != nil { out["sender"] = value } - if value := Text(quoted); value != nil { + resources := Resources(quoted) + if value := projectedResourceText(quoted, resources); value != nil { out["text"] = value } if value := CreateTime(quoted); value != nil { @@ -366,7 +369,7 @@ func QuotedMessage(m map[string]any) map[string]any { if value := MessageType(quoted); value != nil { out["messageType"] = value } - if resources := Resources(quoted); len(resources) > 0 { + if len(resources) > 0 { out["resourceRefs"] = resources } return out @@ -400,10 +403,14 @@ func Resources(m map[string]any) []map[string]any { collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs) mediaIDs = uniqueResourceIDs(mediaIDs) sort.Strings(mediaIDs) + mediaNames := make(map[string]resourceNameCandidate) + collectResourceNames(m, "mediaid", mediaNames) fileIDs := make([]string, 0) collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs) fileIDs = uniqueResourceIDs(fileIDs) sort.Strings(fileIDs) + fileNames := make(map[string]resourceNameCandidate) + collectResourceNames(m, "fileid", fileNames) if len(mediaIDs) == 0 && len(fileIDs) == 0 { return nil } @@ -434,7 +441,7 @@ func Resources(m map[string]any) []map[string]any { } else { missing = append(missing, "open-conversation-id") } - out = append(out, map[string]any{ + resource := map[string]any{ "type": "mediaId", "resourceId": id, "download": map[string]any{ @@ -443,10 +450,14 @@ func Resources(m map[string]any) []map[string]any { "ready": len(missing) == 0, "missing": missing, }, - }) + } + if candidate, ok := mediaNames[id]; ok { + resource["name"] = candidate.name + } + out = append(out, resource) } for _, id := range fileIDs { - out = append(out, map[string]any{ + resource := map[string]any{ "type": "fileId", "resourceId": id, "download": map[string]any{ @@ -458,7 +469,11 @@ func Resources(m map[string]any) []map[string]any { "ready": true, "missing": []string{}, }, - }) + } + if candidate, ok := fileNames[id]; ok { + resource["name"] = candidate.name + } + out = append(out, resource) } return out } @@ -503,6 +518,158 @@ func resourcesDeep(m map[string]any, inheritedConversationID string, depth int) var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`) var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`) +var fileNameAndIDTextRE = regexp.MustCompile(`(?i)\[文件\]\s*([^\r\n]*?)\s+file[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`) +var legacyResourceDownloadHintRE = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`) + +// projectedResourceText removes only the exact, machine-generated download +// hint emitted by older IM APIs. The readable resource marker and ID remain in +// text, while resourceRefs publishes the current executable download command. +// Text without an owned mediaId/fileId is left byte-for-byte unchanged so an +// ordinary user sentence mentioning a command can never be rewritten. +func projectedResourceText(m map[string]any, resources []map[string]any) any { + value := Text(m) + text, ok := value.(string) + if !ok || len(resources) == 0 || + (!mediaIDTextRE.MatchString(text) && !fileIDTextRE.MatchString(text)) { + return value + } + return strings.TrimSpace(legacyResourceDownloadHintRE.ReplaceAllString(text, "")) +} + +type resourceNameCandidate struct { + name string + priority int +} + +const ( + resourceNamePriorityText = 1 + resourceNamePriorityStructured = 2 +) + +// collectResourceNames keeps a resource ID paired with a name only when both +// are present in the same structured object or in the legacy, machine-shaped +// "[文件] name fileId: id" text. This deliberately does not borrow a generic +// message title or sender name: an unknown resource name is safer than a +// plausible but incorrect one. +func collectResourceNames(value any, targetKey string, out map[string]resourceNameCandidate) { + switch typed := value.(type) { + case map[string]any: + directIDs := directResourceIDs(typed, targetKey) + if len(directIDs) == 1 { + if name := directResourceName(typed, targetKey); name != "" { + recordResourceName(out, directIDs[0], name, resourceNamePriorityStructured) + } + } + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if isNestedMessageBoundaryKey(normalizeMessageKey(key)) { + continue + } + collectResourceNames(typed[key], targetKey, out) + } + case []any: + for _, child := range typed { + collectResourceNames(child, targetKey, out) + } + case []map[string]any: + for _, child := range typed { + collectResourceNames(child, targetKey, out) + } + case string: + if targetKey == "fileid" { + for _, match := range fileNameAndIDTextRE.FindAllStringSubmatch(typed, -1) { + name := strings.TrimSpace(match[1]) + id := resourceIDScalar(match[2]) + if name != "" && id != "" { + recordResourceName(out, id, name, resourceNamePriorityText) + } + } + } + trimmed := strings.TrimSpace(typed) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + var decoded any + if json.Unmarshal([]byte(trimmed), &decoded) == nil { + collectResourceNames(decoded, targetKey, out) + } + } + } +} + +func directResourceIDs(value map[string]any, targetKey string) []string { + resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint( + firstMessageValue(value, "resourceType", "resource_type")))) + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + ids := make([]string, 0, 1) + for _, key := range keys { + normalizedKey := normalizeMessageKey(key) + if normalizedKey != targetKey && + !(normalizedKey == "resourceid" && resourceType == targetKey) { + continue + } + if id := resourceIDScalar(value[key]); id != "" { + ids = append(ids, id) + } + } + return uniqueResourceIDs(ids) +} + +func directResourceName(value map[string]any, targetKey string) string { + for _, wanted := range []string{"filename", "resourcename", "originalfilename"} { + if name := directResourceString(value, wanted); name != "" { + return name + } + } + // A bare "name" is accepted only inside an explicit resource envelope. + // Message rows also commonly contain a sender/group name, which must never + // become the attachment filename merely because the row has a resource ID. + resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint( + firstMessageValue(value, "resourceType", "resource_type")))) + if resourceType == targetKey && directResourceString(value, "resourceid") != "" { + return directResourceString(value, "name") + } + return "" +} + +func directResourceString(value map[string]any, wanted string) string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if normalizeMessageKey(key) != wanted { + continue + } + if text, ok := value[key].(string); ok { + return strings.TrimSpace(text) + } + } + return "" +} + +func recordResourceName( + out map[string]resourceNameCandidate, + id, name string, + priority int, +) { + id = resourceIDScalar(id) + name = strings.TrimSpace(name) + if id == "" || name == "" { + return + } + if current, ok := out[id]; ok && current.priority >= priority { + return + } + out[id] = resourceNameCandidate{name: name, priority: priority} +} func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) { switch typed := value.(type) { diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go index c9f7f244..36826e94 100644 --- a/internal/shortcut/chatmsg/chatmsg_test.go +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -540,6 +540,28 @@ func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *tes } } +func TestCrossPlatformCoverageResourcesKeepsNestedResourceNamesWithTheirOwner(t *testing.T) { + message := map[string]any{ + "openMessageId": "parent-message", + "openConversationId": "cid-1", + "content": `{"fileId":"parent-file","fileName":"parent.pdf"}`, + "quotedMessage": map[string]any{ + "openMessageId": "quoted-message", + "content": `{"fileId":"quoted-file","file_name":"quoted.pdf"}`, + }, + } + resources := ResourcesDeep(message) + if len(resources) != 2 { + t.Fatalf("resources = %#v", resources) + } + if resources[0]["resourceId"] != "parent-file" || resources[0]["name"] != "parent.pdf" { + t.Fatalf("parent resource = %#v", resources[0]) + } + if resources[1]["resourceId"] != "quoted-file" || resources[1]["name"] != "quoted.pdf" { + t.Fatalf("quoted resource = %#v", resources[1]) + } +} + func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) { resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`}) if len(resources) != 1 { diff --git a/internal/shortcut/chatmsg/coverage_completion_test.go b/internal/shortcut/chatmsg/coverage_completion_test.go index ef8c8742..9227a460 100644 --- a/internal/shortcut/chatmsg/coverage_completion_test.go +++ b/internal/shortcut/chatmsg/coverage_completion_test.go @@ -28,16 +28,18 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) { } resources := Resources(map[string]any{ "attachments": []map[string]any{ - {"resourceType": "mediaId", "resourceId": "@file-a"}, - {"resourceType": "fileId", "resourceId": "drive-file"}, + {"resourceType": "mediaId", "resourceId": "@file-a", "name": "photo.png"}, + {"resourceType": "fileId", "resourceId": "drive-file", "fileName": "canonical-report.txt"}, {"mediaId": 42, "fileId": 42}, }, "content": `[文件] report.txt fileId: drive-file`, }) if len(resources) != 2 || resources[0]["resourceId"] != "@file-a" || + resources[0]["name"] != "photo.png" || resources[1]["resourceId"] != "drive-file" || - resources[1]["type"] != "fileId" { + resources[1]["type"] != "fileId" || + resources[1]["name"] != "canonical-report.txt" { t.Fatalf("resources = %#v", resources) } fileDownload := resources[1]["download"].(map[string]any) @@ -53,6 +55,65 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) { } } +func TestCrossPlatformCoverageResourcesExtractsLegacyFileNameWithoutGuessing(t *testing.T) { + resources := Resources(map[string]any{ + "name": "sender-name-must-not-leak", + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "content": `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用旧命令`, + "unrelatedAttachment": map[string]any{"mediaId": "@image-without-name"}, + }) + if len(resources) != 2 { + t.Fatalf("resources = %#v", resources) + } + if resources[0]["resourceId"] != "@image-without-name" { + t.Fatalf("first resource = %#v", resources[0]) + } + if _, leaked := resources[0]["name"]; leaked { + t.Fatalf("message sender name leaked into media resource: %#v", resources[0]) + } + if resources[1]["resourceId"] != "drive-file" || + resources[1]["name"] != "项目最终报告 2026.pdf" { + t.Fatalf("file resource = %#v", resources[1]) + } +} + +func TestCrossPlatformCoverageResourceNameRejectsIncompletePairs(t *testing.T) { + names := map[string]resourceNameCandidate{} + recordResourceName(names, "", "report.pdf", resourceNamePriorityStructured) + recordResourceName(names, "file-1", "", resourceNamePriorityStructured) + if len(names) != 0 { + t.Fatalf("incomplete resource-name pairs were retained: %#v", names) + } +} + +func TestCrossPlatformCoverageProjectionRemovesOnlyLegacyResourceDownloadHint(t *testing.T) { + legacy := `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用dws drive download命令下载` + row := ProjectMessageV1(map[string]any{"content": legacy}, false) + if row["text"] != `[文件] 项目最终报告 2026.pdf fileId: drive-file` { + t.Fatalf("projected text = %#v", row["text"]) + } + resources := row["resourceRefs"].([]map[string]any) + if len(resources) != 1 || + resources[0]["name"] != "项目最终报告 2026.pdf" || + resources[0]["download"].(map[string]any)["shortcut"] != "+messages-resource-download" { + t.Fatalf("projected resources = %#v", resources) + } + mediaRow := ProjectMessageV1(map[string]any{ + "openMessageId": "msg-media", + "openConversationId": "cid-media", + "content": `[图片消息](mediaId=@media) 注意:如需下载使用dws chat message download-media命令下载`, + }, false) + if mediaRow["text"] != `[图片消息](mediaId=@media)` { + t.Fatalf("projected media text = %#v", mediaRow["text"]) + } + + ordinary := `团队规范:注意:如需下载使用dws drive download命令下载` + if got := ProjectMessageV1(map[string]any{"content": ordinary}, false)["text"]; got != ordinary { + t.Fatalf("ordinary text was rewritten: got %#v, want %q", got, ordinary) + } +} + func TestCrossPlatformCoverageReactionShapeVariants(t *testing.T) { got := Reactions(map[string]any{ "reactions": []map[string]any{ diff --git a/internal/shortcut/chatmsg/search.go b/internal/shortcut/chatmsg/search.go new file mode 100644 index 00000000..a57de031 --- /dev/null +++ b/internal/shortcut/chatmsg/search.go @@ -0,0 +1,192 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "fmt" + "strings" +) + +// SearchItems locates and flattens the message list returned by the two +// DingTalk message-search interfaces. Grouped search responses carry the +// conversation identity on the group rather than each message, so the +// flattener copies that identity onto every returned message before callers +// perform scope checks. +func SearchItems(data map[string]any) []map[string]any { + if data == nil { + return nil + } + for _, root := range []map[string]any{data, childMap(data, "result")} { + if root == nil { + continue + } + if groups, ok := root["conversationMessagesList"].([]any); ok { + return flattenSearchGroups(groups) + } + } + keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"} + for _, key := range keys { + if arr, ok := data[key].([]any); ok { + return searchMaps(arr) + } + if inner, ok := data[key].(map[string]any); ok { + for _, innerKey := range []string{"list", "messages", "messageList", "items", "data", "records"} { + if arr, ok := inner[innerKey].([]any); ok { + return searchMaps(arr) + } + } + } + } + return nil +} + +func childMap(data map[string]any, key string) map[string]any { + if value, ok := data[key].(map[string]any); ok { + return value + } + return nil +} + +func flattenSearchGroups(groups []any) []map[string]any { + out := make([]map[string]any, 0) + for _, rawGroup := range groups { + group, ok := rawGroup.(map[string]any) + if !ok { + continue + } + messages, ok := group["messages"].([]any) + if !ok { + continue + } + conversationID := cleanSearchScalar(group["openConversationId"]) + conversationTitle := cleanSearchScalar(group["title"]) + singleChat, hasSingleChat := group["singleChat"] + for _, rawMessage := range messages { + message, ok := rawMessage.(map[string]any) + if !ok { + continue + } + item := make(map[string]any, len(message)+3) + for key, value := range message { + item[key] = value + } + if _, exists := item["openConversationId"]; !exists && conversationID != "" { + item["openConversationId"] = conversationID + } + if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" { + item["conversationTitle"] = conversationTitle + } + if _, exists := item["singleChat"]; !exists && hasSingleChat { + item["singleChat"] = singleChat + } + out = append(out, item) + } + } + return out +} + +func searchMaps(items []any) []map[string]any { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if message, ok := item.(map[string]any); ok { + out = append(out, message) + } + } + return out +} + +// FilterConversationScope keeps only messages belonging to the explicitly +// requested conversations. A message without a conversation identity is +// reported as unverifiable rather than treated as in-scope. +func FilterConversationScope(messages []map[string]any, conversationIDs []string) (matched []map[string]any, unverifiableMessageIDs []string) { + requested := make(map[string]struct{}, len(conversationIDs)) + for _, conversationID := range conversationIDs { + if value := strings.TrimSpace(conversationID); value != "" { + requested[value] = struct{}{} + } + } + matched = make([]map[string]any, 0, len(messages)) + for _, message := range messages { + conversationID := cleanSearchScalar(ConversationID(message)) + if conversationID == "" { + messageID := cleanSearchScalar(MessageID(message)) + if messageID == "" { + messageID = "" + } + unverifiableMessageIDs = append(unverifiableMessageIDs, messageID) + continue + } + if _, ok := requested[conversationID]; ok { + matched = append(matched, message) + } + } + return matched, uniqueStrings(unverifiableMessageIDs) +} + +// GroupSearchMessages restores the established typed search envelope after a +// client-side scoped scan. Group order follows first occurrence in the search +// result, and each message retains its original fields. +func GroupSearchMessages(messages []map[string]any) []map[string]any { + groups := make([]map[string]any, 0) + index := make(map[string]int) + for _, message := range messages { + conversationID := cleanSearchScalar(ConversationID(message)) + if conversationID == "" { + continue + } + groupIndex, ok := index[conversationID] + if !ok { + group := map[string]any{ + "openConversationId": conversationID, + "messages": []map[string]any{}, + } + if title := cleanSearchScalar(message["conversationTitle"]); title != "" { + group["title"] = title + } + if singleChat, exists := message["singleChat"]; exists { + group["singleChat"] = singleChat + } + groups = append(groups, group) + groupIndex = len(groups) - 1 + index[conversationID] = groupIndex + } + groupMessages := groups[groupIndex]["messages"].([]map[string]any) + groups[groupIndex]["messages"] = append(groupMessages, message) + } + return groups +} + +func cleanSearchScalar(value any) string { + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" || strings.EqualFold(text, "null") { + return "" + } + return text +} + +func uniqueStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/internal/shortcut/chatmsg/search_test.go b/internal/shortcut/chatmsg/search_test.go new file mode 100644 index 00000000..923fd56b --- /dev/null +++ b/internal/shortcut/chatmsg/search_test.go @@ -0,0 +1,104 @@ +// 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) + } +} + +func TestCrossPlatformCoverageSearchProjectionEdgeBranches(t *testing.T) { + if SearchItems(nil) != nil { + t.Fatal("nil search response returned messages") + } + + matched, missing := FilterConversationScope( + []map[string]any{{}}, + []string{"", "cid-target"}, + ) + if len(matched) != 0 || !reflect.DeepEqual(missing, []string{""}) { + t.Fatalf("scope result = matched:%#v missing:%#v", matched, missing) + } + + groups := GroupSearchMessages([]map[string]any{ + {"openMessageId": "missing-scope"}, + {"openMessageId": "m1", "openConversationId": "cid-1", "singleChat": true}, + }) + if len(groups) != 1 || groups[0]["singleChat"] != true { + t.Fatalf("groups = %#v", groups) + } + + if cleanSearchScalar(nil) != "" || cleanSearchScalar(" null ") != "" || cleanSearchScalar(" value ") != "value" { + t.Fatal("cleanSearchScalar did not normalize sentinel values") + } + if got := uniqueStrings([]string{"m1", "m1", "m2"}); !reflect.DeepEqual(got, []string{"m1", "m2"}) { + t.Fatalf("uniqueStrings = %#v", got) + } +} diff --git a/internal/shortcut/chatmsg/send_status.go b/internal/shortcut/chatmsg/send_status.go new file mode 100644 index 00000000..2c6aea5d --- /dev/null +++ b/internal/shortcut/chatmsg/send_status.go @@ -0,0 +1,162 @@ +// 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. + +package chatmsg + +import ( + "strings" +) + +// MessageSendStatusContractVersion identifies the additive workflow fields +// projected by the high-level send-status shortcut. The lower response fields +// remain at their original locations for compatibility. +const MessageSendStatusContractVersion = "im.message-send-status.v1" + +// MessageSendReceiptContractVersion identifies the additive receipt attached +// to high-level current-user send results. +const MessageSendReceiptContractVersion = "im.message-send-receipt.v1" + +// ProjectMessageSendReceipt connects a send result to its asynchronous status +// query without treating openTaskId as a message identifier. +func ProjectMessageSendReceipt(raw map[string]any) map[string]any { + taskID := firstSendStatusString(raw, "openTaskId", "taskId") + messageID := firstSendStatusString(raw, "openMessageId", "messageId", "msgId") + conversationID := firstSendStatusString(raw, "openConversationId", "conversationId", "openCid") + ready := messageID != "" && conversationID != "" + receipt := map[string]any{ + "contractVersion": MessageSendReceiptContractVersion, + "openTaskId": taskID, + "readyForMessageActions": ready, + "nextActions": []map[string]any{}, + } + if messageID != "" || conversationID != "" { + messageRef := map[string]any{} + if messageID != "" { + messageRef["openMessageId"] = messageID + } + if conversationID != "" { + messageRef["openConversationId"] = conversationID + } + receipt["messageRef"] = messageRef + } + switch { + case ready: + receipt["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, true) + case taskID != "": + receipt["nextActions"] = []map[string]any{{ + "cliPath": "chat +messages-query-send-status", + "arguments": map[string]any{ + "open-task-id": taskID, + }, + "ready": true, + "when": "需要确认投递结果或取得真实消息 ID 时", + }} + default: + receipt["capabilityGap"] = "下层发送响应未返回 openTaskId 或完整 messageRef,CLI 无法生成后续状态查询" + } + return receipt +} + +// ProjectMessageSendStatus preserves the lower response and adds a stable +// receipt that connects openTaskId to the message identifiers required by +// edit, recall, and read-status. It never manufactures a message reference: +// downstream actions are marked ready only when both IDs are actually present. +func ProjectMessageSendStatus(raw map[string]any, requestedTaskID string) map[string]any { + payload := cloneSendStatusMap(raw) + taskID := firstSendStatusString(payload, "openTaskId", "taskId") + if taskID == "" { + taskID = strings.TrimSpace(requestedTaskID) + } + messageID := firstSendStatusString(payload, "openMessageId", "messageId", "msgId") + conversationID := firstSendStatusString(payload, "openConversationId", "conversationId", "openCid") + + payload["contractVersion"] = MessageSendStatusContractVersion + payload["openTaskId"] = taskID + messageRef := map[string]any{} + if messageID != "" { + messageRef["openMessageId"] = messageID + } + if conversationID != "" { + messageRef["openConversationId"] = conversationID + } + if len(messageRef) > 0 { + payload["messageRef"] = messageRef + } + ready := messageID != "" && conversationID != "" + payload["readyForMessageActions"] = ready + payload["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, ready) + return payload +} + +func sendStatusNextActions(taskID, messageID, conversationID string, ready bool) []map[string]any { + if !ready { + return []map[string]any{{ + "cliPath": "chat message query-send-status", + "arguments": map[string]any{ + "open-task-id": taskID, + }, + "ready": false, + "when": "投递任务尚未返回 openMessageId 和 openConversationId 时稍后重查", + }} + } + messageArgs := map[string]any{ + "conversation-id": conversationID, + "msg-id": messageID, + } + return []map[string]any{ + { + "cliPath": "chat message recall", + "arguments": cloneSendStatusMap(messageArgs), + "ready": true, + }, + { + "cliPath": "chat message edit", + "arguments": map[string]any{ + "conversation-id": conversationID, + "msg-id": messageID, + }, + "requiredArguments": []string{"text 或 content"}, + "ready": false, + }, + { + "cliPath": "chat message read-status", + "arguments": map[string]any{ + "conversation-id": conversationID, + "message-id": messageID, + }, + "ready": true, + }, + } +} + +func firstSendStatusString(value any, keys ...string) string { + switch typed := value.(type) { + case map[string]any: + for _, key := range keys { + if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" { + return strings.TrimSpace(candidate) + } + } + for _, key := range []string{"result", "data", "response", "content", "message"} { + if candidate := firstSendStatusString(typed[key], keys...); candidate != "" { + return candidate + } + } + case []any: + for _, item := range typed { + if candidate := firstSendStatusString(item, keys...); candidate != "" { + return candidate + } + } + } + return "" +} + +func cloneSendStatusMap(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)+5) + for key, value := range source { + clone[key] = value + } + return clone +} diff --git a/internal/shortcut/chatmsg/send_status_test.go b/internal/shortcut/chatmsg/send_status_test.go new file mode 100644 index 00000000..96c73a89 --- /dev/null +++ b/internal/shortcut/chatmsg/send_status_test.go @@ -0,0 +1,94 @@ +// 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. + +package chatmsg + +import "testing" + +func TestCrossPlatformCoverageProjectMessageSendReceiptLinksStatusQuery(t *testing.T) { + receipt := ProjectMessageSendReceipt(map[string]any{ + "result": map[string]any{"openTaskId": "task-1"}, + }) + if receipt["contractVersion"] != MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-1" || receipt["readyForMessageActions"] != false { + t.Fatalf("receipt = %#v", receipt) + } + actions, _ := receipt["nextActions"].([]map[string]any) + if len(actions) != 1 || actions[0]["cliPath"] != "chat +messages-query-send-status" || actions[0]["ready"] != true { + t.Fatalf("nextActions = %#v", actions) + } +} + +func TestCrossPlatformCoverageProjectMessageSendReceiptReadyWorkflow(t *testing.T) { + receipt := ProjectMessageSendReceipt(map[string]any{ + "openTaskId": "task-ready", + "openMessageId": "msg-ready", + "openConversationId": "cid-ready", + }) + if receipt["readyForMessageActions"] != true { + t.Fatalf("receipt = %#v", receipt) + } + ref, _ := receipt["messageRef"].(map[string]any) + if ref["openMessageId"] != "msg-ready" || ref["openConversationId"] != "cid-ready" { + t.Fatalf("messageRef = %#v", ref) + } + actions, _ := receipt["nextActions"].([]map[string]any) + if len(actions) != 3 || actions[0]["ready"] != true { + t.Fatalf("nextActions = %#v", actions) + } +} + +func TestCrossPlatformCoverageProjectMessageSendStatusReadyWorkflow(t *testing.T) { + raw := map[string]any{ + "result": map[string]any{ + "openTaskId": "task-1", + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "status": "SUCCESS", + }, + } + payload := ProjectMessageSendStatus(raw, "ignored") + if payload["contractVersion"] != MessageSendStatusContractVersion || + payload["openTaskId"] != "task-1" || payload["readyForMessageActions"] != true { + t.Fatalf("payload = %#v", payload) + } + ref, _ := payload["messageRef"].(map[string]any) + if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" { + t.Fatalf("messageRef = %#v", ref) + } + actions, _ := payload["nextActions"].([]map[string]any) + if len(actions) != 3 || actions[0]["cliPath"] != "chat message recall" || actions[2]["cliPath"] != "chat message read-status" { + t.Fatalf("nextActions = %#v", actions) + } + if _, ok := payload["result"]; !ok { + t.Fatal("raw response field was not preserved") + } +} + +func TestCrossPlatformCoverageProjectMessageSendStatusPendingDoesNotInventMessageRef(t *testing.T) { + payload := ProjectMessageSendStatus(map[string]any{ + "result": map[string]any{"status": "PENDING"}, + }, "task-pending") + if payload["openTaskId"] != "task-pending" || payload["readyForMessageActions"] != false { + t.Fatalf("payload = %#v", payload) + } + if _, exists := payload["messageRef"]; exists { + t.Fatalf("pending payload invented messageRef: %#v", payload) + } + actions, _ := payload["nextActions"].([]map[string]any) + if len(actions) != 1 || actions[0]["ready"] != false { + t.Fatalf("nextActions = %#v", actions) + } +} + +func TestCrossPlatformCoverageFirstSendStatusStringTraversesArrays(t *testing.T) { + value := []any{ + nil, + map[string]any{"result": []any{ + map[string]any{"openTaskId": " task-from-array "}, + }}, + } + if got := firstSendStatusString(value, "openTaskId"); got != "task-from-array" { + t.Fatalf("firstSendStatusString() = %q", got) + } +} diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index f6d0b6c6..06bc299d 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -72,8 +72,9 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin text = f.chatMessagesResult } case "im/search_messages": - if f.searchMessagesResult != "" { - text = f.searchMessagesResult + text = f.searchMessagesResult + if text == "" { + text = `{"result":{"messages":[],"hasMore":false}}` } } return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil @@ -405,7 +406,8 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--no-enrich", "--yes"}, wantProduct: "im", wantTool: "search_messages", - wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"}, + wantArgs: map[string]any{"keyword": "树莓派"}, + wantAbsent: []string{"openConversationIds"}, }, } diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go index dd0f2ec9..df5bc3ae 100644 --- a/internal/shortcut/smart/natural_target_resolution_test.go +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -323,15 +323,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) @@ -392,14 +395,17 @@ func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) if err := root.Execute(); err != nil { t.Fatal(err) } - if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" { + if len(fake.calls) != 2 || fake.calls[0].tool != "get_conversation_info" || fake.calls[1].tool != "search_messages" { t.Fatalf("calls = %#v", fake.calls) } - if got, want := fake.calls[0].args["openConversationIds"], []string{"cid-fixture-chat-0002"}; !reflect.DeepEqual(got, want) { - t.Fatalf("openConversationIds = %#v, want %#v", got, want) + if fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0002" { + t.Fatalf("scope preflight = %#v", fake.calls[0]) } - if fake.calls[0].args["keyword"] != "评测" { - t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"]) + if _, exists := fake.calls[1].args["openConversationIds"]; exists { + t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", fake.calls[1].args) + } + if fake.calls[1].args["keyword"] != "评测" { + t.Fatalf("keyword = %#v", fake.calls[1].args["keyword"]) } } diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index a584a6a9..b3498ac9 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -23,6 +23,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" @@ -34,15 +35,18 @@ import ( // search hits through list_messages_by_ids in chunks of 50. A later-page or // enrichment failure never turns a partial result into a false success: the // output carries an explicit failure ledger and complete=false. +const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" + + "显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" + + "--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + + "--download-resources 使用安全本地路径、默认不覆盖和原子落盘。" + var SearchMsg = shortcut.Shortcut{ Service: "chat", Command: "+search-msg", Product: "im", - Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话", - Intent: "当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。" + - "--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + - "--download-resources 使用安全本地路径、默认不覆盖和原子落盘。", - Risk: shortcut.RiskRead, + Description: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化", + Intent: searchMsgIntent, + Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", @@ -55,15 +59,15 @@ var SearchMsg = shortcut.Shortcut{ CLIPath: "chat +search-msg", PrimaryCLIPath: "chat +search-msg", }, - Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话", + 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: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话", - UseWhen: []string{"当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"}, + AgentSummary: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化", + UseWhen: []string{searchMsgIntent}, AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"}, Examples: []string{ "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all", @@ -104,7 +108,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()...), @@ -145,9 +149,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") @@ -176,7 +192,15 @@ var SearchMsg = shortcut.Shortcut{ break } pagesFetched++ - for _, message := range searchMsgItems(data) { + pageMessages := searchMsgItems(data) + if scopedSearch { + var unverifiableMessageIDs []string + pageMessages, unverifiableMessageIDs = chatmsg.FilterConversationScope(pageMessages, requestedConversationIDs) + if len(unverifiableMessageIDs) > 0 { + return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs) + } + } + for _, message := range pageMessages { messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) if messageID != "" && messageID != "" { if seen[messageID] { @@ -204,7 +228,7 @@ var SearchMsg = shortcut.Shortcut{ } } hasMore = hasMoreValue - if !rt.Bool("page-all") || !hasMore { + if !scanAllPages || !hasMore { complete = !hasMore break } @@ -218,7 +242,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), @@ -235,6 +259,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 + } order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort"))) if order == "" { @@ -262,6 +296,9 @@ var SearchMsg = shortcut.Shortcut{ if len(resolvedFilters.Senders) > 0 { payload["resolvedFilters"] = resolvedFilters } + if scopedSearch { + payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore) + } if hasMore && nextCursor != "" && nextCursor != "" { payload["nextCursor"] = nextCursor } @@ -432,6 +469,63 @@ 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 + } + return helpers.NormalizeSearchConversationScopeError(conversationID, err) + } + return nil +} + +func searchScopeUnverifiedError(conversationIDs, messageIDs []string) error { + return apperrors.NewAPI( + "搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出", + apperrors.WithReason("search_conversation_scope_unverified"), + apperrors.WithDetails(map[string]any{ + "requestedConversationIds": conversationIDs, + "unverifiableMessageIds": messageIDs, + }), + apperrors.WithRetryable(false), + apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"), + ) +} + +func searchScopeViolationError(conversationIDs []string, messages []map[string]any) error { + observed := make([]string, 0, len(messages)) + for _, message := range messages { + conversationID := strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(message))) + if conversationID == "" || conversationID == "" { + continue + } + observed = append(observed, conversationID) + } + return apperrors.NewAPI( + "消息富化结果超出请求的会话范围;已停止输出", + apperrors.WithReason("search_conversation_scope_violation"), + apperrors.WithDetails(map[string]any{ + "requestedConversationIds": conversationIDs, + "observedConversationIds": uniqueSearchStrings(observed), + }), + apperrors.WithRetryable(false), + ) +} + +func searchScopePayload(conversationIDs []string, sourceComplete bool) map[string]any { + return map[string]any{ + "requestedConversationIds": append([]string(nil), conversationIDs...), + "targetsValidated": true, + "filterApplied": true, + "filterMode": "client", + "resultsWithinScope": true, + "sourceComplete": sourceComplete, + } +} + func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) { detailsByID := map[string]map[string]any{} failures := make([]map[string]any, 0) @@ -505,31 +599,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 { @@ -539,56 +609,6 @@ func searchMsgChildMap(data map[string]any, key string) map[string]any { return nil } -func searchMsgFlattenGroups(groups []any) []map[string]any { - out := make([]map[string]any, 0) - for _, rawGroup := range groups { - group, ok := rawGroup.(map[string]any) - if !ok { - continue - } - messages, ok := group["messages"].([]any) - if !ok { - continue - } - conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"])) - conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"])) - singleChat, hasSingleChat := group["singleChat"] - for _, rawMessage := range messages { - message, ok := rawMessage.(map[string]any) - if !ok { - continue - } - item := make(map[string]any, len(message)+3) - for key, value := range message { - item[key] = value - } - if _, exists := item["openConversationId"]; !exists && - conversationID != "" && conversationID != "" { - item["openConversationId"] = conversationID - } - if _, exists := item["conversationTitle"]; !exists && - conversationTitle != "" && conversationTitle != "" { - item["conversationTitle"] = conversationTitle - } - if _, exists := item["singleChat"]; !exists && hasSingleChat { - item["singleChat"] = singleChat - } - out = append(out, item) - } - } - return out -} - -func searchMsgToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - // searchMsgProject reshapes one matched message into {sender, time, text, // messageId}, running text through the shared chatmsg cleaning (card/auto-reply // JSON → readable, ciphertext → marker) and recursively expanding any forwarded diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index 92a6c7d0..da6ad2fd 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + 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" ) @@ -23,34 +24,57 @@ type searchMsgExecutionCaller struct { failEnrichment bool omitPagination bool omitMgetItem bool + failPreflight bool + preflightError error + searchResponse string + wrongMgetScope bool + missingMgetCID bool firstResponse string mgetResponse string } 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.preflightError != nil { + return nil, f.preflightError + } + 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 } if f.firstResponse != "" { return searchMsgToolResult(f.firstResponse), 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.missingMgetCID { + return searchMsgToolResult(`{"result":[{"openMessageId":"m1","openConversationId":null,"content":"detail-1"}]}`), nil + } if f.omitMgetItem { return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil } @@ -78,19 +102,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 TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { @@ -112,15 +144,18 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te "--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"}, @@ -133,10 +168,13 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te 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) || @@ -149,6 +187,10 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te 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 TestCrossPlatformCoverageSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) { @@ -209,6 +251,175 @@ func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t * } } +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 TestCrossPlatformCoverageSearchMsgPreservesAmbiguousPreflightMCPToolErrors(t *testing.T) { + for _, test := range []struct { + name string + want *helpers.CLIError + }{ + { + name: "rate limited", + want: &helpers.CLIError{ + Code: helpers.CodeMCPToolError, + Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`, + }, + }, + { + name: "permission denied", + want: &helpers.CLIError{ + Code: helpers.CodeMCPToolError, + Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := &searchMsgExecutionCaller{preflightError: test.want} + _, err := executeSearchMsgResult(caller, + "--group", "cid-target", "--query", "周报", "--no-enrich") + if err != test.want { + t.Fatalf("error = %#v, want original %#v", err, test.want) + } + 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 TestCrossPlatformCoverageSearchMsgScopeFailureBranches(t *testing.T) { + want := errors.New("preflight unavailable") + caller := &searchMsgExecutionCaller{preflightError: want} + _, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报", "--no-enrich") + if !errors.Is(err, want) { + t.Fatalf("preflight error = %v, want %v", err, want) + } + + caller = &searchMsgExecutionCaller{ + searchResponse: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target","content":"sparse"}],"hasMore":false}}`, + missingMgetCID: true, + } + _, err = executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" { + t.Fatalf("enrichment scope error = %#v", err) + } + + err = searchScopeViolationError( + []string{"cid-target"}, + []map[string]any{{"openMessageId": "m-empty"}, {"openMessageId": "m-other", "openConversationId": "cid-other"}}, + ) + typed = nil + if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_violation" { + t.Fatalf("scope violation 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) + } +} + func TestCrossPlatformCoverageSearchMsgLarkTimeAliasesAndAscendingOrder(t *testing.T) { caller := &searchMsgExecutionCaller{ firstResponse: `{"result":{"messages":[{"openMessageId":"m2","createTime":1782892800000,"content":"later"},{"openMessageId":"m1","createTime":1782806400000,"content":"earlier"}],"hasMore":false}}`, diff --git a/internal/transport/client.go b/internal/transport/client.go index 5c2759ec..c9184a41 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -1047,6 +1047,7 @@ func authActions(snapshotPath string) []string { func runtimeActions(snapshotPath string) []string { actions := []string{ i18n.T("检查认证、权限和参数后重试原命令"), + i18n.T("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"), } _ = snapshotPath return actions @@ -1056,6 +1057,7 @@ func networkActions(snapshotPath string) []string { actions := []string{ i18n.T("检查网络、代理和 DNS 配置后重试原命令"), i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"), + i18n.T("运行 dws doctor 获取可共享的环境诊断结果"), } _ = snapshotPath return actions diff --git a/internal/transport/coverage_edges_test.go b/internal/transport/coverage_edges_test.go index 4b17e065..0ff98ed7 100644 --- a/internal/transport/coverage_edges_test.go +++ b/internal/transport/coverage_edges_test.go @@ -397,13 +397,13 @@ func TestCrossPlatformCoverageHTTPStatusAndDiagnosticsEdges(t *testing.T) { t.Fatalf("HTTP %d returned nil", tc.code) } } - if got := networkActions("snap"); len(got) != 2 { + if got := networkActions("snap"); len(got) != 3 { t.Fatalf("network actions = %#v", got) } if got := authActions("snap"); len(got) != 2 { t.Fatalf("auth actions = %#v", got) } - if got := runtimeActions("snap"); len(got) != 1 { + if got := runtimeActions("snap"); len(got) != 2 { t.Fatalf("runtime actions = %#v", got) } if got := actionsForMethod("tools/call", "snap"); len(got) == 0 { diff --git a/internal/transport/diagnostics.go b/internal/transport/diagnostics.go index ed8306b8..8a643f7f 100644 --- a/internal/transport/diagnostics.go +++ b/internal/transport/diagnostics.go @@ -42,18 +42,48 @@ func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDia return apperrors.ServerDiagnostics{} } diag := apperrors.ServerDiagnostics{ - TraceID: stringFromMap(content, "trace_id", "traceId"), + TraceID: stringFromMapRecursive(content, 0, "trace_id", "traceId", "request_id", "requestId"), ServerErrorCode: serverErrorCodeFromMap(content, 0), - TechnicalDetail: stringFromMap(content, "technical_detail"), + TechnicalDetail: stringFromMapRecursive(content, 0, "technical_detail", "technicalDetail"), FriendlyHint: stringFromMapRecursive(content, 0, "friendly_hint", "friendlyHint"), ActionURL: stringFromMapRecursive(content, 0, "action_url", "actionUrl"), } - if v, ok := content["retryable"].(bool); ok { + if v, ok := boolFromMapRecursive(content, 0, "retryable", "server_retryable", "serverRetryable"); ok { diag.ServerRetryable = &v } return diag } +func boolFromMapRecursive(content map[string]any, depth int, keys ...string) (bool, bool) { + if content == nil || depth > 8 { + return false, false + } + for _, key := range keys { + if value, ok := content[key].(bool); ok { + return value, true + } + } + for _, key := range []string{"content", "result", "data"} { + switch child := content[key].(type) { + case map[string]any: + if value, ok := boolFromMapRecursive(child, depth+1, keys...); ok { + return value, true + } + case []any: + for _, item := range child { + childMap, ok := item.(map[string]any) + if !ok { + continue + } + if value, ok := boolFromMapRecursive(childMap, depth+1, keys...); ok { + return value, true + } + } + } + } + return false, false +} + func stringFromMapRecursive(content map[string]any, depth int, keys ...string) string { if content == nil || depth > 8 { return "" diff --git a/internal/transport/diagnostics_test.go b/internal/transport/diagnostics_test.go index 35e21724..788cf5ef 100644 --- a/internal/transport/diagnostics_test.go +++ b/internal/transport/diagnostics_test.go @@ -110,6 +110,44 @@ func TestExtractServerDiagnosticsFromMapNestedBusinessCode(t *testing.T) { } } +func TestExtractServerDiagnosticsFromMapNestedDiagnosticEnvelope(t *testing.T) { + t.Parallel() + content := map[string]any{ + "result": map[string]any{ + "data": map[string]any{ + "requestId": "request-nested", + "technicalDetail": "nested detail", + "serverRetryable": false, + }, + }, + } + diag := ExtractServerDiagnosticsFromMap(content) + if diag.TraceID != "request-nested" || diag.TechnicalDetail != "nested detail" || + diag.ServerRetryable == nil || *diag.ServerRetryable { + t.Fatalf("nested diagnostics = %#v", diag) + } +} + +func TestCrossPlatformCoverageBoolDiagnosticsRecursiveEdges(t *testing.T) { + t.Parallel() + if value, ok := boolFromMapRecursive(nil, 0, "retryable"); ok || value { + t.Fatalf("nil recursive bool = (%v, %v)", value, ok) + } + if value, ok := boolFromMapRecursive(map[string]any{"retryable": true}, 9, "retryable"); ok || value { + t.Fatalf("over-depth recursive bool = (%v, %v)", value, ok) + } + content := map[string]any{ + "result": []any{ + "ignore", + map[string]any{"data": map[string]any{"serverRetryable": true}}, + }, + } + value, ok := boolFromMapRecursive(content, 0, "serverRetryable") + if !ok || !value { + t.Fatalf("nested array recursive bool = (%v, %v)", value, ok) + } +} + func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) { t.Parallel() diag := ExtractServerDiagnosticsFromMap(nil) diff --git a/scripts/ci/eval_comment_parse.py b/scripts/ci/eval_comment_parse.py new file mode 100644 index 00000000..24596151 --- /dev/null +++ b/scripts/ci/eval_comment_parse.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""解析 PR 评论中的 `/eval [sha=] [cases=]` 触发命令。 + +供 .github/workflows/eval-dispatch.yml 调用:评论体经环境变量 COMMENT_BODY +传入(避免 argv 注入),解析结果以 GitHub Actions output +(products / cases_ref / reviewed_sha)写出;格式非法时把单行错误写入 +output `error` 并以非零退出。 + +产品集显式必填;`sha=` 在评测他人 PR 时必填(审核背书凭据,由 guard 校验), +评测自己创建的 PR 时可省略(自助模式,自动钉住派发时刻的当前 head); +`cases=` 为用例仓库版本逃生舱(破坏性变更配对验证)。 +""" + +import os +import re +import sys + +PRODUCT_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +REF_CHARSET_RE = re.compile(r"^[A-Za-z0-9._/-]+$") +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +USAGE = ( + "用法: /eval [,...] [sha=<40位PR-head-SHA>] [cases=];" + "评测他人 PR 时 sha= 必填,自己的 PR 可省略;" + "示例: /eval drive,doc sha=0123456789abcdef0123456789abcdef01234567" +) + + +def _is_valid_cases_ref(ref: str) -> bool: + """等价于 git check-ref-format --allow-onelevel 的结构校验(不执行 git)。 + + 字符白名单之上补齐结构规则:禁止首尾斜杠与连续斜杠、尾部点号、 + 任意位置的 `..`、以点开头或以 .lock 结尾的路径分量;另禁止 `-` + 开头,避免被下游 `git fetch origin ` 当作选项解析。 + """ + if not ref or not REF_CHARSET_RE.match(ref): + return False + if ref.startswith(("-", "/")) or ref.endswith(("/", ".")): + return False + if ".." in ref or "//" in ref: + return False + for component in ref.split("/"): + if component.startswith(".") or component.endswith(".lock"): + return False + return True + + +def parse(body: str): + lines = [line.strip() for line in (body or "").splitlines() if line.strip()] + if not lines: + raise ValueError(f"评论为空。{USAGE}") + tokens = lines[0].split() + if tokens[0] != "/eval": + raise ValueError(f"首行必须以 /eval 命令开头。{USAGE}") + if len(tokens) < 2: + raise ValueError(f"产品集显式必填。{USAGE}") + + products = [p for p in tokens[1].split(",") if p] + if not products: + raise ValueError(f"产品集显式必填。{USAGE}") + for product in products: + if not PRODUCT_RE.match(product): + raise ValueError(f"产品名非法: {product}。{USAGE}") + + cases_ref = "" + reviewed_sha = "" + for extra in tokens[2:]: + if extra.startswith("cases="): + if cases_ref: + raise ValueError(f"cases 引用只能指定一次。{USAGE}") + cases_ref = extra[len("cases="):] + if not _is_valid_cases_ref(cases_ref): + raise ValueError(f"cases 引用非法: {extra}。{USAGE}") + elif extra.startswith("sha="): + if reviewed_sha: + raise ValueError(f"审核 SHA 只能指定一次。{USAGE}") + reviewed_sha = extra[len("sha="):] + if not SHA_RE.match(reviewed_sha): + raise ValueError(f"审核 SHA 非法: {extra}。{USAGE}") + else: + raise ValueError(f"未知参数: {extra}。{USAGE}") + + if not reviewed_sha: + # 是否允许省略由 guard 按“评论者是否 PR 作者”裁决,解析层不拦 + return ",".join(products), cases_ref, "" + + return ",".join(products), cases_ref, reviewed_sha.lower() + + +def _write_outputs(pairs): + lines = [f"{key}={value}" for key, value in pairs] + output_path = os.environ.get("GITHUB_OUTPUT", "") + if output_path: + with open(output_path, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + print("\n".join(lines)) + + +def main() -> int: + try: + products, cases_ref, reviewed_sha = parse(os.environ.get("COMMENT_BODY", "")) + except ValueError as exc: + _write_outputs([("error", str(exc))]) + print(str(exc), file=sys.stderr) + return 1 + _write_outputs( + [ + ("products", products), + ("cases_ref", cases_ref), + ("reviewed_sha", reviewed_sha), + ] + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/eval_comment_parse_test.py b/scripts/ci/eval_comment_parse_test.py new file mode 100644 index 00000000..419b1f7c --- /dev/null +++ b/scripts/ci/eval_comment_parse_test.py @@ -0,0 +1,92 @@ +import pytest + +from eval_comment_parse import parse + +SHA = "0123456789abcdef0123456789abcdef01234567" + + +def test_single_product(): + assert parse(f"/eval drive sha={SHA}") == ("drive", "", SHA) + + +def test_multiple_products(): + assert parse(f"/eval drive,doc,edu-app sha={SHA}") == ("drive,doc,edu-app", "", SHA) + + +def test_cases_ref_escape_hatch(): + assert parse(f"/eval drive sha={SHA} cases=feat/drive-latest") == ( + "drive", + "feat/drive-latest", + SHA, + ) + + +def test_extra_lines_after_command_are_ignored(): + body = f"/eval drive sha={SHA}\n\n顺便说明:这个 PR 只动了 drive 的 --latest。" + assert parse(body) == ("drive", "", SHA) + + +def test_missing_products_rejected(): + with pytest.raises(ValueError, match="产品集显式必填"): + parse("/eval") + + +def test_omitted_reviewed_sha_defers_to_guard(): + # 是否允许省略由 guard 按“评论者是否 PR 作者”裁决,解析层放行并输出空 reviewed_sha + assert parse("/eval drive") == ("drive", "", "") + + +def test_similar_command_prefix_rejected(): + with pytest.raises(ValueError, match="/eval 命令开头"): + parse(f"/evaluate drive sha={SHA}") + + +def test_illegal_product_name_rejected(): + with pytest.raises(ValueError, match="产品名非法"): + parse(f"/eval drive;rm sha={SHA}") + + +def test_unknown_extra_token_rejected(): + with pytest.raises(ValueError, match="未知参数"): + parse(f"/eval drive sha={SHA} --force") + + +def test_illegal_cases_ref_rejected(): + with pytest.raises(ValueError, match="cases 引用非法"): + parse(f"/eval drive sha={SHA} cases=$(whoami)") + + +def test_structurally_invalid_cases_refs_rejected(): + # git check-ref-format 结构规则:字符合法但结构非法的引用必须拒绝 + invalid_refs = [ + "..", # 以点开头且含连续点号 + "/main", # 首部斜杠 + "feature//x", # 连续斜杠 + "feature/", # 尾部斜杠 + "release.", # 尾部点号 + "a..b", # 任意位置连续点号 + ".hidden", # 分量以点开头 + "a/.hidden", # 非首分量以点开头 + "a.lock", # 分量以 .lock 结尾 + "a/b.lock", # 非首分量以 .lock 结尾 + "-flag", # 以 - 开头,防 git fetch 选项注入 + ] + for ref in invalid_refs: + with pytest.raises(ValueError, match="cases 引用非法"): + parse(f"/eval drive sha={SHA} cases={ref}") + + +def test_structurally_valid_cases_refs_accepted(): + valid_refs = ["main", "feat/drive-latest", "release/v1.0", "a.b/c-d_e", SHA] + for ref in valid_refs: + assert parse(f"/eval drive sha={SHA} cases={ref}") == ("drive", ref, SHA) + + +def test_short_reviewed_sha_rejected(): + with pytest.raises(ValueError, match="审核 SHA 非法"): + parse("/eval drive sha=0123456") + + +def test_empty_comment_rejected(): + with pytest.raises(ValueError, match="评论为空"): + parse(" \n ") diff --git a/scripts/ci/eval_dispatch_guard.py b/scripts/ci/eval_dispatch_guard.py new file mode 100644 index 00000000..d4be187a --- /dev/null +++ b/scripts/ci/eval_dispatch_guard.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""校验 `/eval` 派发的仓库权限与已审核 PR head。""" + +import json +import os +import re +import sys + + +TRUSTED_PERMISSIONS = frozenset({"write", "maintain", "admin"}) +FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def _read_api_response(): + try: + value = json.load(sys.stdin) + except (json.JSONDecodeError, OSError) as exc: + raise ValueError(f"GitHub API response is not valid JSON: {exc}") from exc + if not isinstance(value, dict): + raise ValueError("GitHub API response must be a JSON object") + return value + + +def load_allowlist(path: str) -> frozenset: + """读取自助触发允许名单(默认分支文件;# 注释与空行忽略;大小写不敏感)。""" + if not path: + return frozenset() + try: + with open(path, encoding="utf-8") as f: + lines = f.read().splitlines() + except FileNotFoundError: + return frozenset() + entries = set() + for line in lines: + entry = line.split("#", 1)[0].strip() + if entry: + entries.add(entry.lower()) + return frozenset(entries) + + +def authorize_dispatch(response, commenter: str, pr_author: str, allowlist: frozenset): + """两级授权:仓库写权限可派发任意 PR;名单内用户仅可派发自己创建的 PR。""" + permission = response.get("permission") + if permission in TRUSTED_PERMISSIONS: + return f"maintainer:{permission}" + commenter_key = (commenter or "").strip().lower() + author_key = (pr_author or "").strip().lower() + if commenter_key and commenter_key in allowlist: + if commenter_key == author_key: + return "allowlisted-author" + raise PermissionError( + f"{commenter or 'commenter'} is allowlisted for self-service only " + "and may not dispatch other authors' PRs" + ) + raise PermissionError( + f"{commenter or 'commenter'} does not have write, maintain, or admin permission " + "and is not an allowlisted PR author" + ) + + +def require_reviewed_current_head(response, expected_pr_number: str, reviewed_sha: str, commenter: str): + try: + expected_number = int(expected_pr_number) + except (TypeError, ValueError) as exc: + raise ValueError("expected PR number is invalid") from exc + if response.get("number") != expected_number: + raise ValueError("GitHub API response does not match the requested PR") + if response.get("state") != "open": + raise ValueError("PR is not open") + + head = response.get("head") + current_sha = head.get("sha") if isinstance(head, dict) else "" + if not FULL_SHA_RE.fullmatch(current_sha or ""): + raise ValueError("GitHub API response does not contain a valid PR head SHA") + + if reviewed_sha: + if not FULL_SHA_RE.fullmatch(reviewed_sha): + raise ValueError("reviewed SHA must be a full 40-character commit SHA") + if current_sha.lower() != reviewed_sha.lower(): + raise ValueError( + f"PR head changed after review: reviewed {reviewed_sha.lower()}, " + f"current {current_sha.lower()}" + ) + return reviewed_sha.lower() + + # sha 省略:仅限评论者本人创建的 PR(自助模式)——作者对自己分支的 + # tip 背书不存在第三方偷换窗口;钉住派发时刻的当前 head,内网侧 + # FETCH_HEAD 校验继续兜底派发→执行窗口。评测他人 PR 必须显式带 sha=。 + author = ((response.get("user") or {}).get("login") or "").strip().lower() + commenter_key = (commenter or "").strip().lower() + if not author or commenter_key != author: + raise ValueError( + "dispatching another author's PR requires an explicit reviewed sha=" + ) + return current_sha.lower() + + +def main() -> int: + mode = sys.argv[1] if len(sys.argv) == 2 else "" + try: + response = _read_api_response() + if mode == "permission": + role = authorize_dispatch( + response, + os.environ.get("COMMENTER", ""), + os.environ.get("PR_AUTHOR", ""), + load_allowlist(os.environ.get("EVAL_ALLOWLIST_PATH", "")), + ) + print(f"authorized={role}") + return 0 + if mode == "head": + head_sha = require_reviewed_current_head( + response, + os.environ.get("EXPECTED_PR_NUMBER", ""), + os.environ.get("REVIEWED_SHA", ""), + os.environ.get("COMMENTER", ""), + ) + print(f"head_sha={head_sha}") + return 0 + raise ValueError(f"unknown guard mode: {mode}") + except (PermissionError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/multi/dingtalk-chat/references/contracts.md b/skills/multi/dingtalk-chat/references/contracts.md index 88bb2c11..9c1fb723 100644 --- a/skills/multi/dingtalk-chat/references/contracts.md +++ b/skills/multi/dingtalk-chat/references/contracts.md @@ -12,7 +12,7 @@ - `version`: `im.message-list.v1` - `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs` -- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `resourceDownloads` +- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads` 当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary; diff --git a/test/scripts/eval_dispatch_workflow_test.go b/test/scripts/eval_dispatch_workflow_test.go new file mode 100644 index 00000000..235d1f31 --- /dev/null +++ b/test/scripts/eval_dispatch_workflow_test.go @@ -0,0 +1,291 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T) { + t.Parallel() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml")) + if err != nil { + t.Fatalf("read eval-dispatch workflow: %v", err) + } + workflow := string(data) + + for _, want := range []string{ + "/collaborators/${COMMENTER}/permission", + "eval_dispatch_guard.py permission", + "PR_AUTHOR: ${{ github.event.issue.user.login }}", + "EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt", + "REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}", + "eval_dispatch_guard.py head", + } { + if !strings.Contains(workflow, want) { + t.Errorf("eval-dispatch workflow missing security contract %q", want) + } + } + if strings.Contains(workflow, "author_association") { + t.Error("eval-dispatch workflow must not authorize from author_association") + } + if _, err := os.Stat(filepath.Join(root, ".github", "eval-allowlist.txt")); err != nil { + t.Errorf("eval allowlist file missing on default branch: %v", err) + } +} + +func TestEvalDispatchRejectsLowRepositoryPermissions(t *testing.T) { + t.Parallel() + + for _, permission := range []string{"read", "triage", "none"} { + permission := permission + t.Run(permission, func(t *testing.T) { + t.Parallel() + output, err := runEvalDispatchGuard(t, "permission", `{"permission":"`+permission+`"}`, "COMMENTER=low-privilege-user") + if err == nil { + t.Fatalf("permission %q unexpectedly allowed; output=%s", permission, output) + } + if !strings.Contains(output, "does not have write, maintain, or admin permission") { + t.Fatalf("permission %q rejection = %q, want trusted-permission error", permission, output) + } + }) + } +} + +func TestEvalDispatchAllowsTrustedRepositoryPermissions(t *testing.T) { + t.Parallel() + + for _, permission := range []string{"write", "maintain", "admin"} { + permission := permission + t.Run(permission, func(t *testing.T) { + t.Parallel() + output, err := runEvalDispatchGuard(t, "permission", `{"permission":"`+permission+`"}`, "COMMENTER=maintainer") + if err != nil { + t.Fatalf("permission %q rejected: %v\n%s", permission, err, output) + } + }) + } +} + +func TestEvalDispatchRejectsChangedPRHead(t *testing.T) { + t.Parallel() + + const reviewedSHA = "1111111111111111111111111111111111111111" + const currentSHA = "2222222222222222222222222222222222222222" + pr := `{"number":934,"state":"open","head":{"sha":"` + currentSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA="+reviewedSHA, + ) + if err == nil { + t.Fatalf("changed PR head unexpectedly allowed; output=%s", output) + } + if !strings.Contains(output, "PR head changed after review") { + t.Fatalf("changed-head rejection = %q, want explicit stale-review error", output) + } +} + +func TestEvalDispatchAcceptsReviewedCurrentPRHead(t *testing.T) { + t.Parallel() + + const reviewedSHA = "1111111111111111111111111111111111111111" + pr := `{"number":934,"state":"open","head":{"sha":"` + reviewedSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA="+reviewedSHA, + ) + if err != nil { + t.Fatalf("reviewed current PR head rejected: %v\n%s", err, output) + } + if !strings.Contains(output, "head_sha="+reviewedSHA) { + t.Fatalf("head guard output = %q, want pinned head SHA", output) + } +} + +func TestEvalCommentRequiresExplicitReviewedSHA(t *testing.T) { + t.Parallel() + + const reviewedSHA = "1111111111111111111111111111111111111111" + output, err := runEvalCommentParser(t, "/eval drive sha="+reviewedSHA) + if err != nil { + t.Fatalf("explicit reviewed SHA rejected: %v\n%s", err, output) + } + for _, want := range []string{"products=drive", "reviewed_sha=" + reviewedSHA} { + if !strings.Contains(output, want) { + t.Errorf("parser output = %q, want %q", output, want) + } + } + + // sha 省略在解析层放行(空 reviewed_sha),由 guard 按评论者是否 PR 作者裁决 + output, err = runEvalCommentParser(t, "/eval drive") + if err != nil { + t.Fatalf("omitted reviewed SHA rejected at parse layer: %v\n%s", err, output) + } + if !strings.Contains(output, "reviewed_sha=\n") && !strings.HasSuffix(output, "reviewed_sha=") { + t.Fatalf("parser output = %q, want empty reviewed_sha passthrough", output) + } +} + +func TestEvalDispatchAutoPinsOwnPRHeadWhenShaOmitted(t *testing.T) { + t.Parallel() + + const currentSHA = "3333333333333333333333333333333333333333" + pr := `{"number":934,"state":"open","user":{"login":"Internal-Contributor"},"head":{"sha":"` + currentSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA=", + "COMMENTER=internal-contributor", + ) + if err != nil { + t.Fatalf("own-PR auto pin rejected: %v\n%s", err, output) + } + if !strings.Contains(output, "head_sha="+currentSHA) { + t.Fatalf("guard output = %q, want auto-pinned current head", output) + } +} + +func TestEvalDispatchRequiresShaForOthersPRs(t *testing.T) { + t.Parallel() + + const currentSHA = "3333333333333333333333333333333333333333" + pr := `{"number":934,"state":"open","user":{"login":"someone-else"},"head":{"sha":"` + currentSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA=", + "COMMENTER=internal-contributor", + ) + if err == nil { + t.Fatalf("other author's PR without sha unexpectedly allowed; output=%s", output) + } + if !strings.Contains(output, "requires an explicit reviewed sha") { + t.Fatalf("missing-sha rejection = %q, want explicit-sha requirement", output) + } +} + +func TestEvalDispatchAllowsAllowlistedAuthorOnOwnPR(t *testing.T) { + t.Parallel() + + allowlist := writeEvalAllowlist(t, "# 自助评测名单\nInternal-Contributor # 内部贡献者\n") + output, err := runEvalDispatchGuard( + t, + "permission", + `{"message":"Not Found"}`, + "COMMENTER=internal-contributor", + "PR_AUTHOR=Internal-Contributor", + "EVAL_ALLOWLIST_PATH="+allowlist, + ) + if err != nil { + t.Fatalf("allowlisted author rejected on own PR: %v\n%s", err, output) + } + if !strings.Contains(output, "authorized=allowlisted-author") { + t.Fatalf("guard output = %q, want allowlisted-author authorization", output) + } +} + +func TestEvalDispatchRejectsAllowlistedUserOnOthersPR(t *testing.T) { + t.Parallel() + + allowlist := writeEvalAllowlist(t, "internal-contributor\n") + output, err := runEvalDispatchGuard( + t, + "permission", + `{"message":"Not Found"}`, + "COMMENTER=internal-contributor", + "PR_AUTHOR=someone-else", + "EVAL_ALLOWLIST_PATH="+allowlist, + ) + if err == nil { + t.Fatalf("allowlisted user unexpectedly dispatched another author's PR; output=%s", output) + } + if !strings.Contains(output, "self-service only") { + t.Fatalf("cross-PR rejection = %q, want self-service-only error", output) + } +} + +func TestEvalDispatchRejectsUnlistedCommenterWithoutWrite(t *testing.T) { + t.Parallel() + + allowlist := writeEvalAllowlist(t, "# 空名单\n") + output, err := runEvalDispatchGuard( + t, + "permission", + `{"permission":"read"}`, + "COMMENTER=stranger", + "PR_AUTHOR=stranger", + "EVAL_ALLOWLIST_PATH="+allowlist, + ) + if err == nil { + t.Fatalf("unlisted commenter unexpectedly allowed; output=%s", output) + } + if !strings.Contains(output, "not an allowlisted PR author") { + t.Fatalf("unlisted rejection = %q, want allowlist-aware error", output) + } +} + +func writeEvalAllowlist(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "eval-allowlist.txt") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write allowlist fixture: %v", err) + } + return path +} + +func runEvalDispatchGuard(t *testing.T, mode, input string, env ...string) (string, error) { + t.Helper() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + cmd := exec.Command("python3", filepath.Join(root, "scripts", "ci", "eval_dispatch_guard.py"), mode) + cmd.Stdin = strings.NewReader(input) + cmd.Env = append(os.Environ(), env...) + output, runErr := cmd.CombinedOutput() + return string(output), runErr +} + +func runEvalCommentParser(t *testing.T, comment string) (string, error) { + t.Helper() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + outputPath := filepath.Join(t.TempDir(), "github-output") + cmd := exec.Command("python3", filepath.Join(root, "scripts", "ci", "eval_comment_parse.py")) + cmd.Env = append( + os.Environ(), + "COMMENT_BODY="+comment, + "GITHUB_OUTPUT="+outputPath, + ) + combined, runErr := cmd.CombinedOutput() + fileOutput, readErr := os.ReadFile(outputPath) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("read parser GitHub output: %v", readErr) + } + return string(combined) + string(fileOutput), runErr +}