Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be001949e4 | ||
|
|
bcd91aca1f | ||
|
|
12e6632692 | ||
|
|
a5111f486b | ||
|
|
7a9348f9aa | ||
|
|
6c78db7467 | ||
|
|
b539e15e6d | ||
|
|
c9426622f0 | ||
|
|
5b01f29f2f | ||
|
|
5efb6210b0 | ||
|
|
113e084a8d | ||
|
|
a76492e16e | ||
|
|
a0cc9b4b51 | ||
|
|
45df573d0e | ||
|
|
608edfa309 | ||
|
|
e8ef510d3a | ||
|
|
8c6266f158 | ||
|
|
91f0fb7b11 | ||
|
|
570d2e6756 | ||
|
|
c3ffb9c831 | ||
|
|
e6821176a4 | ||
|
|
44857449d6 | ||
|
|
29f2f1c813 | ||
|
|
d21f18af04 | ||
|
|
dd604455cc | ||
|
|
26049a158a | ||
|
|
95f9d168f1 | ||
|
|
6d58520f57 | ||
|
|
91090a13b9 | ||
|
|
395712490d | ||
|
|
29c00341fa | ||
|
|
2eef6fdaa2 | ||
|
|
14a2175434 | ||
|
|
973671bdf1 | ||
|
|
2d24f74980 | ||
|
|
90278ab2fc | ||
|
|
658f1e8e34 | ||
|
|
a32608f964 | ||
|
|
1f2fbca4de | ||
|
|
58a8dddf31 | ||
|
|
0dc6735da2 | ||
|
|
e46c4d0d71 | ||
|
|
35f399e2cf | ||
|
|
9ff74c852a | ||
|
|
9be59ddfec | ||
|
|
9eeb0681ff | ||
|
|
75bd518447 | ||
|
|
c118a6a795 | ||
|
|
3d6c93196a | ||
|
|
dc762dc6e3 | ||
|
|
45a80185f6 | ||
|
|
273a3ab5dd | ||
|
|
647bdb251c | ||
|
|
9c59206d2f | ||
|
|
9edc587e96 | ||
|
|
db2caf6544 | ||
|
|
54535bec11 | ||
|
|
d32bbe009d | ||
|
|
0ea3d9810e | ||
|
|
1e13413f79 | ||
|
|
891dde7d03 | ||
|
|
def6ed4d2f | ||
|
|
7bf8ce79bd | ||
|
|
ed6e7e493c | ||
|
|
ec4a730287 | ||
|
|
19a21b8f7e | ||
|
|
fa00da3507 | ||
|
|
a7ac4a264e | ||
|
|
1a9945f299 | ||
|
|
b92ac4db0f | ||
|
|
9a3796c401 | ||
|
|
6bf78f1783 | ||
|
|
bb68baf0a9 | ||
|
|
657f9ee368 | ||
|
|
221e42b103 | ||
|
|
bcc324cc8f | ||
|
|
cf8dd167a4 | ||
|
|
d82e12d09e | ||
|
|
30f3273a17 | ||
|
|
3e362fb3d1 | ||
|
|
d40a22aeb0 | ||
|
|
b29a12abbf | ||
|
|
65bedd5f8c | ||
|
|
388ae0d37b | ||
|
|
07c5d25d55 | ||
|
|
e9bbfdd20c | ||
|
|
1f7d8c16bd | ||
|
|
9c14d9a6e1 | ||
|
|
fd26152141 | ||
|
|
a53971b146 | ||
|
|
56bb50913b |
@@ -0,0 +1,12 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
@@ -153,6 +153,12 @@ jobs:
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
// Parameter aliases are reduced against the live command tree.
|
||||
// Their reverse-dependency set is too large for one focused
|
||||
// race job, so use the existing full-suite shards.
|
||||
filename === 'internal/cli/param_concepts.json' ||
|
||||
filename === 'internal/cli/param_concepts.schema.json' ||
|
||||
filename === 'internal/cli/param_aliases_generated.go' ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
@@ -559,7 +565,8 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
|
||||
# app runs several independently bounded processes; cli/smoke need headroom
|
||||
# beyond go test -timeout for setup + assembly.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -595,10 +602,19 @@ jobs:
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
|
||||
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
@@ -2645,6 +2645,40 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
# The sealed candidate tag is intentionally visible while its GitHub
|
||||
# authority is checked above. Compatibility must instead discover the
|
||||
# previous delivered stable tag, so hide only this verified candidate
|
||||
# from this isolated runner's local tag namespace.
|
||||
- name: Prepare delivered-stable compatibility ref view
|
||||
if: ${{ matrix.check == 'compatibility' }}
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
|
||||
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
|
||||
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
|
||||
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
|
||||
run: |
|
||||
set -eu
|
||||
test -n "$RELEASE_VERSION"
|
||||
test -n "$RELEASE_COMMIT"
|
||||
test -n "$RELEASE_TAG_OBJECT"
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
test -n "$PREVIOUS_STABLE_COMMIT"
|
||||
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
|
||||
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
|
||||
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
|
||||
|
||||
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
|
||||
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
|
||||
exit 2
|
||||
fi
|
||||
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57"
|
||||
version "1.0.58"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -68,6 +68,45 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "list_files":
|
||||
return `{"success":true,"result":{"files":[],"hasMore":false}}`
|
||||
case "list_recycle_items":
|
||||
return `{"success":true,"result":{"recycleItems":[{"recycleItemId":"recycle-1","originalName":"Fixture Node"}],"hasMore":false}}`
|
||||
case "get_star_list":
|
||||
return `{"success":true,"result":{"starList":[],"hasMore":false}}`
|
||||
case "list_file_versions":
|
||||
return `{"success":true,"result":{"versions":[{"version":3,"name":"Fixture Version"}],"hasMore":false}}`
|
||||
case "get_file_info":
|
||||
name := "Fixture Node"
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
switch call.tool {
|
||||
case "create_folder":
|
||||
if value, ok := call.args["name"].(string); ok {
|
||||
name = value
|
||||
}
|
||||
index = -1
|
||||
case "rename_document":
|
||||
if value, ok := call.args["newName"].(string); ok {
|
||||
name = value
|
||||
}
|
||||
index = -1
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"fileId": "node-1", "name": name}})
|
||||
return string(encoded)
|
||||
case "get_cover", "get_node_stats":
|
||||
return `{"success":true,"result":{"nodeId":"node-1"}}`
|
||||
case "get_file_publish_status":
|
||||
return `{"success":true,"result":{"fileId":"node-1","published":false}}`
|
||||
case "create_folder", "create_shortcut":
|
||||
return `{"success":true,"fileId":"node-1"}`
|
||||
case "delete_document", "mark_star", "unmark_star", "restore_recycle_item", "rename_document", "revert_file_version":
|
||||
return `{"success":true,"fileId":"node-1"}`
|
||||
case "set_file_publish":
|
||||
return `{"success":true}`
|
||||
case "download_file", "download_file_version":
|
||||
return `{"success":true,"result":{"downloadUrl":"http://invalid.test/fixture.bin","fileName":"fixture.bin"}}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
@@ -322,9 +361,9 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--to-user", "D-recipient",
|
||||
"--to-user", appFixtureCurrentDOpenID,
|
||||
"--text", "hello alias",
|
||||
"--uuid", "alias-e2e",
|
||||
"--idempotency-key", "alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat write alias E2E error = %v", err)
|
||||
@@ -336,7 +375,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
t.Fatalf("chat payload identity fields = %#v", payload)
|
||||
}
|
||||
content, _ := payload["content"].(string)
|
||||
@@ -350,6 +389,29 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSendLegacyUUIDAliasFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
_, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--group", "fixture-conversation",
|
||||
"--text", "hello legacy uuid",
|
||||
"--uuid", "legacy-alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message send legacy uuid error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["uuid"] != "legacy-alias-e2e" || payload["openConversationId"] != "fixture-conversation" {
|
||||
t.Fatalf("chat legacy uuid payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["idempotency-key"]; exists {
|
||||
t.Fatalf("chat payload leaked CLI-only idempotency-key: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -627,11 +689,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
|
||||
tool: "send_personal_message",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/pipeline"
|
||||
)
|
||||
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
// fixture command path. Every argv is a complete, business-valid invocation:
|
||||
// required companion flags are present, time and enum values are valid, and
|
||||
@@ -32,12 +39,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
@@ -55,7 +62,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
@@ -68,10 +75,10 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
|
||||
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
|
||||
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
|
||||
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
|
||||
@@ -86,9 +93,9 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
@@ -124,6 +131,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
@@ -131,9 +139,43 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc create": {"doc", "create", "--name", "Fixture Document", "--workspace", "workspace-1"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive +cover": {"drive", "+cover", "--node", "node-1"},
|
||||
"drive +create-folder": {"drive", "+create-folder", "--name", "Fixture Folder", "--space-id", "space-1", "--folder", "folder-1"},
|
||||
"drive +create-shortcut": {"drive", "+create-shortcut", "--node", "node-1", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"drive +delete": {"drive", "+delete", "--node", "node-1", "--yes"},
|
||||
"drive +download": {"drive", "+download", "--node", "node-1", "--space-id", "space-1", "--output", "downloads/fixture.bin"},
|
||||
"drive +info": {"drive", "+info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive +inspect": {"drive", "+inspect", "--node", "node-1", "--space-id", "space-1", "--include-stats"},
|
||||
"drive +list": {"drive", "+list", "--space-id", "space-1", "--folder", "folder-1", "--limit", "7", "--cursor", "cursor-1", "--order-by", "name", "--order", "asc"},
|
||||
"drive +publish-get": {"drive", "+publish-get", "--node", "node-1"},
|
||||
"drive +publish-unset": {"drive", "+publish-unset", "--node", "node-1", "--yes"},
|
||||
"drive +recycle-list": {"drive", "+recycle-list", "--space-id", "space-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +recycle-restore": {"drive", "+recycle-restore", "--id", "recycle-1", "--yes"},
|
||||
"drive +rename": {"drive", "+rename", "--node", "node-1", "--name", "Fixture Renamed", "--yes"},
|
||||
"drive +search": {"drive", "+search", "--query", "fixture"},
|
||||
"drive +star-add": {"drive", "+star-add", "--node", "node-1"},
|
||||
"drive +star-list": {"drive", "+star-list", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +star-remove": {"drive", "+star-remove", "--node", "node-1"},
|
||||
"drive +stats": {"drive", "+stats", "--node", "node-1"},
|
||||
"drive +upload": {"drive", "+upload", "--file", "param_alias_payload_equivalence_test.go", "--file-name", "fixture.txt", "--mime-type", "text/plain", "--space-id", "space-1", "--node", "node-1", "--yes"},
|
||||
"drive +version-download": {"drive", "+version-download", "--node", "node-1", "--version", "3", "--output", "downloads/fixture-v3.bin"},
|
||||
"drive +version-get": {"drive", "+version-get", "--node", "node-1", "--version", "3"},
|
||||
"drive +version-history": {"drive", "+version-history", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +version-revert": {"drive", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive commit": {"drive", "commit", "--file-name", "fixture.txt", "--file-size", "7", "--upload-id", "upload-1", "--space-id", "space-1"},
|
||||
"drive copy": {"drive", "copy", "--node", "node-1", "--folder", "folder-1"},
|
||||
"drive download": {"drive", "download", "--node", "node-1", "--output", "downloads/fixture.bin", "--version", "3", "--space-id", "space-1"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"drive mkdir": {"drive", "mkdir", "--name", "Fixture Folder", "--space-id", "space-1"},
|
||||
"drive permission add": {"drive", "permission", "add", "--node", "node-1", "--users", "user-1,user-2", "--role", "READER"},
|
||||
"drive recycle list": {"drive", "recycle", "list", "--space-id", "space-1", "--limit", "7"},
|
||||
"drive recycle restore": {"drive", "recycle", "restore", "--id", "recycle-1"},
|
||||
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
|
||||
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
|
||||
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
@@ -156,15 +198,29 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"doc +inspect": {
|
||||
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
|
||||
},
|
||||
"doc +search": {
|
||||
"created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"},
|
||||
"created-to": {"doc", "+search", "--query", "fixture", "--created-to", "2"},
|
||||
"creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"},
|
||||
},
|
||||
"drive list": {
|
||||
"workspace": {"drive", "list", "--workspace", "workspace-1", "--limit", "7"},
|
||||
"order-by": {"drive", "list", "--folder", "folder-1", "--order-by", "name", "--limit", "7"},
|
||||
"space-id": {"drive", "list", "--space-id", "space-1", "--limit", "7"},
|
||||
"order": {"drive", "list", "--folder", "folder-1", "--order", "asc", "--limit", "7"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
@@ -241,12 +297,105 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveCases is the exact executable-alias set introduced by the
|
||||
// reviewed Drive expansion. Guard fixtures are covered separately by the
|
||||
// exhaustive runtime-contract tests; every entry here must preserve the final
|
||||
// transport payload of its canonical spelling.
|
||||
var paramAliasNewDriveCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "drive +cover", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +create-folder", emitted: "folder-name", canonical: "name"},
|
||||
{command: "drive +create-folder", emitted: "storage-space-id", canonical: "space-id"},
|
||||
{command: "drive +create-shortcut", emitted: "source-file-id", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "target-folder-id", canonical: "folder"},
|
||||
{command: "drive +create-shortcut", emitted: "target-workspace-id", canonical: "workspace"},
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +download", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +download", emitted: "destination-path", canonical: "output"},
|
||||
{command: "drive +inspect", emitted: "include-statistics", canonical: "include-stats"},
|
||||
{command: "drive +list", emitted: "folder-id", canonical: "folder"},
|
||||
{command: "drive +list", emitted: "page-size", canonical: "limit"},
|
||||
{command: "drive +list", emitted: "next-token", canonical: "cursor"},
|
||||
{command: "drive +list", emitted: "sort-direction", canonical: "order"},
|
||||
{command: "drive +list", emitted: "sort-by", canonical: "order-by"},
|
||||
{command: "drive +publish-get", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +recycle-list", emitted: "storage-space-id", canonical: "space-id"},
|
||||
{command: "drive +recycle-list", emitted: "page-token", canonical: "cursor"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
{command: "drive +rename", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "new-name", canonical: "name"},
|
||||
{command: "drive +star-add", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +star-list", emitted: "max-results", canonical: "limit"},
|
||||
{command: "drive +star-remove", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "source-file", canonical: "file"},
|
||||
{command: "drive +upload", emitted: "name", canonical: "file-name"},
|
||||
{command: "drive +upload", emitted: "overwrite-node-id", canonical: "node"},
|
||||
{command: "drive +version-download", emitted: "version-number", canonical: "version"},
|
||||
{command: "drive +version-download", emitted: "save-path", canonical: "output"},
|
||||
{command: "drive +version-get", emitted: "version-no", canonical: "version"},
|
||||
{command: "drive +version-history", emitted: "next-cursor", canonical: "cursor"},
|
||||
{command: "drive +version-history", emitted: "page-size", canonical: "limit"},
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
{command: "drive +cover", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +delete", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +download", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +publish-get", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +star-add", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +star-remove", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-download", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-get", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-history", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-revert", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +cover", emitted: "url", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +delete", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +publish-get", emitted: "url", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +star-add", emitted: "url", canonical: "node"},
|
||||
{command: "drive +star-remove", emitted: "doc-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "file-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
|
||||
// every Drive command in the expansion whose declared runtime safety requires
|
||||
// confirmation. The full matrix below proves all spellings preserve the
|
||||
// confirmed payload; this smaller matrix proves aliases cannot cross the
|
||||
// confirmation boundary before any transport call is made.
|
||||
var paramAliasNewDriveConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
{command: "drive +rename", emitted: "new-name", canonical: "name"},
|
||||
{command: "drive +upload", emitted: "source-file", canonical: "file"},
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
// checked through the embedded PreParse delivery path and against a complete
|
||||
// business-valid command template. The separate IM gate below continues to
|
||||
// execute every alias introduced by the current IM optimization.
|
||||
// business-valid command template. The dedicated product gates below continue
|
||||
// to execute every alias introduced by the reviewed IM and Drive expansions.
|
||||
//
|
||||
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
|
||||
// executing the complete 800+ command Root twice per spelling under -race.
|
||||
@@ -261,6 +410,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc create", "space-id"): true, // published Doc workspace compatibility remains payload-equivalent
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
@@ -269,6 +419,8 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +search", "create-time-start"): true, // observed lower-bound spelling preserves milliseconds
|
||||
paramAliasPayloadCaseKey("doc +search", "create-time-end"): true, // observed upper-bound spelling preserves milliseconds
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
@@ -278,6 +430,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("drive info", "workspace"): true, // published numeric storage-space compatibility remains payload-equivalent
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
@@ -421,14 +574,118 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewDriveCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active {
|
||||
return
|
||||
}
|
||||
if target != test.canonical {
|
||||
t.Fatalf("active reviewed Drive alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
|
||||
}
|
||||
activeAliases++
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if activeAliases != len(paramAliasNewDriveCases) {
|
||||
t.Fatalf("new Drive aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewDriveCases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewDriveConfirmationCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, complete)
|
||||
}
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias command crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Some artifact commands deliberately continue past the final captured
|
||||
// transport call into local download/upload handling. Deterministic invalid
|
||||
// resource responses form their expected post-transport test boundary;
|
||||
// canonical and alias calls must still produce the same request and error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch command {
|
||||
case "chat +messages-resource-download":
|
||||
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
case "drive +download", "drive +version-download":
|
||||
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
|
||||
case "drive +upload":
|
||||
return strings.Contains(err.Error(), "incomplete drive upload credentials")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
@@ -483,3 +740,16 @@ func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
|
||||
}
|
||||
return out, replacements
|
||||
}
|
||||
|
||||
func removeExactArg(args []string, target string) ([]string, int) {
|
||||
out := make([]string, 0, len(args))
|
||||
removals := 0
|
||||
for _, arg := range args {
|
||||
if arg == target {
|
||||
removals++
|
||||
continue
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
return out, removals
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
)
|
||||
|
||||
@@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) {
|
||||
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
|
||||
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
|
||||
// GetMCPBaseURL() returns the test server address.
|
||||
func stubPATPollAccessToken(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) {
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
})
|
||||
}
|
||||
|
||||
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
stubPATPollAccessToken(t)
|
||||
var callCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
|
||||
stubPATPollAccessToken(t)
|
||||
// When server returns 302 (SSO redirect), poll should continue until real response.
|
||||
var callCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor
|
||||
// It responds to device poll requests with the given status after the first poll.
|
||||
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
stubPATPollAccessToken(t)
|
||||
var pollCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -369,6 +370,20 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat message send missing --%s", flag)
|
||||
}
|
||||
}
|
||||
idempotencyKey := send.Flags().Lookup("idempotency-key")
|
||||
if idempotencyKey == nil {
|
||||
t.Fatal("chat message send missing --idempotency-key")
|
||||
}
|
||||
legacyUUID := send.Flags().Lookup("uuid")
|
||||
if legacyUUID == nil || !legacyUUID.Hidden {
|
||||
t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID)
|
||||
}
|
||||
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "idempotency-key" {
|
||||
t.Fatalf("chat message send --uuid alias_of = %#v, want idempotency-key", got)
|
||||
}
|
||||
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("chat message send --uuid alias_origin = %#v, want %s", got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"chat", "file", "upload",
|
||||
|
||||
@@ -83,6 +83,10 @@ func TestMain(m *testing.M) {
|
||||
// remove a TempDir while the audit lock is still open.
|
||||
setupAuditSink()
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
// App tests may run in filtered CI processes. Install the same lazy Schema
|
||||
// source factory as NewRootCommand without constructing a root so ResolveMeta
|
||||
// tests never depend on an earlier test having initialized process state.
|
||||
registerSchemaRuntimeDelivery()
|
||||
code := m.Run()
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
|
||||
+1323
-221
File diff suppressed because it is too large
Load Diff
@@ -515,3 +515,68 @@ func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishedSpaceWorkspaceAliasesRemainExecutable(t *testing.T) {
|
||||
docCommands := []string{
|
||||
"doc +access-change",
|
||||
"doc +access-grant",
|
||||
"doc +access-revoke",
|
||||
"doc +copy",
|
||||
"doc +create",
|
||||
"doc +create-from-template",
|
||||
"doc +grant-and-share",
|
||||
"doc +import",
|
||||
"doc +list",
|
||||
"doc +move",
|
||||
"doc create",
|
||||
"doc file create",
|
||||
"doc import",
|
||||
"doc template apply",
|
||||
}
|
||||
for _, command := range docCommands {
|
||||
entry, ok := LookupParamAlias(command)
|
||||
if !ok {
|
||||
t.Errorf("published Doc command %q has no generated parameter-alias entry", command)
|
||||
continue
|
||||
}
|
||||
for _, emitted := range []string{"space", "space-id"} {
|
||||
target, active := entry.ResolveAlias(emitted)
|
||||
if !active || target != "workspace" {
|
||||
t.Errorf("%s --%s resolution = active:%v target:%q, want --workspace", command, emitted, active, target)
|
||||
}
|
||||
if entry.IsBlocked(emitted) || entry.IsAmbiguous(emitted) {
|
||||
t.Errorf("%s --%s remains protected after restoring its published alias", command, emitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driveInfo, ok := LookupParamAlias("drive info")
|
||||
if !ok {
|
||||
t.Fatal("published drive info command has no generated parameter-alias entry")
|
||||
}
|
||||
for _, emitted := range []string{"space", "workspace", "workspace-id"} {
|
||||
target, active := driveInfo.ResolveAlias(emitted)
|
||||
if !active || target != "space-id" {
|
||||
t.Errorf("drive info --%s resolution = active:%v target:%q, want --space-id", emitted, active, target)
|
||||
}
|
||||
if driveInfo.IsBlocked(emitted) || driveInfo.IsAmbiguous(emitted) {
|
||||
t.Errorf("drive info --%s remains protected after restoring its published alias", emitted)
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility remains exact-path scoped. Strong type spellings introduced
|
||||
// after the split continue to guard the two value domains elsewhere.
|
||||
for _, test := range []struct {
|
||||
command string
|
||||
emitted string
|
||||
}{
|
||||
{command: "doc create", emitted: "storage-space-id"},
|
||||
{command: "drive +upload", emitted: "workspace-id"},
|
||||
{command: "drive info", emitted: "knowledge-base-id"},
|
||||
} {
|
||||
entry, ok := LookupParamAlias(test.command)
|
||||
if !ok || !entry.IsBlocked(test.emitted) {
|
||||
t.Errorf("%s --%s must remain blocked outside the published compatibility set", test.command, test.emitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "drive +find-file", "drive +search", "drive +search-docs", "drive search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +search-docs", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive permission list", "drive recent", "drive recycle list", "drive search", "drive star list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive recent", "drive recycle list", "drive search", "drive star list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
@@ -31,20 +31,31 @@
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
|
||||
"drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single document or Drive ordinary-file historical version number with unchanged positive integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"},
|
||||
"drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"},
|
||||
"drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
|
||||
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
|
||||
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
|
||||
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
|
||||
"creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
@@ -54,8 +65,8 @@
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
|
||||
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
|
||||
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
|
||||
@@ -101,9 +112,9 @@
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
@@ -114,7 +125,7 @@
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"doc update": {"block": ["stdin", "version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -158,21 +169,86 @@
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."},
|
||||
"doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "block": ["after-block-id", "before-block-id"], "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."},
|
||||
"drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]},
|
||||
"drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]},
|
||||
"drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]},
|
||||
"drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]},
|
||||
"drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]},
|
||||
"drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]},
|
||||
"drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."},
|
||||
"drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."},
|
||||
"drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]},
|
||||
"drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."},
|
||||
"drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."},
|
||||
"drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
|
||||
"drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."},
|
||||
"drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."},
|
||||
"drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."},
|
||||
"drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."},
|
||||
"drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."},
|
||||
"drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."},
|
||||
"drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."},
|
||||
"drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."},
|
||||
"drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
|
||||
"drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
|
||||
"drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
|
||||
"drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."},
|
||||
"drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."},
|
||||
"drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."},
|
||||
"drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."},
|
||||
"drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."},
|
||||
"drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}},
|
||||
"drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
|
||||
"drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
|
||||
"drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."},
|
||||
"drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -243,7 +319,7 @@
|
||||
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2},
|
||||
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
|
||||
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -325,7 +401,7 @@
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
@@ -415,9 +491,9 @@
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
@@ -425,11 +501,154 @@
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
|
||||
{"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"},
|
||||
{"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"},
|
||||
{"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"},
|
||||
{"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
|
||||
{"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"},
|
||||
{"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"},
|
||||
{"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
|
||||
{"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"},
|
||||
{"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"},
|
||||
{"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"},
|
||||
{"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
|
||||
{"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
|
||||
{"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"},
|
||||
{"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
|
||||
{"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
|
||||
{"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1},
|
||||
{"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1},
|
||||
{"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
|
||||
{"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
|
||||
{"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"},
|
||||
{"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"},
|
||||
{"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
|
||||
{"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6},
|
||||
{"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
|
||||
{"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
|
||||
{"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3},
|
||||
{"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1},
|
||||
{"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3},
|
||||
{"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2},
|
||||
{"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2},
|
||||
{"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1},
|
||||
{"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2},
|
||||
{"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1},
|
||||
{"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1},
|
||||
{"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"},
|
||||
{"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1},
|
||||
{"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1},
|
||||
{"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1},
|
||||
{"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
|
||||
{"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
|
||||
{"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
{"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1},
|
||||
{"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
|
||||
{"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"},
|
||||
{"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"},
|
||||
{"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"},
|
||||
{"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"},
|
||||
{"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
|
||||
{"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"},
|
||||
{"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"},
|
||||
{"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"},
|
||||
{"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"},
|
||||
{"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
|
||||
{"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"},
|
||||
{"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"},
|
||||
{"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"},
|
||||
{"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"},
|
||||
{"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"},
|
||||
{"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
|
||||
{"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"},
|
||||
{"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"},
|
||||
{"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"},
|
||||
{"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"},
|
||||
{"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"},
|
||||
{"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,6 +1356,7 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message send", "idempotency-key", "uuid", false, ""},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
@@ -1407,6 +1408,14 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden)
|
||||
}
|
||||
}
|
||||
sendLeaf, err = queryDeliverySchemaPayload([]string{"chat message send"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sendParams = schemaMap(sendLeaf["parameters"])
|
||||
if _, ok := sendParams["uuid"]; ok {
|
||||
t.Fatal("chat message send unexpectedly publishes hidden alias --uuid")
|
||||
}
|
||||
listByConv, err := queryDeliverySchemaPayload([]string{"chat category list-by-conv"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+144
-61
@@ -18,6 +18,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
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"
|
||||
@@ -467,22 +468,50 @@ func validateNativeSearchConversationScope(conversationIDs []string) error {
|
||||
}
|
||||
|
||||
func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
|
||||
startRaw, endRaw, err := defaultChatMessageListAllTimeRange(cmd, 24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", startRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cursor, _ := cmd.Flags().GetString("cursor")
|
||||
return map[string]any{
|
||||
"startTime": mustGetFlag(cmd, "start"),
|
||||
"endTime": mustGetFlag(cmd, "end"),
|
||||
"startTime": startRaw,
|
||||
"endTime": endRaw,
|
||||
"limit": chatIntFlagOrFallback(cmd, "limit", "size"),
|
||||
"cursor": cursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if err := validateRequiredFlags(cmd, "start"); err != nil {
|
||||
return nil, err
|
||||
func defaultChatMessageListAllTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) {
|
||||
startRaw := mustGetFlag(cmd, "start")
|
||||
endRaw := mustGetFlag(cmd, "end")
|
||||
anchor := time.Now()
|
||||
if endRaw == "" {
|
||||
endRaw = formatChatMessageListAllTime(anchor.UnixMilli())
|
||||
} else if startRaw == "" {
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
anchor = time.UnixMilli(endMs)
|
||||
}
|
||||
if startRaw == "" {
|
||||
startRaw = formatChatMessageListAllTime(anchor.Add(-lookback).UnixMilli())
|
||||
}
|
||||
return startRaw, endRaw, nil
|
||||
}
|
||||
|
||||
func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
senderUserID := flagOrFallback(cmd, "sender-user-id", "sender")
|
||||
senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id")
|
||||
if senderUserID != "" && senderOpenDingTalkID != "" {
|
||||
@@ -491,13 +520,18 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if senderUserID == "" && senderOpenDingTalkID == "" {
|
||||
return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
if senderOpenDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endRaw, _ := cmd.Flags().GetString("end")
|
||||
if strings.TrimSpace(endRaw) == "" {
|
||||
endRaw = time.Now().Format(time.RFC3339)
|
||||
startMs, err := parseISOTimeToMillis("start", startRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
@@ -522,14 +556,15 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
}
|
||||
|
||||
func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end"))
|
||||
startMs, err := parseISOTimeToMillis("start", startRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -564,14 +599,15 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end"))
|
||||
startMs, err := parseISOTimeToMillis("start", startRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -592,6 +628,33 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) {
|
||||
startRaw := mustGetFlag(cmd, "start")
|
||||
endRaw := mustGetFlag(cmd, "end")
|
||||
anchor := time.Now()
|
||||
if endRaw == "" {
|
||||
endRaw = anchor.Format(time.RFC3339)
|
||||
} else if startRaw == "" {
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
anchor = time.UnixMilli(endMs)
|
||||
}
|
||||
if startRaw == "" {
|
||||
startRaw = anchor.Add(-lookback).Format(time.RFC3339)
|
||||
}
|
||||
return startRaw, endRaw, nil
|
||||
}
|
||||
|
||||
func formatChatMessageListAllTime(ms int64) string {
|
||||
return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func defaultChatMessageListTime() string {
|
||||
return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
toolArgs := map[string]any{}
|
||||
if v := flagOrFallback(cmd, "query", "keyword"); v != "" {
|
||||
@@ -993,8 +1056,7 @@ func isNumericUserID(value string) bool {
|
||||
}
|
||||
|
||||
func isOpenDingTalkID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
|
||||
return targetresolver.LooksLikeCurrentDOpenDingTalkID(value)
|
||||
}
|
||||
|
||||
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
|
||||
@@ -2605,8 +2667,9 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "拉取会话消息内容",
|
||||
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
|
||||
Example: ` dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
|
||||
Example: ` dws chat message list --group <openconversation_id>
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
|
||||
@@ -2614,9 +2677,6 @@ func newChatCommand() *cobra.Command {
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
# 查询 userId: dws contact user search --query "姓名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "time"); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
@@ -2636,15 +2696,25 @@ func newChatCommand() *cobra.Command {
|
||||
if specified == 0 {
|
||||
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
}
|
||||
forward, err := resolveMessageForward(cmd, true)
|
||||
timeVal := mustGetFlag(cmd, "time")
|
||||
defaultForward := true
|
||||
if timeVal == "" {
|
||||
timeVal = defaultChatMessageListTime()
|
||||
defaultForward = false
|
||||
}
|
||||
forward, err := resolveMessageForward(cmd, defaultForward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timeVal := mustGetFlag(cmd, "time")
|
||||
if groupID != "" {
|
||||
toolArgs := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
@@ -2695,7 +2765,7 @@ func newChatCommand() *cobra.Command {
|
||||
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
|
||||
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
|
||||
Examples: []string{
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
|
||||
"dws chat message list --group <openConversationId> --limit 50",
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
@@ -2723,6 +2793,11 @@ func newChatCommand() *cobra.Command {
|
||||
if userID == "" && openDingTalkID == "" {
|
||||
return fmt.Errorf("--user or --open-dingtalk-id is required")
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
@@ -2730,7 +2805,7 @@ func newChatCommand() *cobra.Command {
|
||||
timeVal, _ := cmd.Flags().GetString("time")
|
||||
defaultForward := true
|
||||
if strings.TrimSpace(timeVal) == "" {
|
||||
timeVal = time.Now().Format("2006-01-02 15:04:05")
|
||||
timeVal = defaultChatMessageListTime()
|
||||
defaultForward = false
|
||||
}
|
||||
forward, err := resolveMessageForward(cmd, defaultForward)
|
||||
@@ -2835,7 +2910,7 @@ func newChatCommand() *cobra.Command {
|
||||
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
msgUuid, _ := cmd.Flags().GetString("uuid")
|
||||
msgUuid := flagOrFallback(cmd, "idempotency-key", "uuid")
|
||||
specified := 0
|
||||
if groupID != "" {
|
||||
specified++
|
||||
@@ -2852,6 +2927,11 @@ func newChatCommand() *cobra.Command {
|
||||
if specified == 0 {
|
||||
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
@@ -3050,6 +3130,7 @@ func newChatCommand() *cobra.Command {
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
|
||||
{Name: "group", Property: "openConversationId"},
|
||||
{Name: "idempotency-key", Property: "uuid"},
|
||||
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"},
|
||||
},
|
||||
},
|
||||
@@ -3487,7 +3568,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageListAllCmd := &cobra.Command{
|
||||
Use: "list-all",
|
||||
Short: "拉取指定时间范围内当前用户的所有会话消息",
|
||||
Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`,
|
||||
Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 可选,不传时默认最近 1 天到当前时间,避免跨全部会话范围过大;--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`,
|
||||
Example: ` dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
|
||||
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
|
||||
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`,
|
||||
@@ -3534,7 +3615,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageListBySenderCmd := &cobra.Command{
|
||||
Use: "list-by-sender",
|
||||
Short: "拉取指定发送者的消息(包含单聊和群聊)",
|
||||
Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
|
||||
Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。--start 和 --end 可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 50)和 --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 list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0
|
||||
@@ -3586,7 +3667,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageListMentionsCmd := &cobra.Command{
|
||||
Use: "list-mentions",
|
||||
Short: "拉取 @我 的消息",
|
||||
Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
|
||||
Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --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 list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0
|
||||
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
|
||||
@@ -3774,7 +3855,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "按关键词搜索消息",
|
||||
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--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 控制页间等待毫秒数。`,
|
||||
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --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>
|
||||
@@ -4332,9 +4413,9 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
chatMessageListCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)")
|
||||
chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)")
|
||||
chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)")
|
||||
chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
|
||||
chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(推荐)")
|
||||
chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older")
|
||||
chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)")
|
||||
chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)")
|
||||
chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)")
|
||||
_ = chatMessageListCmd.Flags().MarkHidden("forward")
|
||||
chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制")
|
||||
chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
@@ -4385,7 +4466,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("file-type")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("file-size")
|
||||
chatMessageSendCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
||||
chatMessageSendCmd.Flags().String("uuid", "", "幂等 UUID,相同 uuid 在 24h 内不会重复发送(可选)")
|
||||
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
|
||||
Name: "idempotency-key",
|
||||
Usage: "幂等键,相同 key 在 24h 内不会重复发送(可选)",
|
||||
Aliases: []string{"uuid"},
|
||||
}})
|
||||
cli.AttachRuntimeSchema(chatMessageSendCmd, "chat", "send_personal_message", "hardcoded:chat")
|
||||
cli.AnnotateRuntimeConstraints(chatMessageSendCmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}},
|
||||
@@ -4448,10 +4533,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
chatMessageListTopicRepliesCmd.Flags().String("forward", "false", "true 等价 --direction newer,false 等价 --direction older(默认 false)")
|
||||
_ = chatMessageListTopicRepliesCmd.Flags().MarkHidden("forward")
|
||||
|
||||
chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
|
||||
_ = chatMessageListAllCmd.MarkFlagRequired("start")
|
||||
chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
|
||||
_ = chatMessageListAllCmd.MarkFlagRequired("end")
|
||||
chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间前 1 天)")
|
||||
chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)")
|
||||
chatMessageListAllCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
|
||||
chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = chatMessageListAllCmd.Flags().MarkHidden("size")
|
||||
@@ -4465,9 +4548,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
chatMessageListBySenderCmd.Flags().String("sender-open-dingtalk-id", "", "发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)")
|
||||
chatMessageListBySenderCmd.Flags().String("user", "", "")
|
||||
_ = chatMessageListBySenderCmd.Flags().MarkHidden("user")
|
||||
chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
|
||||
_ = chatMessageListBySenderCmd.MarkFlagRequired("start")
|
||||
chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
|
||||
chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
|
||||
chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
|
||||
chatMessageListBySenderCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
|
||||
chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = chatMessageListBySenderCmd.Flags().MarkHidden("size")
|
||||
@@ -4476,10 +4558,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
|
||||
// list-mentions flags
|
||||
chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)")
|
||||
chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
|
||||
_ = chatMessageListMentionsCmd.MarkFlagRequired("start")
|
||||
chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
|
||||
_ = chatMessageListMentionsCmd.MarkFlagRequired("end")
|
||||
chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
|
||||
chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
|
||||
chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
|
||||
chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = chatMessageListMentionsCmd.Flags().MarkHidden("size")
|
||||
@@ -4504,10 +4584,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名")
|
||||
_ = chatMessageSearchCmd.Flags().MarkHidden("keyword")
|
||||
chatMessageSearchCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则搜索所有会话)")
|
||||
chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
|
||||
_ = chatMessageSearchCmd.MarkFlagRequired("start")
|
||||
chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
|
||||
_ = chatMessageSearchCmd.MarkFlagRequired("end")
|
||||
chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
|
||||
chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
|
||||
chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)")
|
||||
chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = chatMessageSearchCmd.Flags().MarkHidden("size")
|
||||
@@ -4667,9 +4745,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
|
||||
userID := rawUserID
|
||||
openDingTalkID := rawOpenDingTalkID
|
||||
if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) {
|
||||
userID = openDingTalkID
|
||||
openDingTalkID = ""
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
@@ -5917,6 +5996,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
Short: "下载消息中的资源(图片/视频/语音等)到本地",
|
||||
Long: `下载聊天消息中的图片、视频、语音等资源到本地文件。
|
||||
|
||||
本命令只支持聊天消息 mediaId 下载,不支持钉盘 fileId。
|
||||
如需用 fileId 下载,请使用钉盘/drive 下载命令。
|
||||
|
||||
流程:
|
||||
1. 根据 resourceType + resource-id + message-id + open-conversation-id 向服务端获取下载 URL
|
||||
2. HTTP GET 下载文件到本地
|
||||
@@ -5927,6 +6009,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./photo.jpg
|
||||
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
|
||||
# 不支持钉盘 fileId;fileId 下载请使用钉盘/drive 下载命令
|
||||
# message-id: 从 dws chat message list 返回的 openMessageId
|
||||
# open-conversation-id: 从 dws chat search 获取 openConversationId`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -6056,16 +6139,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "下载消息中的媒体资源到本地",
|
||||
UseWhen: []string{"已知消息、会话和资源 ID,需要保存媒体文件时"},
|
||||
AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令"},
|
||||
AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令", "资源是钉盘 fileId 时使用钉盘/drive 下载命令"},
|
||||
Examples: []string{"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// download-media flags
|
||||
chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId (必填)")
|
||||
chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId(必填;仅支持聊天消息 mediaId,不支持钉盘 fileId)")
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("type")
|
||||
chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)")
|
||||
chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值(必填;不是钉盘 fileId)")
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("resource-id")
|
||||
chatMessageDownloadMediaCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填)")
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
@@ -160,6 +160,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
caller := &productExampleCaller{}
|
||||
if got := formatChatMessageListAllTime(0); got == "" {
|
||||
t.Fatal("formatChatMessageListAllTime returned empty string")
|
||||
}
|
||||
|
||||
commands := [][]string{
|
||||
{"chmod", "chat.read", "--ttl="},
|
||||
@@ -168,9 +171,17 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"},
|
||||
{"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"},
|
||||
{"message", "list-direct", "--user=D-user", "--limit=1"},
|
||||
{"message", "list-all"},
|
||||
{"message", "list-all", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-all", "--end=bad"},
|
||||
{"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"},
|
||||
{"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"},
|
||||
{"message", "list-all", "--start=2027-01-01 00:00:00"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"},
|
||||
{"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"},
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
helperCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
helperCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
func newChatFlagTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "chat"}
|
||||
cmd.Flags().String("forward", "", "")
|
||||
@@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) {
|
||||
userIDs, openIDs := splitChatIDValues([]string{
|
||||
helperCurrentDOpenID,
|
||||
"D-prefix-fixture-user",
|
||||
"d-prefix-fixture-user",
|
||||
"D-invalid",
|
||||
"fixture-user-id",
|
||||
})
|
||||
if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID {
|
||||
t.Fatalf("open IDs=%#v", openIDs)
|
||||
}
|
||||
if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" {
|
||||
t.Fatalf("user IDs=%#v", userIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
|
||||
search := &cobra.Command{Use: "search"}
|
||||
search.Flags().String("nicks", "", "")
|
||||
@@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) {
|
||||
if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
|
||||
if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID {
|
||||
t.Fatalf("direct ID = %q, %v", id, err)
|
||||
}
|
||||
if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
|
||||
t.Fatal("empty ID unexpectedly resolved")
|
||||
}
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID {
|
||||
t.Fatalf("direct IDs = %#v, %v", ids, err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 {
|
||||
t.Fatalf("resolved IDs = %#v, %v", ids, err)
|
||||
}
|
||||
caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
caller := &chatFilePathCaller{}
|
||||
commandArgs := []string{
|
||||
"message", "send",
|
||||
"--open-dingtalk-id=D-target",
|
||||
"--open-dingtalk-id=" + helperCurrentDOpenID,
|
||||
"--msg-type=file",
|
||||
"--file-path=" + filePath,
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
server: "im",
|
||||
tool: "init_conversation_file_upload",
|
||||
args: map[string]any{
|
||||
"openDingTalkId": "D-target",
|
||||
"openDingTalkId": helperCurrentDOpenID,
|
||||
"fileName": "report.pdf",
|
||||
"fileSize": int64(len(payload)),
|
||||
"md5": fileMD5,
|
||||
@@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
server: "im",
|
||||
tool: "commit_conversation_file_upload",
|
||||
args: map[string]any{
|
||||
"openDingTalkId": "D-target",
|
||||
"openDingTalkId": helperCurrentDOpenID,
|
||||
"uploadKey": "upload-key",
|
||||
"fileName": "report.pdf",
|
||||
"fileSize": int64(len(payload)),
|
||||
@@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
|
||||
t.Fatalf("send direct target call = %#v", send)
|
||||
}
|
||||
if send.args["receiverOpenDingTalkId"] != "D-target" {
|
||||
if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID {
|
||||
t.Fatalf("send direct target = %#v", send.args)
|
||||
}
|
||||
if _, ok := send.args["openDingTalkId"]; ok {
|
||||
|
||||
@@ -90,3 +90,96 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
contains []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
args: []string{"message", "list", "--help"},
|
||||
contains: []string{
|
||||
"--time 可选,不传时默认上海时间当前时间并向旧消息拉取",
|
||||
"默认上海时间当前时间",
|
||||
"未传 --time 时默认 older",
|
||||
},
|
||||
absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
args: []string{"message", "search", "--help"},
|
||||
contains: []string{
|
||||
"可选,不传时默认最近 7 天到当前时间",
|
||||
"默认当前时间前 7 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
|
||||
},
|
||||
{
|
||||
name: "list-by-sender",
|
||||
args: []string{"message", "list-by-sender", "--help"},
|
||||
contains: []string{
|
||||
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
|
||||
"默认当前时间前 7 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
|
||||
},
|
||||
{
|
||||
name: "list-mentions",
|
||||
args: []string{"message", "list-mentions", "--help"},
|
||||
contains: []string{
|
||||
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
|
||||
"默认当前时间前 7 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
|
||||
},
|
||||
{
|
||||
name: "list-all",
|
||||
args: []string{"message", "list-all", "--help"},
|
||||
contains: []string{
|
||||
"--start 和 --end 可选,不传时默认最近 1 天到当前时间",
|
||||
"默认当前时间前 1 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
|
||||
},
|
||||
{
|
||||
name: "download-media",
|
||||
args: []string{"message", "download-media", "--help"},
|
||||
contains: []string{
|
||||
"只支持聊天消息 mediaId 下载,不支持钉盘 fileId",
|
||||
"fileId 下载请使用钉盘/drive 下载命令",
|
||||
"仅支持聊天消息 mediaId,不支持钉盘 fileId",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(test.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dws chat %s: %v\n%s", strings.Join(test.args, " "), err, output.String())
|
||||
}
|
||||
help := output.String()
|
||||
for _, want := range test.contains {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Errorf("chat message %s help missing %q:\n%s", test.name, want, help)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range test.absent {
|
||||
if strings.Contains(help, unwanted) {
|
||||
t.Errorf("chat message %s help still contains %q:\n%s", test.name, unwanted, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -148,6 +149,356 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantLookback time.Duration
|
||||
}{
|
||||
{
|
||||
name: "list-all defaults to one day",
|
||||
args: []string{"message", "list-all"},
|
||||
wantTool: "search_messages_by_time_range",
|
||||
wantLookback: 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-by-sender defaults to seven days",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1"},
|
||||
wantTool: "search_messages_by_sender",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-mentions defaults to seven days",
|
||||
args: []string{"message", "list-mentions"},
|
||||
wantTool: "search_at_me_message",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "search defaults to seven days",
|
||||
args: []string{"message", "search", "--query", "发布"},
|
||||
wantTool: "search_messages_by_keyword",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
before := time.Now()
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.tool != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
|
||||
}
|
||||
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
|
||||
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
|
||||
wantEndMin := before.Truncate(time.Second).UnixMilli()
|
||||
wantEndMax := after.Add(time.Second).UnixMilli()
|
||||
if endMs < wantEndMin || endMs > wantEndMax {
|
||||
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
|
||||
}
|
||||
wantStartMin := before.Add(-tt.wantLookback).Truncate(time.Second).UnixMilli()
|
||||
wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli()
|
||||
if startMs < wantStartMin || startMs > wantStartMax {
|
||||
t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax)
|
||||
}
|
||||
if tt.wantTool == "search_messages_by_time_range" {
|
||||
assertChatMessageListAllTimeFormat(t, got.args["startTime"])
|
||||
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) {
|
||||
endRaw := "2026-01-01T00:00:00+08:00"
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantLookback time.Duration
|
||||
}{
|
||||
{
|
||||
name: "list-all defaults start one day before explicit end",
|
||||
args: []string{"message", "list-all", "--end", endRaw},
|
||||
wantTool: "search_messages_by_time_range",
|
||||
wantLookback: 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-by-sender defaults start seven days before explicit end",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--end", endRaw},
|
||||
wantTool: "search_messages_by_sender",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-mentions defaults start seven days before explicit end",
|
||||
args: []string{"message", "list-mentions", "--end", endRaw},
|
||||
wantTool: "search_at_me_message",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "search defaults start seven days before explicit end",
|
||||
args: []string{"message", "search", "--query", "发布", "--end", endRaw},
|
||||
wantTool: "search_messages_by_keyword",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.tool != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
|
||||
}
|
||||
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
|
||||
gotEndMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
|
||||
if gotEndMs != endMs {
|
||||
t.Fatalf("endTime = %d, want %d", gotEndMs, endMs)
|
||||
}
|
||||
wantStartMs := time.UnixMilli(endMs).Add(-tt.wantLookback).UnixMilli()
|
||||
if startMs != wantStartMs {
|
||||
t.Fatalf("startTime = %d, want %d", startMs, wantStartMs)
|
||||
}
|
||||
if tt.wantTool == "search_messages_by_time_range" {
|
||||
assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs))
|
||||
assertStringArg(t, got.args["endTime"], endRaw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageListAllDefaultStartFromTimezoneLessEndUsesShanghai(t *testing.T) {
|
||||
previousLocal := time.Local
|
||||
t.Cleanup(func() { time.Local = previousLocal })
|
||||
|
||||
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
|
||||
t.Run(loc.String(), func(t *testing.T) {
|
||||
time.Local = loc
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, "message", "list-all", "--end", "2026-03-01 00:00:00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
assertStringArg(t, got.args["endTime"], "2026-03-01 00:00:00")
|
||||
assertStringArg(t, got.args["startTime"], "2026-02-28 00:00:00")
|
||||
|
||||
startMs, err := parseISOTimeToMillis("start", got.args["startTime"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", got.args["endTime"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotWindow := time.Duration(endMs-startMs) * time.Millisecond; gotWindow != 24*time.Hour {
|
||||
t.Fatalf("window = %v, want 24h", gotWindow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) {
|
||||
startRaw := "2026-01-01T00:00:00+08:00"
|
||||
startMs, err := parseISOTimeToMillis("start", startRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
}{
|
||||
{
|
||||
name: "list-all defaults end to now",
|
||||
args: []string{"message", "list-all", "--start", startRaw},
|
||||
wantTool: "search_messages_by_time_range",
|
||||
},
|
||||
{
|
||||
name: "list-by-sender defaults end to now",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", startRaw},
|
||||
wantTool: "search_messages_by_sender",
|
||||
},
|
||||
{
|
||||
name: "list-mentions defaults end to now",
|
||||
args: []string{"message", "list-mentions", "--start", startRaw},
|
||||
wantTool: "search_at_me_message",
|
||||
},
|
||||
{
|
||||
name: "search defaults end to now",
|
||||
args: []string{"message", "search", "--query", "发布", "--start", startRaw},
|
||||
wantTool: "search_messages_by_keyword",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
before := time.Now()
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.tool != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
|
||||
}
|
||||
gotStartMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
|
||||
if gotStartMs != startMs {
|
||||
t.Fatalf("startTime = %d, want %d", gotStartMs, startMs)
|
||||
}
|
||||
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
|
||||
wantEndMin := before.Truncate(time.Second).UnixMilli()
|
||||
wantEndMax := after.Add(time.Second).UnixMilli()
|
||||
if endMs < wantEndMin || endMs > wantEndMax {
|
||||
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
|
||||
}
|
||||
if tt.wantTool == "search_messages_by_time_range" {
|
||||
assertStringArg(t, got.args["startTime"], startRaw)
|
||||
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageListAllRejectsInvalidTimeBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid start",
|
||||
args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"},
|
||||
wantErr: "cannot parse time for --start",
|
||||
},
|
||||
{
|
||||
name: "end before start",
|
||||
args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"},
|
||||
wantErr: "--end must be after --start",
|
||||
},
|
||||
{
|
||||
name: "default end before future start",
|
||||
args: []string{"message", "list-all", "--start", "2027-01-01 00:00:00"},
|
||||
wantErr: "--end must be after --start",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want contains %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls = %#v, want no MCP call", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func numericArgAsInt64(t *testing.T, value any) int64 {
|
||||
t.Helper()
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
parsed, err := parseISOTimeToMillis("time", v)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time arg %q: %v", v, err)
|
||||
}
|
||||
return parsed
|
||||
default:
|
||||
t.Fatalf("unsupported numeric arg type %T (%#v)", value, value)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func chatMessageTimeArgAsMillis(t *testing.T, value any, tool string) int64 {
|
||||
t.Helper()
|
||||
if tool == "search_messages_by_time_range" {
|
||||
return parseChatMessageListAllTimeArg(t, value).UnixMilli()
|
||||
}
|
||||
return numericArgAsInt64(t, value)
|
||||
}
|
||||
|
||||
func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time {
|
||||
t.Helper()
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("time arg = %#v, want time string", value)
|
||||
}
|
||||
if strings.Contains(raw, "T") {
|
||||
parsedMs, err := parseISOTimeToMillis("time", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
return time.UnixMilli(parsedMs)
|
||||
}
|
||||
parsedMs, err := parseISOTimeToMillis("time", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
return time.UnixMilli(parsedMs)
|
||||
}
|
||||
|
||||
func assertChatMessageListAllTimeFormat(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("time arg = %#v, want time string", value)
|
||||
}
|
||||
if strings.Contains(raw, "T") {
|
||||
t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw)
|
||||
}
|
||||
if _, err := parseISOTimeToMillis("time", raw); err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringArg(t *testing.T, value any, want string) {
|
||||
t.Helper()
|
||||
got, ok := value.(string)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("arg = %#v, want %q", value, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -724,6 +724,99 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) {
|
||||
previousLocal := time.Local
|
||||
t.Cleanup(func() { time.Local = previousLocal })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantTarget map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"message", "list", "--group", "cid-1", "--limit", "50"},
|
||||
wantTool: "list_conversation_message_v2",
|
||||
wantTarget: map[string]any{"openconversation_id": "cid-1"},
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
args: []string{"message", "list", "--user", "user-1", "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"userId": "user-1"},
|
||||
},
|
||||
{
|
||||
name: "user open DingTalk ID fallback",
|
||||
args: []string{"message", "list", "--user", helperCurrentDOpenID, "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "open DingTalk ID",
|
||||
args: []string{"message", "list", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "direct user",
|
||||
args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"userId": "user-1"},
|
||||
},
|
||||
{
|
||||
name: "direct open DingTalk ID",
|
||||
args: []string{"message", "list-direct", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
|
||||
t.Run(loc.String(), func(t *testing.T) {
|
||||
time.Local = loc
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
before := time.Now()
|
||||
err := executeChatChangedContract(t, caller, tt.args...)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one MCP call", caller.calls)
|
||||
}
|
||||
if caller.calls[0].toolName != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool)
|
||||
}
|
||||
for key, want := range tt.wantTarget {
|
||||
if got := caller.calls[0].args[key]; got != want {
|
||||
t.Fatalf("arg %s = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
raw, ok := caller.calls[0].args["time"].(string)
|
||||
if !ok || raw == "" {
|
||||
t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"])
|
||||
}
|
||||
gotMs, err := parseISOTimeToMillis("time", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
wantMin := before.Add(-time.Second).UnixMilli()
|
||||
wantMax := after.Add(time.Second).UnixMilli()
|
||||
if gotMs < wantMin || gotMs > wantMax {
|
||||
t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax)
|
||||
}
|
||||
if caller.calls[0].args["forward"] != false {
|
||||
t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
@@ -786,11 +879,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
|
||||
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -819,11 +912,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
|
||||
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -857,25 +950,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
name: "send",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
|
||||
},
|
||||
{
|
||||
name: "send keeps missing member placeholders unchanged",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "DWS 发消息自测",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID,
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "DWS 发消息自测",
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
@@ -883,15 +976,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders",
|
||||
@@ -899,13 +992,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "DWS 回复艾特前津(非主用)自测",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "DWS synthetic reply mention test",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
|
||||
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
|
||||
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
|
||||
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders after at-all",
|
||||
@@ -913,15 +1006,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "请大家确认",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID,
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> <@D-target> 请大家确认",
|
||||
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "reply at-all preserves alliance word",
|
||||
@@ -929,7 +1022,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "联系 @alliance",
|
||||
"--at-all",
|
||||
},
|
||||
@@ -943,7 +1036,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "联系 @alliance",
|
||||
},
|
||||
contentField: "content",
|
||||
|
||||
@@ -413,6 +413,13 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// --type/--start/--end 客户端过滤:激活即切 BFS 全量拉取后筛(两路由统一);
|
||||
// 未启用返回零值,存量分支字节级不变。互斥/非法值/start>end 在此拒绝。
|
||||
filter, err := parseDriveListFilter(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -454,7 +461,8 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 || latest > 0 {
|
||||
// filter 激活时 depth==1 也走 BFS 退化态(全量拉取→CLI 侧筛)。
|
||||
if depth > 1 || latest > 0 || filter.active() {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -463,7 +471,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -487,7 +495,9 @@ func newDriveCommand() *cobra.Command {
|
||||
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
|
||||
}
|
||||
|
||||
if depth > 1 {
|
||||
// 钉盘:filter 激活即 depth=1 单层退化态(全量翻完当前目录后 CLI 侧筛);
|
||||
// filter+latest 单层同走 BFS(先筛后排,不走 runDriveListLatest 凑够即停)。
|
||||
if depth > 1 || filter.active() {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
@@ -563,6 +573,11 @@ func newDriveCommand() *cobra.Command {
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
argsMap["withThumbnail"] = true
|
||||
}
|
||||
// --pattern 页内过滤(现状缺口附带修复:透传即打印无法夹过滤,取回解析后筛);
|
||||
// 不带 pattern 时保持 callMCPTool 纯透传,存量行为不变。
|
||||
if pattern != "" {
|
||||
return callDriveListPageWithPattern(argsMap, pattern)
|
||||
}
|
||||
return callMCPTool("list_files", argsMap)
|
||||
},
|
||||
}
|
||||
@@ -591,11 +606,13 @@ func newDriveCommand() *cobra.Command {
|
||||
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
|
||||
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
|
||||
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
|
||||
"要在已知目录内按类型/修改时间做无关键词筛选时:--type file --start 7d(钉盘与知识库路由均可,CLI 侧过滤)",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"只记得关键词、不知道所在目录时改用 dws drive search",
|
||||
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
|
||||
"要找最近打开/编辑过的文档改用 dws drive recent",
|
||||
"带关键词的过滤改用 dws drive search(--extensions/--modified-from 等已可用)",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
@@ -1211,6 +1228,9 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
|
||||
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
|
||||
driveListCmd.Flags().String("end", "", "按修改时间过滤·截止: 语法同 --start(客户端过滤,互斥同 --type)(可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
|
||||
@@ -178,7 +178,8 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
// filter 零值 = 未启用;启用时由 emitDriveDepthResult 管线在 pattern 之后、latest 之前筛。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int, filter driveListFilter) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +210,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +221,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +234,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -297,7 +298,7 @@ bfs:
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -338,18 +339,18 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -364,8 +365,13 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if filter.active() {
|
||||
// 类型(route.isFolder 反判)+ 时间(直读收集段注入的 sortTime 毫秒)区间筛
|
||||
items = applyDriveListFilter(items, route, filter)
|
||||
}
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
// --type folder 时 latest 对过滤后的目录取 Top-N,避免「先留目录再剔目录」的空结果。
|
||||
items = applyDriveListLatest(items, latest, filter.nodeType == "folder")
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
@@ -383,7 +389,7 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
if (latest > 0 || filter.active()) && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0, driveListFilter{})
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0, driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -49,10 +49,11 @@ func driveLatestExclusiveError(flag string, latest int) error {
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
// foldersOnly=true 时对目录取 Top-N(--type folder --latest 组合),否则对文件取 Top-N。
|
||||
func applyDriveListLatest(items []map[string]any, latest int, foldersOnly bool) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
if (isDriveDepthFolder(item) || isDocDepthFolder(item)) != foldersOnly {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drive list --type/--start/--end 客户端过滤(CLI 侧筛)
|
||||
//
|
||||
// 两路由统一(拍板⑤):钉盘与知识库均为全量拉取后在进程内筛——
|
||||
// 复用 depth/latest 的 BFS 基建(钉盘单层 = depth=1 退化态,全量翻完当前目录不下钻),
|
||||
// 过滤挂在 emitDriveDepthResult 管线内(pattern 名称筛之后、latest Top-N 之前)。
|
||||
// 类型判定复用 route.isFolder 反判;时间比较直读 BFS 收集段注入的 sortTime 毫秒。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
type driveListFilter struct {
|
||||
nodeType string // ""=不过滤 / "file" / "folder"
|
||||
startMs int64
|
||||
endMs int64
|
||||
hasStart bool
|
||||
hasEnd bool
|
||||
}
|
||||
|
||||
func (f driveListFilter) active() bool {
|
||||
return f.nodeType != "" || f.hasStart || f.hasEnd
|
||||
}
|
||||
|
||||
// parseDriveListFilter 读取并校验 --type/--start/--end。
|
||||
// 三 flag 均未给值时返回零值(active()=false,调用方走存量分支)。
|
||||
// 非法值 / start>end / 互斥组合在此拒绝(退出码 3,风格对齐 drive_latest.go 互斥条款)。
|
||||
func parseDriveListFilter(cmd *cobra.Command) (driveListFilter, error) {
|
||||
var filter driveListFilter
|
||||
nodeType, _ := cmd.Flags().GetString("type")
|
||||
startRaw, _ := cmd.Flags().GetString("start")
|
||||
endRaw, _ := cmd.Flags().GetString("end")
|
||||
nodeType = strings.TrimSpace(nodeType)
|
||||
startRaw = strings.TrimSpace(startRaw)
|
||||
endRaw = strings.TrimSpace(endRaw)
|
||||
if nodeType == "" && startRaw == "" && endRaw == "" {
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
if nodeType != "" {
|
||||
switch strings.ToLower(nodeType) {
|
||||
case "file", "folder":
|
||||
filter.nodeType = strings.ToLower(nodeType)
|
||||
default:
|
||||
return filter, &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--type 取值非法: %q(合法值: file|folder)", nodeType),
|
||||
}
|
||||
}
|
||||
}
|
||||
if startRaw != "" {
|
||||
ms, err := parseDriveListTime(startRaw)
|
||||
if err != nil {
|
||||
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--start %s", err)}
|
||||
}
|
||||
filter.startMs = ms
|
||||
filter.hasStart = true
|
||||
}
|
||||
if endRaw != "" {
|
||||
ms, err := parseDriveListTime(endRaw)
|
||||
if err != nil {
|
||||
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--end %s", err)}
|
||||
}
|
||||
filter.endMs = ms
|
||||
filter.hasEnd = true
|
||||
}
|
||||
if filter.hasStart && filter.hasEnd && filter.startMs > filter.endMs {
|
||||
return filter, &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--start %q 晚于 --end %q,请修正时间范围", startRaw, endRaw),
|
||||
}
|
||||
}
|
||||
|
||||
// 互斥:filter 为 CLI 侧全量扫描模式,服务端分页/排序语义无法保持。
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return filter, driveListFilterExclusiveError("versions", "--versions 为独立的版本列表模式,请去掉 --versions 或过滤条件")
|
||||
}
|
||||
// 用 Changed 判定而非值非空:显式 --cursor= 空值同样视为启用游标分页。
|
||||
for _, f := range []string{"cursor", "next-token", "page-token"} {
|
||||
if fl := cmd.Flags().Lookup(f); fl != nil && fl.Changed {
|
||||
return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件")
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return filter, driveListFilterExclusiveError(f, "过滤模式输出由客户端筛选后重排,服务端排序语义无法保持;如需服务端排序请去掉过滤条件")
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return filter, driveListFilterExclusiveError("limit", "过滤模式为全量扫描,数据量由全局上限 2000 与目录范围控制;如需控制单页条数请去掉过滤条件")
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func driveListFilterExclusiveError(flag, guidance string) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--type/--start/--end 不能与 --%s 同时使用:%s", flag, guidance),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDriveListTime 单一时间解析入口:先试相对时间语法,失败回落 ISO8601
|
||||
// (agoal.go parseISO8601ToMillis,RFC3339/无时区默认 Asia/Shanghai/仅日期)。
|
||||
// 两 flag 共用同一入口;不接受毫秒时间戳(规约红线,与 search 历史遗留切割)。
|
||||
func parseDriveListTime(value string) (int64, error) {
|
||||
if ms, ok := parseDriveRelativeTimeAgo(value); ok {
|
||||
return ms, nil
|
||||
}
|
||||
if ms, err := parseISO8601ToMillis(value); err == nil {
|
||||
return ms, nil
|
||||
}
|
||||
return 0, fmt.Errorf("时间格式不支持: %q(支持: 相对时间如 24h/7d/2w、RFC3339 如 2026-08-01T00:00:00+08:00、无时区 ISO8601 如 2026-08-01 08:00:00(默认 Asia/Shanghai)、仅日期 2026-08-01;不支持毫秒时间戳与 m 单位)", value)
|
||||
}
|
||||
|
||||
// parseDriveRelativeTimeAgo 相对时间 Nh/Nd/Nw(小时/天/周),按本机时钟换算为绝对毫秒。
|
||||
// 不支持 m 单位(lark 双解析器 m=分钟/月语义打架的教训,直接规避);
|
||||
// ok=false 表示非相对时间语法,由调用方回落 ISO8601 解析。
|
||||
func parseDriveRelativeTimeAgo(value string) (int64, bool) {
|
||||
s := strings.TrimSpace(value)
|
||||
if len(s) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
var unit time.Duration
|
||||
switch s[len(s)-1] {
|
||||
case 'h':
|
||||
unit = time.Hour
|
||||
case 'd':
|
||||
unit = 24 * time.Hour
|
||||
case 'w':
|
||||
unit = 7 * 24 * time.Hour
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(s[:len(s)-1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return time.Now().Add(-time.Duration(n) * unit).UnixMilli(), true
|
||||
}
|
||||
|
||||
// applyDriveListFilter 在 emit 管线内做类型/时间筛(pattern 之后、latest Top-N 之前)。
|
||||
func applyDriveListFilter(items []map[string]any, route driveDepthRoute, filter driveListFilter) []map[string]any {
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if filter.nodeType != "" {
|
||||
folder := route.isFolder(item)
|
||||
if filter.nodeType == "folder" && !folder {
|
||||
continue
|
||||
}
|
||||
if filter.nodeType == "file" && folder {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filter.hasStart || filter.hasEnd {
|
||||
// sortTime 由 BFS 收集段无条件注入(drive_depth.go 时间戳归一);
|
||||
// 无时间信息的条目(sortTime<=0)在时间条件下不可判定,保守滤除。
|
||||
ts, _ := item["sortTime"].(int64)
|
||||
if ts <= 0 {
|
||||
continue
|
||||
}
|
||||
if filter.hasStart && ts < filter.startMs {
|
||||
continue
|
||||
}
|
||||
if filter.hasEnd && ts > filter.endMs {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// callDriveListPageWithPattern 单层钉盘 --pattern 页内过滤(现状缺口附带修复:
|
||||
// 纯透传分支此前未消费 pattern,flag 文案承诺的客户端过滤静默失效)。
|
||||
// callMCPTool 透传即打印、无夹过滤的钩子,故取回解析后页内筛再输出;
|
||||
// 输出保留原 body 形态(nextToken 等分页字段不动),仅条目数组被筛。
|
||||
func callDriveListPageWithPattern(argsMap map[string]any, pattern string) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": "list_files",
|
||||
"arguments": argsMap,
|
||||
"pattern": pattern,
|
||||
"note": "pattern 为客户端过滤,仅作用于本页返回条目",
|
||||
})
|
||||
}
|
||||
text, err := callMCPToolReturnText(context.Background(), "list_files", argsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(text), &body) != nil {
|
||||
// 非 JSON 返回无法筛,原样输出(与透传分支的兜底形态一致)。
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
target := body
|
||||
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
|
||||
target = inner
|
||||
}
|
||||
if key := driveDepthListItemsKey(target); key != "" {
|
||||
arr, _ := target[key].([]any)
|
||||
filtered := make([]any, 0, len(arr))
|
||||
for _, entry := range arr {
|
||||
item, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
filtered = append(filtered, entry)
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if matchDriveNamePattern(name, pattern) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
target[key] = filtered
|
||||
}
|
||||
return deps.Out.PrintJSON(body)
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// drive list --type/--start/--end 客户端过滤测试矩阵(设计稿 §9.3)。
|
||||
// 命令级用例统一经 executeDriveListCapture 捕获 stdout JSON;
|
||||
// 零值 filter 的存量分支行为由 drive_depth_test.go / drive_list_modes_test.go 既有用例兜底。
|
||||
|
||||
// 钉盘路由(serverID 空)经 resolveProductID 扫 os.Args 推断产品,命令级测试需伪装命令行。
|
||||
func useDriveListArgs(t *testing.T) {
|
||||
t.Helper()
|
||||
old := os.Args
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
t.Cleanup(func() { os.Args = old })
|
||||
}
|
||||
|
||||
func executeDriveListCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
buf := &bytes.Buffer{}
|
||||
deps.Out.w = buf
|
||||
deps.Out.errW = io.Discard
|
||||
root := newDriveCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return buf, err
|
||||
}
|
||||
|
||||
// 1. 正向:--workspace --type file → list_nodes 全量拉取后仅 file,单层剥装饰字段。
|
||||
func TestCrossPlatformCoverageDriveListFilterWorkspaceTypeFile(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[
|
||||
{"nodeId":"n1","name":"doc1","nodeType":"doc","updateTime":1754000000000},
|
||||
{"nodeId":"n2","name":"sub","nodeType":"folder","updateTime":1754000000000}
|
||||
],"hasMore":false}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
items := result["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("filtered items = %#v", items)
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["nodeId"] != "n1" {
|
||||
t.Fatalf("item = %#v", item)
|
||||
}
|
||||
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
|
||||
if _, ok := item[key]; ok {
|
||||
t.Fatalf("decoration %q not stripped: %#v", key, item)
|
||||
}
|
||||
}
|
||||
if result["truncated"] != false || result["maxDepth"] != float64(1) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 正向:--workspace --start 7d --end <RFC3339> → sortTime 区间断言。
|
||||
func TestCrossPlatformCoverageDriveListFilterWorkspaceTimeRange(t *testing.T) {
|
||||
now := time.Now()
|
||||
within := now.Add(-24 * time.Hour).UnixMilli()
|
||||
before := now.Add(-30 * 24 * time.Hour).UnixMilli()
|
||||
after := now.Add(24 * time.Hour).UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"nodes":[
|
||||
{"nodeId":"in","name":"within","nodeType":"doc","updateTime":%d},
|
||||
{"nodeId":"old","name":"before","nodeType":"doc","updateTime":%d},
|
||||
{"nodeId":"new","name":"after","nodeType":"doc","updateTime":%d}
|
||||
],"hasMore":false}`, within, before, after)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller,
|
||||
"list", "--workspace", "ws-1", "--start", "7d", "--end", now.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["nodeId"] != "in" {
|
||||
t.Fatalf("time-range items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 组合:--workspace --depth 2 --type file → BFS 路径 filter 生效,装饰字段保留。
|
||||
func TestCrossPlatformCoverageDriveListFilterWithDepthKeepsDecorations(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[
|
||||
{"nodeId":"fA","name":"dirA","nodeType":"folder"},
|
||||
{"nodeId":"n1","name":"doc1","nodeType":"doc"}
|
||||
],"hasMore":false}`},
|
||||
{text: `{"nodes":[{"nodeId":"n2","name":"doc2","nodeType":"doc"}],"hasMore":false}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller,
|
||||
"list", "--workspace", "ws-1", "--depth", "2", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2(depth=2 下钻 dirA)", caller.calls)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("filtered items = %#v(folder 应被滤除)", items)
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if _, ok := item["depth"]; !ok {
|
||||
t.Fatalf("depth>1 装饰字段应保留: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 组合:--workspace --type file --latest 3 → filter 先筛后 Top-N;
|
||||
// 触顶时 LATEST_SCAN_TRUNCATED 拒绝优先于 filter 的 partial 语义。
|
||||
func TestCrossPlatformCoverageDriveListFilterWithLatestTopN(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[
|
||||
{"nodeId":"d1","name":"dir","nodeType":"folder","updateTime":400},
|
||||
{"nodeId":"f1","name":"a","nodeType":"doc","updateTime":100},
|
||||
{"nodeId":"f2","name":"b","nodeType":"doc","updateTime":300},
|
||||
{"nodeId":"f3","name":"c","nodeType":"doc","updateTime":200},
|
||||
{"nodeId":"f4","name":"d","nodeType":"doc","updateTime":50}
|
||||
],"hasMore":false}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller,
|
||||
"list", "--workspace", "ws-1", "--type", "file", "--latest", "2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("top-n items = %#v", items)
|
||||
}
|
||||
// folder(updateTime 最大)不得进入 Top-N 排序基;筛选后按 sortTime 倒序。
|
||||
if items[0].(map[string]any)["nodeId"] != "f2" || items[1].(map[string]any)["nodeId"] != "f3" {
|
||||
t.Fatalf("top-n order = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterLatestTruncatedRejected(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
_, err := executeDriveListCapture(t, caller, "list", "--type", "file", "--latest", "2")
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err = %v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 钉盘路由:--folder + --type file 单层退化态全量翻页后仅 file;
|
||||
// --start 7d --latest 3 走 BFS(depth=1);filter 与 cursor/order-by/order/limit/versions 互斥退出码 3。
|
||||
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFile(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"d1","name":"sub","type":"FOLDER"},
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE"}
|
||||
],"nextToken":"p2"}`},
|
||||
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want 2(翻完当前目录两页,sub 不下钻)", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0]["parentId"] != "root-1" || caller.calls[0]["maxResults"] != float64(driveDepthPageSize) {
|
||||
t.Fatalf("page args = %#v", caller.calls[0])
|
||||
}
|
||||
if caller.calls[1]["nextToken"] != "p2" {
|
||||
t.Fatalf("page2 args = %#v", caller.calls[1])
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("filtered items = %#v", items)
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if item["type"] != "FILE" {
|
||||
t.Fatalf("folder leaked: %#v", item)
|
||||
}
|
||||
if _, ok := item["depth"]; ok {
|
||||
t.Fatalf("single-layer decorations not stripped: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterPanStartWithLatest(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
now := time.Now()
|
||||
within := now.Add(-24 * time.Hour).UnixMilli()
|
||||
stale := now.Add(-30 * 24 * time.Hour).UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
|
||||
{"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":%d},
|
||||
{"fileId":"f3","name":"c.txt","type":"FILE","modifyTime":%d}
|
||||
]}`, within-1000, within, stale)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d", "--latest", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if _, ok := result["truncated"]; !ok {
|
||||
t.Fatalf("filter+latest 应走 BFS 聚合形态: %#v", result)
|
||||
}
|
||||
items := result["items"].([]any)
|
||||
// --start 7d 先筛掉 stale,Top-1 取 sortTime 最大的 f2。
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f2" {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterExclusiveFlags(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"list", "--type", "file", "--cursor", "c1"},
|
||||
{"list", "--type", "file", "--cursor", ""},
|
||||
{"list", "--type", "file", "--order-by", "name"},
|
||||
{"list", "--start", "7d", "--order", "asc"},
|
||||
{"list", "--type", "file", "--limit", "5"},
|
||||
{"list", "--end", "2026-08-01", "--max", "5"},
|
||||
{"list", "--versions", "--node", "n1", "--type", "file"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
caller := &scriptedToolCaller{}
|
||||
_, err := executeDriveListCapture(t, caller, args...)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
|
||||
t.Fatalf("args %v: err = %v, want CLIError exit 3", args, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不能与") || !strings.Contains(err.Error(), "同时使用") {
|
||||
t.Fatalf("args %v: err = %v, want exclusivity message", args, err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("args %v: calls = %d, want 0", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 边界:--type 非法值列合法值;start>end 退出码 3;相对时间解析与 m/毫秒拒绝。
|
||||
func TestCrossPlatformCoverageDriveListFilterInvalidValues(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
_, err := executeDriveListCapture(t, caller, "list", "--type", "dir")
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
|
||||
t.Fatalf("--type dir err = %v, want exit 3", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file|folder") {
|
||||
t.Fatalf("--type dir err = %v, want valid values listed", err)
|
||||
}
|
||||
|
||||
_, err = executeDriveListCapture(t, caller, "list", "--start", "2026-08-10", "--end", "2026-08-01")
|
||||
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
|
||||
t.Fatalf("start>end err = %v, want exit 3", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "晚于") {
|
||||
t.Fatalf("start>end err = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterTimeParsing(t *testing.T) {
|
||||
// 相对时间按本机时钟换算为绝对毫秒。
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
unit time.Duration
|
||||
}{
|
||||
{"24h", time.Hour},
|
||||
{"7d", 24 * time.Hour},
|
||||
{"2w", 7 * 24 * time.Hour},
|
||||
} {
|
||||
before := time.Now()
|
||||
ms, err := parseDriveListTime(tc.raw)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.raw, err)
|
||||
}
|
||||
expected := time.Duration(mustAtoi(t, tc.raw[:len(tc.raw)-1])) * tc.unit
|
||||
lo := before.Add(-expected).UnixMilli()
|
||||
hi := after.Add(-expected).UnixMilli()
|
||||
if ms < lo || ms > hi {
|
||||
t.Fatalf("%s: ms = %d, want in [%d, %d]", tc.raw, ms, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
// RFC3339 / 无时区 ISO8601(默认 Asia/Shanghai)/ 仅日期。
|
||||
shanghai := time.FixedZone("CST", 8*3600)
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want int64
|
||||
}{
|
||||
{"2026-08-01T00:00:00+08:00", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
|
||||
{"2026-08-01 08:00:00", time.Date(2026, 8, 1, 8, 0, 0, 0, shanghai).UnixMilli()},
|
||||
{"2026-08-01", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
|
||||
} {
|
||||
ms, err := parseDriveListTime(tc.raw)
|
||||
if err != nil || ms != tc.want {
|
||||
t.Fatalf("%s: ms = %d err = %v, want %d", tc.raw, ms, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// m 单位 / 毫秒时间戳 / 零与负值一律拒绝。
|
||||
for _, raw := range []string{"7m", "30m", "1754000000000", "0d", "-3d", "abc"} {
|
||||
if _, err := parseDriveListTime(raw); err == nil {
|
||||
t.Fatalf("%q accepted, want rejection", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustAtoi(t *testing.T, s string) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
|
||||
t.Fatalf("atoi %q: %v", s, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 7. dry-run:--workspace --type file --dry-run → printDriveDepthDryRun 路径,不发起调用。
|
||||
func TestCrossPlatformCoverageDriveListFilterDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry-run calls = %d", caller.calls)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if result["dry_run"] != true || result["tool"] != "list_nodes" || result["maxDepth"] != float64(1) {
|
||||
t.Fatalf("dry-run payload = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 触顶:2000 条上限 → partial + truncated=true + 退出码 0(filter 触顶结果不全但不会错)。
|
||||
func TestCrossPlatformCoverageDriveListFilterTruncatedPartial(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatalf("filter 触顶应放行(退出码 0): %v", err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("truncated = %#v", result["truncated"])
|
||||
}
|
||||
if got := len(result["items"].([]any)); got != driveDepthMaxItems {
|
||||
t.Fatalf("items len = %d, want %d(全 FILE 类型筛全保留)", got, driveDepthMaxItems)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 回归:不带 filter 的存量分支行为不变;--pattern 单层钉盘页内过滤为本次有意修复。
|
||||
func TestCrossPlatformCoverageDriveListFilterRegressionUnaffectedPaths(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
// 单层纯透传(无 filter/pattern):body 原样输出,args 语义不变。
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"f1","name":"a.txt"}],"nextToken":"nt"}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if result["nextToken"] != "nt" || len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("透传 body 被改变: %#v", result)
|
||||
}
|
||||
if _, ok := result["truncated"]; ok {
|
||||
t.Fatalf("透传形态不应含 BFS 聚合字段: %#v", result)
|
||||
}
|
||||
if caller.args["maxResults"] != float64(20) {
|
||||
t.Fatalf("args = %#v", caller.args)
|
||||
}
|
||||
|
||||
// workspace 单层透传:无 filter/depth/latest 时仍走单页 list_nodes。
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`},
|
||||
}}
|
||||
buf2, err := executeDriveListCapture(t, caller2, "list", "--workspace", "ws-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result2 := decodeDepthResult(t, buf2)
|
||||
if _, ok := result2["nodes"]; !ok {
|
||||
t.Fatalf("workspace 单层透传形态被改变: %#v", result2)
|
||||
}
|
||||
if caller2.args["pageSize"] != 20 || caller2.args["workspaceId"] != "ws-1" {
|
||||
t.Fatalf("args = %#v", caller2.args)
|
||||
}
|
||||
|
||||
// 钉盘单层 --latest(无 filter):仍走 runDriveListLatest(非 BFS 聚合形态)。
|
||||
now := time.Now().UnixMilli()
|
||||
caller3 := &depthArgsRecordingCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}],"nextToken":""}`, now)},
|
||||
}}
|
||||
buf3, err := executeDriveListCapture(t, caller3, "list", "--latest", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result3 := decodeDepthResult(t, buf3)
|
||||
if _, ok := result3["truncated"]; ok {
|
||||
t.Fatalf("单层 latest 不应切到 BFS 聚合形态: %#v", result3)
|
||||
}
|
||||
if len(result3["items"].([]any)) != 1 {
|
||||
t.Fatalf("items = %#v", result3["items"])
|
||||
}
|
||||
if caller3.calls[0]["orderBy"] != "modifyTime" || caller3.calls[0]["order"] != "desc" {
|
||||
t.Fatalf("latest 扫描参数被改变: %#v", caller3.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListPatternSingleLayerFixed(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"f1","name":"a.txt"},
|
||||
{"fileId":"f2","name":"b.md"}
|
||||
],"nextToken":"nt"}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--pattern", "*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
items := result["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
|
||||
t.Fatalf("pattern 页内过滤未生效: %#v", items)
|
||||
}
|
||||
if result["nextToken"] != "nt" {
|
||||
t.Fatalf("分页字段应保留: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 钉盘路由:--type folder 反向筛(FILE 被滤除,仅 FOLDER 保留)。
|
||||
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFolder(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"d1","name":"sub","type":"FOLDER"},
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE"}
|
||||
]}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d1" {
|
||||
t.Fatalf("folder filter items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 钉盘路由:--start 下无时间信息的条目(sortTime<=0)不可判定,保守滤除。
|
||||
func TestCrossPlatformCoverageDriveListFilterPanMissingTimeDropped(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
now := time.Now().UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
|
||||
{"fileId":"f2","name":"no-time.txt","type":"FILE"}
|
||||
]}`, now)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
|
||||
t.Fatalf("missing-time 条目应被滤除: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 单层钉盘 --pattern 页内过滤边界:dry-run 预览 / MCP 错误透传 / 非 JSON 原样输出 /
|
||||
// 非 map 条目保留 + name 缺失时 fileName 兜底匹配。
|
||||
func TestCrossPlatformCoverageDriveListPatternPassthroughEdges(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
|
||||
// dry-run:打印预览,不发起调用。
|
||||
dryCaller := &scriptedToolCaller{dry: true}
|
||||
buf, err := executeDriveListCapture(t, dryCaller, "list", "--pattern", "*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dryCaller.calls != 0 {
|
||||
t.Fatalf("dry-run calls = %d", dryCaller.calls)
|
||||
}
|
||||
preview := decodeDepthResult(t, buf)
|
||||
if preview["dry_run"] != true || preview["tool"] != "list_files" || preview["pattern"] != "*.txt" {
|
||||
t.Fatalf("dry-run payload = %#v", preview)
|
||||
}
|
||||
|
||||
// MCP 错误:原样返回。
|
||||
errCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
|
||||
if _, err := executeDriveListCapture(t, errCaller, "list", "--pattern", "*.txt"); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("err = %v, want boom", err)
|
||||
}
|
||||
|
||||
// 非 JSON 返回:无法筛,原样输出(与透传分支兜底形态一致)。
|
||||
rawCaller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "plain-text-result"}}}
|
||||
rawBuf, err := executeDriveListCapture(t, rawCaller, "list", "--pattern", "*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rawBuf.String(), "plain-text-result") {
|
||||
t.Fatalf("raw output = %q", rawBuf.String())
|
||||
}
|
||||
|
||||
// 非 map 条目原样保留;name 缺失时 fileName 兜底参与匹配。
|
||||
mixedCaller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
"raw-entry",
|
||||
{"fileId":"f1","fileName":"日报模板.doc"},
|
||||
{"fileId":"f2","name":"其他.txt"}
|
||||
],"nextToken":"nt"}`},
|
||||
}}
|
||||
mixedBuf, err := executeDriveListCapture(t, mixedCaller, "list", "--pattern", "*模板*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mixed := decodeDepthResult(t, mixedBuf)["items"].([]any)
|
||||
if len(mixed) != 2 || mixed[0] != "raw-entry" || mixed[1].(map[string]any)["fileId"] != "f1" {
|
||||
t.Fatalf("items = %#v", mixed)
|
||||
}
|
||||
}
|
||||
|
||||
// 13. --type folder --latest 回归(P1 CR):latest 对过滤后的目录按时间取 Top-N,
|
||||
// 不再「先留目录再剔目录」返回空列表。
|
||||
func TestCrossPlatformCoverageDriveListFilterFolderLatest(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
now := time.Now().UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[
|
||||
{"fileId":"d1","name":"old-dir","type":"FOLDER","modifyTime":%d},
|
||||
{"fileId":"d2","name":"new-dir","type":"FOLDER","modifyTime":%d},
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}
|
||||
]}`, now-5000, now, now)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--type", "folder", "--latest", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d2" {
|
||||
t.Fatalf("folder latest items = %#v", items)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -39,11 +41,16 @@ type imReadResultCaller struct {
|
||||
results map[string]*edition.ToolResult
|
||||
errors map[string]error
|
||||
calls []imReadResultCall
|
||||
args map[string]any
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName})
|
||||
c.args = map[string]any{}
|
||||
for k, v := range args {
|
||||
c.args[k] = v
|
||||
}
|
||||
if err := c.errors[toolName]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -203,6 +210,37 @@ func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLeg
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) {
|
||||
payload := `{"result":{"messages":[],"hasMore":false}}`
|
||||
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
|
||||
loc := shanghaiLocation()
|
||||
|
||||
before := time.Now().In(loc)
|
||||
_, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
|
||||
"message", "list", "--group", "cid-1", "--limit", "50")
|
||||
after := time.Now().In(loc)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message list returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
|
||||
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
|
||||
}
|
||||
if got := caller.args["forward"]; got != false {
|
||||
t.Fatalf("forward = %#v, want false when --time is omitted", got)
|
||||
}
|
||||
timeRaw, ok := caller.args["time"].(string)
|
||||
if !ok || strings.TrimSpace(timeRaw) == "" {
|
||||
t.Fatalf("time arg = %#v, want generated current time", caller.args["time"])
|
||||
}
|
||||
gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, loc)
|
||||
if err != nil {
|
||||
t.Fatalf("parse generated time %q: %v", timeRaw, err)
|
||||
}
|
||||
if gotTime.Before(before.Add(-time.Second)) || gotTime.After(after.Add(time.Second)) {
|
||||
t.Fatalf("time = %s, want between %s and %s", gotTime, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
|
||||
payload := `{
|
||||
"result": {
|
||||
|
||||
@@ -611,7 +611,7 @@ func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
|
||||
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
|
||||
{"nodeType": "Folder", "name": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 10)
|
||||
got := applyDriveListLatest(items, 10, false)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
@@ -674,7 +674,7 @@ func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
|
||||
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
|
||||
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 1)
|
||||
got := applyDriveListLatest(items, 1, false)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("latest len=%d", len(got))
|
||||
}
|
||||
|
||||
@@ -24,13 +24,14 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// isOpenID reports whether value looks like an openDingTalkId (starts with D/d),
|
||||
// mirroring helpers.isOpenDingTalkID.
|
||||
// isOpenID classifies mixed userId/openDingTalkId inputs using the canonical
|
||||
// current-D wire format shared with helpers and smart shortcuts.
|
||||
func isOpenID(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
return len(v) > 0 && (v[0] == 'D' || v[0] == 'd')
|
||||
return targetresolver.LooksLikeCurrentDOpenDingTalkID(v)
|
||||
}
|
||||
|
||||
// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring
|
||||
@@ -50,6 +51,18 @@ func splitIDs(vals []string) (userIDs, openIDs []string) {
|
||||
return userIDs, openIDs
|
||||
}
|
||||
|
||||
func validateExplicitOpenIDs(flagName string, values []string) error {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID(flagName, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64.
|
||||
func toInt64Slice(vals []string) ([]int64, error) {
|
||||
out := make([]int64, 0, len(vals))
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ConversationInfo gets conversation info (get_conversation_info, chat server).
|
||||
@@ -71,6 +72,9 @@ var ConversationInfo = shortcut.Shortcut{
|
||||
params["openConversationId"] = rt.Str("group")
|
||||
}
|
||||
if rt.Str("open-dingtalk-id") != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
}
|
||||
if len(params) == 0 {
|
||||
|
||||
@@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--users", rt.StrSlice("users")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("list_group_member_by_ids", map[string]any{
|
||||
"openConversationId": rt.Str("id"),
|
||||
"cid": rt.Str("id"),
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
const directMessagesHardPageLimit = 500
|
||||
@@ -55,6 +56,9 @@ var MessagesSendByBot = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"openConversationId": rt.Str("group"),
|
||||
@@ -93,6 +97,9 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"title": rt.Str("title"),
|
||||
@@ -493,6 +500,9 @@ func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
switch {
|
||||
case rt.Str("open-dingtalk-id") != "":
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
case rt.Str("user") != "":
|
||||
params["userId"] = rt.Str("user")
|
||||
@@ -1515,6 +1525,14 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if receiverOpenID := rt.Str("receiver-open-dingtalk-id"); receiverOpenID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--receiver-open-dingtalk-id", receiverOpenID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
|
||||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
fixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
type platformCoverageCaller struct {
|
||||
product string
|
||||
tool string
|
||||
@@ -53,7 +58,7 @@ func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool s
|
||||
case "contact/get_user_info_by_user_ids":
|
||||
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}`
|
||||
case "chat/get_group_members":
|
||||
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}`
|
||||
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"` + fixtureCurrentDOpenID2 + `"}]}}`
|
||||
case "im/set_group_member_mute_list":
|
||||
text = `{"success":true}`
|
||||
default:
|
||||
@@ -235,8 +240,10 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{value: "DingTalk-open-id", want: true},
|
||||
{value: " dingtalk-open-id ", want: true},
|
||||
{value: "DAAAAAAAAAAAiE", want: true},
|
||||
{value: " DAAAAAAAAAAAiE ", want: true},
|
||||
{value: "D-prefix-fixture-user", want: false},
|
||||
{value: "d-prefix-fixture-user", want: false},
|
||||
{value: "user-id", want: false},
|
||||
{value: " ", want: false},
|
||||
}
|
||||
@@ -248,11 +255,11 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("split mixed IDs", func(t *testing.T) {
|
||||
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"})
|
||||
if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) {
|
||||
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "DAAAAAAAAAAAiE", "d-open-2", "D-prefix-fixture-user", "user-2"})
|
||||
if want := []string{"user-1", "d-open-2", "D-prefix-fixture-user", "user-2"}; !reflect.DeepEqual(userIDs, want) {
|
||||
t.Fatalf("user IDs = %#v, want %#v", userIDs, want)
|
||||
}
|
||||
if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) {
|
||||
if want := []string{"DAAAAAAAAAAAiE"}; !reflect.DeepEqual(openIDs, want) {
|
||||
t.Fatalf("open IDs = %#v, want %#v", openIDs, want)
|
||||
}
|
||||
})
|
||||
@@ -281,7 +288,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-mute-member",
|
||||
"--group", "cid-1",
|
||||
"--users", "user-2,D-open-2",
|
||||
"--users", "user-2," + fixtureCurrentDOpenID2,
|
||||
"--mute-time", "300000",
|
||||
"--yes",
|
||||
})
|
||||
@@ -298,7 +305,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
|
||||
if _, ok := final.args["uids"]; ok {
|
||||
t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args)
|
||||
}
|
||||
if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := final.args["openDingTalkIds"], []string{fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("openDingTalkIds = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
{
|
||||
name: "reply write",
|
||||
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
|
||||
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"},
|
||||
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"},
|
||||
wantError: "fixture lower call failed",
|
||||
},
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
}}
|
||||
helpers.InitDeps(external)
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
|
||||
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -400,17 +400,17 @@ func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"},
|
||||
{"--identity", "user", "--text", "x"},
|
||||
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
|
||||
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
||||
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
|
||||
{"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"},
|
||||
{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--at-all", "--text", "x"},
|
||||
{"--identity", "bot", "--group", "cid", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"},
|
||||
{"--identity", "webhook", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"},
|
||||
}
|
||||
for _, tail := range cases {
|
||||
@@ -437,23 +437,23 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
|
||||
}{
|
||||
{
|
||||
name: "user group mentions",
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"},
|
||||
want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true},
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
||||
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
|
||||
},
|
||||
{
|
||||
name: "user direct",
|
||||
args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": "D1"},
|
||||
args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "bot group mentions",
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"},
|
||||
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}},
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
|
||||
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}},
|
||||
},
|
||||
{
|
||||
name: "bot direct targets",
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"},
|
||||
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"},
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"},
|
||||
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "isAtAll": "true"},
|
||||
},
|
||||
{
|
||||
name: "webhook mentions",
|
||||
@@ -580,7 +580,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
|
||||
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
|
||||
"--at-open-dingtalk-ids", "D1", "--at-all", "--yes",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -590,7 +590,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
|
||||
}
|
||||
for _, call := range fake.calls {
|
||||
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
|
||||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
|
||||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) ||
|
||||
call.args["isAtAll"] != "true" {
|
||||
t.Fatalf("batch mention args = %#v", call.args)
|
||||
}
|
||||
|
||||
@@ -417,8 +417,8 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1",
|
||||
"--owner-open-dingtalk-id", "D-owner", "--yes",
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", fixtureCurrentDOpenID + ",user-1",
|
||||
"--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -427,24 +427,24 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
|
||||
t.Fatalf("explicit owner calls = %#v", fake.calls)
|
||||
}
|
||||
create := fake.calls[0].args
|
||||
if create["ownerOpenDingTalkId"] != "D-owner" {
|
||||
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
|
||||
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
|
||||
}
|
||||
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`,
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"测试用户甲","userId":"owner-user","openDingTalkId":"` + fixtureCurrentDOpenID + `"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
|
||||
"--owner-query", "张三", "--yes",
|
||||
"--owner-query", "测试用户甲", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -454,10 +454,10 @@ func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *
|
||||
t.Fatalf("owner query calls = %#v", fake.calls)
|
||||
}
|
||||
create := fake.calls[1].args
|
||||
if create["ownerOpenDingTalkId"] != "D-owner" {
|
||||
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
|
||||
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
|
||||
}
|
||||
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send",
|
||||
"--open-dingtalk-id", "D-target",
|
||||
"--open-dingtalk-id", fixtureCurrentDOpenID,
|
||||
"--file", "./fixture.bin",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
@@ -773,7 +773,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2 + "," + fixtureCurrentDOpenID,
|
||||
"--at-all",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
@@ -786,7 +786,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
fake.calls[1].tool != "update_streaming_card" {
|
||||
t.Fatalf("card calls = %#v", fake.calls)
|
||||
}
|
||||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
|
||||
}
|
||||
if fake.calls[0].args["atAll"] != true {
|
||||
@@ -842,7 +842,7 @@ func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *te
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-mentioned",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
|
||||
"--content", "正文",
|
||||
"--yes",
|
||||
})
|
||||
@@ -891,7 +891,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--receiver-open-dingtalk-id", "D-direct",
|
||||
"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2,
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
@@ -902,7 +902,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
|
||||
fake.calls[0].tool != "create_and_send_card" {
|
||||
t.Fatalf("card open receiver calls = %#v", fake.calls)
|
||||
}
|
||||
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
|
||||
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != fixtureCurrentDOpenID2 {
|
||||
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
|
||||
}
|
||||
}
|
||||
@@ -969,7 +969,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-mentioned",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
|
||||
"--at-all",
|
||||
"--content", "处理中",
|
||||
"--dry-run",
|
||||
@@ -990,7 +990,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
createArguments, _ := create["arguments"].(map[string]any)
|
||||
update, _ := actions[1].(map[string]any)
|
||||
updateArguments, _ := update["arguments"].(map[string]any)
|
||||
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
|
||||
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{fixtureCurrentDOpenID}) || createArguments["atAll"] != true {
|
||||
t.Fatalf("card create dry-run mentions = %#v", createArguments)
|
||||
}
|
||||
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
|
||||
@@ -1090,10 +1090,10 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
for _, args := range [][]string{
|
||||
{"--group", "cid", "--content", "x", "--flow-status", "6"},
|
||||
{"--group", "cid", "--flow-status", "2"},
|
||||
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
|
||||
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
|
||||
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
|
||||
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
|
||||
{"--group", "cid", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
|
||||
{"--receiver", "user-id", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
|
||||
{"--receiver", "user-id", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
|
||||
{"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--at-all"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -1104,6 +1104,35 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitOpenIDValidationEdges(t *testing.T) {
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{" ", fixtureCurrentDOpenID}); err != nil {
|
||||
t.Fatalf("blank and valid IDs: %v", err)
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{fixtureCurrentDOpenID, "not-an-open-id"}); err == nil {
|
||||
t.Fatal("invalid explicit open ID unexpectedly accepted")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+messages-send", "--open-dingtalk-id", "not-an-open-id", "--text", "x", "--yes"},
|
||||
{"chat", "+messages-send", "--open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
|
||||
{"chat", "+messages-send", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
|
||||
{"chat", "+messages-send-card", "--receiver-open-dingtalk-id", "not-an-open-id", "--yes"},
|
||||
{"chat", "+messages-send-card", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
|
||||
{"chat", "+conversation-info", "--open-dingtalk-id", "not-an-open-id"},
|
||||
{"chat", "+chat-members-get", "--id", "cid", "--users", "not-an-open-id"},
|
||||
{"chat", "+messages-send-by-bot", "--robot-code", "robot", "--group", "cid", "--title", "title", "--text", "text", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
|
||||
{"chat", "+messages-batch-send-by-bot", "--robot-code", "robot", "--title", "title", "--text", "text", "--open-dingtalk-ids", "not-an-open-id", "--yes"},
|
||||
{"chat", "+messages-list-direct", "--open-dingtalk-id", "not-an-open-id", "--time", "2026-01-01 00:00:00"},
|
||||
{"chat", "+chat-create", "--name", "fixture", "--users", "user-1", "--owner-open-dingtalk-id", "not-an-open-id", "--yes"},
|
||||
} {
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid explicit open ID unexpectedly accepted: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
|
||||
@@ -52,6 +52,11 @@ var ChatCreate = shortcut.Shortcut{
|
||||
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if ownerOpenID := rt.Str("owner-open-dingtalk-id"); ownerOpenID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--owner-open-dingtalk-id", ownerOpenID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
resolvedMembers, err := targetresolver.ResolveUsers(
|
||||
rt,
|
||||
rt.StrSlice("member-query"),
|
||||
@@ -339,7 +344,7 @@ func findOpenDingTalkID(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "<nil>" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -374,7 +379,7 @@ func findMessageSenderOpenDingTalkID(message map[string]any) string {
|
||||
"senderOpenDingtalkId",
|
||||
"senderOpenId",
|
||||
} {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); candidate != "" && candidate != "<nil>" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,12 +289,12 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
|
||||
}{
|
||||
{
|
||||
name: "user",
|
||||
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
|
||||
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @" + fixtureCurrentDOpenID, "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--idempotency-key", "u1", "--yes"},
|
||||
product: "chat",
|
||||
tool: "send_personal_message",
|
||||
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
|
||||
bodyKey: "content",
|
||||
body: "<@all> hello <@D1>",
|
||||
body: "<@all> hello <@" + fixtureCurrentDOpenID + ">",
|
||||
},
|
||||
{
|
||||
name: "bot",
|
||||
@@ -471,7 +471,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid",
|
||||
"--message-id", "msg",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", fixtureCurrentDOpenID,
|
||||
"--text", "收到",
|
||||
"--idempotency-key", "reply-uuid",
|
||||
"--yes",
|
||||
@@ -494,7 +494,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content["referenceOpenMessageId"] != "msg" ||
|
||||
content["srcMsgSendOpenDingTalkId"] != "D-sender" ||
|
||||
content["srcMsgSendOpenDingTalkId"] != fixtureCurrentDOpenID ||
|
||||
content["replyMsgType"] != "text" ||
|
||||
content["content"] != "收到" {
|
||||
t.Fatalf("reply content = %#v", content)
|
||||
@@ -525,7 +525,7 @@ func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T)
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid",
|
||||
"--message-id", "msg",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", fixtureCurrentDOpenID,
|
||||
"--text", "收到",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *test
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
|
||||
"chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID,
|
||||
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
|
||||
"--page-all", "--page-limit", "5",
|
||||
})
|
||||
|
||||
@@ -142,6 +142,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
|
||||
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
|
||||
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
|
||||
if openID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
contentType, err := messagesSendContentType(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
Command: "+chat-messages",
|
||||
Product: "chat",
|
||||
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
@@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
@@ -104,7 +104,8 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
|
||||
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 筛选"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
|
||||
@@ -133,6 +134,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"sender", "sender-query"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
|
||||
@@ -223,105 +225,285 @@ type chatMessagesRequest struct {
|
||||
}
|
||||
|
||||
type chatMessagesSenderFilter struct {
|
||||
requested bool
|
||||
applied bool
|
||||
stableIDs map[string]bool
|
||||
resolutions []targetresolver.UserResolution
|
||||
failure map[string]any
|
||||
requested bool
|
||||
applied bool
|
||||
inputs []string
|
||||
inputMode string
|
||||
stableIDs map[string]bool
|
||||
resolutions []targetresolver.UserResolution
|
||||
resolutionFailure map[string]any
|
||||
resolutionErr error
|
||||
scopeErr error
|
||||
}
|
||||
|
||||
// resolveOptionalChatMessagesSenderFilter is deliberately local to
|
||||
// +chat-messages. Sender resolution is an optional post-read filter here: an
|
||||
// unresolved name must not suppress the command's primary conversation-list
|
||||
// result. +search-msg keeps its stricter semantics because sender identity is
|
||||
// part of the lower search request there.
|
||||
// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target
|
||||
// as +search-msg while retaining --sender-query as a compatibility entry. It
|
||||
// resolves only explicitly requested senders; it never enumerates every member
|
||||
// or tries to infer a person from message display names.
|
||||
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
|
||||
queries := rt.StrSlice("sender-query")
|
||||
direct := uniqueChatMessageTargets(rt.StrSlice("sender"))
|
||||
queries := uniqueChatMessageTargets(rt.StrSlice("sender-query"))
|
||||
inputs := direct
|
||||
inputMode := "sender"
|
||||
if len(inputs) == 0 {
|
||||
inputs = queries
|
||||
inputMode = "sender-query"
|
||||
}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: len(queries) > 0,
|
||||
requested: len(inputs) > 0,
|
||||
inputs: inputs,
|
||||
inputMode: inputMode,
|
||||
stableIDs: map[string]bool{},
|
||||
}
|
||||
if !filter.requested {
|
||||
return filter
|
||||
}
|
||||
|
||||
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
var resolutions []targetresolver.UserResolution
|
||||
var err error
|
||||
if len(direct) > 0 {
|
||||
for _, value := range direct {
|
||||
var resolved targetresolver.UserResolution
|
||||
resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
resolutions = append(resolutions, resolved)
|
||||
}
|
||||
} else {
|
||||
resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
}
|
||||
if err != nil {
|
||||
failure := map[string]any{
|
||||
"stage": "sender_resolution",
|
||||
"queries": queries,
|
||||
"error": err.Error(),
|
||||
"stage": "sender_resolution",
|
||||
"inputs": inputs,
|
||||
"mode": inputMode,
|
||||
"error": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
if typed.Reason != "" {
|
||||
failure["reason"] = typed.Reason
|
||||
}
|
||||
if typed.Hint != "" {
|
||||
failure["hint"] = typed.Hint
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
failure["details"] = typed.Details
|
||||
}
|
||||
}
|
||||
filter.failure = failure
|
||||
filter.resolutionFailure = failure
|
||||
filter.resolutionErr = err
|
||||
return filter
|
||||
}
|
||||
|
||||
filter.resolutions = resolutions
|
||||
for _, resolution := range resolutions {
|
||||
for _, identity := range []string{
|
||||
resolution.Selected.UserID,
|
||||
resolution.Selected.OpenDingTalkID,
|
||||
} {
|
||||
if identity = strings.TrimSpace(identity); identity != "" {
|
||||
filter.stableIDs[identity] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// ResolveUsers(IdentityAny) only returns users extracted with at least one
|
||||
// stable userId/openDingTalkId and fails when no resolvable user remains.
|
||||
addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions)
|
||||
filter.applied = true
|
||||
return filter
|
||||
}
|
||||
|
||||
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
|
||||
// when optional resolution fails. Successful resolution filters both the
|
||||
func uniqueChatMessageTargets(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) {
|
||||
for _, resolution := range resolutions {
|
||||
if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" {
|
||||
ids[identity] = true
|
||||
}
|
||||
if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" {
|
||||
ids[identity] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyOptionalChatMessagesSenderFilter fails closed when an explicitly
|
||||
// requested sender cannot be resolved. Successful resolution filters both the
|
||||
// public projection and raw rows so exports and resource downloads cannot
|
||||
// accidentally include messages outside the requested sender set.
|
||||
func applyOptionalChatMessagesSenderFilter(
|
||||
rt *shortcut.RuntimeContext,
|
||||
payload map[string]any,
|
||||
rawItems []map[string]any,
|
||||
filter chatMessagesSenderFilter,
|
||||
filter *chatMessagesSenderFilter,
|
||||
) []map[string]any {
|
||||
if !filter.requested || payload == nil {
|
||||
if filter == nil || !filter.requested || payload == nil {
|
||||
return rawItems
|
||||
}
|
||||
if !filter.applied {
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
priorFailures := len(failures)
|
||||
failures = append(failures, filter.failure)
|
||||
failure := filter.resolutionFailure
|
||||
if failure == nil {
|
||||
failure = map[string]any{
|
||||
"stage": "sender_resolution",
|
||||
"inputs": filter.inputs,
|
||||
"mode": filter.inputMode,
|
||||
"error": "无法把发送者目标唯一解析为稳定身份",
|
||||
}
|
||||
}
|
||||
failures = append(failures, failure)
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(rawItems) > 0
|
||||
payload["partial"] = false
|
||||
payload["messages"] = []map[string]any{}
|
||||
payload["count"] = 0
|
||||
payload["senderFilter"] = map[string]any{
|
||||
"requested": true,
|
||||
"inputs": filter.inputs,
|
||||
"mode": filter.inputMode,
|
||||
"applied": false,
|
||||
"status": "resolution_failed",
|
||||
"suppressedCount": len(rawItems),
|
||||
}
|
||||
payload["identityResult"] = map[string]any{
|
||||
"status": "resolution_failed",
|
||||
"nameComparisonAllowed": false,
|
||||
"stableIdField": "senderId",
|
||||
"hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。",
|
||||
}
|
||||
if priorFailures == 0 {
|
||||
payload["stopReason"] = "sender_resolution_failed"
|
||||
}
|
||||
return rawItems
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(rawItems))
|
||||
unverifiableMessageIDs := make([]string, 0)
|
||||
for _, item := range rawItems {
|
||||
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
|
||||
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
|
||||
if identity == "" || identity == "<nil>" {
|
||||
messageID := chatmsg.StableMessageID(item)
|
||||
if messageID == "" {
|
||||
messageID = "<unknown>"
|
||||
}
|
||||
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
|
||||
continue
|
||||
}
|
||||
if filter.stableIDs[identity] {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
if len(unverifiableMessageIDs) > 0 {
|
||||
filter.scopeErr = apperrors.NewAPI(
|
||||
"部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出",
|
||||
apperrors.WithReason("chat_messages_sender_scope_unverified"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedSenders": filter.inputs,
|
||||
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
|
||||
}),
|
||||
)
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "sender_scope_verification",
|
||||
"error": filter.scopeErr.Error(),
|
||||
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
|
||||
})
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(filtered) > 0
|
||||
}
|
||||
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filtered, filter.resolutions)
|
||||
if len(unverifiedSenderInputs) > 0 {
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "sender_identity_verification",
|
||||
"inputs": unverifiedSenderInputs,
|
||||
"error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 过滤,但不能据此作完整否定结论",
|
||||
})
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(filtered) > 0
|
||||
}
|
||||
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
|
||||
payload["count"] = len(filtered)
|
||||
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
|
||||
filterStatus := "applied"
|
||||
if filter.scopeErr != nil {
|
||||
filterStatus = "scope_unverified"
|
||||
} else if len(unverifiedSenderInputs) > 0 {
|
||||
filterStatus = "identity_unverified"
|
||||
}
|
||||
payload["senderFilter"] = map[string]any{
|
||||
"requested": true,
|
||||
"inputs": filter.inputs,
|
||||
"mode": filter.inputMode,
|
||||
"applied": true,
|
||||
"status": filterStatus,
|
||||
}
|
||||
if len(unverifiedSenderInputs) > 0 {
|
||||
payload["senderFilter"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs
|
||||
}
|
||||
identityResult := map[string]any{
|
||||
"status": "evaluated",
|
||||
"nameComparisonAllowed": false,
|
||||
"negativeConclusionAllowed": true,
|
||||
"stableIdField": "senderId",
|
||||
}
|
||||
if filter.scopeErr != nil {
|
||||
identityResult["status"] = "scope_unverified"
|
||||
identityResult["negativeConclusionAllowed"] = false
|
||||
identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。"
|
||||
} else if len(unverifiedSenderInputs) > 0 {
|
||||
identityResult["status"] = "identity_unverified"
|
||||
identityResult["negativeConclusionAllowed"] = false
|
||||
identityResult["hint"] = "当前混合发送者参数未能经通讯录确认;已按原值 userId 精确比较,若无命中仍不能断言该人员没有发过消息。"
|
||||
}
|
||||
payload["identityResult"] = identityResult
|
||||
return filtered
|
||||
}
|
||||
|
||||
func chatMessagesUnverifiedSenderInputs(
|
||||
messages []map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
) []string {
|
||||
observed := map[string]bool{}
|
||||
for _, message := range messages {
|
||||
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
|
||||
observed[senderID] = true
|
||||
}
|
||||
}
|
||||
values := make([]string, 0)
|
||||
for _, resolution := range resolutions {
|
||||
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
|
||||
values = append(values, resolution.Query)
|
||||
}
|
||||
}
|
||||
return uniqueChatMessageTargets(values)
|
||||
}
|
||||
|
||||
func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) {
|
||||
if payload == nil || filter.requested {
|
||||
return
|
||||
}
|
||||
payload["senderFilter"] = map[string]any{
|
||||
"requested": false,
|
||||
"applied": false,
|
||||
"status": "not_requested",
|
||||
}
|
||||
payload["identityResult"] = map[string]any{
|
||||
"status": "requires_query_check",
|
||||
"negativeConclusionAllowed": false,
|
||||
"hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。",
|
||||
}
|
||||
}
|
||||
|
||||
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
|
||||
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
|
||||
userID := rt.Str("user")
|
||||
@@ -331,6 +513,11 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
|
||||
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
|
||||
)
|
||||
}
|
||||
if openID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
|
||||
return chatMessagesRequest{}, err
|
||||
}
|
||||
}
|
||||
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
|
||||
if err != nil {
|
||||
@@ -408,7 +595,8 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
|
||||
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
|
||||
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, &senderFilter)
|
||||
attachUnfilteredSenderIdentitySemantics(payload, senderFilter)
|
||||
if err != nil {
|
||||
// Full-page collection returns its failure ledger together with a
|
||||
// non-zero error. Publish that ledger for diagnosis, but stop before
|
||||
@@ -420,6 +608,22 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if senderFilter.requested && !senderFilter.applied {
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return senderFilter.resolutionErr
|
||||
}
|
||||
if senderFilter.scopeErr != nil {
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return senderFilter.scopeErr
|
||||
}
|
||||
if rt.Bool("download-resources") {
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
|
||||
@@ -20,12 +20,14 @@ import (
|
||||
stderrors "errors"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"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/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -546,7 +548,7 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
|
||||
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"sender-query": "测试用户甲",
|
||||
}))
|
||||
if filter.applied || filter.failure == nil {
|
||||
if filter.applied || filter.resolutionFailure == nil {
|
||||
t.Fatalf("identity-free sender resolution = %#v", filter)
|
||||
}
|
||||
|
||||
@@ -577,6 +579,257 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesSenderScopeIgnoresNonMatchingIdentityFamily(t *testing.T) {
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"DAAAAAAAAAAAiE"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true},
|
||||
}
|
||||
payload := map[string]any{
|
||||
"complete": true,
|
||||
"failures": []map[string]any{},
|
||||
}
|
||||
filtered := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil),
|
||||
payload,
|
||||
[]map[string]any{
|
||||
{"openMessageId": "wanted", "senderOpenDingTalkId": "DAAAAAAAAAAAiE"},
|
||||
{"openMessageId": "other-family", "senderUserId": "other-user"},
|
||||
},
|
||||
&filter,
|
||||
)
|
||||
if len(filtered) != 1 || filter.scopeErr != nil || payload["complete"] != true || payload["count"] != 1 {
|
||||
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) {
|
||||
t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[{"userId":"other-user","name":"其他用户"}],"hasMore":false}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"sender": "fixture-user-id",
|
||||
}))
|
||||
if !filter.applied || !filter.stableIDs["fixture-user-id"] || filter.stableIDs["other-user"] {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct user ids continue when directory is unavailable", func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"}
|
||||
helpers.InitDeps(fake)
|
||||
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"sender": testCurrentDOpenID + ",fixture-name,ignored-name",
|
||||
}))
|
||||
if !filter.applied || filter.resolutionErr != nil || len(fake.calls) != 2 ||
|
||||
!filter.stableIDs["fixture-name"] || !filter.stableIDs["ignored-name"] {
|
||||
t.Fatalf("filter=%#v calls=%#v", filter, fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unverified mixed sender becomes verified by an exact sender id match", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
resolution := targetresolver.UserResolution{
|
||||
Status: targetresolver.StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: "stable-user-id",
|
||||
MatchType: "unverified_user_id",
|
||||
Selected: targetresolver.User{UserID: "stable-user-id"},
|
||||
}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"stable-user-id"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"stable-user-id": true},
|
||||
resolutions: []targetresolver.UserResolution{resolution},
|
||||
}
|
||||
filtered := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload,
|
||||
[]map[string]any{
|
||||
{"openMessageId": "wanted", "senderUserId": "stable-user-id"},
|
||||
{"openMessageId": "other", "senderUserId": "other-user"},
|
||||
}, &filter,
|
||||
)
|
||||
if len(filtered) != 1 || payload["complete"] != true || len(payload["failures"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("filtered=%#v payload=%#v", filtered, payload)
|
||||
}
|
||||
identity := payload["identityResult"].(map[string]any)
|
||||
if identity["status"] != "evaluated" || identity["negativeConclusionAllowed"] != true {
|
||||
t.Fatalf("identityResult=%#v", identity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unverified mixed sender without a match blocks complete negative conclusion", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
resolution := targetresolver.UserResolution{
|
||||
Status: targetresolver.StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: "possibly-a-name",
|
||||
MatchType: "unverified_user_id",
|
||||
Selected: targetresolver.User{UserID: "possibly-a-name"},
|
||||
}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"possibly-a-name"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"possibly-a-name": true},
|
||||
resolutions: []targetresolver.UserResolution{resolution},
|
||||
}
|
||||
filtered := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload,
|
||||
[]map[string]any{{"openMessageId": "other", "senderUserId": "other-user"}}, &filter,
|
||||
)
|
||||
if len(filtered) != 0 || payload["complete"] != false || payload["failedCount"] != 1 {
|
||||
t.Fatalf("filtered=%#v payload=%#v", filtered, payload)
|
||||
}
|
||||
identity := payload["identityResult"].(map[string]any)
|
||||
if identity["status"] != "identity_unverified" || identity["negativeConclusionAllowed"] != false {
|
||||
t.Fatalf("identityResult=%#v", identity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dedupes blanks and repeated values", func(t *testing.T) {
|
||||
got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "})
|
||||
if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("targets=%#v want=%#v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("synthesizes failure when none was supplied", func(t *testing.T) {
|
||||
payload := map[string]any{"failures": []map[string]any{{"stage": "existing"}}}
|
||||
filter := chatMessagesSenderFilter{requested: true, inputs: []string{"fixture"}, inputMode: "sender"}
|
||||
if got := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"openMessageId": "hidden"}}, &filter,
|
||||
); got != nil {
|
||||
t.Fatalf("filtered=%#v", got)
|
||||
}
|
||||
if _, exists := payload["stopReason"]; exists {
|
||||
t.Fatalf("existing failure should retain stop reason ownership: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects only missing identities", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"fixture"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"wanted-user": true},
|
||||
}
|
||||
filtered := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil),
|
||||
payload,
|
||||
[]map[string]any{
|
||||
{"content": "missing sender and id"},
|
||||
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
|
||||
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
|
||||
},
|
||||
&filter,
|
||||
)
|
||||
if len(filtered) != 1 || filter.scopeErr == nil {
|
||||
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores user identity when only open ids were resolved", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{testCurrentDOpenID},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{testCurrentDOpenID: true},
|
||||
}
|
||||
applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload,
|
||||
[]map[string]any{{"senderUserId": "other-user"}}, &filter,
|
||||
)
|
||||
if filter.scopeErr != nil || payload["complete"] != true {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores open identity when only user ids were resolved", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"wanted-user"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"wanted-user": true},
|
||||
}
|
||||
applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload,
|
||||
[]map[string]any{{"senderOpenDingTalkId": testCurrentDOpenID}}, &filter,
|
||||
)
|
||||
if filter.scopeErr != nil || payload["complete"] != true {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"open-dingtalk-id": "not-an-open-id",
|
||||
})); err == nil {
|
||||
t.Fatal("invalid explicit open ID unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) {
|
||||
t.Run("ambiguous direct sender prioritizes output error", func(t *testing.T) {
|
||||
caller := &platformCoverageCaller{contactSearchResult: `{"result":[
|
||||
{"userId":"fixture-user-1","name":"测试同名发送者"},
|
||||
{"userId":"fixture-user-2","name":"测试同名发送者"}
|
||||
],"hasMore":false}`}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", "测试同名发送者"})
|
||||
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sender string
|
||||
}{
|
||||
{name: "resolution", sender: "fixture-name"},
|
||||
{name: "scope", sender: testCurrentDOpenID},
|
||||
} {
|
||||
t.Run(tc.name+" returns domain error", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("sender failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+" prioritizes output error", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
|
||||
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
|
||||
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", testCurrentDOpenID, "--limit", "1", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *t
|
||||
want []string
|
||||
}{
|
||||
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
|
||||
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
|
||||
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender", "sender-query"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -160,7 +160,7 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender-query", "测试用户甲", "--page-all",
|
||||
"--sender", "测试用户甲", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -201,7 +201,64 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
|
||||
func TestChatMessagesFormatValidSenderSkipsDirectoryAndFiltersStableID(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
{"openMessageId":"wanted","sender":"目标展示名","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"保留"},
|
||||
{"openMessageId":"other","sender":"其他人","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"过滤"}
|
||||
]}}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender", "DAAAAAAAAAAAiE", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
|
||||
t.Fatalf("format-valid sender unexpectedly preflighted: %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != true {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesWithoutSenderDoesNotResolveEveryMessageIdentity(t *testing.T) {
|
||||
fake := &platformCoverageCaller{chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
{"openMessageId":"m1","sender":"群昵称一","senderOpenDingTalkId":"D1","content":"一"},
|
||||
{"openMessageId":"m2","sender":"群昵称二","senderOpenDingTalkId":"D2","content":"二"}
|
||||
]}}`}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
|
||||
t.Fatalf("calls=%#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := payload["identityResult"].(map[string]any)
|
||||
if payload["count"] != float64(2) || identity["status"] != "requires_query_check" ||
|
||||
identity["negativeConclusionAllowed"] != false {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureStopsWithoutUnfilteredMessages(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[]}`,
|
||||
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
@@ -216,22 +273,26 @@ func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfiltered
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender-query", "不存在的人", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("optional sender failure stopped message read: %v", err)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("sender resolution failure unexpectedly succeeded")
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
|
||||
fake.calls[1].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
|
||||
t.Fatalf("calls = %#v, want message read followed by failed resolve", fake.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != false ||
|
||||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
|
||||
if payload["count"] != float64(0) || payload["complete"] != false ||
|
||||
payload["partial"] != false || payload["failedCount"] != float64(1) ||
|
||||
payload["stopReason"] != "sender_resolution_failed" {
|
||||
t.Fatalf("unfiltered fallback payload = %#v", payload)
|
||||
t.Fatalf("fail-closed payload = %#v", payload)
|
||||
}
|
||||
messages, ok := payload["messages"].([]any)
|
||||
if !ok || len(messages) != 0 {
|
||||
t.Fatalf("failed resolution leaked unfiltered messages: %#v", payload["messages"])
|
||||
}
|
||||
if _, exists := payload["resolvedFilters"]; exists {
|
||||
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
|
||||
|
||||
@@ -35,7 +35,7 @@ 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 天,也可指定精确起止时间和输出顺序。" +
|
||||
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 执行,但无稳定 senderId 命中不得作完整否定结论。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
|
||||
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
|
||||
@@ -70,8 +70,8 @@ var SearchMsg = shortcut.Shortcut{
|
||||
UseWhen: []string{searchMsgIntent},
|
||||
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +search-msg --group \"项目群\" --sender \"测试用户甲\" --page-all",
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -80,15 +80,15 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
|
||||
{Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "单个群名或 openConversationId;自动唯一解析并校验"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 查询"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名,保留同样的三态解析与安全降级语义"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
|
||||
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
|
||||
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
|
||||
@@ -140,6 +140,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +search-msg --group "项目群" --sender "测试用户甲" --page-all`,
|
||||
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
|
||||
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
|
||||
`dws chat +search-msg --group <openConversationId> --query "changefree" --jq '.messages[] | {messageId, text}'`,
|
||||
@@ -270,6 +271,22 @@ var SearchMsg = shortcut.Shortcut{
|
||||
}
|
||||
messages = validatedMessages
|
||||
}
|
||||
if len(resolvedFilters.Senders) > 0 {
|
||||
var unverifiableMessageIDs []string
|
||||
messages, unverifiableMessageIDs = filterSearchSenderScope(messages, resolvedFilters.Senders)
|
||||
if len(unverifiableMessageIDs) > 0 {
|
||||
return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs)
|
||||
}
|
||||
}
|
||||
unverifiedSenderInputs := searchUnverifiedSenderInputs(messages, resolvedFilters.Senders)
|
||||
if len(unverifiedSenderInputs) > 0 {
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "sender_identity_verification",
|
||||
"inputs": unverifiedSenderInputs,
|
||||
"error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 执行,但不能据此作完整否定结论",
|
||||
})
|
||||
complete = false
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
|
||||
if order == "" {
|
||||
@@ -293,9 +310,23 @@ var SearchMsg = shortcut.Shortcut{
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"queryRange": searchMessageQueryRange(params, order),
|
||||
"timeCoverage": searchMessageTimeCoverage(rt),
|
||||
"conclusionGuard": searchMessageConclusionGuard(rt, complete, len(results)),
|
||||
}
|
||||
if len(resolvedFilters.Chats) > 0 || len(resolvedFilters.Senders) > 0 {
|
||||
payload["resolvedFilters"] = resolvedFilters
|
||||
}
|
||||
if len(resolvedFilters.Senders) > 0 {
|
||||
payload["resolvedFilters"] = resolvedFilters
|
||||
payload["senderScope"] = map[string]any{
|
||||
"targetsResolved": len(unverifiedSenderInputs) == 0,
|
||||
"filterApplied": true,
|
||||
"filterMode": "server_and_client",
|
||||
"resultsWithinScope": true,
|
||||
}
|
||||
if len(unverifiedSenderInputs) > 0 {
|
||||
payload["senderScope"].(map[string]any)["status"] = "identity_unverified"
|
||||
payload["senderScope"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs
|
||||
}
|
||||
}
|
||||
if scopedSearch {
|
||||
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
|
||||
@@ -313,6 +344,39 @@ var SearchMsg = shortcut.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func searchMessageTimeCoverage(rt *shortcut.RuntimeContext) map[string]any {
|
||||
source := "implicit_default"
|
||||
if rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") || rt.Changed("end-time") {
|
||||
source = "explicit_range"
|
||||
} else if rt.Changed("days") {
|
||||
source = "explicit_days"
|
||||
}
|
||||
|
||||
coverage := map[string]any{
|
||||
"source": source,
|
||||
"allHistory": false,
|
||||
}
|
||||
if source != "explicit_range" {
|
||||
coverage["days"] = rt.Int("days")
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func searchMessageConclusionGuard(rt *shortcut.RuntimeContext, complete bool, resultCount int) map[string]any {
|
||||
coverage := searchMessageTimeCoverage(rt)
|
||||
guard := map[string]any{
|
||||
"absenceWithinQueryRangeAllowed": complete,
|
||||
"absenceAcrossAllHistoryAllowed": false,
|
||||
"allHistoryCountAllowed": false,
|
||||
"requiredActionForAllHistory": "widen_time_range_or_reuse_complete_message_ledger",
|
||||
}
|
||||
if coverage["source"] == "implicit_default" && resultCount == 0 {
|
||||
guard["requiredAction"] = "widen_time_range_or_reuse_complete_message_ledger"
|
||||
guard["hint"] = "当前只证明默认 7 天时间窗内没有命中,不能据此判断全部历史没有。若已有完整会话消息,请解析稳定身份后复用现有消息;否则扩大时间范围。"
|
||||
}
|
||||
return guard
|
||||
}
|
||||
|
||||
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateSearchMsg(rt); err != nil {
|
||||
return err
|
||||
@@ -350,6 +414,39 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-40 之间")
|
||||
}
|
||||
if err := validateSearchMsgStrictConversationAliases(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The conventional --group/--groups flags are intentionally dual-form. Only
|
||||
// the explicit ID aliases remain strict so scripts that select them retain a
|
||||
// deterministic contract.
|
||||
func validateSearchMsgStrictConversationAliases(rt *shortcut.RuntimeContext) error {
|
||||
values := append([]string{}, rt.StrSlice("chat-id")...)
|
||||
if value := rt.StrFirst("conversation-id", "id"); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
for _, value := range uniqueSearchStrings(values) {
|
||||
if strings.HasPrefix(strings.ToLower(value), "cid") {
|
||||
continue
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("显式会话 ID 参数收到非 openConversationId 值 %q;已停止消息搜索", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint(fmt.Sprintf("--chat-id/--conversation-id/--id 只接受 cid 开头的 openConversationId;群名请使用 --group %q 或 --groups %q。", value, value)),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"flags": []string{"chat-id", "conversation-id", "id"},
|
||||
"expectedType": "openConversationId",
|
||||
"providedValue": value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -358,6 +455,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
// the directory name, so consumers must join the selected stable identity to
|
||||
// each projected message's senderId instead of comparing those two names.
|
||||
type searchResolvedFilters struct {
|
||||
Chats []targetresolver.ChatResolution `json:"chats,omitempty"`
|
||||
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
|
||||
}
|
||||
|
||||
@@ -367,41 +465,58 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
|
||||
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
|
||||
params["keyword"] = value
|
||||
}
|
||||
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
|
||||
conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...)
|
||||
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
|
||||
conversationIDs := append([]string{}, rt.StrSlice("chat-id")...)
|
||||
if value := rt.StrFirst("conversation-id", "id"); value != "" {
|
||||
conversationIDs = append(conversationIDs, value)
|
||||
}
|
||||
groupTargets := append([]string{}, rt.StrSlice("groups")...)
|
||||
if value := rt.Str("group"); value != "" {
|
||||
groupTargets = append(groupTargets, value)
|
||||
}
|
||||
for _, target := range uniqueSearchStrings(groupTargets) {
|
||||
if strings.HasPrefix(strings.ToLower(target), "cid") {
|
||||
conversationIDs = append(conversationIDs, target)
|
||||
continue
|
||||
}
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, target, "")
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
|
||||
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
|
||||
}
|
||||
if queries := rt.StrSlice("chat-query"); len(queries) > 0 {
|
||||
for _, query := range queries {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
|
||||
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
|
||||
}
|
||||
}
|
||||
if values := uniqueSearchStrings(conversationIDs); len(values) > 0 {
|
||||
params["openConversationIds"] = values
|
||||
}
|
||||
senders := append([]string{}, rt.StrSlice("senders")...)
|
||||
senders = append(senders, rt.StrSlice("sender")...)
|
||||
senderTargets := append([]string{}, rt.StrSlice("senders")...)
|
||||
senderTargets = append(senderTargets, rt.StrSlice("sender")...)
|
||||
for _, target := range uniqueSearchStrings(senderTargets) {
|
||||
resolved, err := targetresolver.ResolveSenderTarget(rt, target, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Senders = append(resolvedFilters.Senders, resolved)
|
||||
}
|
||||
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
|
||||
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
|
||||
for _, resolved := range resolvedUsers {
|
||||
identity := resolved.Selected.OpenDingTalkID
|
||||
if identity == "" {
|
||||
identity = resolved.Selected.UserID
|
||||
}
|
||||
senders = append(senders, identity)
|
||||
}
|
||||
}
|
||||
appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds")
|
||||
appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds")
|
||||
appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds")
|
||||
atUsers := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids"))
|
||||
appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds")
|
||||
if rt.Bool("at-me") || rt.Bool("is-at-me") {
|
||||
params["atMe"] = true
|
||||
}
|
||||
@@ -439,15 +554,40 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
|
||||
return params, resolvedFilters, nil
|
||||
}
|
||||
|
||||
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
|
||||
var userIDs, openIDs []string
|
||||
func resolveSearchStableActorTargets(
|
||||
rt *shortcut.RuntimeContext,
|
||||
values []string,
|
||||
) []targetresolver.UserResolution {
|
||||
resolvedUsers := make([]targetresolver.UserResolution, 0, len(values))
|
||||
for _, value := range uniqueSearchStrings(values) {
|
||||
if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') {
|
||||
openIDs = append(openIDs, value)
|
||||
} else {
|
||||
userIDs = append(userIDs, value)
|
||||
// IdentityAny accepts every non-empty value as exactly one stable ID
|
||||
// family, and uniqueSearchStrings has already removed empty values.
|
||||
resolved, _ := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny)
|
||||
resolvedUsers = append(resolvedUsers, resolved)
|
||||
}
|
||||
return resolvedUsers
|
||||
}
|
||||
|
||||
// appendResolvedSearchActorIDs prefers openDingTalkId whenever directory
|
||||
// resolution supplies it because the current message-search backend reliably
|
||||
// applies the open-ID field but may ignore senderUserIds.
|
||||
func appendResolvedSearchActorIDs(
|
||||
params map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
userKey, openIDKey string,
|
||||
) {
|
||||
var userIDs, openIDs []string
|
||||
for _, resolved := range resolutions {
|
||||
openID := strings.TrimSpace(resolved.Selected.OpenDingTalkID)
|
||||
userID := strings.TrimSpace(resolved.Selected.UserID)
|
||||
if openID != "" {
|
||||
openIDs = append(openIDs, openID)
|
||||
} else if userID != "" {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
userIDs = uniqueSearchStrings(userIDs)
|
||||
openIDs = uniqueSearchStrings(openIDs)
|
||||
if len(userIDs) > 0 {
|
||||
params[userKey] = userIDs
|
||||
}
|
||||
@@ -527,6 +667,78 @@ func searchScopePayload(conversationIDs []string, sourceComplete bool) map[strin
|
||||
}
|
||||
}
|
||||
|
||||
func filterSearchSenderScope(
|
||||
messages []map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
) ([]map[string]any, []string) {
|
||||
allowed := map[string]bool{}
|
||||
for _, resolution := range resolutions {
|
||||
if value := strings.TrimSpace(resolution.Selected.UserID); value != "" {
|
||||
allowed[value] = true
|
||||
}
|
||||
if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" {
|
||||
allowed[value] = true
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(messages))
|
||||
unverifiable := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message)))
|
||||
if senderID == "" || senderID == "<nil>" {
|
||||
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
|
||||
if messageID == "" || messageID == "<nil>" {
|
||||
messageID = "<unknown>"
|
||||
}
|
||||
unverifiable = append(unverifiable, messageID)
|
||||
continue
|
||||
}
|
||||
if allowed[senderID] {
|
||||
filtered = append(filtered, message)
|
||||
}
|
||||
}
|
||||
return filtered, uniqueSearchStrings(unverifiable)
|
||||
}
|
||||
|
||||
func searchUnverifiedSenderInputs(
|
||||
messages []map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
) []string {
|
||||
observed := map[string]bool{}
|
||||
for _, message := range messages {
|
||||
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
|
||||
observed[senderID] = true
|
||||
}
|
||||
}
|
||||
values := make([]string, 0)
|
||||
for _, resolution := range resolutions {
|
||||
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
|
||||
values = append(values, resolution.Query)
|
||||
}
|
||||
}
|
||||
return uniqueSearchStrings(values)
|
||||
}
|
||||
|
||||
func searchSenderScopeUnverifiedError(
|
||||
resolutions []targetresolver.UserResolution,
|
||||
messageIDs []string,
|
||||
) error {
|
||||
queries := make([]string, 0, len(resolutions))
|
||||
for _, resolution := range resolutions {
|
||||
queries = append(queries, resolution.Query)
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"搜索结果缺少 senderId,无法证明发送者过滤范围;已停止输出",
|
||||
apperrors.WithReason("search_sender_scope_unverified"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedSenders": uniqueSearchStrings(queries),
|
||||
"unverifiableMessageIds": messageIDs,
|
||||
}),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回稳定 senderId"),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -15,25 +15,35 @@ 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/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type searchMsgExecutionCaller struct {
|
||||
calls []platformCoverageCall
|
||||
failSecondPage bool
|
||||
failEnrichment bool
|
||||
omitPagination bool
|
||||
omitMgetItem bool
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
searchResponse string
|
||||
wrongMgetScope bool
|
||||
missingMgetCID bool
|
||||
firstResponse string
|
||||
mgetResponse string
|
||||
numericZeroEnd bool
|
||||
calls []platformCoverageCall
|
||||
failSecondPage bool
|
||||
failEnrichment bool
|
||||
omitPagination bool
|
||||
omitMgetItem bool
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
searchResponse string
|
||||
wrongMgetScope bool
|
||||
missingMgetCID bool
|
||||
firstResponse string
|
||||
mgetResponse string
|
||||
numericZeroEnd bool
|
||||
contactResponse string
|
||||
groupResponse string
|
||||
failContactKeyword string
|
||||
failGroupKeyword string
|
||||
}
|
||||
|
||||
const (
|
||||
testCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
testCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
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" {
|
||||
@@ -45,10 +55,28 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
|
||||
}
|
||||
if product == "contact" && tool == "search_contact_by_key_word" {
|
||||
if f.failContactKeyword != "" && args["keyword"] == f.failContactKeyword {
|
||||
return nil, errors.New("fixture contact failure")
|
||||
}
|
||||
if f.contactResponse != "" {
|
||||
return searchMsgToolResult(f.contactResponse), nil
|
||||
}
|
||||
keyword := args["keyword"].(string)
|
||||
return searchMsgToolResult(`{"result":[{"userId":"user-` + keyword + `","openDingTalkId":"` + testCurrentDOpenID + `","name":"` + keyword + `"}],"hasMore":false}`), nil
|
||||
}
|
||||
if product != "im" {
|
||||
return nil, errors.New("unexpected product")
|
||||
}
|
||||
switch tool {
|
||||
case "search_groups":
|
||||
if f.failGroupKeyword != "" && args["keyword"] == f.failGroupKeyword {
|
||||
return nil, errors.New("fixture group failure")
|
||||
}
|
||||
if f.groupResponse != "" {
|
||||
return searchMsgToolResult(f.groupResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":[{"openConversationId":"cid-resolved-group","title":"` + args["keyword"].(string) + `"}],"hasMore":false}`), nil
|
||||
case "search_messages":
|
||||
if f.searchResponse != "" {
|
||||
return searchMsgToolResult(f.searchResponse), nil
|
||||
@@ -63,12 +91,12 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
|
||||
if f.failSecondPage {
|
||||
return nil, errors.New("second page unavailable")
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
}
|
||||
if f.firstResponse != "" {
|
||||
return searchMsgToolResult(f.firstResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
case "list_messages_by_ids":
|
||||
if f.failEnrichment {
|
||||
return nil, errors.New("mget unavailable")
|
||||
@@ -129,13 +157,264 @@ func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (m
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func TestSearchMsgMixedSenderClassifiesFormatWithoutIDPreflight(t *testing.T) {
|
||||
tests := []string{"D-prefix-fixture-user", "d-prefix-fixture-user", "测试用户甲", "fixture-user-id"}
|
||||
for _, target := range tests {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--sender", target, "--no-enrich")
|
||||
if payload["complete"] != true || len(caller.calls) != 2 {
|
||||
t.Fatalf("payload=%#v calls=%#v", payload, caller.calls)
|
||||
}
|
||||
if caller.calls[0].product != "contact" || caller.calls[0].tool != "search_contact_by_key_word" ||
|
||||
caller.calls[1].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
if got, want := caller.calls[1].args["senderOpenDingTakIds"], []string{testCurrentDOpenID}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("senderOpenDingTakIds=%#v want=%#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
|
||||
t.Fatalf("format-valid ID unexpectedly preflighted: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
|
||||
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
|
||||
{"openMessageId":"other","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"drop"}
|
||||
],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
if payload["count"] != float64(1) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
messages := payload["messages"].([]any)
|
||||
if messages[0].(map[string]any)["messageId"] != "wanted" {
|
||||
t.Fatalf("messages=%#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgSenderScopeIgnoresNonMatchingIdentityFamily(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
|
||||
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
|
||||
{"openMessageId":"other-family","senderUserId":"other-user","content":"drop"}
|
||||
],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
if payload["complete"] != true || payload["count"] != float64(1) || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
|
||||
filtered, unverifiable := filterSearchSenderScope(
|
||||
[]map[string]any{
|
||||
{"openMessageId": "wanted-user", "senderUserId": "wanted-user"},
|
||||
{"openMessageId": "other-open-family", "senderOpenDingTalkId": testCurrentDOpenID},
|
||||
},
|
||||
[]targetresolver.UserResolution{{Selected: targetresolver.User{UserID: "wanted-user"}}},
|
||||
)
|
||||
if len(filtered) != 1 || len(unverifiable) != 0 {
|
||||
t.Fatalf("reverse family filtered=%#v unverifiable=%#v", filtered, unverifiable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller *searchMsgExecutionCaller
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "group target",
|
||||
caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"},
|
||||
args: []string{"--group", "missing-group", "--no-enrich"},
|
||||
},
|
||||
{
|
||||
name: "sender query",
|
||||
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"},
|
||||
args: []string{"--sender-query", "missing-query", "--no-enrich"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil {
|
||||
t.Fatal("resolution failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgRejectsAmbiguousDirectSender(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{contactResponse: `{"result":[
|
||||
{"userId":"fixture-user-1","name":"测试同名发送者"},
|
||||
{"userId":"fixture-user-2","name":"测试同名发送者"}
|
||||
],"hasMore":false}`}
|
||||
_, err := executeSearchMsgResult(caller, "--sender", "测试同名发送者", "--no-enrich")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("error=%#v, want resolution_ambiguous", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgStableUserIDContinuesWhenDirectoryIsUnavailable(t *testing.T) {
|
||||
t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
contactResponse: `{"result":[{"userId":"other-user","name":"其他用户"}],"hasMore":false}`,
|
||||
searchResponse: `{"result":{"messages":[
|
||||
{"openMessageId":"wanted","senderUserId":"fixture-user-id","content":"keep"},
|
||||
{"openMessageId":"other","senderUserId":"other-user","content":"drop"}
|
||||
],"hasMore":false}}`,
|
||||
}
|
||||
payload := executeSearchMsg(t, caller, "--sender", "fixture-user-id", "--no-enrich")
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "search_contact_by_key_word" ||
|
||||
caller.calls[1].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"fixture-user-id"}) {
|
||||
t.Fatalf("senderUserIds=%#v args=%#v", got, caller.calls[1].args)
|
||||
}
|
||||
if _, exists := caller.calls[1].args["senderOpenDingTakIds"]; exists {
|
||||
t.Fatalf("unrelated candidate leaked into request: %#v", caller.calls[1].args)
|
||||
}
|
||||
if payload["complete"] != true || payload["count"] != float64(1) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed sender preserves positive matches and blocks complete negative conclusion", func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
failContactKeyword: "stable-user-id",
|
||||
searchResponse: `{"result":{"messages":[
|
||||
{"openMessageId":"wanted","senderUserId":"stable-user-id","content":"keep"},
|
||||
{"openMessageId":"other","senderUserId":"other-user","content":"drop"}
|
||||
],"hasMore":false}}`,
|
||||
}
|
||||
payload := executeSearchMsg(t, caller, "--sender", "stable-user-id", "--no-enrich")
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "search_contact_by_key_word" ||
|
||||
caller.calls[1].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"stable-user-id"}) {
|
||||
t.Fatalf("senderUserIds=%#v", got)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != true || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
scope := payload["senderScope"].(map[string]any)
|
||||
if _, exists := scope["status"]; exists || scope["targetsResolved"] != true {
|
||||
t.Fatalf("senderScope=%#v", scope)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed sender without a stable id match cannot produce a complete negative conclusion", func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
failContactKeyword: "possibly-a-name",
|
||||
searchResponse: `{"result":{"messages":[],"hasMore":false}}`,
|
||||
}
|
||||
payload := executeSearchMsg(t, caller, "--sender", "possibly-a-name", "--no-enrich")
|
||||
if payload["count"] != float64(0) || payload["complete"] != false || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
scope := payload["senderScope"].(map[string]any)
|
||||
if scope["status"] != "identity_unverified" || scope["targetsResolved"] != false {
|
||||
t.Fatalf("senderScope=%#v", scope)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("id-only at target bypasses directory", func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
failContactKeyword: "stable-at-user-id",
|
||||
searchResponse: `{"result":{"messages":[],"hasMore":false}}`,
|
||||
}
|
||||
payload := executeSearchMsg(t, caller, "--at-ids", "stable-at-user-id", "--no-enrich")
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
if got := caller.calls[0].args["atUserIds"]; !reflect.DeepEqual(got, []string{"stable-at-user-id"}) {
|
||||
t.Fatalf("atUserIds=%#v", got)
|
||||
}
|
||||
if payload["complete"] != true {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) {
|
||||
t.Run("execute rejects senderless backend rows", func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`}
|
||||
_, err := executeSearchMsgResult(caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
resolutions := []targetresolver.UserResolution{{
|
||||
Query: "fixture-user",
|
||||
Selected: targetresolver.User{UserID: "wanted-user"},
|
||||
}}
|
||||
filtered, unverifiable := filterSearchSenderScope(
|
||||
[]map[string]any{
|
||||
{"content": "missing identity"},
|
||||
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
|
||||
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
|
||||
},
|
||||
resolutions,
|
||||
)
|
||||
if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"<unknown>"}) {
|
||||
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
|
||||
}
|
||||
err := searchSenderScopeUnverifiedError(
|
||||
append(resolutions, resolutions...),
|
||||
[]string{"message-1"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgExplicitDaysMetadata(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--query", "fixture", "--days", "3", "--no-enrich")
|
||||
coverage := payload["timeCoverage"].(map[string]any)
|
||||
if coverage["source"] != "explicit_days" || coverage["days"] != float64(3) {
|
||||
t.Fatalf("coverage=%#v", coverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgRejectsNonConversationIDAlias(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{}
|
||||
_, err := executeSearchMsgResult(caller, "--query", "fixture", "--chat-id", "group-name", "--no-enrich")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgGroupNameAndDefaultWindowMetadata(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--group", "项目讨论群", "--no-enrich")
|
||||
if len(caller.calls) != 3 || caller.calls[0].tool != "search_groups" ||
|
||||
caller.calls[1].tool != "get_conversation_info" || caller.calls[2].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
coverage := payload["timeCoverage"].(map[string]any)
|
||||
guard := payload["conclusionGuard"].(map[string]any)
|
||||
if coverage["source"] != "implicit_default" || coverage["days"] != float64(7) ||
|
||||
guard["absenceAcrossAllHistoryAllowed"] != false {
|
||||
t.Fatalf("coverage=%#v guard=%#v", coverage, guard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{}
|
||||
payload := executeSearchMsg(t, caller,
|
||||
"--query", "周报",
|
||||
"--chat-id", "cid-1,cid-2",
|
||||
"--sender", "42,Dsender",
|
||||
"--at-ids", "43,Dat",
|
||||
"--sender", testCurrentDOpenID,
|
||||
"--at-ids", testCurrentDOpenID2,
|
||||
"--is-at-me",
|
||||
"--message-type", "text",
|
||||
"--only-robot",
|
||||
@@ -160,10 +439,8 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
|
||||
t.Fatalf("first call = %#v", first)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"senderUserIds": []string{"42"},
|
||||
"senderOpenDingTakIds": []string{"Dsender"},
|
||||
"atUserIds": []string{"43"},
|
||||
"atOpenDingTakIds": []string{"Dat"},
|
||||
"senderOpenDingTakIds": []string{testCurrentDOpenID},
|
||||
"atOpenDingTakIds": []string{testCurrentDOpenID2},
|
||||
"messageType": "text",
|
||||
"onlyRobotMessages": true,
|
||||
"searchConvType": "group",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
package targetresolver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -69,6 +70,16 @@ type UserResolution struct {
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
const matchTypeUnverifiedUserID = "unverified_user_id"
|
||||
|
||||
// IsUnverifiedUserIDResolution reports a mixed sender value that could not be
|
||||
// classified by the directory and is therefore being tried as an exact userId.
|
||||
// Consumers may use positive senderId equality, but must not claim a negative
|
||||
// result is complete because the original value may instead have been a name.
|
||||
func IsUnverifiedUserIDResolution(resolution UserResolution) bool {
|
||||
return resolution.MatchType == matchTypeUnverifiedUserID
|
||||
}
|
||||
|
||||
// Chat is the public identity returned by group resolution.
|
||||
type Chat struct {
|
||||
OpenConversationID string `json:"openConversationId"`
|
||||
@@ -105,6 +116,292 @@ func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (User
|
||||
return resolveUserCandidates(ExtractUsers(data), query, requirement)
|
||||
}
|
||||
|
||||
// ResolveUserTarget accepts the mixed user target used by public convenience
|
||||
// flags: a natural name, userId, or current-version openDingTalkId. The local
|
||||
// format check is deliberately a classifier rather than an existence check.
|
||||
// Format-valid IDs go directly to the downstream business API; all other
|
||||
// values use the directory resolver, which can match names and exact userIds.
|
||||
func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
"必须指定用户姓名、userId 或 openDingTalkId",
|
||||
apperrors.WithReason("missing_target"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if !LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
return ResolveUser(rt, value, requirement)
|
||||
}
|
||||
if requirement == IdentityUserID {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: User{OpenDingTalkID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveSenderTarget resolves the mixed identity accepted specifically by
|
||||
// sender convenience flags. Unlike the shared name resolver, it may select a
|
||||
// unique candidate whose stable userId/openDingTalkId exactly equals the
|
||||
// supplied value. Natural-name resolution accepts only exact names and retains
|
||||
// fail-closed ambiguity behavior; a lone unrelated directory candidate must
|
||||
// never replace the supplied value. If the directory cannot classify a value
|
||||
// and userId is accepted downstream, the original value is retained as an
|
||||
// unverified userId candidate so a valid stable ID is not blocked by directory
|
||||
// availability.
|
||||
func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
return ResolveUserTarget(rt, value, requirement)
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": value,
|
||||
})
|
||||
if err != nil {
|
||||
if requirement == IdentityOpenDingTalkID {
|
||||
return UserResolution{}, err
|
||||
}
|
||||
return unverifiedSenderUserID(value), nil
|
||||
}
|
||||
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
|
||||
stableMatches := make([]User, 0, 1)
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(user.UserID) == value ||
|
||||
strings.TrimSpace(user.OpenDingTalkID) == value {
|
||||
stableMatches = append(stableMatches, user)
|
||||
}
|
||||
}
|
||||
if len(stableMatches) == 1 {
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: stableMatches[0],
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
if len(stableMatches) > 1 {
|
||||
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
|
||||
}
|
||||
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
|
||||
if requirement == IdentityOpenDingTalkID {
|
||||
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
|
||||
}
|
||||
return unverifiedSenderUserID(value), nil
|
||||
}
|
||||
exactNameMatches := make([]User, 0, 1)
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(strings.TrimSpace(user.Name), value) {
|
||||
exactNameMatches = append(exactNameMatches, user)
|
||||
}
|
||||
}
|
||||
if len(exactNameMatches) == 1 {
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "exact",
|
||||
Selected: exactNameMatches[0],
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
if len(exactNameMatches) > 1 {
|
||||
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, exactNameMatches)
|
||||
}
|
||||
if requirement == IdentityOpenDingTalkID {
|
||||
return UserResolution{}, newResolutionError(StatusNotFound, "user", value, users)
|
||||
}
|
||||
return unverifiedSenderUserID(value), nil
|
||||
}
|
||||
|
||||
func unverifiedSenderUserID(value string) UserResolution {
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: matchTypeUnverifiedUserID,
|
||||
Selected: User{UserID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveStableUserTarget accepts only a userId or current-version
|
||||
// openDingTalkId. Because callers use it only for ID-only flags, a non-D value
|
||||
// is already explicitly typed as userId and can be passed through without a
|
||||
// directory availability dependency.
|
||||
func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
"必须指定 userId 或 openDingTalkId",
|
||||
apperrors.WithReason("missing_target"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
if requirement == IdentityUserID {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: User{OpenDingTalkID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
if requirement == IdentityOpenDingTalkID {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("用户目标参数类型不匹配:%q 是 userId,但当前参数只接受 openDingTalkId", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: User{UserID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical
|
||||
// wire format emitted by the current D-version encoder. It does not decrypt
|
||||
// the value and therefore does not prove that the identity exists or is
|
||||
// accessible to the current profile.
|
||||
func LooksLikeCurrentDOpenDingTalkID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 2 || value[0] != 'D' {
|
||||
return false
|
||||
}
|
||||
|
||||
encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded)
|
||||
if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Require the canonical Base64 and escape spelling. This rejects alternate
|
||||
// but decodable strings and keeps the classifier aligned with the encoder.
|
||||
canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext))
|
||||
return canonical == value[1:]
|
||||
}
|
||||
|
||||
func unescapeCurrentDOpenDingTalkID(value string) (string, bool) {
|
||||
var decoded strings.Builder
|
||||
decoded.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
current := value[i]
|
||||
if current != 'i' {
|
||||
if !isASCIIAlphaNumeric(current) {
|
||||
return "", false
|
||||
}
|
||||
decoded.WriteByte(current)
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(value) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
switch value[i] {
|
||||
case 'i':
|
||||
decoded.WriteByte('i')
|
||||
case 'P':
|
||||
decoded.WriteByte('+')
|
||||
case 'S':
|
||||
decoded.WriteByte('/')
|
||||
case 'E':
|
||||
decoded.WriteByte('=')
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return decoded.String(), true
|
||||
}
|
||||
|
||||
func escapeCurrentDOpenDingTalkID(value string) string {
|
||||
var encoded strings.Builder
|
||||
encoded.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case 'i':
|
||||
encoded.WriteString("ii")
|
||||
case '+':
|
||||
encoded.WriteString("iP")
|
||||
case '/':
|
||||
encoded.WriteString("iS")
|
||||
case '=':
|
||||
encoded.WriteString("iE")
|
||||
default:
|
||||
encoded.WriteByte(value[i])
|
||||
}
|
||||
}
|
||||
return encoded.String()
|
||||
}
|
||||
|
||||
func isASCIIAlphaNumeric(value byte) bool {
|
||||
return value >= '0' && value <= '9' ||
|
||||
value >= 'A' && value <= 'Z' ||
|
||||
value >= 'a' && value <= 'z'
|
||||
}
|
||||
|
||||
// ValidateExplicitOpenDingTalkID applies the current-version format contract
|
||||
// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID
|
||||
// must fail locally instead of falling back to a name or userId lookup.
|
||||
func ValidateExplicitOpenDingTalkID(flagName, value string) error {
|
||||
if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
return nil
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"flag": flagName,
|
||||
"expectedType": "current_d_openDingTalkId",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ResolveEnterpriseUser resolves an organization member through the same
|
||||
// name-calibrated enterprise search used by `dws aisearch person`. Unlike the
|
||||
// contact search, this source understands organization nicknames and aliases.
|
||||
|
||||
@@ -686,3 +686,347 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "canonical current D ciphertext", value: "DAAAAAAAAAAAiE", want: true},
|
||||
{name: "synthetic current D ciphertext with escaped plus", value: "DiPiPiPiPiPiPiPiPiPiP8iE", want: true},
|
||||
{name: "synthetic current D ciphertext with escaped literal i", value: "DYmJiiYmJiiYmIiE", want: true},
|
||||
{name: "surrounding whitespace", value: " DAAAAAAAAAAAiE ", want: true},
|
||||
{name: "empty", value: "", want: false},
|
||||
{name: "prefix only", value: "D", want: false},
|
||||
{name: "lowercase prefix", value: "dAAAAAAAAAAAiE", want: false},
|
||||
{name: "natural uppercase D fixture", value: "D-prefix-fixture-user", want: false},
|
||||
{name: "natural lowercase d fixture", value: "d-prefix-fixture-user", want: false},
|
||||
{name: "invalid escape", value: "DAAAAAiDAAAAAiE", want: false},
|
||||
{name: "non alphanumeric body", value: "DAAAAA/AAAAAiE", want: false},
|
||||
{name: "invalid base64", value: "Dabc", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := LooksLikeCurrentDOpenDingTalkID(tc.value); got != tc.want {
|
||||
t.Fatalf("LooksLikeCurrentDOpenDingTalkID(%q) = %v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) {
|
||||
const openID = "DAAAAAAAAAAAiE"
|
||||
calls := 0
|
||||
resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
return nil, stderrors.New("unexpected remote call")
|
||||
}), openID, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
|
||||
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSenderInputs(t *testing.T) {
|
||||
calls := 0
|
||||
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
if product != "contact" || tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("unexpected tool %s/%s", product, tool)
|
||||
}
|
||||
query := fmt.Sprint(params["keyword"])
|
||||
switch query {
|
||||
case "D-prefix-fixture-user":
|
||||
return map[string]any{"result": []any{map[string]any{
|
||||
"userId": "fixture-user-d-upper", "openDingTalkId": "D-directory-value", "name": "D-prefix-fixture-user",
|
||||
}}}, nil
|
||||
case "d-prefix-fixture-user":
|
||||
return map[string]any{"result": []any{map[string]any{
|
||||
"userId": "fixture-user-d-lower", "openDingTalkId": "D-directory-value-2", "name": "d-prefix-fixture-user",
|
||||
}}}, nil
|
||||
case "fixture-user-id":
|
||||
return map[string]any{"result": []any{
|
||||
map[string]any{"userId": "other", "name": "相似候选"},
|
||||
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "D-directory-value-3", "name": "测试目标用户"},
|
||||
}}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected query %q", query)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
for _, query := range []string{"D-prefix-fixture-user", "d-prefix-fixture-user"} {
|
||||
_, err := ResolveSenderTarget(reader, query, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := ResolveUser(reader, "fixture-user-id", IdentityAny)
|
||||
var ambiguous *apperrors.Error
|
||||
if !stderrors.As(err, &ambiguous) || ambiguous.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("shared name resolver error = %#v, want resolution_ambiguous", err)
|
||||
}
|
||||
|
||||
resolved, err := ResolveSenderTarget(reader, "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("exact sender userId resolution = %#v", resolved)
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Fatalf("calls=%d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSenderTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
|
||||
const openID = "DAAAAAAAAAAAiE"
|
||||
calls := 0
|
||||
resolved, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
return nil, stderrors.New("unexpected directory call")
|
||||
}), openID, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
|
||||
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSenderTargetNeverSubstitutesUnrelatedUniqueDirectoryCandidate(t *testing.T) {
|
||||
resolved, err := ResolveSenderTarget(resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
if product != "contact" || tool != "search_contact_by_key_word" || params["keyword"] != "fixture-user-id" {
|
||||
t.Fatalf("unexpected call %s/%s %#v", product, tool, params)
|
||||
}
|
||||
return map[string]any{
|
||||
"result": []any{map[string]any{
|
||||
"userId": "other-user",
|
||||
"name": "其他用户",
|
||||
}},
|
||||
"hasMore": false,
|
||||
}, nil
|
||||
}), "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("resolution=%#v", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSenderTargetOpenIDFailureEdges(t *testing.T) {
|
||||
t.Run("directory failure", func(t *testing.T) {
|
||||
wantErr := stderrors.New("directory unavailable")
|
||||
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, wantErr
|
||||
}), "fixture-sender", IdentityOpenDingTalkID)
|
||||
if !stderrors.Is(err, wantErr) {
|
||||
t.Fatalf("error=%#v, want directory error", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incomplete directory result", func(t *testing.T) {
|
||||
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{map[string]any{
|
||||
"openDingTalkId": "fixture-open-id-1",
|
||||
"name": "测试候选用户",
|
||||
}},
|
||||
"hasMore": true,
|
||||
}, nil
|
||||
}), "fixture-sender", IdentityOpenDingTalkID)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
|
||||
t.Fatalf("error=%#v, want resolution_incomplete", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple exact names remain ambiguous", func(t *testing.T) {
|
||||
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{
|
||||
map[string]any{"openDingTalkId": "fixture-open-id-1", "name": "测试同名用户"},
|
||||
map[string]any{"openDingTalkId": "fixture-open-id-2", "name": "测试同名用户"},
|
||||
},
|
||||
"hasMore": false,
|
||||
}, nil
|
||||
}), "测试同名用户", IdentityOpenDingTalkID)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("error=%#v, want resolution_ambiguous", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no exact open id or name match", func(t *testing.T) {
|
||||
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{map[string]any{
|
||||
"openDingTalkId": "fixture-open-id-1",
|
||||
"name": "其他测试用户",
|
||||
}},
|
||||
"hasMore": false,
|
||||
}, nil
|
||||
}), "fixture-sender", IdentityOpenDingTalkID)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_not_found" {
|
||||
t.Fatalf("error=%#v, want resolution_not_found", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) {
|
||||
if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil {
|
||||
t.Fatalf("valid format: %v", err)
|
||||
}
|
||||
err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "D-prefix-fixture-user")
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
err = ValidateExplicitOpenDingTalkID("--open-dingtalk-id", " DAAAAAAAAAAAiE ")
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("whitespace-wrapped explicit ID error=%#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveStableUserTargetPassesExplicitUserIDWithoutDirectory(t *testing.T) {
|
||||
calls := 0
|
||||
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
return nil, stderrors.New("directory unavailable")
|
||||
})
|
||||
|
||||
resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("exact userId resolution = %#v", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
|
||||
const openID = "DAAAAAAAAAAAiE"
|
||||
calls := 0
|
||||
resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
return nil, stderrors.New("unexpected remote call")
|
||||
}), openID, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || resolved.Selected.OpenDingTalkID != openID {
|
||||
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableIdentityExactMatchWinsEvenWhenDirectoryPageIsIncomplete(t *testing.T) {
|
||||
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{
|
||||
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试目标用户"},
|
||||
map[string]any{"userId": "other", "name": "其他候选"},
|
||||
},
|
||||
"hasMore": true,
|
||||
}, nil
|
||||
})
|
||||
for _, resolve := range []struct {
|
||||
name string
|
||||
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
|
||||
}{
|
||||
{name: "sender", fn: ResolveSenderTarget},
|
||||
{name: "stable-only", fn: ResolveStableUserTarget},
|
||||
} {
|
||||
t.Run(resolve.name, func(t *testing.T) {
|
||||
resolved, err := resolve.fn(reader, "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("resolved=%#v", resolved)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) {
|
||||
unexpectedReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, stderrors.New("directory unavailable")
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(Reader, string, IdentityRequirement) (UserResolution, error)
|
||||
value string
|
||||
requirement IdentityRequirement
|
||||
wantReason string
|
||||
}{
|
||||
{name: "mixed empty", resolve: ResolveUserTarget, wantReason: "missing_target"},
|
||||
{name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
|
||||
{name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"},
|
||||
{name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
|
||||
{name: "stable user id requires open id", resolve: ResolveStableUserTarget, value: "ordinary-user", requirement: IdentityOpenDingTalkID, wantReason: "target_type_mismatch"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.resolve(unexpectedReader, tc.value, tc.requirement)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != tc.wantReason {
|
||||
t.Fatalf("error=%#v, want reason %q", err, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
|
||||
t.Fatalf("mixed directory error=%v", err)
|
||||
}
|
||||
resolved, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny)
|
||||
if err != nil || !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "ordinary-user" {
|
||||
t.Fatalf("sender fallback resolution=%#v error=%v", resolved, err)
|
||||
}
|
||||
resolved, err = ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny)
|
||||
if err != nil || resolved.MatchType != "stable_id" || resolved.Selected.UserID != "ordinary-user" {
|
||||
t.Fatalf("stable passthrough resolution=%#v error=%v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testing.T) {
|
||||
duplicateReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": []any{
|
||||
map[string]any{"userId": "duplicate", "openDingTalkId": "D-one", "name": "甲"},
|
||||
map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"},
|
||||
}}, nil
|
||||
})
|
||||
_, err := ResolveSenderTarget(duplicateReader, "duplicate", IdentityAny)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("ambiguous sender error=%#v", err)
|
||||
}
|
||||
|
||||
incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{map[string]any{"userId": "other", "name": "其他"}},
|
||||
"hasMore": true,
|
||||
}, nil
|
||||
})
|
||||
resolved, err := ResolveSenderTarget(incompleteReader, "missing", IdentityAny)
|
||||
if err != nil || !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "missing" {
|
||||
t.Fatalf("incomplete sender fallback=%#v error=%v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCurrentDCodecEdges(t *testing.T) {
|
||||
if _, ok := unescapeCurrentDOpenDingTalkID("abcdi"); ok {
|
||||
t.Fatal("dangling escape unexpectedly accepted")
|
||||
}
|
||||
if decoded, ok := unescapeCurrentDOpenDingTalkID("iS"); !ok || decoded != "/" {
|
||||
t.Fatalf("slash escape decoded=%q ok=%v", decoded, ok)
|
||||
}
|
||||
if got := escapeCurrentDOpenDingTalkID("i+/="); got != "iiiPiSiE" {
|
||||
t.Fatalf("escaped=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
"usage: $0 verify <app-package>" \
|
||||
" $0 run <app-package>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ "$#" -eq 2 ] || usage
|
||||
mode="$1"
|
||||
app_package="$2"
|
||||
|
||||
case "$mode" in
|
||||
verify|run) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-app-race-tests.XXXXXX")"
|
||||
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
|
||||
|
||||
tests="$workdir/tests"
|
||||
duplicates="$workdir/duplicates"
|
||||
list_output="$workdir/list-output"
|
||||
|
||||
cd "$ROOT"
|
||||
if ! go test "$app_package" -list '^(Test|Example|Fuzz)' > "$list_output"; then
|
||||
printf 'app race partition discovery failed for %s\n' "$app_package" >&2
|
||||
exit 1
|
||||
fi
|
||||
awk '/^(Test|Example|Fuzz)/ { print $1 }' "$list_output" > "$tests"
|
||||
|
||||
if [ ! -s "$tests" ]; then
|
||||
printf 'app race partition discovery found no tests in %s\n' "$app_package" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LC_ALL=C sort "$tests" | uniq -d > "$duplicates"
|
||||
if [ -s "$duplicates" ]; then
|
||||
printf '%s\n' 'app race partition discovery found duplicate top-level tests:' >&2
|
||||
sed 's/^/ /' "$duplicates" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
schema_count=0
|
||||
ab_count=0
|
||||
c_count=0
|
||||
dr_count=0
|
||||
sz_count=0
|
||||
unmatched_count=0
|
||||
|
||||
while IFS= read -r test_name; do
|
||||
case "$test_name" in
|
||||
Test*Schema*) schema_count=$((schema_count + 1)) ;;
|
||||
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
|
||||
TestC*) c_count=$((c_count + 1)) ;;
|
||||
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
|
||||
Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;;
|
||||
*)
|
||||
printf 'unmatched app race test: %s\n' "$test_name" >&2
|
||||
unmatched_count=$((unmatched_count + 1))
|
||||
;;
|
||||
esac
|
||||
done < "$tests"
|
||||
|
||||
if [ "$unmatched_count" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for partition in \
|
||||
"schema:$schema_count" \
|
||||
"a-b:$ab_count" \
|
||||
"c:$c_count" \
|
||||
"d-r:$dr_count" \
|
||||
"s-z-example-fuzz:$sz_count"
|
||||
do
|
||||
name="${partition%%:*}"
|
||||
count="${partition#*:}"
|
||||
if [ "$count" -eq 0 ]; then
|
||||
printf 'app race partition %s is empty\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
total_count="$(wc -l < "$tests" | tr -d ' ')"
|
||||
assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
|
||||
if [ "$assigned_count" -ne "$total_count" ]; then
|
||||
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count"
|
||||
|
||||
if [ "$mode" = "verify" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_partition() {
|
||||
name="$1"
|
||||
run_pattern="$2"
|
||||
skip_pattern="${3:-}"
|
||||
|
||||
printf 'running internal/app race partition %s\n' "$name"
|
||||
if [ -n "$skip_pattern" ]; then
|
||||
go test -v -race -count=1 -timeout=15m \
|
||||
-run "$run_pattern" -skip "$skip_pattern" "$app_package"
|
||||
else
|
||||
go test -v -race -count=1 -timeout=15m \
|
||||
-run "$run_pattern" "$app_package"
|
||||
fi
|
||||
}
|
||||
|
||||
# Schema assembly has the largest transient memory footprint. Run those tests
|
||||
# in a fresh process, then keep each remaining name range in its own process so
|
||||
# command trees retained by process-global registries are released between
|
||||
# partitions. The complementary run/skip patterns preserve the full test set.
|
||||
schema_pattern='^Test.*Schema'
|
||||
run_partition schema "$schema_pattern"
|
||||
run_partition a-b '^Test[A-B]' "$schema_pattern"
|
||||
run_partition c '^TestC' "$schema_pattern"
|
||||
run_partition d-r '^Test[D-R]' "$schema_pattern"
|
||||
run_partition s-z-example-fuzz '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
|
||||
@@ -928,6 +928,39 @@
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
"command": "dws chat message send",
|
||||
"legacy": {
|
||||
"name": "uuid",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local",
|
||||
"required": false
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local",
|
||||
"hidden": true,
|
||||
"alias_of": "idempotency-key"
|
||||
}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "idempotency-key",
|
||||
"before": {
|
||||
"present": false
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
"command": "dws chat message set-pin-msg",
|
||||
"legacy": {
|
||||
|
||||
@@ -34,6 +34,7 @@ Usage:
|
||||
Example:
|
||||
dws drive list --limit 20
|
||||
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --folder <dentryUuid> --type file --start 7d
|
||||
Flags:
|
||||
--limit int 每页返回数量,默认 20,最大 50 (可选)
|
||||
--cursor string 分页游标,首次不传 (可选)
|
||||
@@ -43,10 +44,30 @@ Flags:
|
||||
--space-id string 钉盘空间 ID (纯数字),不传则使用「我的文件」对应 spaceId (可选)
|
||||
--workspace string 文档空间/知识库 ID (加密 string 或 URL),传入则路由到文档空间 (可选)
|
||||
--thumbnail 是否返回缩略图信息 (可选,仅钉盘)
|
||||
--pattern string 按名称通配过滤结果,如 "*日报*"(客户端过滤,无通配符时按子串匹配)(可选)
|
||||
--depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选)
|
||||
--type string 按节点类型过滤: file|folder(客户端过滤,见下节)(可选)
|
||||
--start string 按修改时间过滤·起始,如 7d / 2026-08-01 / RFC3339 (可选)
|
||||
--end string 按修改时间过滤·截止,语法同 --start (可选)
|
||||
```
|
||||
|
||||
> 统一入口:默认列钉盘空间(`--space-id` 纯数字);传 `--workspace` 时路由到文档空间/知识库列表。
|
||||
|
||||
类型/时间过滤(`--type` / `--start` / `--end`):
|
||||
- 语义:`--type` 按节点类型(file=文件 / folder=文件夹);`--start`/`--end` 按**修改时间**圈定区间。
|
||||
注意与 `dws drive search` 的 `--modified-from/--modified-to` 区分:那两个收毫秒时间戳,这里收字符串语法;
|
||||
`--type`(节点类型)与 search 的 `--file-types`(内容类型 alidoc/image/...)也不是一回事。
|
||||
- 时间语法:相对时间 `24h`/`7d`/`2w`(小时/天/周,按本机时钟换算)、RFC3339(`2026-08-01T00:00:00+08:00`)、
|
||||
无时区 ISO8601(`2026-08-01 08:00:00`,默认 Asia/Shanghai)、仅日期(`2026-08-01`);
|
||||
不支持毫秒时间戳,不支持 `m` 单位。
|
||||
- 执行方式:钉盘与知识库(--workspace)两路由统一为**客户端过滤**——全量扫描当前目录后在进程内筛选;
|
||||
与 `--depth>1` 组合时递归扫描后筛(被滤掉的条目仍占 2000 条全局上限)。
|
||||
- 互斥:与 `--versions`/`--cursor`/`--order-by`/`--order`/`--limit` 不能同时使用(过滤模式为全量扫描,
|
||||
无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`--latest` 表示「符合条件的条目中最新 N 个」。
|
||||
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
|
||||
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
|
||||
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
> **Deprecated**:推荐改用 `dws wiki space list --type orgSpace` / `--type mySpace`(见 [wiki.md](./wiki.md))。本命令仍可用,仅作兼容保留。
|
||||
|
||||
@@ -30,6 +30,7 @@ Usage:
|
||||
Example:
|
||||
dws drive list --limit 20
|
||||
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --folder <dentryUuid> --type file --start 7d
|
||||
Flags:
|
||||
--limit int 每页返回数量,默认 20,最大 50 (可选)
|
||||
--cursor string 分页游标,首次不传 (可选)
|
||||
@@ -37,9 +38,30 @@ Flags:
|
||||
--order-by string 排序字段: createTime|modifyTime|name (可选)
|
||||
--folder string 父节点 ID (dentryUuid),不传则列出空间根目录 (可选)
|
||||
--space-id string 空间 ID,不传则使用「我的文件」对应 spaceId (可选)
|
||||
--workspace string 文档空间/知识库 ID (加密 string 或 URL),传入则路由到文档空间 (可选)
|
||||
--thumbnail 是否返回缩略图信息 (可选)
|
||||
--pattern string 按名称通配过滤结果,如 "*日报*"(客户端过滤,无通配符时按子串匹配)(可选)
|
||||
--depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选)
|
||||
--type string 按节点类型过滤: file|folder(客户端过滤,见下节)(可选)
|
||||
--start string 按修改时间过滤·起始,如 7d / 2026-08-01 / RFC3339 (可选)
|
||||
--end string 按修改时间过滤·截止,语法同 --start (可选)
|
||||
```
|
||||
|
||||
类型/时间过滤(`--type` / `--start` / `--end`):
|
||||
- 语义:`--type` 按节点类型(file=文件 / folder=文件夹);`--start`/`--end` 按**修改时间**圈定区间。
|
||||
注意与 `dws drive search` 的 `--modified-from/--modified-to` 区分:那两个收毫秒时间戳,这里收字符串语法;
|
||||
`--type`(节点类型)与 search 的 `--file-types`(内容类型 alidoc/image/...)也不是一回事。
|
||||
- 时间语法:相对时间 `24h`/`7d`/`2w`(小时/天/周,按本机时钟换算)、RFC3339(`2026-08-01T00:00:00+08:00`)、
|
||||
无时区 ISO8601(`2026-08-01 08:00:00`,默认 Asia/Shanghai)、仅日期(`2026-08-01`);
|
||||
不支持毫秒时间戳,不支持 `m` 单位。
|
||||
- 执行方式:钉盘与知识库(--workspace)两路由统一为**客户端过滤**——全量扫描当前目录后在进程内筛选;
|
||||
与 `--depth>1` 组合时递归扫描后筛(被滤掉的条目仍占 2000 条全局上限)。
|
||||
- 互斥:与 `--versions`/`--cursor`/`--order-by`/`--order`/`--limit` 不能同时使用(过滤模式为全量扫描,
|
||||
无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`--latest` 表示「符合条件的条目中最新 N 个」。
|
||||
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
|
||||
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
|
||||
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
```
|
||||
|
||||
+4
-4
@@ -2692,7 +2692,7 @@
|
||||
[chat.message.list-all]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[chat.message.list-by-ids]
|
||||
runnable: true
|
||||
@@ -2702,7 +2702,7 @@
|
||||
[chat.message.list-by-sender]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --sender:string|required=false|hidden=true|no-opt=""|scope=local, --sender-open-dingtalk-id:string|required=false|hidden=false|no-opt=""|scope=local, --sender-user-id:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --user:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --sender:string|required=false|hidden=true|no-opt=""|scope=local, --sender-open-dingtalk-id:string|required=false|hidden=false|no-opt=""|scope=local, --sender-user-id:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --user:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[chat.message.list-emotion-replies]
|
||||
runnable: true
|
||||
@@ -2722,7 +2722,7 @@
|
||||
[chat.message.list-mentions]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[chat.message.list-pin-msg]
|
||||
runnable: true
|
||||
@@ -2782,7 +2782,7 @@
|
||||
[chat.message.search]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=true|no-opt=""|scope=local, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --query:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=true|no-opt=""|scope=local, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --query:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[chat.message.search-advanced]
|
||||
runnable: true
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
const (
|
||||
mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
|
||||
mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE"
|
||||
mockCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
)
|
||||
|
||||
type recordedToolCall struct {
|
||||
@@ -293,7 +294,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
stdout, stderr, err := runCLI(t, env,
|
||||
"--token", "ci-smoke-token", "--format", "json",
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--user-query", "张三", "--text", "你好", "--yes",
|
||||
"--user-query", "测试用户甲", "--text", "你好", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("natural send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
@@ -302,7 +303,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v, want resolve + one send", calls)
|
||||
}
|
||||
if calls[1].arguments["receiverOpenDingTalkId"] != "D-user-1" {
|
||||
if calls[1].arguments["receiverOpenDingTalkId"] != mockCurrentDOpenID {
|
||||
t.Fatalf("send target = %#v", calls[1].arguments)
|
||||
}
|
||||
assertMultiIMCallsAreLocalAndAuthorized(t, calls)
|
||||
@@ -473,7 +474,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
t.Fatalf("messages = %#v", payload["messages"])
|
||||
}
|
||||
message, _ := messages[0].(map[string]any)
|
||||
if message["messageId"] != "msg-1" || message["senderId"] != "D-sender" || message["senderType"] != "user" {
|
||||
if message["messageId"] != "msg-1" || message["senderId"] != mockCurrentDOpenID || message["senderType"] != "user" {
|
||||
t.Fatalf("projected message = %#v", message)
|
||||
}
|
||||
})
|
||||
@@ -483,7 +484,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
stdout, stderr, err := runCLI(t, env,
|
||||
"--token", "ci-smoke-token", "--format", "json",
|
||||
"chat", "+chat-create", "--name", "新群",
|
||||
"--member-query", "张三", "--dry-run", "--yes",
|
||||
"--member-query", "测试用户甲", "--dry-run", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat-create dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
@@ -505,7 +506,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
"--token", "ci-smoke-token", "--format", "json",
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid-1", "--message-id", "msg-1",
|
||||
"--ref-sender", "D-sender", "--text", "收到",
|
||||
"--ref-sender", mockCurrentDOpenID, "--text", "收到",
|
||||
"--idempotency-key", "reply-key", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -532,7 +533,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
|
||||
if arguments["keyword"] == "同名用户" {
|
||||
return `{"result":[{"name":"同名用户","userId":"u1","openDingTalkId":"D1"},{"name":"同名用户","userId":"u2","openDingTalkId":"D2"}]}`
|
||||
}
|
||||
return `{"result":[{"name":"张三","userId":"user-1","openDingTalkId":"D-user-1"}]}`
|
||||
return `{"result":[{"name":"测试用户甲","userId":"user-1","openDingTalkId":"` + mockCurrentDOpenID + `"}]}`
|
||||
case "search_groups":
|
||||
if arguments["keyword"] == "资源群" {
|
||||
return `{"result":[{"title":"资源群","openConversationId":"cid-resources"}]}`
|
||||
@@ -540,9 +541,9 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
|
||||
return `{"result":[{"title":"项目群","openConversationId":"cid-1"}]}`
|
||||
case "list_conversation_message_v2":
|
||||
if arguments["openconversation_id"] == "cid-resources" {
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
}
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
case "get_resource_download_url":
|
||||
body, _ := json.Marshal(map[string]any{"result": map[string]any{
|
||||
"resourceUrl": resourceURL,
|
||||
@@ -561,7 +562,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
|
||||
if arguments["keyword"] == "分页失败" && fmt.Sprint(arguments["cursor"]) == "c2" {
|
||||
return `{"success":false,"code":"MOCK_PAGE_FAILURE","message":"simulated second page failure"}`
|
||||
}
|
||||
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"D-sender","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
|
||||
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"` + mockCurrentDOpenID + `","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
|
||||
case "get_current_user_profile":
|
||||
return `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
|
||||
case "create_group_conversation":
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type changelogGateRepo struct {
|
||||
@@ -71,6 +72,20 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
// Git may briefly continue automatic repository maintenance after a command
|
||||
// exits on hosted runners. Proactively remove the fixture with bounded
|
||||
// retries before testing.TempDir performs its strict final cleanup.
|
||||
t.Cleanup(func() {
|
||||
var cleanupErr error
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
cleanupErr = os.RemoveAll(root)
|
||||
if cleanupErr == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
}
|
||||
t.Errorf("RemoveAll changelog gate repository after retries: %v", cleanupErr)
|
||||
})
|
||||
|
||||
for _, path := range []string{
|
||||
"LICENSE",
|
||||
@@ -108,6 +123,8 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
|
||||
changelogGateWrite(t, root, "CHANGELOG.md", changelogGateBase, 0o644)
|
||||
|
||||
changelogGateGit(t, root, "init", "-b", "main")
|
||||
changelogGateGit(t, root, "config", "gc.auto", "0")
|
||||
changelogGateGit(t, root, "config", "maintenance.auto", "false")
|
||||
changelogGateGit(t, root, "config", "user.name", "Changelog Gate Test")
|
||||
changelogGateGit(t, root, "config", "user.email", "changelog-gate@example.com")
|
||||
changelogGateGit(t, root, "add", ".")
|
||||
@@ -973,7 +990,7 @@ if (!isInterfaceSensitive("internal/corecmd/corecmd.go")) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighRiskClassificationShardsHelperChanges(t *testing.T) {
|
||||
func TestHighRiskClassificationShardsFanoutChanges(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
@@ -996,6 +1013,18 @@ if (!isHighRisk("internal/helpers/minutes.go")) {
|
||||
if (isHighRisk("internal/helpersx/minutes.go")) {
|
||||
throw new Error("helper high-risk classification must respect the path boundary");
|
||||
}
|
||||
for (const filename of [
|
||||
"internal/cli/param_concepts.json",
|
||||
"internal/cli/param_concepts.schema.json",
|
||||
"internal/cli/param_aliases_generated.go",
|
||||
]) {
|
||||
if (!isHighRisk(filename)) {
|
||||
throw new Error(filename + " must use the sharded full suite");
|
||||
}
|
||||
}
|
||||
if (isHighRisk("internal/cli/param_concepts.json.bak")) {
|
||||
throw new Error("parameter-alias high-risk classification must use exact paths");
|
||||
}
|
||||
`
|
||||
cmd := exec.Command("node", "-e", probe)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
@@ -1191,11 +1220,17 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
t.Fatal("Code Admission workflow missing race test job boundaries")
|
||||
}
|
||||
raceJob := admission[raceStart:raceEnd]
|
||||
// Full race shards use a dynamic package-level timeout: default/floor 12m,
|
||||
// with cli/smoke raised to 15m for NewRootCommand / Schema assembly under -race.
|
||||
// The app shard uses independently bounded test processes so process-global
|
||||
// command registries are released before the Schema assembly peak. Other full
|
||||
// race shards retain the dynamic package timeout: default/floor 12m, with
|
||||
// cli/smoke raised to 15m on slower hosted runners.
|
||||
for _, want := range []string{
|
||||
`if [ "$TEST_SHARD" = "app" ]; then`,
|
||||
`test "${#packages[@]}" -eq 1`,
|
||||
`./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`,
|
||||
"timeout_budget=12m",
|
||||
`if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then`,
|
||||
`if [ "$TEST_SHARD" = "cli" ] ||`,
|
||||
`[ "$TEST_SHARD" = "smoke" ]; then`,
|
||||
"timeout_budget=15m",
|
||||
`go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`,
|
||||
"- smoke",
|
||||
|
||||
@@ -47,6 +47,25 @@ func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIAppRacePartitionsCoverTopLevelTestsExactlyOnce(t *testing.T) {
|
||||
root := testPackagePlanRoot(t)
|
||||
packages := strings.Fields(runTestPackagePlan(t, root, "list", "app"))
|
||||
if len(packages) != 1 {
|
||||
t.Fatalf("app package shard = %v, want exactly one package", packages)
|
||||
}
|
||||
|
||||
script := filepath.Join(root, "scripts", "ci", "run-app-race-tests.sh")
|
||||
cmd := exec.Command("sh", script, "verify", packages[0])
|
||||
cmd.Dir = root
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s verify %s failed: %v\n%s", script, packages[0], err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "top-level tests exactly once") {
|
||||
t.Fatalf("verify output = %q, want exact coverage summary", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) {
|
||||
root := testPackagePlanRoot(t)
|
||||
fakeBin := t.TempDir()
|
||||
|
||||
@@ -1222,6 +1222,136 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
validation := releaseWorkflowSection(t, workflow, " release-validation:\n", "\n release-plan:\n")
|
||||
preparedView := releaseWorkflowSection(
|
||||
t,
|
||||
validation,
|
||||
" - name: Prepare delivered-stable compatibility ref view\n",
|
||||
"\n - name: Set up Go\n",
|
||||
)
|
||||
script := releaseWorkflowRunScript(
|
||||
t,
|
||||
validation,
|
||||
"Prepare delivered-stable compatibility ref view",
|
||||
"Set up Go",
|
||||
)
|
||||
|
||||
if !strings.Contains(preparedView, "if: ${{ matrix.check == 'compatibility' }}") {
|
||||
t.Fatal("delivered-stable ref view must run only for the compatibility matrix leg")
|
||||
}
|
||||
for _, required := range []string{
|
||||
"RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}",
|
||||
"RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}",
|
||||
"RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}",
|
||||
"PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}",
|
||||
"PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}",
|
||||
} {
|
||||
if !strings.Contains(preparedView, required) {
|
||||
t.Errorf("delivered-stable compatibility view is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
`test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"`,
|
||||
`test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"`,
|
||||
`test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"`,
|
||||
`test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"`,
|
||||
`test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"`,
|
||||
`git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"`,
|
||||
`git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"`,
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("delivered-stable compatibility view is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
||||
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
||||
compatibility := strings.Index(validation, "check-command-compatibility.sh")
|
||||
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
||||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
||||
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
||||
}
|
||||
for _, forbidden := range []string{"git push --delete", "github.rest.git.deleteRef"} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("release workflow must not delete a remote ref: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := os.MkdirTemp(".", ".release-compatibility-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp(release compatibility test repository) error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(repo); err != nil {
|
||||
t.Errorf("RemoveAll(%s) error = %v", repo, err)
|
||||
}
|
||||
})
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "release-compatibility@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("stable\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "tracked")
|
||||
mustRun(t, repo, "git", "commit", "-m", "stable release")
|
||||
previousStableCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
||||
mustRun(t, repo, "git", "tag", "-a", "v1.0.57", "-m", "v1.0.57")
|
||||
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("sealed\n"), 0o644)
|
||||
mustRun(t, repo, "git", "commit", "-am", "sealed release")
|
||||
releaseCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
||||
mustRun(t, repo, "git", "tag", "-a", "v1.0.58", "-m", "v1.0.58")
|
||||
releaseTagObject := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58"))
|
||||
|
||||
run := func(tagObject string) ([]byte, error) {
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = make([]string, 0, len(os.Environ())+5)
|
||||
for _, entry := range os.Environ() {
|
||||
if strings.HasPrefix(entry, "RELEASE_VERSION=") ||
|
||||
strings.HasPrefix(entry, "RELEASE_COMMIT=") ||
|
||||
strings.HasPrefix(entry, "RELEASE_TAG_OBJECT=") ||
|
||||
strings.HasPrefix(entry, "PREVIOUS_STABLE=") ||
|
||||
strings.HasPrefix(entry, "PREVIOUS_STABLE_COMMIT=") {
|
||||
continue
|
||||
}
|
||||
cmd.Env = append(cmd.Env, entry)
|
||||
}
|
||||
cmd.Env = append(cmd.Env,
|
||||
"RELEASE_VERSION=v1.0.58",
|
||||
"RELEASE_COMMIT="+releaseCommit,
|
||||
"RELEASE_TAG_OBJECT="+tagObject,
|
||||
"PREVIOUS_STABLE=v1.0.57",
|
||||
"PREVIOUS_STABLE_COMMIT="+previousStableCommit,
|
||||
)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
if output, err := run(releaseTagObject); err != nil {
|
||||
t.Fatalf("prepared compatibility ref view failed: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
candidateTag := exec.Command("git", "rev-parse", "--verify", "refs/tags/v1.0.58")
|
||||
candidateTag.Dir = repo
|
||||
if _, err := candidateTag.CombinedOutput(); err == nil {
|
||||
t.Fatal("prepared compatibility ref view left the sealed candidate tag visible")
|
||||
}
|
||||
mustRun(t, repo, "git", "cat-file", "-e", releaseTagObject+"^{tag}")
|
||||
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.57^{commit}")); got != previousStableCommit {
|
||||
t.Fatalf("previous stable tag commit = %s, want %s", got, previousStableCommit)
|
||||
}
|
||||
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")); got != releaseCommit {
|
||||
t.Fatalf("HEAD after prepared compatibility ref view = %s, want %s", got, releaseCommit)
|
||||
}
|
||||
|
||||
mustRun(t, repo, "git", "update-ref", "refs/tags/v1.0.58", releaseTagObject)
|
||||
if output, err := run(strings.Repeat("0", 40)); err == nil {
|
||||
t.Fatalf("prepared compatibility ref view unexpectedly accepted a mismatched tag object\noutput:\n%s", output)
|
||||
}
|
||||
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58")); got != releaseTagObject {
|
||||
t.Fatalf("mismatched tag object removed or changed the local sealed tag: got %s, want %s", got, releaseTagObject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := t.TempDir()
|
||||
|
||||
Reference in New Issue
Block a user