Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core

# Conflicts:
#	internal/errors/errors.go
#	internal/errors/errors_test.go
This commit is contained in:
玉澜
2026-08-11 16:40:15 +08:00
47 changed files with 4432 additions and 203 deletions
+61
View File
@@ -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
+139
View File
@@ -0,0 +1,139 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` 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
@@ -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)
}
}
}
})
}
}
@@ -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},
@@ -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 {
@@ -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) {
+1 -1
View File
@@ -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()
+16 -2
View File
@@ -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) {
+32 -1
View File
@@ -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()
+419 -20
View File
@@ -6,6 +6,7 @@ import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -18,6 +19,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -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 != "<nil>" {
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 == "<nil>" || 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 <openConversationId> \"项目已更新\""},
},
@@ -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 <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
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 <userId> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --users <userId1>,<userId2> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
@@ -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 <userId>
# 查询人员: 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>
# openTaskId 由 dws chat message send 返回`,
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>`,
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 <openTaskId>"},
},
},
@@ -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 <bizId> --content \"处理完成\" --flow-status 2"},
},
Parameters: []contract.ParamDecl{
{Name: "biz-id", Property: "bizId"},
{Name: "content", Property: "msgContent"},
{Name: "flow-status", Property: "flowStatus"},
},
},
})
+163
View File
@@ -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", "<bizId>", "--content", "完成", "--flow-status", "3"},
{"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"},
} {
caller := &scriptedToolCaller{}
if err := runNativeCardUpdate(t, caller, args...); err == nil {
t.Fatalf("args %v unexpectedly succeeded", args)
}
if caller.calls != 0 {
t.Fatalf("args %v made %d calls", args, caller.calls)
}
}
})
t.Run("dry run publishes unverified plan without write", func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-preview",
"--content", "完成",
"--flow-status", "3",
"--dry-run",
)
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d calls", caller.calls)
}
})
}
+1 -1
View File
@@ -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())
}
+565 -21
View File
@@ -16,6 +16,7 @@ package helpers
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
@@ -23,8 +24,10 @@ import (
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type chatMessageSearchCall struct {
@@ -34,12 +37,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()
}
+123
View File
@@ -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
}
@@ -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)
}
})
}
}
+2 -2
View File
@@ -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)
}
+3 -1
View File
@@ -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",
@@ -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) {
+124 -20
View File
@@ -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 <openTaskId>"},
},
},
@@ -1237,7 +1244,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
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 <bizId> --content \"内容\" --flow-status 3"},
},
},
@@ -1708,20 +1741,91 @@ var MessagesUpdateCard = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
Validate: func(rt *shortcut.RuntimeContext) error {
if _, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")); err != nil {
return err
}
if !validCardFlowStatus(rt.Int("flow-status")) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_streaming_card", map[string]any{
"bizId": rt.Str("biz-id"),
// 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",
@@ -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 {
+279
View File
@@ -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", "<bizId>",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "占位符") {
t.Fatalf("error = %v, want placeholder validation", err)
}
if len(fake.calls) != 0 {
t.Fatalf("invalid placeholder made calls: %#v", fake.calls)
}
})
t.Run("dry run only publishes plan", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-preview",
"--content", "完成",
"--flow-status", "3",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["executed"] != false || payload["verified"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
@@ -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{
@@ -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":
+11 -4
View File
@@ -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) {
+65
View File
@@ -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
}
@@ -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")
}
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chatmsg
import (
"encoding/json"
"errors"
"fmt"
"strings"
"unicode"
)
var (
ErrCardUpdateNotApplied = errors.New("streaming card update was not applied")
ErrCardUpdateUnverified = errors.New("streaming card update could not be verified")
ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId")
)
// NormalizeCardBizID performs only format-independent checks. bizId is an
// opaque server-issued identifier; a stricter character or prefix contract
// must not be invented by the CLI without an authoritative API declaration.
func NormalizeCardBizID(raw string) (string, error) {
bizID := strings.TrimSpace(raw)
if bizID == "" {
return "", fmt.Errorf("--biz-id 不能为空")
}
for _, r := range bizID {
if unicode.IsControl(r) || unicode.IsSpace(r) {
return "", fmt.Errorf("--biz-id 必须是 send-card 返回的单个不透明 ID,不能包含空白或控制字符")
}
}
if isCardBizIDPlaceholder(bizID) {
return "", fmt.Errorf("--biz-id 仍是占位符 %q;请传入 send-card 实际返回的 bizId", bizID)
}
return bizID, nil
}
func isCardBizIDPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "bizid", "biz-id", "your-biz-id", "your_biz_id",
"<bizid>", "<biz-id>", "<your-biz-id>",
"{bizid}", "{biz-id}", "${bizid}", "${biz-id}":
return true
default:
return false
}
}
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
// write took effect. A transport-level {success:true,errorCode:null} only says
// that the RPC returned normally and is deliberately not accepted as proof of
// a card update.
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
requestedBizID = strings.TrimSpace(requestedBizID)
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
observeCardUpdate(response, &observation)
for responseBizID := range observation.bizIDs {
if requestedBizID != "" && responseBizID != requestedBizID {
return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
}
}
if observation.positiveEvidence != "" && observation.negativeEvidence != "" {
return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence)
}
if observation.positiveEvidence != "" {
return observation.positiveEvidence, nil
}
if observation.negativeEvidence != "" {
return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
}
return "", ErrCardUpdateUnverified
}
type cardUpdateObservation struct {
bizIDs map[string]struct{}
positiveEvidence string
negativeEvidence string
}
func observeCardUpdate(value any, observation *cardUpdateObservation) {
switch typed := value.(type) {
case map[string]any:
observeCardUpdateMap(typed, observation)
case []any:
for _, child := range typed {
observeCardUpdate(child, observation)
}
case bool:
if typed {
setPositiveCardUpdateEvidence(observation, "result=true")
} else {
setNegativeCardUpdateEvidence(observation, "result=false")
}
}
}
func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) {
for _, key := range []string{"bizId", "bizID", "biz_id"} {
if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" {
observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{}
}
}
for _, key := range []string{"updated", "applied"} {
if applied, ok := value[key].(bool); ok {
if applied {
setPositiveCardUpdateEvidence(observation, key+"=true")
} else {
setNegativeCardUpdateEvidence(observation, key+"=false")
}
}
}
for _, key := range []string{"affectedCount", "updatedCount", "modifiedCount"} {
if count, ok := cardUpdateCount(value[key]); ok {
if count > 0 {
setPositiveCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
} else if count == 0 {
setNegativeCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
}
}
}
// Only documented response envelopes are traversed. This prevents an
// unrelated extension field containing "updated":true from proving the
// business write.
for _, key := range []string{"result", "data", "response", "card"} {
if child, exists := value[key]; exists {
observeCardUpdate(child, observation)
}
}
}
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.positiveEvidence == "" {
observation.positiveEvidence = evidence
}
}
func setNegativeCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.negativeEvidence == "" {
observation.negativeEvidence = evidence
}
}
func cardUpdateCount(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case float32:
return int64(typed), float32(int64(typed)) == typed
case float64:
return int64(typed), float64(int64(typed)) == typed
case json.Number:
count, err := typed.Int64()
return count, err == nil
default:
return 0, false
}
}
@@ -0,0 +1,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: "<bizId>", wantErr: true},
{name: "internal space", raw: "card token", wantErr: true},
{name: "control", raw: "card\ntoken", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
got, err := NormalizeCardBizID(test.raw)
if test.wantErr {
if err == nil {
t.Fatalf("NormalizeCardBizID(%q) unexpectedly succeeded", test.raw)
}
return
}
if err != nil || got != test.want {
t.Fatalf("NormalizeCardBizID(%q) = %q, %v; want %q", test.raw, got, err, test.want)
}
})
}
}
func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
for _, test := range []struct {
name string
response map[string]any
wantProof string
wantErrIs error
}{
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"},
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"},
{name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"},
{name: "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)
}
}
}
+174 -7
View File
@@ -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) {
+22
View File
@@ -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 {
@@ -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{
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chatmsg
import (
"fmt"
"strings"
)
// SearchItems locates and flattens the message list returned by the two
// DingTalk message-search interfaces. Grouped search responses carry the
// conversation identity on the group rather than each message, so the
// flattener copies that identity onto every returned message before callers
// perform scope checks.
func SearchItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, childMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return flattenSearchGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, innerKey := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if arr, ok := inner[innerKey].([]any); ok {
return searchMaps(arr)
}
}
}
}
return nil
}
func childMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func flattenSearchGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := cleanSearchScalar(group["openConversationId"])
conversationTitle := cleanSearchScalar(group["title"])
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists && conversationID != "" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMaps(items []any) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if message, ok := item.(map[string]any); ok {
out = append(out, message)
}
}
return out
}
// FilterConversationScope keeps only messages belonging to the explicitly
// requested conversations. A message without a conversation identity is
// reported as unverifiable rather than treated as in-scope.
func FilterConversationScope(messages []map[string]any, conversationIDs []string) (matched []map[string]any, unverifiableMessageIDs []string) {
requested := make(map[string]struct{}, len(conversationIDs))
for _, conversationID := range conversationIDs {
if value := strings.TrimSpace(conversationID); value != "" {
requested[value] = struct{}{}
}
}
matched = make([]map[string]any, 0, len(messages))
for _, message := range messages {
conversationID := cleanSearchScalar(ConversationID(message))
if conversationID == "" {
messageID := cleanSearchScalar(MessageID(message))
if messageID == "" {
messageID = "<unknown>"
}
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
continue
}
if _, ok := requested[conversationID]; ok {
matched = append(matched, message)
}
}
return matched, uniqueStrings(unverifiableMessageIDs)
}
// GroupSearchMessages restores the established typed search envelope after a
// client-side scoped scan. Group order follows first occurrence in the search
// result, and each message retains its original fields.
func GroupSearchMessages(messages []map[string]any) []map[string]any {
groups := make([]map[string]any, 0)
index := make(map[string]int)
for _, message := range messages {
conversationID := cleanSearchScalar(ConversationID(message))
if conversationID == "" {
continue
}
groupIndex, ok := index[conversationID]
if !ok {
group := map[string]any{
"openConversationId": conversationID,
"messages": []map[string]any{},
}
if title := cleanSearchScalar(message["conversationTitle"]); title != "" {
group["title"] = title
}
if singleChat, exists := message["singleChat"]; exists {
group["singleChat"] = singleChat
}
groups = append(groups, group)
groupIndex = len(groups) - 1
index[conversationID] = groupIndex
}
groupMessages := groups[groupIndex]["messages"].([]map[string]any)
groups[groupIndex]["messages"] = append(groupMessages, message)
}
return groups
}
func cleanSearchScalar(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || strings.EqualFold(text, "null") {
return ""
}
return text
}
func uniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
+104
View File
@@ -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{"<unknown>"}) {
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)
}
}
+162
View File
@@ -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
}
@@ -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)
}
}
@@ -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"},
},
}
@@ -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"])
}
}
+108 -88
View File
@@ -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 <openDingTalkId> --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 != "<nil>" {
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 != "<nil>" {
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 == "<nil>" {
continue
}
observed = append(observed, conversationID)
}
return apperrors.NewAPI(
"消息富化结果超出请求的会话范围;已停止输出",
apperrors.WithReason("search_conversation_scope_violation"),
apperrors.WithDetails(map[string]any{
"requestedConversationIds": conversationIDs,
"observedConversationIds": uniqueSearchStrings(observed),
}),
apperrors.WithRetryable(false),
)
}
func searchScopePayload(conversationIDs []string, sourceComplete bool) map[string]any {
return map[string]any{
"requestedConversationIds": append([]string(nil), conversationIDs...),
"targetsValidated": true,
"filterApplied": true,
"filterMode": "client",
"resultsWithinScope": true,
"sourceComplete": sourceComplete,
}
}
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
detailsByID := map[string]map[string]any{}
failures := make([]map[string]any, 0)
@@ -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 != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// searchMsgProject reshapes one matched message into {sender, time, text,
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
@@ -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}}`,
+2
View File
@@ -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
+2 -2
View File
@@ -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 {
+33 -3
View File
@@ -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 ""
+38
View File
@@ -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)
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""解析 PR 评论中的 `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` 触发命令。
供 .github/workflows/eval-dispatch.yml 调用:评论体经环境变量 COMMENT_BODY
传入(避免 argv 注入),解析结果以 GitHub Actions output
(products / cases_ref / reviewed_sha)写出;格式非法时把单行错误写入
output `error` 并以非零退出。
产品集显式必填;`sha=` 在评测他人 PR 时必填(审核背书凭据,由 guard 校验),
评测自己创建的 PR 时可省略(自助模式,自动钉住派发时刻的当前 head);
`cases=<ref>` 为用例仓库版本逃生舱(破坏性变更配对验证)。
"""
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 <product>[,<product>...] [sha=<40位PR-head-SHA>] [cases=<ref>];"
"评测他人 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 <ref>` 当作选项解析。
"""
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())
+92
View File
@@ -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 ")
+127
View File
@@ -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=<full-head-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())
@@ -12,7 +12,7 @@
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
- `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`
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
+291
View File
@@ -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
}