Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0beb1c6b0c | ||
|
|
3b38d4c8da | ||
|
|
d68e340a5b | ||
|
|
98799effba | ||
|
|
9239f9070a | ||
|
|
202c5ce697 | ||
|
|
8ab2ac5e7c | ||
|
|
b85a342e9f | ||
|
|
ad72cf4b3d | ||
|
|
89154b3952 | ||
|
|
b01febf52e | ||
|
|
74b7690cbb | ||
|
|
8312c4f30e | ||
|
|
35c6fd95e1 | ||
|
|
564ff8563f | ||
|
|
f7229091ae | ||
|
|
77aa813467 | ||
|
|
1f595571c0 | ||
|
|
cd90d1c322 | ||
|
|
49ab53ea0d | ||
|
|
78433198fb | ||
|
|
4863152a4a | ||
|
|
2057fec3b0 | ||
|
|
1308d08862 | ||
|
|
8b56e9bc9e | ||
|
|
87e141f2de | ||
|
|
9b521f0392 | ||
|
|
4dcd528bc1 | ||
|
|
0bbb3a9d32 | ||
|
|
0ebd840ba9 | ||
|
|
9d356cd664 | ||
|
|
b00f43ee06 | ||
|
|
23167ef974 | ||
|
|
8b003aef16 | ||
|
|
11934eed05 | ||
|
|
23e1085a37 | ||
|
|
a352615e77 | ||
|
|
d210da501a | ||
|
|
6288199a93 | ||
|
|
6d9781fe15 | ||
|
|
5b34ed1a7e | ||
|
|
3d9a469347 | ||
|
|
7640ba7614 | ||
|
|
c790fe3c3b | ||
|
|
4886bdb3f5 | ||
|
|
4aef07ccd3 | ||
|
|
e0c49377d6 | ||
|
|
dc37dd2c34 | ||
|
|
02aad9020a | ||
|
|
d59d1091dc | ||
|
|
60d6bfeec4 | ||
|
|
bf89acb3d2 | ||
|
|
35d6f47bd6 | ||
|
|
d24b71614a | ||
|
|
765b961f4d | ||
|
|
9ab4bd10a5 | ||
|
|
14c5bed4fc | ||
|
|
c1bd6dcf64 | ||
|
|
1c8b83ec2f | ||
|
|
bb6f470df5 | ||
|
|
01af71a5ae | ||
|
|
d4ff8a5f4f | ||
|
|
47e3c2b3c5 | ||
|
|
e8ecff586a | ||
|
|
11a9ad5d49 | ||
|
|
23940e4752 | ||
|
|
8cb0f64477 | ||
|
|
bbaf033618 | ||
|
|
57cca7ef71 | ||
|
|
2d38beb7be | ||
|
|
4372a8c5ba | ||
|
|
422dc0fde3 | ||
|
|
3d59411a1a | ||
|
|
fe66ac18a4 | ||
|
|
bda408d966 | ||
|
|
33631502c9 | ||
|
|
f2a608d146 | ||
|
|
378b9f67a2 | ||
|
|
43ba466783 | ||
|
|
5c9f738012 | ||
|
|
56c5fb35b8 | ||
|
|
b19c52f61b | ||
|
|
c0641dbf64 | ||
|
|
3b5cb3b0cb | ||
|
|
fd2ed2174f | ||
|
|
5bbaa304e3 | ||
|
|
db938778af | ||
|
|
1155b9b5c0 | ||
|
|
8fa93ef030 | ||
|
|
f4ad1a15f5 | ||
|
|
d2cbd928e2 | ||
|
|
2346dfba4e | ||
|
|
f6ad2fa01a | ||
|
|
7bc56f3dea | ||
|
|
03001eb4e0 | ||
|
|
91974ce981 | ||
|
|
c6417e3527 | ||
|
|
161687ae4c | ||
|
|
4da5a07d1d | ||
|
|
2cb83d388b | ||
|
|
ba9c0f624e | ||
|
|
ff65f80c98 | ||
|
|
42240f5e9e | ||
|
|
e6b06b561d | ||
|
|
11cbc30a10 | ||
|
|
60a474f30c | ||
|
|
6e8fec5684 | ||
|
|
470aa42d7b | ||
|
|
a74d96bb96 | ||
|
|
9798a60728 | ||
|
|
e028d443d5 | ||
|
|
74859b966b | ||
|
|
76a5559ca2 | ||
|
|
c4a1018213 | ||
|
|
62d72ad84c | ||
|
|
e6fe475aea | ||
|
|
e95ac52ff4 | ||
|
|
61f8140f91 | ||
|
|
09c0cd7849 | ||
|
|
5a368a9ab8 | ||
|
|
fbcd8887ee | ||
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
7f4318a10d | ||
|
|
5232f632c8 | ||
|
|
615a775fdf | ||
|
|
b10da77e09 | ||
|
|
cefc5c005c | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
103b05413e | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
169bbe88c0 | ||
|
|
08595594d7 | ||
|
|
30782020ad | ||
|
|
540bbac35b | ||
|
|
c4d5595ca9 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
d87cdef00b | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
ce57cdf260 | ||
|
|
999e7a7b9d | ||
|
|
94cee4388e | ||
|
|
3ec138ba99 | ||
|
|
dcc7e72ec1 | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
2a1ed8cc7a | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba | ||
|
|
5a001f33b6 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
+3
-1
@@ -25,7 +25,9 @@ category: Added
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
|
||||
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
|
||||
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
@@ -534,7 +534,8 @@ jobs:
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
# its job silently. The CrossPlatformCoverage-heavy C range is split again
|
||||
# to retain headroom on runners reclaimed near the five-minute mark.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -542,7 +543,10 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -678,7 +682,8 @@ jobs:
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
|
||||
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -686,7 +691,10 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
|
||||
@@ -14,9 +14,12 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
const webhooks = [
|
||||
process.env.DINGTALK_WEBHOOK,
|
||||
process.env.DINGTALK_WEBHOOK_SECONDARY
|
||||
].filter(Boolean);
|
||||
if (webhooks.length === 0) {
|
||||
console.log('⚠️ No DingTalk webhook configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +42,15 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
|
||||
console.log('✅ DingTalk notification sent');
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
|
||||
@@ -2698,9 +2698,11 @@ jobs:
|
||||
;;
|
||||
compatibility)
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||
--repo-root "$GITHUB_WORKSPACE" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$PREVIOUS_STABLE"
|
||||
--stable-ref "$PREVIOUS_STABLE" \
|
||||
--candidate-ref HEAD
|
||||
;;
|
||||
e2e)
|
||||
bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
@@ -6,6 +6,54 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
|
||||
|
||||
## [1.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
|
||||
|
||||
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
## [1.0.59-beta.5] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
|
||||
## [1.0.59-beta.4] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.4"
|
||||
version "1.0.60-beta.1"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
|
||||
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
|
||||
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -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.58"
|
||||
version "1.0.59"
|
||||
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.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
|
||||
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
|
||||
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
|
||||
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
|
||||
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
|
||||
+63
-5
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
// Interactive commands must remain in the terminal's foreground session so
|
||||
// prompts can use /dev/tty. Non-interactive launches use a separate process
|
||||
// group, allowing a signal sent only to this wrapper to reach the full vendor
|
||||
// process tree exactly once.
|
||||
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
|
||||
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
let spawnFailed = false;
|
||||
let forwardedSignal = null;
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM"];
|
||||
|
||||
function forwardSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (!isolateVendorProcessGroup) {
|
||||
// Ctrl-C is generated for the whole foreground process group, including
|
||||
// the vendor. SIGTERM is not terminal-generated and still needs an
|
||||
// explicit handoff when a process manager targets only this wrapper.
|
||||
if (signal === "SIGTERM") {
|
||||
child.kill(signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// detached makes the vendor PID the leader of its POSIX process group.
|
||||
// Signal the whole group so any subprocesses inherit the same shutdown.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
// The group may have completed between the state check and kill.
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
const signalHandlers = new Map(
|
||||
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
|
||||
);
|
||||
for (const signal of forwardedSignals) {
|
||||
process.on(signal, signalHandlers.get(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
spawnFailed = true;
|
||||
console.error(error.message);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
for (const forwarded of forwardedSignals) {
|
||||
process.removeListener(forwarded, signalHandlers.get(forwarded));
|
||||
}
|
||||
|
||||
const exitSignal = forwardedSignal || signal;
|
||||
if (exitSignal && process.platform !== "win32") {
|
||||
process.kill(process.pid, exitSignal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = spawnFailed || code === null ? 1 : code;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
本文定义两种受控 flag 迁移:
|
||||
|
||||
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
|
||||
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
|
||||
|
||||
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
@@ -68,6 +75,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
|
||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||
|
||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||
@@ -86,22 +104,22 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
@@ -146,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
@@ -166,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -177,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
@@ -204,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
|
||||
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
|
||||
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
|
||||
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
|
||||
任何不相干漂移继续阻塞。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
+10
-5
@@ -23,7 +23,7 @@
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
|
||||
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
|
||||
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
|
||||
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
|
||||
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
|
||||
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
|
||||
归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
|
||||
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,198 +0,0 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-19T10:35:58.304269",
|
||||
"count": 423,
|
||||
"generated_at": "2026-08-21T10:07:48.251313",
|
||||
"count": 418,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -2494,32 +2494,14 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "ding",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+recall-personal",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "ding",
|
||||
"command": "+receiver-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+send-personal",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3602,81 +3584,74 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-cc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-executed",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-pending",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-submitted",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+my-initiated",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "oa",
|
||||
"command": "+search-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+inbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+outbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+report-latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+list-sheets",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+read",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3888,6 +3863,26 @@
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+query",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+update",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
|
||||
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
|
||||
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
|
||||
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
|
||||
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
|
||||
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
hasTarget := false
|
||||
for _, id := range subscribeIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
hasTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
|
||||
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
t.Fatal("targeted stop called without a subscribe_id")
|
||||
return transport.ConsumerStopResp{}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried without a subscribe_id")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
|
||||
t.Fatalf("empty target stop = %v", err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
|
||||
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
|
||||
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
|
||||
}
|
||||
|
||||
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
registered := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "markdown" {
|
||||
registered++
|
||||
}
|
||||
}
|
||||
if registered != 0 {
|
||||
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
|
||||
}
|
||||
|
||||
type route struct {
|
||||
canonical string
|
||||
confirmation string
|
||||
flags []string
|
||||
}
|
||||
routes := map[string]route{
|
||||
"create": {
|
||||
canonical: "markdown.create", confirmation: "not_required",
|
||||
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
|
||||
},
|
||||
"fetch": {
|
||||
canonical: "markdown.fetch", confirmation: "not_required",
|
||||
flags: []string{"node", "output", "space-id", "workspace"},
|
||||
},
|
||||
"overwrite": {
|
||||
canonical: "markdown.overwrite", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
|
||||
},
|
||||
"patch": {
|
||||
canonical: "markdown.patch", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
|
||||
},
|
||||
"diff": {
|
||||
canonical: "markdown.diff", confirmation: "not_required",
|
||||
flags: []string{"context", "file", "node", "version", "version2"},
|
||||
},
|
||||
}
|
||||
|
||||
group := mustFindCommand(t, root, "markdown")
|
||||
children := map[string]bool{}
|
||||
for _, child := range group.Commands() {
|
||||
children[child.Name()] = true
|
||||
}
|
||||
if len(children) != len(routes) {
|
||||
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
|
||||
}
|
||||
|
||||
for name, want := range routes {
|
||||
leaf := mustFindCommand(t, root, "markdown", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !children[name] {
|
||||
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
|
||||
}
|
||||
for _, flag := range want.flags {
|
||||
if leaf.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
|
||||
}
|
||||
}
|
||||
if shortcut.InPublicCatalog("markdown", "+"+name) {
|
||||
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("markdown " + name)
|
||||
if !ok {
|
||||
t.Errorf("markdown %s missing from assembled Schema", name)
|
||||
continue
|
||||
}
|
||||
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
|
||||
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
|
||||
}
|
||||
if meta.Safety.Confirmation != want.confirmation {
|
||||
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
|
||||
}
|
||||
|
||||
tool := tools[want.canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("markdown %s missing from full delivery Schema", name)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("markdown %s availability=%q, want available", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
driveShortcuts := map[string]string{
|
||||
"+copy": "drive.shortcut_copy",
|
||||
"+delete": "drive.shortcut_delete",
|
||||
"+find-file": "drive.shortcut_find_file",
|
||||
"+list": "drive.shortcut_list",
|
||||
"+move": "drive.shortcut_move",
|
||||
"+publish-get": "drive.shortcut_publish_get",
|
||||
"+recycle-restore": "drive.shortcut_recycle_restore",
|
||||
"+rename": "drive.shortcut_rename",
|
||||
"+version-download": "drive.shortcut_version_download",
|
||||
"+version-get": "drive.shortcut_version_get",
|
||||
"+version-history": "drive.shortcut_version_history",
|
||||
"+version-revert": "drive.shortcut_version_revert",
|
||||
}
|
||||
for name, canonical := range driveShortcuts {
|
||||
leaf := mustFindCommand(t, root, "drive", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !shortcut.InPublicCatalog("drive", name) {
|
||||
t.Errorf("drive %s is not in the public Shortcut catalog", name)
|
||||
}
|
||||
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
|
||||
}
|
||||
|
||||
ordinaryRoutes := map[string]string{
|
||||
"drive permission list": "drive.list_permission",
|
||||
"drive pull": "drive.folder_pull",
|
||||
"drive push": "drive.folder_push",
|
||||
"drive status": "drive.folder_status",
|
||||
"drive sync": "drive.folder_sync",
|
||||
"wiki node list": "wiki.list_nodes",
|
||||
}
|
||||
for cliPath, canonical := range ordinaryRoutes {
|
||||
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
|
||||
t.Helper()
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
|
||||
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
for _, canonical := range []string{
|
||||
"oa.shortcut_approve_by",
|
||||
"oa.shortcut_done_approvals",
|
||||
"oa.shortcut_list_cc",
|
||||
"oa.shortcut_list_executed",
|
||||
"oa.shortcut_list_forms",
|
||||
"oa.shortcut_list_pending",
|
||||
"oa.shortcut_list_submitted",
|
||||
"oa.shortcut_my_initiated",
|
||||
"oa.shortcut_pending",
|
||||
"oa.shortcut_search_forms",
|
||||
} {
|
||||
tool, ok := snapshot.Tools[canonical]
|
||||
if !ok {
|
||||
t.Errorf("final Schema lacks OA tool %s", canonical)
|
||||
continue
|
||||
}
|
||||
if got := tool["availability"]; got != contract.InterfaceAvailable {
|
||||
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
|
||||
@@ -226,12 +226,72 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
|
||||
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
|
||||
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for the
|
||||
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
|
||||
// separate map lets this test file land before the draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
@@ -531,6 +591,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -600,6 +674,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
@@ -709,6 +807,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -717,6 +816,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -729,8 +830,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +842,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,7 +1208,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -1173,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
|
||||
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
|
||||
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["oa.attachment_upload"]
|
||||
if tool == nil {
|
||||
t.Fatal("oa.attachment_upload is missing from final Schema")
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
|
||||
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("interface_mode = %q, want composite", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Fatal("composite upload command must document an interface reason")
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != "write" {
|
||||
t.Fatalf("effect = %q, want write", got)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for _, flag := range []string{"file", "file-name", "md5"} {
|
||||
if parameters[flag] == nil {
|
||||
t.Fatalf("upload --%s is missing from final Schema", flag)
|
||||
}
|
||||
}
|
||||
if required, _ := parameters["file"]["required"].(bool); !required {
|
||||
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
if properties["fileId"] == nil {
|
||||
t.Fatal("upload Result data_schema is missing fileId")
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 422
|
||||
publicShortcutCount = 417
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 447
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 455
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 422
|
||||
publiclyDeliveredShortcutCount = 417
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
for canonical, command := range map[string]string{
|
||||
"whiteboard.shortcut_query": "+query",
|
||||
"whiteboard.shortcut_update": "+update",
|
||||
} {
|
||||
leaf, _, err := root.Find([]string{"whiteboard", command})
|
||||
if err != nil || leaf == nil || leaf.Name() != command {
|
||||
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
|
||||
} else if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("public %s missing from delivery Schema surface", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability=%q, want available", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("%s missing composite adapter reason", canonical)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
}
|
||||
return oauthRefreshToken(p, ctx, data)
|
||||
refreshed, rErr := oauthRefreshToken(p, ctx, data)
|
||||
if rErr == nil || !isRefreshTokenRejected(rErr) {
|
||||
return refreshed, rErr
|
||||
}
|
||||
// A stale identity slot can survive an older organization-only refresh.
|
||||
// Retry once with the same-corp organization mirror while holding the
|
||||
// existing dual lock; the fallback marks the publication so the rotated
|
||||
// credential is written back into the mirror slot it consumed.
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.triggered",
|
||||
"corp_id", strings.TrimSpace(data.CorpID),
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"error", rErr,
|
||||
)
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
|
||||
// the organization slot. The mirror must match the current corp, be valid,
|
||||
// and differ from the rejected token. When both slots carry user identities,
|
||||
// they must agree; legacy mirrors with an empty UserID are backfilled from the
|
||||
// current identity before refresh.
|
||||
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("no current token data")
|
||||
}
|
||||
corpID := strings.TrimSpace(current.CorpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("current token has no corpId")
|
||||
}
|
||||
orgData, err := tokenLoadKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgData == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(orgData.CorpID) != corpID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
|
||||
}
|
||||
if !orgData.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
|
||||
}
|
||||
if orgData.RefreshToken == current.RefreshToken {
|
||||
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
|
||||
}
|
||||
currentUserID := strings.TrimSpace(current.UserID)
|
||||
orgUserID := strings.TrimSpace(orgData.UserID)
|
||||
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
|
||||
}
|
||||
if orgUserID == "" {
|
||||
orgData.UserID = current.UserID
|
||||
orgData.UserName = current.UserName
|
||||
}
|
||||
// The refresh below consumes the mirror's refresh_token. Mark the
|
||||
// publication so persistence writes the rotated credential back into the
|
||||
// organization slot even under an explicit runtime selector whose plan
|
||||
// would otherwise skip it (for example a preserved unresolved sibling).
|
||||
orgData.RepairOrganizationMirror = true
|
||||
refreshed, err := oauthRefreshToken(p, ctx, orgData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.success",
|
||||
"corp_id", corpID,
|
||||
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
|
||||
)
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
|
||||
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
|
||||
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
|
||||
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
|
||||
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
|
||||
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
|
||||
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
|
||||
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
|
||||
{"plain error is unknown", errors.New("boom"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRefreshTokenRejected(tt.err); got != tt.want {
|
||||
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
|
||||
// and records refresh attempts plus organization slot lookups.
|
||||
type orgSlotFallbackFixture struct {
|
||||
provider *OAuthProvider
|
||||
stale *TokenData
|
||||
orgMirror *TokenData
|
||||
renewed *TokenData
|
||||
rejected *MCPTokenExchangeError
|
||||
refreshErr error
|
||||
orgRefreshErr error
|
||||
refreshCalls []string
|
||||
refreshUserIDs []string
|
||||
orgLoads int
|
||||
}
|
||||
|
||||
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
|
||||
t.Helper()
|
||||
isolateOAuthPersistence(t)
|
||||
|
||||
f := &orgSlotFallbackFixture{
|
||||
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
|
||||
stale: &TokenData{
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
orgMirror: &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
renewed: &TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
|
||||
}
|
||||
f.refreshErr = f.rejected
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
|
||||
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
|
||||
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
|
||||
switch data.RefreshToken {
|
||||
case "stale-refresh":
|
||||
return nil, f.refreshErr
|
||||
case "org-refresh":
|
||||
return f.renewed, f.orgRefreshErr
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
if corpID != "corp-1" {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
return f.orgMirror, nil
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
|
||||
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
|
||||
}
|
||||
if f.orgLoads != 1 {
|
||||
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "legacy", CorpID: "corp-1", UserID: ""},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
}
|
||||
selector := profileSelector("corp-1", "user-1")
|
||||
|
||||
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
|
||||
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
|
||||
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
|
||||
}
|
||||
|
||||
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
|
||||
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
|
||||
t.Fatalf("repair marker did not force the organization slot write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Fake MCP refresh endpoint: the first call rejects the stale identity
|
||||
// refresh_token with the reviewed business code; the second call (the
|
||||
// organization mirror) succeeds and returns a rotated credential.
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshCalls.Add(1) == 1 {
|
||||
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
resetAppConfigCache()
|
||||
|
||||
// Seed the pre-fallback state: an identity slot whose refresh_token the
|
||||
// server rejects, plus a legacy organization mirror (no userId) with a
|
||||
// still-valid refresh_token and a preserved unresolved sibling profile so
|
||||
// an explicit --profile refresh would normally skip the org slot.
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
|
||||
},
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
orgMirror := &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
staleIdentity := &TokenData{
|
||||
AccessToken: "stale-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
UserName: "User One",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp-1:user-1")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
got, err := p.lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
|
||||
}
|
||||
if refreshCalls.Load() != 2 {
|
||||
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
|
||||
}
|
||||
|
||||
// The fallback consumed the mirror's refresh_token: both persisted slots
|
||||
// must now carry the rotated credential instead of the consumed one.
|
||||
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
|
||||
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
|
||||
}
|
||||
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
if identitySlot.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
|
||||
t.Run("transient failure does not fall back", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing org slot preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, ErrTokenDataNotFound
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different user in org slot is rejected", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = "user-2"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = ""
|
||||
f.orgMirror.UserName = ""
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
|
||||
}
|
||||
if f.refreshUserIDs[1] != "user-1" {
|
||||
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshToken = "stale-refresh"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired org refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing corp id skips fallback", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.stale.CorpID = ""
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot(nil) succeeded")
|
||||
}
|
||||
f.orgMirror.CorpID = "corp-2"
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
|
||||
}
|
||||
if len(f.refreshCalls) != 0 {
|
||||
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
|
||||
}
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
|
||||
// isRefreshTokenRejected reports whether the server returned a reviewed
|
||||
// business code that definitively rejects the presented refresh_token.
|
||||
// Only the reviewed MCP business code enables the organization-slot
|
||||
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
|
||||
// reviewed business code and deliberately do not trigger the fallback.
|
||||
func isRefreshTokenRejected(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
|
||||
}
|
||||
|
||||
+16
-1
@@ -107,6 +107,13 @@ type TokenData struct {
|
||||
// transient marker to reject ambiguous UID-less logins without breaking
|
||||
// legitimate refreshes of unresolved accounts.
|
||||
FreshAuthorization bool `json:"-"`
|
||||
// RepairOrganizationMirror marks a fallback refresh that consumed the
|
||||
// organization mirror's refresh_token. The regular write plan can skip the
|
||||
// organization slot under an explicit runtime selector (for example when an
|
||||
// unresolved sibling profile still owns it), which would strand a
|
||||
// refresh_token the server has already rotated; the marker forces the
|
||||
// rotated credential back into that slot.
|
||||
RepairOrganizationMirror bool `json:"-"`
|
||||
}
|
||||
|
||||
// tokenPersistenceWritePlan is the single source of truth for deciding which
|
||||
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
|
||||
ExistingIdentity bool
|
||||
UpgradesLegacyProfile bool
|
||||
PreserveUnresolvedOrganization bool
|
||||
RepairOrganizationMirror bool
|
||||
WriteIdentity bool
|
||||
WriteOrganization bool
|
||||
WriteGlobal bool
|
||||
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
|
||||
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
|
||||
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
|
||||
!plan.UpgradesLegacyProfile
|
||||
// A fallback refresh consumed the organization mirror's refresh_token;
|
||||
// the rotated credential must go back into that slot even when the
|
||||
// selector-driven plan would skip it (for example an explicit --profile
|
||||
// that preserves an unresolved sibling profile's slot).
|
||||
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
plan.WriteIdentity = plan.UserID != ""
|
||||
orgCurrentSelector := ""
|
||||
if cfg != nil {
|
||||
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
|
||||
// reauthorization. Its organization slot must move with the newly exact
|
||||
// identity even when an explicit runtime selector keeps it from becoming
|
||||
// process-global current.
|
||||
plan.WriteOrganization = plan.UserID == "" ||
|
||||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
|
||||
plan.UserID == "" ||
|
||||
plan.UpgradesLegacyProfile ||
|
||||
(!plan.PreserveUnresolvedOrganization &&
|
||||
(plan.MakeCurrent ||
|
||||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
"persistence_profile", plan.PersistenceSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"repair_org_mirror", plan.RepairOrganizationMirror,
|
||||
"write_global_mirror", plan.WriteGlobal,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
|
||||
@@ -191,12 +191,12 @@
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"oa approval list-submitted",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
|
||||
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
|
||||
if parameters[flagName]["required"] != false {
|
||||
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
|
||||
}
|
||||
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
if parameters["max-depth"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
|
||||
}
|
||||
if got := parameters["version"]["property"]; got != "historyVersion" {
|
||||
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
|
||||
}
|
||||
if parameters["version"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
|
||||
}
|
||||
if got := parameters["password"]["property"]; got != "password" {
|
||||
t.Fatalf("doc read --password property = %#v, want password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
|
||||
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"agoal scorecard detail",
|
||||
"agoal scorecard entity-detail",
|
||||
"agoal scorecard update",
|
||||
"agoal scorecard search-entities",
|
||||
"agoal strategy detail",
|
||||
"agoal strategy list",
|
||||
"agoal strategy update",
|
||||
|
||||
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if err := validateSchemaValue("$", params, schema); err != nil {
|
||||
if err := ValidateJSONSchemaValue(params, schema); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSONSchemaValue validates one decoded JSON value against the
|
||||
// required/type/enum/properties/items subset used by reviewed CLI contracts.
|
||||
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
|
||||
return validateSchemaValue("$", value, schema)
|
||||
}
|
||||
|
||||
func validateSchemaValue(path string, value any, schema map[string]any) error {
|
||||
if len(schema) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package commentreaction validates the reviewed DingTalk names accepted by
|
||||
// comment emoji replies. The names mirror the bundled default emoji catalog;
|
||||
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
|
||||
package commentreaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// Validate rejects values outside DingTalk's reviewed default reaction-name
|
||||
// catalog. Names are case-sensitive because they are sent verbatim to the
|
||||
// doc-comment service.
|
||||
func Validate(value string) error {
|
||||
name := strings.TrimSpace(value)
|
||||
if value != name || !utf8.ValidString(name) || !supported(name) {
|
||||
return apperrors.NewValidation(
|
||||
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
|
||||
apperrors.WithReason("unsupported_comment_reaction"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supported(name string) bool {
|
||||
switch name {
|
||||
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
|
||||
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
|
||||
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
|
||||
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
|
||||
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
|
||||
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
|
||||
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
|
||||
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
|
||||
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
|
||||
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
|
||||
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
|
||||
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
|
||||
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
|
||||
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
|
||||
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
|
||||
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
|
||||
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
|
||||
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
|
||||
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
|
||||
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package commentreaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageValidate(t *testing.T) {
|
||||
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
|
||||
if err := Validate(value); err != nil {
|
||||
t.Errorf("supported reaction %q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
|
||||
if err := Validate(value); err == nil {
|
||||
t.Errorf("unsupported reaction %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
@@ -359,7 +360,37 @@ scopeType 支持:
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
scorecardSearchContentCmd := &cobra.Command{
|
||||
Use: "search-entities",
|
||||
Short: "搜索计分卡指标与关键事项",
|
||||
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
|
||||
Example: ` dws agoal scorecard search-entities --keyword "业绩"
|
||||
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"keyword": mustGetFlag(cmd, "keyword"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
return callMCPTool("search_score_card_entities", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
|
||||
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
|
||||
func newChatEmotionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "emotion",
|
||||
Short: "个人收藏表情",
|
||||
Long: "查询、发送和新增当前用户的个人收藏表情。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newChatEmotionListCommand(),
|
||||
newChatEmotionSendCommand(),
|
||||
newChatEmotionFavoriteCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收藏表情",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "list_personal_emotions",
|
||||
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
|
||||
},
|
||||
Description: "列出当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出当前用户的个人收藏表情",
|
||||
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
|
||||
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
|
||||
Examples: []string{"dws chat emotion list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionSendCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "发送个人收藏表情",
|
||||
Long: `发送当前用户的个人收藏表情。
|
||||
|
||||
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
|
||||
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
target, err := personalEmotionSendTarget(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
emotionID, _ := cmd.Flags().GetString("emotion-id")
|
||||
if strings.TrimSpace(emotionID) != "" {
|
||||
payload["emotionId"] = strings.TrimSpace(emotionID)
|
||||
}
|
||||
for key, value := range target {
|
||||
payload[key] = value
|
||||
}
|
||||
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
|
||||
payload["uuid"] = uuid
|
||||
}
|
||||
return callMCPToolOnServer("im", "send_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
|
||||
cmd.Flags().String("emotion-id", "", "表情 ID")
|
||||
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
|
||||
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
|
||||
cmd.Flags().String("uuid", "", "幂等键")
|
||||
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
|
||||
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
})
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionSendContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionFavoriteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "favorite",
|
||||
Short: "新增个人收藏表情",
|
||||
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
|
||||
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
|
||||
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(name) != "" {
|
||||
payload["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if strings.TrimSpace(sourceConversationID) != "" {
|
||||
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
|
||||
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
|
||||
}
|
||||
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
|
||||
cmd.Flags().String("name", "", "表情名称")
|
||||
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
|
||||
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionFavoriteContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
|
||||
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
userID = strings.TrimSpace(userID)
|
||||
openDingTalkID = strings.TrimSpace(openDingTalkID)
|
||||
specified := 0
|
||||
for _, value := range []string{groupID, userID, openDingTalkID} {
|
||||
if value != "" {
|
||||
specified++
|
||||
}
|
||||
}
|
||||
if specified != 1 {
|
||||
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
|
||||
}
|
||||
if groupID != "" {
|
||||
return map[string]any{"openConversationId": groupID}, nil
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
|
||||
}
|
||||
if isOpenDingTalkID(userID) {
|
||||
return map[string]any{"receiverOpenDingTalkId": userID}, nil
|
||||
}
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
|
||||
}
|
||||
|
||||
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
|
||||
hasConversation := strings.TrimSpace(sourceConversationID) != ""
|
||||
hasMessage := strings.TrimSpace(sourceMessageID) != ""
|
||||
if hasConversation != hasMessage {
|
||||
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalEmotionSendContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "send_personal_emotion",
|
||||
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
|
||||
},
|
||||
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以当前用户身份发送个人收藏表情",
|
||||
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
|
||||
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
|
||||
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
|
||||
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func personalEmotionFavoriteContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "favorite_personal_emotion",
|
||||
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
|
||||
},
|
||||
Description: "将 mediaId 新增到当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "新增当前用户的个人收藏表情",
|
||||
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
|
||||
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
|
||||
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "name", Required: boolPtr(false)},
|
||||
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
|
||||
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type personalEmotionCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type personalEmotionCaller struct {
|
||||
calls []personalEmotionCall
|
||||
}
|
||||
|
||||
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
|
||||
if server == "contact" && tool == "get_user_info_by_user_ids" {
|
||||
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
|
||||
}
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*personalEmotionCaller) Format() string { return "json" }
|
||||
func (*personalEmotionCaller) DryRun() bool { return false }
|
||||
func (*personalEmotionCaller) Fields() string { return "" }
|
||||
func (*personalEmotionCaller) JQ() string { return "" }
|
||||
|
||||
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installHelpersCoreDeps(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
|
||||
t.Helper()
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "im" || call.tool != tool {
|
||||
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
|
||||
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
|
||||
caller := &personalEmotionCaller{}
|
||||
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
|
||||
t.Fatalf("chat emotion list returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
|
||||
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--emotion-id", "emotion123",
|
||||
"--group", "cid123",
|
||||
"--idempotency-key", "idem-001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"emotionId": "emotion123",
|
||||
"openConversationId": "cid123",
|
||||
"uuid": "idem-001",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
|
||||
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--open-dingtalk-id", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
|
||||
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
|
||||
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", "u1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
resolveCall := caller.calls[0]
|
||||
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
|
||||
}
|
||||
sendCall := caller.calls[1]
|
||||
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
|
||||
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
|
||||
}
|
||||
want := map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID2,
|
||||
}
|
||||
if !reflect.DeepEqual(sendCall.args, want) {
|
||||
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"emotion", "send", "--media-id", "@media"},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "send", "--group", "cid"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
|
||||
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "favorite",
|
||||
"--media-id", "@media",
|
||||
"--name", "赞",
|
||||
"--source-conversation-id", "cid123",
|
||||
"--source-message-id", "msg123",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion favorite returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"name": "赞",
|
||||
"sourceConversationId": "cid123",
|
||||
"sourceMessageId": "msg123",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "favorite", "--name", "赞"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
{
|
||||
name: "source conversation only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
{
|
||||
name: "source message only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
|
||||
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
|
||||
for _, surface := range []string{"doc", "sheet"} {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
|
||||
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
|
||||
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
|
||||
} {
|
||||
t.Run(surface+"/"+test.name, func(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
|
||||
t.Fatal("unsupported reaction accepted")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
|
||||
@@ -17,6 +17,16 @@ import (
|
||||
// remindType: 服务端 API 1=应用内 2=短信 3=电话
|
||||
var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3}
|
||||
|
||||
var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"}
|
||||
|
||||
func dingPersonalRemindType(value string) (string, error) {
|
||||
remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("--type must be one of app, sms, call")
|
||||
}
|
||||
return remindType, nil
|
||||
}
|
||||
|
||||
func newDingCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/ding.json
|
||||
// products.ding). Catalog assembly stamps provenance contract_final.
|
||||
@@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "users", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := parseCSVValues(mustGetFlag(cmd, "users"))
|
||||
toolArgs := map[string]any{
|
||||
"receiverOpenDingTalkIds": users,
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
"remindType": mustGetFlag(cmd, "type"),
|
||||
"remindType": remindType,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
@@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil {
|
||||
return err
|
||||
}
|
||||
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := parseCSVValues(mustGetFlag(cmd, "users"))
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"openMessageId": mustGetFlag(cmd, "message-id"),
|
||||
"receiverOpenDingTalkIds": users,
|
||||
"remindType": mustGetFlag(cmd, "type"),
|
||||
"remindType": remindType,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) {
|
||||
for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} {
|
||||
got, err := dingPersonalRemindType(input)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want)
|
||||
}
|
||||
}
|
||||
if got, err := dingPersonalRemindType("push"); err == nil || got != "" {
|
||||
t.Fatalf("unsupported remind type=(%q,%v)", got, err)
|
||||
}
|
||||
}
|
||||
+52
-10
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -1428,9 +1429,14 @@ func newDocCommand() *cobra.Command {
|
||||
readCmd := &cobra.Command{
|
||||
Use: "read",
|
||||
Short: "读取文档内容 (Markdown)",
|
||||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
|
||||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。
|
||||
|
||||
互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。
|
||||
--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`,
|
||||
Example: ` dws doc read --node DOC_ID
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc read --node PUBLIC_URL --password <ACCESS_PASSWORD>
|
||||
dws doc read --node DOC_ID --version 3`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -1440,6 +1446,15 @@ func newDocCommand() *cobra.Command {
|
||||
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
|
||||
return err
|
||||
}
|
||||
password, _ := cmd.Flags().GetString("password")
|
||||
historyVersion := 0
|
||||
historyVersionSet := cmd.Flags().Changed("version")
|
||||
if historyVersionSet {
|
||||
historyVersion, _ = cmd.Flags().GetInt("version")
|
||||
if historyVersion < 0 {
|
||||
return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion)
|
||||
}
|
||||
}
|
||||
format, _ := cmd.Flags().GetString("content-format")
|
||||
scope, _ := cmd.Flags().GetString("scope")
|
||||
tags, _ := cmd.Flags().GetString("tags")
|
||||
@@ -1480,15 +1495,18 @@ func newDocCommand() *cobra.Command {
|
||||
startBlockID,
|
||||
endBlockID,
|
||||
outputPath,
|
||||
password,
|
||||
historyVersion,
|
||||
historyVersionSet,
|
||||
)
|
||||
}
|
||||
if format == "jsonml" {
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
return runDocReadJsonML(cmd, nodeID, outputPath)
|
||||
return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet)
|
||||
}
|
||||
return callMCPTool("get_document_content", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
})
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
|
||||
return callMCPTool("get_document_content", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(readCmd, LeafSpec{
|
||||
@@ -1515,6 +1533,8 @@ func newDocCommand() *cobra.Command {
|
||||
UseWhen: []string{
|
||||
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
|
||||
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
|
||||
"互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码",
|
||||
"要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)",
|
||||
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
@@ -1532,9 +1552,11 @@ func newDocCommand() *cobra.Command {
|
||||
{Name: "content-format", Property: "format", Required: boolPtr(false)},
|
||||
{Name: "end-block-id", Required: boolPtr(false)},
|
||||
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
|
||||
{Name: "password", Property: "password", Required: boolPtr(false)},
|
||||
{Name: "scope", Required: boolPtr(false)},
|
||||
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
|
||||
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
|
||||
{Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2679,6 +2701,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
|
||||
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
|
||||
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
|
||||
readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入")
|
||||
readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版")
|
||||
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
|
||||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
|
||||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
|
||||
@@ -3220,6 +3244,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("emoji"); v {
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
|
||||
return err
|
||||
}
|
||||
groupMentions, err := commentGroupMentionIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -4748,16 +4775,30 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号)
|
||||
// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段,
|
||||
// 显式 --version 0 表示读取文档初始版本。
|
||||
func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) {
|
||||
if password != "" {
|
||||
args["password"] = password
|
||||
}
|
||||
if historyVersionSet && historyVersion >= 0 {
|
||||
args["historyVersion"] = historyVersion
|
||||
}
|
||||
}
|
||||
|
||||
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
|
||||
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
|
||||
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
|
||||
func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"format": "jsonml",
|
||||
})
|
||||
}
|
||||
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
|
||||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4809,7 +4850,7 @@ func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error
|
||||
|
||||
// runDocReadScope calls get_document_content with JSONML filtering parameters
|
||||
// and preserves the returned read-only fragment container.
|
||||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
|
||||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -4829,6 +4870,7 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool,
|
||||
if endBlockID != "" && scope == "range" {
|
||||
args["endBlockId"] = endBlockID
|
||||
}
|
||||
applyDocReadAccessParams(args, password, historyVersion, historyVersionSet)
|
||||
|
||||
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
|
||||
if err != nil {
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
|
||||
err := runDocReadJsonML("node", tc.output, "", 0, false)
|
||||
if tc.wantFail && err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji", "--mentioned-open-conversation-id", "oc-1",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
|
||||
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji", "--mentioned-open-conversation-id", " ",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) {
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("find read: remaining=%v err=%v", remaining, err)
|
||||
}
|
||||
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} {
|
||||
if read.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("doc read missing --%s", name)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := installDocReadScopeCaller(t, tt.caller)
|
||||
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output)
|
||||
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
||||
@@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
outputPath := filepath.Join(t.TempDir(), "fragment.json")
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
@@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
t.Run("human output reports missing fragment", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{}`, format: "raw"}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil {
|
||||
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "未匹配到节点") {
|
||||
@@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
outputPath := filepath.Join(t.TempDir(), "fragment.json")
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) {
|
||||
@@ -312,3 +312,93 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径
|
||||
// 都把 --password / --version 透传到 get_document_content,且非法版本号在
|
||||
// 本地校验阶段被拒绝、不产生远端调用。
|
||||
func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) {
|
||||
t.Run("markdown path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jsonml path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller,
|
||||
"read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline",
|
||||
"--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative history version is rejected locally", func(t *testing.T) {
|
||||
for _, raw := range []string{"-1", "-3"} {
|
||||
caller := &docReadScopeCaller{}
|
||||
err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") {
|
||||
t.Fatalf("--version %s error = %v, want invalid --version", raw, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit zero history version forwards initial version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "historyVersion": 0}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset history version is not forwarded", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1"}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,9 +147,9 @@ func TestCrossPlatformCoverageRunDocReadJsonMLCoverage(t *testing.T) {
|
||||
`{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`,
|
||||
} {
|
||||
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
|
||||
_ = runDocReadJsonML(nil, "node", "")
|
||||
_ = runDocReadJsonML("node", "", "", 0, false)
|
||||
}
|
||||
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}}
|
||||
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json"))
|
||||
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json"))
|
||||
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "out.json"), "", 0, false)
|
||||
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "missing", "out.json"), "pw", 3, true)
|
||||
}
|
||||
|
||||
+288
-4
@@ -2,10 +2,13 @@ package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +21,9 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment.
|
||||
var computeFileMD5 = fileMD5Hex
|
||||
|
||||
func decodeOARequest(raw string) (map[string]any, error) {
|
||||
dec := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
dec.UseNumber()
|
||||
@@ -126,7 +132,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
|
||||
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
|
||||
return callOAAttachmentResultCtx(cmd.Context(), tool, args)
|
||||
}
|
||||
|
||||
// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
|
||||
// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
|
||||
func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
|
||||
data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,10 +153,225 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
|
||||
return output.Success(result), nil
|
||||
}
|
||||
|
||||
// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result
|
||||
// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer
|
||||
// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation
|
||||
// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。
|
||||
func validateOAAttachmentCommitResult(result any) (map[string]any, error) {
|
||||
resultMap, ok := result.(map[string]any)
|
||||
if !ok || resultMap == nil {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象")
|
||||
}
|
||||
|
||||
// spaceId — 接受 string 或 number,归一化为 int64
|
||||
switch v := resultMap["spaceId"].(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
|
||||
}
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
|
||||
}
|
||||
resultMap["spaceId"] = parsed
|
||||
case float64:
|
||||
// numeric spaceId — keep as-is (already matches JSON number)
|
||||
case json.Number:
|
||||
parsed, err := v.Int64()
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
|
||||
}
|
||||
resultMap["spaceId"] = parsed
|
||||
default:
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
|
||||
}
|
||||
|
||||
// fileName — must be non-empty string
|
||||
if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName")
|
||||
}
|
||||
|
||||
// fileSize — must be > 0 (number),归一化 json.Number → int64
|
||||
switch v := resultMap["fileSize"].(type) {
|
||||
case float64:
|
||||
if v <= 0 {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
|
||||
}
|
||||
case json.Number:
|
||||
parsed, err := v.Int64()
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
|
||||
}
|
||||
resultMap["fileSize"] = parsed
|
||||
default:
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize")
|
||||
}
|
||||
|
||||
// fileId — must be non-empty string
|
||||
if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId")
|
||||
}
|
||||
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
|
||||
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
|
||||
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
|
||||
func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
|
||||
var data map[string]any
|
||||
if err = json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
|
||||
}
|
||||
|
||||
uploadKey, _ = result["uploadKey"].(string)
|
||||
if strings.TrimSpace(uploadKey) == "" {
|
||||
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
|
||||
}
|
||||
|
||||
rawURLs, ok := result["resourceUrls"].([]any)
|
||||
if !ok || len(rawURLs) == 0 {
|
||||
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
|
||||
}
|
||||
resourceURL, _ = rawURLs[0].(string)
|
||||
if strings.TrimSpace(resourceURL) == "" {
|
||||
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
|
||||
}
|
||||
|
||||
rawHeaders, ok := result["headers"].(map[string]any)
|
||||
if !ok {
|
||||
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers")
|
||||
}
|
||||
headers = make(map[string]string)
|
||||
for key, value := range rawHeaders {
|
||||
if str, ok := value.(string); ok {
|
||||
headers[key] = str
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" {
|
||||
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date")
|
||||
}
|
||||
return resourceURL, headers, uploadKey, nil
|
||||
}
|
||||
|
||||
// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
|
||||
// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
|
||||
// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
|
||||
func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
|
||||
filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
|
||||
if filePath == "" {
|
||||
return apperrors.NewValidation("--file 不能为空")
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
|
||||
}
|
||||
if info.IsDir() {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
|
||||
md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
|
||||
if md5Hex == "" {
|
||||
md5Hex, err = computeFileMD5(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成,
|
||||
// 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。
|
||||
// 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果,
|
||||
// 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。
|
||||
// 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。
|
||||
if deps.Caller.DryRun() {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "attachment_upload",
|
||||
"source": "oa",
|
||||
"file": filePath,
|
||||
"file_name": fileName,
|
||||
"file_size": fileSize,
|
||||
"md5": md5Hex,
|
||||
"steps": []map[string]any{
|
||||
{
|
||||
"tool": "oa/init_attachment_upload_info",
|
||||
"args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex},
|
||||
"status": "planned",
|
||||
},
|
||||
{
|
||||
"tool": "HTTP PUT",
|
||||
"args": map[string]any{"file": filePath, "fileSize": fileSize},
|
||||
"status": "planned",
|
||||
},
|
||||
{
|
||||
"tool": "oa/commit_attachment_upload_info",
|
||||
"args": map[string]any{"fileName": fileName, "fileSize": fileSize},
|
||||
"requires": []string{"uploadKey from oa/init_attachment_upload_info"},
|
||||
"status": "planned",
|
||||
},
|
||||
},
|
||||
}, output.WithDryRun()))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
|
||||
initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
"md5": md5Hex,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
|
||||
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。
|
||||
commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{
|
||||
"fileName": fileName,
|
||||
"uploadKey": uploadKey,
|
||||
"fileSize": float64(fileSize),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commitResp, ok := commitData.(map[string]any)
|
||||
if !ok {
|
||||
return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象")
|
||||
}
|
||||
commitResult, _ := commitResp["result"]
|
||||
normalized, err := validateOAAttachmentCommitResult(commitResult)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output.StoreResult(cmd.Context(), output.Success(normalized))
|
||||
}
|
||||
|
||||
func newOAAttachmentCommand() *cobra.Command {
|
||||
attachmentCmd := &cobra.Command{
|
||||
Use: "attachment",
|
||||
Short: "审批附件授权与下载链接",
|
||||
Short: "审批附件授权、上传、下载与链接管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -319,7 +546,64 @@ func newOAAttachmentCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
|
||||
uploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
|
||||
Long: `上传本地文件为审批附件(三步自动完成)。
|
||||
|
||||
流程:
|
||||
1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
|
||||
2. HTTP PUT 上传文件二进制到 OSS
|
||||
3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
|
||||
|
||||
--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
|
||||
Example: ` dws oa approval attachment upload --file ./合同.pdf
|
||||
dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
|
||||
dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
|
||||
RunE: runOAAttachmentUpload,
|
||||
}
|
||||
DeclareLeafMetadata(uploadCmd, LeafSpec{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "attachment_upload",
|
||||
CanonicalPath: "oa.attachment_upload",
|
||||
CLIPath: "oa approval attachment upload",
|
||||
PrimaryCLIPath: "oa approval attachment upload",
|
||||
},
|
||||
Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
|
||||
UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
|
||||
AvoidWhen: []string{
|
||||
"仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
|
||||
"仅需获取已有附件临时下载链接时使用 attachment download-url",
|
||||
},
|
||||
Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
|
||||
uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
|
||||
_ = uploadCmd.MarkFlagRequired("file")
|
||||
|
||||
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
|
||||
return attachmentCmd
|
||||
}
|
||||
|
||||
@@ -421,7 +705,7 @@ func validateOARequestTimeRange(request map[string]any) error {
|
||||
// get_inst_revert_activities, get_process_schema, forecast_process,
|
||||
// start_process_instance, get_process_instances_by_admin,
|
||||
// get_attachment_download_url, auth_download_file,
|
||||
// auth_preview_attachment
|
||||
// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newOaCommand() *cobra.Command {
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo
|
||||
|
||||
cmd := newOaCommand()
|
||||
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行")
|
||||
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
@@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
|
||||
|
||||
func oaUploadCommitResponse(size int64) string {
|
||||
return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
|
||||
strconv.FormatInt(size, 10) +
|
||||
`,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
|
||||
}
|
||||
|
||||
// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
|
||||
func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
return path, int64(len(content))
|
||||
}
|
||||
|
||||
// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
|
||||
// resourceURL 与签名 headers。
|
||||
type capturedPut struct {
|
||||
calls int
|
||||
url string
|
||||
headers map[string]string
|
||||
filePath string
|
||||
fileSize int64
|
||||
}
|
||||
|
||||
func mockOAAttachmentPut(t *testing.T) *capturedPut {
|
||||
t.Helper()
|
||||
put := &capturedPut{}
|
||||
SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
|
||||
put.calls++
|
||||
put.url = url
|
||||
put.headers = headers
|
||||
put.filePath = filePath
|
||||
put.fileSize = fileSize
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
return put
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
|
||||
const content = "pdf-bytes-content"
|
||||
filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
{text: oaUploadCommitResponse(fileSize)},
|
||||
}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "合同.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
|
||||
}
|
||||
if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
|
||||
t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
|
||||
}
|
||||
wantInit := map[string]any{
|
||||
"fileName": "合同.pdf",
|
||||
"fileSize": float64(fileSize),
|
||||
"md5": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
}
|
||||
if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
|
||||
t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
|
||||
}
|
||||
|
||||
if put.calls != 1 {
|
||||
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
|
||||
}
|
||||
if put.url != "https://oss.example.test/upload" {
|
||||
t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
|
||||
}
|
||||
if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
|
||||
t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
|
||||
}
|
||||
if put.filePath != filePath || put.fileSize != fileSize {
|
||||
t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
|
||||
}
|
||||
|
||||
if caller.toolLog[1] != "commit_attachment_upload_info" {
|
||||
t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
|
||||
}
|
||||
wantCommit := map[string]any{
|
||||
"fileName": "合同.pdf",
|
||||
"uploadKey": "key-123",
|
||||
"fileSize": float64(fileSize),
|
||||
}
|
||||
if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
|
||||
t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v", err)
|
||||
}
|
||||
if envelope["ok"] != true || envelope["outcome"] != "success" {
|
||||
t.Fatalf("unified envelope = %#v", envelope)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok || data["fileId"] != "file-abc" {
|
||||
t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
|
||||
const content = "auto-md5-content"
|
||||
filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
|
||||
wantMD5, err := fileMD5Hex(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("compute expected md5: %v", err)
|
||||
}
|
||||
mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
{text: oaUploadCommitResponse(fileSize)},
|
||||
}}
|
||||
// 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
|
||||
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
); err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
|
||||
if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
|
||||
t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
|
||||
}
|
||||
if got := caller.argsLog[0]["md5"]; got != wantMD5 {
|
||||
t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
|
||||
}
|
||||
if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
|
||||
t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing result",
|
||||
response: `{"success":true}`,
|
||||
wantErr: "缺少 result",
|
||||
},
|
||||
{
|
||||
name: "missing uploadKey",
|
||||
response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
|
||||
wantErr: "缺少 uploadKey",
|
||||
},
|
||||
{
|
||||
name: "empty uploadKey",
|
||||
response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
|
||||
wantErr: "缺少 uploadKey",
|
||||
},
|
||||
{
|
||||
name: "empty resourceUrls",
|
||||
response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
|
||||
wantErr: "缺少 resourceUrls",
|
||||
},
|
||||
{
|
||||
name: "empty resourceUrls element",
|
||||
response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
|
||||
wantErr: "首个 resourceUrls 为空",
|
||||
},
|
||||
{
|
||||
name: "missing headers",
|
||||
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
|
||||
wantErr: "缺少 headers",
|
||||
},
|
||||
{
|
||||
name: "headers missing Authorization",
|
||||
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
|
||||
wantErr: "Authorization",
|
||||
},
|
||||
{
|
||||
name: "headers missing x-oss-date",
|
||||
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
|
||||
wantErr: "x-oss-date",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
|
||||
put := mockOAAttachmentPut(t)
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: test.response},
|
||||
// second step should never be reached
|
||||
{text: `{"result":{},"success":true}`},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "test.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
|
||||
}
|
||||
if put.calls != 0 {
|
||||
t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
|
||||
existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing file", args: []string{"approval", "attachment", "upload"}},
|
||||
{name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
|
||||
{name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mockOAAttachmentPut(t)
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller, test.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("%s unexpectedly succeeded", test.name)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证
|
||||
// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、
|
||||
// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) {
|
||||
const content = "dry-run-preview-content"
|
||||
filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content)
|
||||
wantMD5, err := fileMD5Hex(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("compute expected md5: %v", err)
|
||||
}
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
// caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls)
|
||||
}
|
||||
if put.calls != 0 {
|
||||
t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls)
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v\noutput: %s", err, stdout)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unified data missing/invalid: %#v", envelope["data"])
|
||||
}
|
||||
if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" {
|
||||
t.Fatalf("plan flags = %#v", data)
|
||||
}
|
||||
if data["operation"] != "attachment_upload" || data["source"] != "oa" {
|
||||
t.Fatalf("plan operation/source = %#v", data)
|
||||
}
|
||||
if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 {
|
||||
t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5)
|
||||
}
|
||||
steps, ok := data["steps"].([]any)
|
||||
if !ok || len(steps) != 3 {
|
||||
t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"])
|
||||
}
|
||||
wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"}
|
||||
for i, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("step %d not an object: %#v", i, raw)
|
||||
}
|
||||
if step["tool"] != wantTools[i] {
|
||||
t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i])
|
||||
}
|
||||
if step["status"] != "planned" {
|
||||
t.Fatalf("step %d status = %#v, want planned", i, step["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// The commit step (index 2) must NOT contain uploadKey in args (remote-only field)
|
||||
// and must declare the dependency via a "requires" entry.
|
||||
commitStep := steps[2].(map[string]any)
|
||||
commitArgs, _ := commitStep["args"].(map[string]any)
|
||||
if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey {
|
||||
t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs)
|
||||
}
|
||||
requires, ok := commitStep["requires"].([]any)
|
||||
if !ok || len(requires) == 0 {
|
||||
t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"])
|
||||
}
|
||||
found := false
|
||||
for _, r := range requires {
|
||||
if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to
|
||||
// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows).
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello")
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
testseam.Swap(t, &computeFileMD5, func(string) (string, error) {
|
||||
return "", errors.New("permission denied")
|
||||
})
|
||||
|
||||
caller := &scriptedToolCaller{format: "json"}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from MD5 failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MD5") {
|
||||
t.Fatalf("error should mention MD5, got: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls)
|
||||
}
|
||||
if put.calls != 0 {
|
||||
t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT,
|
||||
// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data")
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
|
||||
return errors.New("oss put failed")
|
||||
})
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
// commit 不应被达到
|
||||
{text: `{"result":{},"success":true}`},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "put-fail.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "oss put failed") {
|
||||
t.Fatalf("error = %v, want oss put failed", err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
|
||||
// 缺少必需字段时命令报错(而不是包装为成功)。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commitResponse string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "result is null",
|
||||
commitResponse: `{"success":true,"result":null}`,
|
||||
wantErr: "不是有效的 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "result is empty object",
|
||||
commitResponse: `{"success":true,"result":{}}`,
|
||||
wantErr: "spaceId",
|
||||
},
|
||||
{
|
||||
name: "missing fileId",
|
||||
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
|
||||
wantErr: "fileId",
|
||||
},
|
||||
{
|
||||
name: "empty fileId",
|
||||
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
|
||||
wantErr: "fileId",
|
||||
},
|
||||
{
|
||||
name: "missing spaceId",
|
||||
commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
|
||||
wantErr: "spaceId",
|
||||
},
|
||||
{
|
||||
name: "fileSize is zero",
|
||||
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
|
||||
wantErr: "fileSize",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
{text: test.commitResponse},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "commit-validate.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
|
||||
}
|
||||
// init + commit = 2 MCP calls
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
|
||||
}
|
||||
// PUT should have been called (init succeeded)
|
||||
if put.calls != 1 {
|
||||
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
|
||||
// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
|
||||
// json.Number 类型分支。
|
||||
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
wantErr string // empty means expect nil error
|
||||
}{
|
||||
// result 不是 map
|
||||
{name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
|
||||
{name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
|
||||
{name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
|
||||
{name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
|
||||
|
||||
// spaceId — 空字符串
|
||||
{name: "spaceId empty string", input: map[string]any{
|
||||
"spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: "spaceId"},
|
||||
{name: "spaceId whitespace only", input: map[string]any{
|
||||
"spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: "spaceId"},
|
||||
|
||||
// spaceId — json.Number(通过 UseNumber 解码的数字)
|
||||
{name: "spaceId json.Number", input: map[string]any{
|
||||
"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: ""},
|
||||
|
||||
// spaceId — float64(正常路径)
|
||||
{name: "spaceId float64", input: map[string]any{
|
||||
"spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: ""},
|
||||
|
||||
// spaceId — 非法类型
|
||||
{name: "spaceId bool", input: map[string]any{
|
||||
"spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: "spaceId"},
|
||||
|
||||
// fileName — 非 string 类型
|
||||
{name: "fileName is number", input: map[string]any{
|
||||
"spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: "fileName"},
|
||||
{name: "fileName is nil", input: map[string]any{
|
||||
"spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: "fileName"},
|
||||
{name: "fileName empty", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
|
||||
}, wantErr: "fileName"},
|
||||
|
||||
// fileSize — json.Number 有效
|
||||
{name: "fileSize json.Number valid", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
|
||||
}, wantErr: ""},
|
||||
|
||||
// fileSize — json.Number 无效(<= 0)
|
||||
{name: "fileSize json.Number zero", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
|
||||
}, wantErr: "fileSize"},
|
||||
{name: "fileSize json.Number negative", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
|
||||
}, wantErr: "fileSize"},
|
||||
{name: "fileSize json.Number invalid", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
|
||||
}, wantErr: "fileSize"},
|
||||
|
||||
// fileSize — 非法类型(default 分支)
|
||||
{name: "fileSize is string", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
|
||||
}, wantErr: "fileSize"},
|
||||
{name: "fileSize is nil", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
|
||||
}, wantErr: "fileSize"},
|
||||
|
||||
// fileId — 非 string 类型
|
||||
{name: "fileId is number", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
|
||||
}, wantErr: "fileId"},
|
||||
{name: "fileId whitespace", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
|
||||
}, wantErr: "fileId"},
|
||||
|
||||
// 全部通过
|
||||
{name: "all valid string types", input: map[string]any{
|
||||
"spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
|
||||
}, wantErr: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := validateOAAttachmentCommitResult(test.input)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
|
||||
// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
|
||||
mockOAAttachmentPut(t)
|
||||
caller := &scriptedToolCaller{format: "json"}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", "",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
|
||||
t.Fatalf("error = %v, want --file empty error", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("MCP calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
|
||||
// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{err: errors.New("injected init failure")},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "init-err.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from init call failure, got nil")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
|
||||
}
|
||||
if put.calls != 0 {
|
||||
t.Fatalf("PUT calls = %d, want 0", put.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
|
||||
// 中 commitData 不是 map[string]any 的分支(line 348)。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
|
||||
mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
// commit 返回一个 JSON 字符串而非对象
|
||||
{text: `"not an object"`},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "commit-nonobj.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
|
||||
t.Fatalf("error = %v, want commit non-object error", err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
|
||||
// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{not valid json`},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "bad-init.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
|
||||
t.Fatalf("error = %v, want parse failure", err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
|
||||
}
|
||||
if put.calls != 0 {
|
||||
t.Fatalf("PUT calls = %d, want 0", put.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证
|
||||
// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。
|
||||
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
wantSpaceID any
|
||||
wantSize any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "string spaceId normalized to int64",
|
||||
input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
|
||||
wantSpaceID: int64(27827223951),
|
||||
wantSize: float64(100),
|
||||
},
|
||||
{
|
||||
name: "non-numeric string spaceId returns error",
|
||||
input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
|
||||
wantErr: "spaceId",
|
||||
},
|
||||
{
|
||||
name: "float string spaceId returns error",
|
||||
input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
|
||||
wantErr: "spaceId",
|
||||
},
|
||||
{
|
||||
name: "json.Number spaceId normalized to int64",
|
||||
input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
|
||||
wantSpaceID: int64(27827223951),
|
||||
wantSize: float64(100),
|
||||
},
|
||||
{
|
||||
name: "json.Number spaceId non-integer returns error",
|
||||
input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
|
||||
wantErr: "spaceId",
|
||||
},
|
||||
{
|
||||
name: "json.Number fileSize normalized to int64",
|
||||
input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"},
|
||||
wantSpaceID: float64(123),
|
||||
wantSize: int64(2048),
|
||||
},
|
||||
{
|
||||
name: "float64 spaceId unchanged",
|
||||
input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
|
||||
wantSpaceID: float64(27827223951),
|
||||
wantSize: float64(100),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
normalized, err := validateOAAttachmentCommitResult(test.input)
|
||||
if test.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if normalized["spaceId"] != test.wantSpaceID {
|
||||
t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID)
|
||||
}
|
||||
if normalized["fileSize"] != test.wantSize {
|
||||
t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令
|
||||
// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commitResponse string
|
||||
wantSpaceID float64 // JSON decode 后 number → float64
|
||||
}{
|
||||
{
|
||||
name: "spaceId as number",
|
||||
commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
|
||||
wantSpaceID: float64(27827223951),
|
||||
},
|
||||
{
|
||||
name: "spaceId as string",
|
||||
commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
|
||||
wantSpaceID: float64(27827223951),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
const content = "output-contract-data"
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content)
|
||||
mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
{text: test.commitResponse},
|
||||
}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "合同.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v", err)
|
||||
}
|
||||
if envelope["ok"] != true || envelope["outcome"] != "success" {
|
||||
t.Fatalf("unified envelope = %#v", envelope)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unified data missing: %#v", envelope)
|
||||
}
|
||||
// spaceId must be a number in the JSON output
|
||||
spaceID, ok := data["spaceId"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"])
|
||||
}
|
||||
if spaceID != test.wantSpaceID {
|
||||
t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID)
|
||||
}
|
||||
// fileSize must also be a number
|
||||
if _, ok := data["fileSize"].(float64); !ok {
|
||||
t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
|
||||
// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
|
||||
// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) {
|
||||
filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data")
|
||||
put := mockOAAttachmentPut(t)
|
||||
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: oaUploadInitResponse},
|
||||
{err: errors.New("commit upload failed")},
|
||||
}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "upload",
|
||||
"--file", filePath,
|
||||
"--file-name", "commit-fail.pdf",
|
||||
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "commit upload failed") {
|
||||
t.Fatalf("error = %v, want commit upload failed", err)
|
||||
}
|
||||
if put.calls != 1 {
|
||||
t.Fatalf("PUT calls = %d, want 1", put.calls)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls)
|
||||
}
|
||||
if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" {
|
||||
t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
// Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
|
||||
// can assert each invocation instead of only the last one captured above.
|
||||
serverLog []string
|
||||
toolLog []string
|
||||
argsLog []map[string]any
|
||||
}
|
||||
|
||||
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
|
||||
c.server = serverID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
c.serverLog = append(c.serverLog, serverID)
|
||||
c.toolLog = append(c.toolLog, toolName)
|
||||
c.argsLog = append(c.argsLog, args)
|
||||
if len(c.steps) == 0 {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "sheet",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。",
|
||||
UseWhen: []string{
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
|
||||
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时",
|
||||
"用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
|
||||
@@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet chart create 创建浮动图表
|
||||
dws sheet chart update 更新浮动图表
|
||||
dws sheet chart delete 删除浮动图表
|
||||
dws sheet revision-get 获取工作簿当前 revision
|
||||
dws sheet changeset-get 获取工作簿 revision 区间内的 changeset
|
||||
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
|
||||
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
|
||||
dws sheet import 导入 xlsx/xls 为在线电子表格
|
||||
@@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command {
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
pivotTableCmd := newPivotTableCmd()
|
||||
revisionCmds := newSheetRevisionCmds()
|
||||
|
||||
batchUpdateCmd := newBatchUpdateCmd()
|
||||
DeclareLeafMetadata(batchUpdateCmd, LeafSpec{
|
||||
@@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, revisionCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("emoji"); v {
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["emoji"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
|
||||
"comment", "reply",
|
||||
"--node", "node-1",
|
||||
"--comment-key", "ck-1",
|
||||
"--content", "heart",
|
||||
"--content", "比心",
|
||||
"--emoji",
|
||||
"--mention", "uid1",
|
||||
},
|
||||
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
|
||||
toolName: "reply_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "node-1",
|
||||
"content": "heart",
|
||||
"content": "比心",
|
||||
"replyCommentKey": "ck-1",
|
||||
"emoji": true,
|
||||
"mentionedUserIds": []string{"uid1"},
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
sheetRevisionGetRemoteTool = "get_sheet_revision"
|
||||
sheetChangesetGetRemoteTool = "get_sheet_changeset"
|
||||
sheetChangesetMaxSpan = int64(20)
|
||||
sheetChangesetJSONMaxBytes = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
var sheetRevisionResult = &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"工作簿当前持久化 Delta revision",
|
||||
"properties":{
|
||||
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
|
||||
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
|
||||
"revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"}
|
||||
},
|
||||
"required":["success","logId","revision"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
|
||||
var sheetChangesetResult = &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset",
|
||||
"properties":{
|
||||
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
|
||||
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
|
||||
"schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]},
|
||||
"changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]},
|
||||
"latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"},
|
||||
"startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"},
|
||||
"endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"},
|
||||
"summary":{
|
||||
"type":"object",
|
||||
"description":"本次区间内语义 change 的完整性和影响范围汇总",
|
||||
"properties":{
|
||||
"changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"},
|
||||
"completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"},
|
||||
"partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"},
|
||||
"unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"},
|
||||
"containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"},
|
||||
"containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"},
|
||||
"affectedSheets":{
|
||||
"type":"array",
|
||||
"description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"sheetId":{"type":"string","description":"受影响工作表的稳定 ID"},
|
||||
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"},
|
||||
"ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}}
|
||||
},
|
||||
"required":["sheetId","ranges"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"],
|
||||
"additionalProperties":true
|
||||
},
|
||||
"changesets":{
|
||||
"type":"array",
|
||||
"description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"revision":{"type":"number","description":"该 changeset 对应的 Delta revision"},
|
||||
"createTime":{"type":"string","description":"该 revision 的创建时间"},
|
||||
"isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"},
|
||||
"eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]},
|
||||
"detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
|
||||
"reset":{
|
||||
"type":"object",
|
||||
"description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象",
|
||||
"properties":{
|
||||
"type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]},
|
||||
"targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"},
|
||||
"targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]}
|
||||
},
|
||||
"required":["type","targetStatus"],
|
||||
"additionalProperties":true
|
||||
},
|
||||
"changes":{
|
||||
"type":"array",
|
||||
"description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]},
|
||||
"targets":{
|
||||
"type":"array",
|
||||
"description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]},
|
||||
"sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"},
|
||||
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"},
|
||||
"sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]},
|
||||
"a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"},
|
||||
"role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]}
|
||||
},
|
||||
"required":["scope"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"details":{
|
||||
"type":"object",
|
||||
"description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断",
|
||||
"properties":{
|
||||
"cell":{
|
||||
"type":"object",
|
||||
"description":"前向单元格内容;整体清除时为 {cleared:true}",
|
||||
"properties":{
|
||||
"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},
|
||||
"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},
|
||||
"formula":{"type":"string","description":"本次修改写入的可读公式文本"},
|
||||
"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},
|
||||
"cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
|
||||
"link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}
|
||||
},
|
||||
"additionalProperties":true
|
||||
},
|
||||
"cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
|
||||
"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false},
|
||||
"border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true},
|
||||
"style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true},
|
||||
"properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true},
|
||||
"changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true},
|
||||
"fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]},
|
||||
"copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"},
|
||||
"styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]},
|
||||
"step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true},
|
||||
"pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]},
|
||||
"isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"},
|
||||
"iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]},
|
||||
"includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}},
|
||||
"contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true},
|
||||
"clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}},
|
||||
"preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}},
|
||||
"relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}},
|
||||
"styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}},
|
||||
"dataValidation":{
|
||||
"type":"object",
|
||||
"description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回",
|
||||
"properties":{
|
||||
"type":{"type":"string","description":"数据验证规则类型"},
|
||||
"templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"},
|
||||
"sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]},
|
||||
"options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}},
|
||||
"sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true},
|
||||
"sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]},
|
||||
"sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"},
|
||||
"enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"},
|
||||
"criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true},
|
||||
"settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true}
|
||||
},
|
||||
"required":["type"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"additionalProperties":true
|
||||
},
|
||||
"detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
|
||||
"omissions":{
|
||||
"type":"array",
|
||||
"description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组",
|
||||
"items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true}
|
||||
}
|
||||
},
|
||||
"required":["type","targets","details","detailsStatus","omissions"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
|
||||
type sheetPublishedResultSchemaCache struct {
|
||||
once sync.Once
|
||||
schema map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache
|
||||
sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache
|
||||
)
|
||||
|
||||
func newSheetRevisionCmds() []*cobra.Command {
|
||||
return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()}
|
||||
}
|
||||
|
||||
func newSheetRevisionGetCmd() *cobra.Command {
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "revision-get",
|
||||
Short: "获取表格工作簿当前 revision",
|
||||
Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。",
|
||||
Example: " dws sheet revision-get --node <NODE_ID_OR_URL> --format json",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Server: "sheet",
|
||||
Tool: sheetRevisionGetRemoteTool,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
|
||||
},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "sheet",
|
||||
Name: sheetRevisionGetRemoteTool,
|
||||
CanonicalPath: "sheet." + sheetRevisionGetRemoteTool,
|
||||
CLIPath: "sheet revision-get",
|
||||
PrimaryCLIPath: "sheet revision-get",
|
||||
},
|
||||
Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
|
||||
Result: sheetRevisionResult,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。",
|
||||
UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"},
|
||||
AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"},
|
||||
Examples: []string{"dws sheet revision-get --node <NODE_ID_OR_URL> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
|
||||
},
|
||||
},
|
||||
ResultCall: callSheetRevisionResult,
|
||||
})
|
||||
}
|
||||
|
||||
func newSheetChangesetGetCmd() *cobra.Command {
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "changeset-get",
|
||||
Short: "获取表格工作簿 revision 区间内的 changeset",
|
||||
Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。
|
||||
|
||||
该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`,
|
||||
Example: ` dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json
|
||||
dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --format json`,
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Server: "sheet",
|
||||
Tool: sheetChangesetGetRemoteTool,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
|
||||
{
|
||||
Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision",
|
||||
Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg,
|
||||
},
|
||||
{
|
||||
Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision",
|
||||
Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg,
|
||||
},
|
||||
},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "sheet",
|
||||
Name: sheetChangesetGetRemoteTool,
|
||||
CanonicalPath: "sheet." + sheetChangesetGetRemoteTool,
|
||||
CLIPath: "sheet changeset-get",
|
||||
PrimaryCLIPath: "sheet changeset-get",
|
||||
},
|
||||
Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
|
||||
Result: sheetChangesetResult,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。",
|
||||
UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"},
|
||||
AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"},
|
||||
Examples: []string{"dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
|
||||
{Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"},
|
||||
{Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"},
|
||||
},
|
||||
},
|
||||
Validate: validateSheetChangesetRange,
|
||||
ResultCall: callSheetRevisionResult,
|
||||
})
|
||||
}
|
||||
|
||||
func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
|
||||
if deps.Caller.DryRun() {
|
||||
return output.Success(map[string]any{
|
||||
"executed": false,
|
||||
"tool": tool,
|
||||
"arguments": args,
|
||||
}, output.WithDryRun()), nil
|
||||
}
|
||||
raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := decodeSheetRevisionResult(tool, args, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output.Success(data), nil
|
||||
}
|
||||
|
||||
func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
"MCP sheet read tool returned no non-empty text content",
|
||||
"empty_tool_response",
|
||||
true,
|
||||
)
|
||||
}
|
||||
data, err := decodeSheetSingleJSON(raw)
|
||||
if err != nil {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err),
|
||||
"invalid_tool_response",
|
||||
false,
|
||||
)
|
||||
}
|
||||
object, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
"MCP sheet read tool returned a non-object business result",
|
||||
"invalid_tool_response",
|
||||
false,
|
||||
)
|
||||
}
|
||||
if isBusinessError(object) {
|
||||
return nil, &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: raw,
|
||||
Suggestion: suggestForBusinessError(object),
|
||||
Operation: "sheet/" + tool,
|
||||
}
|
||||
}
|
||||
success, ok := object["success"].(bool)
|
||||
if !ok || !success {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
"MCP sheet read tool returned a business result without success=true",
|
||||
"invalid_tool_response",
|
||||
false,
|
||||
)
|
||||
}
|
||||
if tool == sheetChangesetGetRemoteTool {
|
||||
if err := normalizeSheetChangesetTransport(object); err != nil {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s",
|
||||
err, sheetResultLogIDSuffix(object)),
|
||||
"invalid_tool_response",
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := validateSheetPublishedResult(tool, object); err != nil {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s",
|
||||
err, sheetResultLogIDSuffix(object)),
|
||||
"invalid_tool_response",
|
||||
false,
|
||||
)
|
||||
}
|
||||
if tool == sheetChangesetGetRemoteTool {
|
||||
if err := validateSheetChangesetAudit(request, object); err != nil {
|
||||
return nil, invalidSheetRevisionResponse(tool,
|
||||
fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s",
|
||||
err, sheetResultLogIDSuffix(object)),
|
||||
"invalid_tool_response",
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation("sheet/"+tool),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(retryable),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
func sheetResultLogIDSuffix(object map[string]any) string {
|
||||
logID, ok := object["logId"].(string)
|
||||
if !ok || strings.TrimSpace(logID) == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID))
|
||||
}
|
||||
|
||||
func decodeSheetSingleJSON(raw string) (any, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var data any
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("包含多个 JSON 值")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func normalizeSheetChangesetTransport(object map[string]any) error {
|
||||
encoded, hasEncoded := object["changesetsJson"]
|
||||
if hasEncoded {
|
||||
changesetsJSON, ok := encoded.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("changesetsJson 不是字符串")
|
||||
}
|
||||
if strings.TrimSpace(changesetsJSON) == "" {
|
||||
return fmt.Errorf("changesetsJson 为空")
|
||||
}
|
||||
if len(changesetsJSON) > sheetChangesetJSONMaxBytes {
|
||||
return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes)
|
||||
}
|
||||
decoded, err := decodeSheetSingleJSON(changesetsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err)
|
||||
}
|
||||
changesets, ok := decoded.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("changesetsJson 根节点不是数组")
|
||||
}
|
||||
object["changesets"] = changesets
|
||||
delete(object, "changesetsJson")
|
||||
return nil
|
||||
}
|
||||
|
||||
if legacy, exists := object["changesets"]; exists {
|
||||
if _, ok := legacy.([]any); !ok {
|
||||
return fmt.Errorf("changesets 不是数组")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("成功响应缺少 changesetsJson")
|
||||
}
|
||||
|
||||
func validateSheetPublishedResult(tool string, object map[string]any) error {
|
||||
var rawSchema json.RawMessage
|
||||
var cache *sheetPublishedResultSchemaCache
|
||||
switch tool {
|
||||
case sheetRevisionGetRemoteTool:
|
||||
rawSchema = sheetRevisionResult.DataSchema
|
||||
cache = &sheetRevisionPublishedSchemaCache
|
||||
case sheetChangesetGetRemoteTool:
|
||||
rawSchema = sheetChangesetResult.DataSchema
|
||||
cache = &sheetChangesetPublishedSchemaCache
|
||||
default:
|
||||
return fmt.Errorf("未知工具 %q", tool)
|
||||
}
|
||||
return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache)
|
||||
}
|
||||
|
||||
func validateSheetPublishedResultWithSchema(tool string, object map[string]any,
|
||||
rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache,
|
||||
) error {
|
||||
cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) })
|
||||
if cache.err != nil {
|
||||
return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err)
|
||||
}
|
||||
schema := cache.schema
|
||||
if err := cli.ValidateJSONSchemaValue(object, schema); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(object["logId"].(string)) == "" {
|
||||
return fmt.Errorf("$.logId 不能为空")
|
||||
}
|
||||
|
||||
switch tool {
|
||||
case sheetRevisionGetRemoteTool:
|
||||
if _, err := sheetNonNegativeInteger(object["revision"]); err != nil {
|
||||
return fmt.Errorf("$.revision %v", err)
|
||||
}
|
||||
case sheetChangesetGetRemoteTool:
|
||||
latest, err := sheetNonNegativeInteger(object["latestRevision"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("$.latestRevision %v", err)
|
||||
}
|
||||
start, err := sheetNonNegativeInteger(object["startRevision"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("$.startRevision %v", err)
|
||||
}
|
||||
end, err := sheetNonNegativeInteger(object["endRevision"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("$.endRevision %v", err)
|
||||
}
|
||||
if start > end {
|
||||
return fmt.Errorf("$.startRevision 不能大于 $.endRevision")
|
||||
}
|
||||
if end > latest {
|
||||
return fmt.Errorf("$.endRevision 不能大于 $.latestRevision")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sheetChangesetSummaryAudit struct {
|
||||
changeCount int64
|
||||
completeChangeCount int64
|
||||
partialChangeCount int64
|
||||
unsupportedChangeCount int64
|
||||
containsStateReset bool
|
||||
containsIncompleteChanges bool
|
||||
affectedSheets []sheetChangesetAffectedSheetAudit
|
||||
}
|
||||
|
||||
type sheetChangesetAffectedSheetAudit struct {
|
||||
sheetID string
|
||||
sheetName string
|
||||
ranges []string
|
||||
}
|
||||
|
||||
type sheetChangesetAffectedSheetAccumulator struct {
|
||||
sheetName string
|
||||
ranges map[string]struct{}
|
||||
}
|
||||
|
||||
func validateSheetChangesetAudit(request, object map[string]any) error {
|
||||
requestStart := request["startRevision"].(int64)
|
||||
responseStart, _ := sheetNonNegativeInteger(object["startRevision"])
|
||||
responseEnd, _ := sheetNonNegativeInteger(object["endRevision"])
|
||||
latest, _ := sheetNonNegativeInteger(object["latestRevision"])
|
||||
if responseStart != requestStart {
|
||||
return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart)
|
||||
}
|
||||
if requestEnd, exists := request["endRevision"]; exists {
|
||||
if responseEnd != requestEnd.(int64) {
|
||||
return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd)
|
||||
}
|
||||
} else if responseEnd != latest {
|
||||
return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest)
|
||||
}
|
||||
if responseEnd-responseStart > sheetChangesetMaxSpan {
|
||||
return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan)
|
||||
}
|
||||
|
||||
changesets := object["changesets"].([]any)
|
||||
wantCount := responseEnd - responseStart
|
||||
if int64(len(changesets)) != wantCount {
|
||||
return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd)
|
||||
}
|
||||
for index, rawChangeset := range changesets {
|
||||
changeset := rawChangeset.(map[string]any)
|
||||
revision, err := sheetNonNegativeInteger(changeset["revision"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("$.changesets[%d].revision %v", index, err)
|
||||
}
|
||||
expected := responseStart + int64(index) + 1
|
||||
if revision != expected {
|
||||
return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected)
|
||||
}
|
||||
}
|
||||
|
||||
actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedSummary := summarizeSheetChangesetsForAudit(changesets)
|
||||
if !reflect.DeepEqual(actualSummary, expectedSummary) {
|
||||
return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) {
|
||||
fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"}
|
||||
counts := make([]int64, len(fields))
|
||||
for index, field := range fields {
|
||||
value, err := sheetNonNegativeInteger(raw[field])
|
||||
if err != nil {
|
||||
return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err)
|
||||
}
|
||||
counts[index] = value
|
||||
}
|
||||
|
||||
result := sheetChangesetSummaryAudit{
|
||||
changeCount: counts[0],
|
||||
completeChangeCount: counts[1],
|
||||
partialChangeCount: counts[2],
|
||||
unsupportedChangeCount: counts[3],
|
||||
containsStateReset: raw["containsStateReset"].(bool),
|
||||
containsIncompleteChanges: raw["containsIncompleteChanges"].(bool),
|
||||
}
|
||||
for _, rawSheet := range raw["affectedSheets"].([]any) {
|
||||
sheet := rawSheet.(map[string]any)
|
||||
rawRanges := sheet["ranges"].([]any)
|
||||
affected := sheetChangesetAffectedSheetAudit{
|
||||
sheetID: sheet["sheetId"].(string),
|
||||
ranges: make([]string, 0, len(rawRanges)),
|
||||
}
|
||||
affected.sheetName, _ = sheet["sheetName"].(string)
|
||||
for _, rawRange := range rawRanges {
|
||||
affected.ranges = append(affected.ranges, rawRange.(string))
|
||||
}
|
||||
result.affectedSheets = append(result.affectedSheets, affected)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit {
|
||||
result := sheetChangesetSummaryAudit{}
|
||||
affected := map[string]*sheetChangesetAffectedSheetAccumulator{}
|
||||
for _, rawChangeset := range changesets {
|
||||
changeset := rawChangeset.(map[string]any)
|
||||
result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET"
|
||||
result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE"
|
||||
for _, rawChange := range changeset["changes"].([]any) {
|
||||
change := rawChange.(map[string]any)
|
||||
result.changeCount++
|
||||
switch change["detailsStatus"] {
|
||||
case "COMPLETE":
|
||||
result.completeChangeCount++
|
||||
case "PARTIAL":
|
||||
result.partialChangeCount++
|
||||
}
|
||||
if change["type"] == "UNSUPPORTED_CHANGE" {
|
||||
result.unsupportedChangeCount++
|
||||
}
|
||||
for _, rawTarget := range change["targets"].([]any) {
|
||||
target := rawTarget.(map[string]any)
|
||||
if target["role"] == "SOURCE" {
|
||||
continue
|
||||
}
|
||||
sheetID, _ := target["sheetId"].(string)
|
||||
if strings.TrimSpace(sheetID) == "" {
|
||||
continue
|
||||
}
|
||||
accumulator := affected[sheetID]
|
||||
if accumulator == nil {
|
||||
accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}}
|
||||
affected[sheetID] = accumulator
|
||||
}
|
||||
if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" {
|
||||
accumulator.sheetName = sheetName
|
||||
}
|
||||
if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" {
|
||||
accumulator.ranges[a1Range] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sheetIDs := make([]string, 0, len(affected))
|
||||
for sheetID := range affected {
|
||||
sheetIDs = append(sheetIDs, sheetID)
|
||||
}
|
||||
sort.Strings(sheetIDs)
|
||||
for _, sheetID := range sheetIDs {
|
||||
accumulator := affected[sheetID]
|
||||
ranges := make([]string, 0, len(accumulator.ranges))
|
||||
for a1Range := range accumulator.ranges {
|
||||
ranges = append(ranges, a1Range)
|
||||
}
|
||||
sort.Strings(ranges)
|
||||
result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{
|
||||
sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sheetNonNegativeInteger(value any) (int64, error) {
|
||||
number, ok := value.(json.Number)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("必须是非负整数")
|
||||
}
|
||||
parsed, err := strconv.ParseInt(number.String(), 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, fmt.Errorf("必须是非负整数")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func sheetRevisionNumberArg(raw string) (any, error) {
|
||||
return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
}
|
||||
|
||||
func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error {
|
||||
start, err := parseSheetRevisionFlag(cmd, "start-revision")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if start < 0 {
|
||||
return apperrors.NewValidation("--start-revision 必须是非负整数")
|
||||
}
|
||||
|
||||
endRaw, _ := cmd.Flags().GetString("end-revision")
|
||||
if strings.TrimSpace(endRaw) == "" {
|
||||
return nil
|
||||
}
|
||||
end, err := parseSheetRevisionFlag(cmd, "end-revision")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if end < 0 {
|
||||
return apperrors.NewValidation("--end-revision 必须是非负整数")
|
||||
}
|
||||
if end < start {
|
||||
return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision")
|
||||
}
|
||||
if end-start > sheetChangesetMaxSpan {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d",
|
||||
sheetChangesetMaxSpan, start+sheetChangesetMaxSpan,
|
||||
))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) {
|
||||
raw, _ := cmd.Flags().GetString(name)
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,9 +111,14 @@ func newSheetVersionCmd() *cobra.Command {
|
||||
versionListCmd.Flags().String("cursor", "", "分页游标")
|
||||
|
||||
versionRevertCmd := &cobra.Command{
|
||||
Use: "revert",
|
||||
Short: "[危险] 回滚表格到指定版本",
|
||||
Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
|
||||
Use: "revert",
|
||||
Short: "[危险] 回滚表格到指定历史版本或 revision",
|
||||
Long: `将在线表格恢复到指定历史版本或精确 revision。
|
||||
|
||||
通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时,
|
||||
也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入
|
||||
版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`,
|
||||
Example: ` dws sheet version revert --node SHEET_ID --version 3`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command {
|
||||
CLIPath: "sheet version revert",
|
||||
PrimaryCLIPath: "sheet version revert",
|
||||
},
|
||||
Description: "回滚表格到指定历史版本",
|
||||
Description: "回滚表格到指定历史版本或已确认的精确 revision",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "回滚表格到指定历史版本",
|
||||
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"},
|
||||
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"},
|
||||
AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision",
|
||||
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"},
|
||||
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"},
|
||||
Examples: []string{"dws sheet version revert --node <SHEET_ID> --version 3 --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
|
||||
versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)")
|
||||
|
||||
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
|
||||
c.Flags().String("url", "", "")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) {
|
||||
command := newSheetVersionCmd()
|
||||
var output bytes.Buffer
|
||||
command.SetOut(&output)
|
||||
command.SetErr(&output)
|
||||
command.SetArgs([]string{"revert", "--help"})
|
||||
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatalf("sheet version revert --help returned error: %v", err)
|
||||
}
|
||||
help := output.String()
|
||||
for _, expected := range []string{
|
||||
"通常应从 version list 选择已保存的历史版本",
|
||||
"已从同一工作簿真实查询结果确认的 revision",
|
||||
"禁止猜测 revision",
|
||||
"目标历史版本或已确认 revision",
|
||||
} {
|
||||
if !strings.Contains(help, expected) {
|
||||
t.Fatalf("sheet version revert help missing %q:\n%s", expected, help)
|
||||
}
|
||||
}
|
||||
if strings.Contains(help, "--yes") {
|
||||
t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) {
|
||||
args := []string{"version", "revert", "--node", "node-1", "--version", "37"}
|
||||
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
|
||||
caller = &guardedMutationCaller{}
|
||||
confirmedArgs := append(append([]string(nil), args...), "--yes")
|
||||
if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil {
|
||||
t.Fatalf("sheet version revert after confirmation returned error: %v", err)
|
||||
}
|
||||
want := guardedMutationCall{
|
||||
productID: "doc",
|
||||
toolName: "revert_doc_version",
|
||||
args: map[string]any{
|
||||
"nodeId": "node-1",
|
||||
"version": 37,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want)
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,7 @@ func TestWukongSyncAgoalCommands(t *testing.T) {
|
||||
{[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}},
|
||||
{[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}},
|
||||
{[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}},
|
||||
{[]string{"scorecard", "search-entities"}, []string{"keyword", "page", "page-size", "request-id"}},
|
||||
{[]string{"user", "rules"}, []string{"user-id", "request-id"}},
|
||||
{[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}},
|
||||
{[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}},
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
|
||||
"已取消操作": "Operation cancelled",
|
||||
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
|
||||
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
|
||||
"已取消操作": "已取消操作",
|
||||
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
|
||||
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
|
||||
@@ -479,8 +479,8 @@ func evaluateCommandMigrationLifecycle(
|
||||
}
|
||||
}
|
||||
if allAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
if exists && proposed.State != CommandMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
|
||||
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
|
||||
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
|
||||
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
|
||||
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
|
||||
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
|
||||
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
|
||||
{"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"},
|
||||
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
|
||||
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
|
||||
} {
|
||||
@@ -402,6 +402,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
|
||||
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 {
|
||||
t.Fatalf("retained inert receipt=%#v, %v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("candidate pending plan=%#v, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -889,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) {
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
references := map[string]Snapshot{
|
||||
@@ -897,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl
|
||||
"stable": after,
|
||||
}
|
||||
|
||||
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
|
||||
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
|
||||
report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed)
|
||||
if err != nil {
|
||||
t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons)
|
||||
}
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
|
||||
report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
|
||||
const (
|
||||
FlagMigrationPending = "pending"
|
||||
FlagMigrationConsumed = "consumed"
|
||||
|
||||
FlagMigrationRename = "flag_rename"
|
||||
FlagMigrationRequirednessChange = "requiredness_change"
|
||||
)
|
||||
|
||||
type FlagMigrationManifest struct {
|
||||
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
|
||||
}
|
||||
|
||||
type FlagMigration struct {
|
||||
Command string `json:"command"`
|
||||
Legacy FlagMigrationSide `json:"legacy"`
|
||||
Canonical FlagMigrationSide `json:"canonical"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Legacy FlagMigrationSide `json:"legacy"`
|
||||
Canonical FlagMigrationSide `json:"canonical"`
|
||||
Flag *FlagMigrationSide `json:"flag,omitempty"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type FlagMigrationSide struct {
|
||||
@@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
|
||||
}
|
||||
legacyTargets[legacyKey] = migration.Canonical.Name
|
||||
}
|
||||
for _, requiredness := range m.Migrations {
|
||||
if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
|
||||
continue
|
||||
}
|
||||
for _, rename := range m.Migrations {
|
||||
if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
|
||||
continue
|
||||
}
|
||||
if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
|
||||
return fmt.Errorf(
|
||||
"flag requiredness migration %s overlaps rename migration %s",
|
||||
requiredness.displayKey(),
|
||||
rename.displayKey(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -294,15 +316,6 @@ func (m FlagMigration) validate() error {
|
||||
if !isExactCommandPath(m.Command) {
|
||||
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
|
||||
}
|
||||
if !isExactFlagName(m.Legacy.Name) {
|
||||
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
|
||||
}
|
||||
if !isExactFlagName(m.Canonical.Name) {
|
||||
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
|
||||
}
|
||||
if m.Legacy.Name == m.Canonical.Name {
|
||||
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" {
|
||||
return fmt.Errorf("migration must include a non-empty reason")
|
||||
}
|
||||
@@ -312,6 +325,30 @@ func (m FlagMigration) validate() error {
|
||||
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
|
||||
switch m.EffectiveKind() {
|
||||
case FlagMigrationRename:
|
||||
return m.validateRename()
|
||||
case FlagMigrationRequirednessChange:
|
||||
return m.validateRequirednessChange()
|
||||
default:
|
||||
return fmt.Errorf("invalid kind %q", m.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (m FlagMigration) validateRename() error {
|
||||
if m.Flag != nil {
|
||||
return fmt.Errorf("flag rename must not declare flag requiredness fields")
|
||||
}
|
||||
if !isExactFlagName(m.Legacy.Name) {
|
||||
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
|
||||
}
|
||||
if !isExactFlagName(m.Canonical.Name) {
|
||||
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
|
||||
}
|
||||
if m.Legacy.Name == m.Canonical.Name {
|
||||
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
|
||||
}
|
||||
if err := m.Legacy.Before.validate("legacy before"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -392,6 +429,45 @@ func (m FlagMigration) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m FlagMigration) validateRequirednessChange() error {
|
||||
if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
|
||||
return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
|
||||
}
|
||||
if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
|
||||
return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
|
||||
}
|
||||
if err := m.Flag.Before.validate("flag before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Flag.After.validate("flag after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
|
||||
return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
|
||||
}
|
||||
if m.Flag.Before.Hidden || m.Flag.After.Hidden {
|
||||
return fmt.Errorf("flag requiredness migration flag must stay visible")
|
||||
}
|
||||
if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
|
||||
return fmt.Errorf("flag requiredness migration must not declare alias_of")
|
||||
}
|
||||
before := m.Flag.Before
|
||||
after := m.Flag.After
|
||||
before.Required = false
|
||||
after.Required = false
|
||||
if before != after {
|
||||
return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagMigrationSideName(side *FlagMigrationSide) string {
|
||||
if side == nil {
|
||||
return ""
|
||||
}
|
||||
return side.Name
|
||||
}
|
||||
|
||||
func (s FlagMigrationState) validate(label string) error {
|
||||
if !s.Present {
|
||||
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
|
||||
@@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
|
||||
}
|
||||
|
||||
func (m FlagMigration) key() string {
|
||||
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
|
||||
if m.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
|
||||
}
|
||||
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
|
||||
}
|
||||
|
||||
// EffectiveKind keeps manifests written before kinds were introduced valid.
|
||||
// An omitted kind is the original flag rename primitive.
|
||||
func (m FlagMigration) EffectiveKind() string {
|
||||
if m.Kind == "" {
|
||||
return FlagMigrationRename
|
||||
}
|
||||
return m.Kind
|
||||
}
|
||||
|
||||
func isExactCommandPath(path string) bool {
|
||||
@@ -428,9 +516,9 @@ func isExactFlagName(name string) bool {
|
||||
}
|
||||
|
||||
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
|
||||
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
|
||||
// records participate in the lifecycle check, but never authorize their own
|
||||
// interface change.
|
||||
// then consumes only exact, merge-base-owned flag rename or requiredness
|
||||
// migrations. Candidate-owned records participate in the lifecycle check, but
|
||||
// never authorize their own interface change.
|
||||
func CompareAllWithFlagMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
@@ -579,8 +667,8 @@ func evaluateFlagMigrationLifecycle(
|
||||
}
|
||||
}
|
||||
if allReferencesAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
if exists && proposed.State != FlagMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
|
||||
if !exists {
|
||||
return flagMigrationPartial
|
||||
}
|
||||
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
|
||||
if state == migration.Flag.Before {
|
||||
return flagMigrationBefore
|
||||
}
|
||||
if state == migration.Flag.After {
|
||||
return flagMigrationAfter
|
||||
}
|
||||
return flagMigrationPartial
|
||||
}
|
||||
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
|
||||
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
|
||||
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
|
||||
@@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange(
|
||||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
|
||||
continue
|
||||
}
|
||||
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
|
||||
return true
|
||||
}
|
||||
@@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange(
|
||||
}
|
||||
|
||||
func (m FlagMigration) displayKey() string {
|
||||
if m.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
|
||||
}
|
||||
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
|
||||
}
|
||||
|
||||
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
|
||||
wantErr: "back to pending",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt becomes stale",
|
||||
name: "inert consumed receipt cannot revert to pending",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "is stale after all references reached the after state",
|
||||
candidate: pending,
|
||||
wantErr: "back to pending",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt starts pending",
|
||||
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(
|
||||
t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
consumed,
|
||||
consumed,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||||
t.Fatalf("stale optional migration error = %v", err)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
|
||||
]
|
||||
}`
|
||||
|
||||
const validRequirednessMigrationManifestJSON = `{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func optionalFlagMigrationManifestJSON() string {
|
||||
manifest := strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
|
||||
}
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unknown kind",
|
||||
input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
|
||||
wantErr: "invalid kind",
|
||||
},
|
||||
{
|
||||
name: "missing flag name",
|
||||
input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
|
||||
wantErr: "exact flag",
|
||||
},
|
||||
{
|
||||
name: "invalid before state",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||
`"before": {"present": true, "type": "string", "scope": "anything"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "flag before present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "invalid after state",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "flag after present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "before already required",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "optional to required",
|
||||
},
|
||||
{
|
||||
name: "after remains optional",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "optional to required",
|
||||
},
|
||||
{
|
||||
name: "type drift",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must preserve every flag attribute except requiredness",
|
||||
},
|
||||
{
|
||||
name: "visibility drift",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must stay visible",
|
||||
},
|
||||
{
|
||||
name: "alias relation",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must not declare alias_of",
|
||||
},
|
||||
{
|
||||
name: "rename fields cannot be combined",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"flag": {`,
|
||||
`"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must not declare legacy or canonical",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
missingFlag := FlagMigration{
|
||||
Kind: FlagMigrationRequirednessChange,
|
||||
Command: "dws report entry submit",
|
||||
State: FlagMigrationPending,
|
||||
Reason: "Missing exact flag state.",
|
||||
}
|
||||
if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
|
||||
t.Fatalf("missing requiredness flag error = %v", err)
|
||||
}
|
||||
|
||||
rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rename.Migrations[0].Kind = FlagMigrationRename
|
||||
rename.Migrations[0].Flag = &FlagMigrationSide{
|
||||
Name: "unrelated",
|
||||
Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
|
||||
After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
|
||||
}
|
||||
if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
|
||||
t.Fatalf("rename with requiredness fields error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
|
||||
pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
migration := pending.Migrations[0]
|
||||
before := requirednessMigrationSnapshot(migration, false)
|
||||
after := requirednessMigrationSnapshot(migration, true)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
|
||||
t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
partial := after
|
||||
partial.Commands = append([]Command(nil), after.Commands...)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags = append(
|
||||
[]Flag(nil),
|
||||
after.Commands[len(after.Commands)-1].LocalFlags...,
|
||||
)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
|
||||
if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
|
||||
t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
|
||||
}
|
||||
|
||||
beforeWithUnrelated := before
|
||||
beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
|
||||
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
|
||||
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
|
||||
Flag{Name: "unrelated", Type: "string"},
|
||||
)
|
||||
afterWithUnrelated := after
|
||||
afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
|
||||
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
|
||||
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
|
||||
Flag{Name: "unrelated", Type: "string", Required: true},
|
||||
)
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
afterWithUnrelated,
|
||||
map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
|
||||
t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
|
||||
}
|
||||
|
||||
empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
if _, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||
t.Fatalf("candidate self-authorization error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
|
||||
state := migration.Flag.Before
|
||||
if after {
|
||||
state = migration.Flag.After
|
||||
}
|
||||
flag := Flag{
|
||||
Name: migration.Flag.Name,
|
||||
Type: state.Type,
|
||||
Required: state.Required,
|
||||
Hidden: state.Hidden,
|
||||
Shorthand: state.Shorthand,
|
||||
NoOpt: state.NoOpt,
|
||||
}
|
||||
command := testCommandWithFlagScopes(migration.Command, nil, nil)
|
||||
if state.Scope == "inherited" {
|
||||
command.InheritedFlags = []Flag{flag}
|
||||
} else {
|
||||
command.LocalFlags = []Flag{flag}
|
||||
}
|
||||
return testSnapshot(testCommand("dws"), command)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
|
||||
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
|
||||
if err != nil {
|
||||
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
|
||||
if err == nil || !strings.Contains(err.Error(), "trailing") {
|
||||
t.Fatalf("trailing JSON error = %v", err)
|
||||
}
|
||||
|
||||
requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overlap := manifest
|
||||
overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
rename := overlap.Migrations[0]
|
||||
rename.Command = requiredness.Migrations[0].Command
|
||||
rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
|
||||
rename.Legacy.After.AliasOf = rename.Canonical.Name
|
||||
overlap.Migrations[0] = rename
|
||||
overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
|
||||
if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
|
||||
t.Fatalf("requiredness and rename overlap error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/whiteboard"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_ding.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "ding" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 {
|
||||
t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts))
|
||||
}
|
||||
wantCompatibilityVisible := map[string]bool{
|
||||
"+list": true,
|
||||
"+recall-personal": true,
|
||||
"+send-personal": true,
|
||||
}
|
||||
wantCompatibilityAvailability := map[string]shortcut.Availability{
|
||||
"+list": shortcut.AvailabilityUnavailable,
|
||||
"+recall-personal": shortcut.AvailabilityAvailable,
|
||||
"+send-personal": shortcut.AvailabilityAvailable,
|
||||
}
|
||||
public, unavailable, compatibilityVisible := 0, 0, 0
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk || item.Availability != availability {
|
||||
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) {
|
||||
t.Errorf("%s public availability/visibility drift", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
|
||||
}
|
||||
} else {
|
||||
if record.CompatibilityVisible {
|
||||
compatibilityVisible++
|
||||
if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) {
|
||||
t.Errorf("%s compatibility-visible boundary drift", command)
|
||||
}
|
||||
} else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) {
|
||||
t.Errorf("%s nonpublic shortcut visibility drift", command)
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) {
|
||||
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_oa.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "oa" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 {
|
||||
t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts))
|
||||
}
|
||||
wantCompatibilityVisible := map[string]bool{
|
||||
"+list-cc": true,
|
||||
"+list-executed": true,
|
||||
"+list-forms": true,
|
||||
"+list-pending": true,
|
||||
"+list-submitted": true,
|
||||
"+my-initiated": true,
|
||||
}
|
||||
public, unavailable, compatibilityVisible := 0, 0, 0
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk || item.Availability != availability {
|
||||
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
|
||||
}
|
||||
if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) {
|
||||
t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) {
|
||||
t.Errorf("%s public availability/visibility drift", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
|
||||
}
|
||||
} else {
|
||||
if record.CompatibilityVisible {
|
||||
compatibilityVisible++
|
||||
if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) {
|
||||
t.Errorf("%s compatibility-visible boundary drift", command)
|
||||
}
|
||||
} else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) {
|
||||
t.Errorf("%s nonpublic shortcut visibility drift", command)
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) {
|
||||
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_report.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "report" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 {
|
||||
t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts))
|
||||
}
|
||||
public, unavailable := 0, 0
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk || item.Availability != availability {
|
||||
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) {
|
||||
t.Errorf("%s public availability/visibility drift", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
|
||||
}
|
||||
} else {
|
||||
if !item.Hidden || shortcut.InPublicCatalog("report", command) {
|
||||
t.Errorf("%s nonpublic shortcut is visible", command)
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
}
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 4 || unavailable != 0 {
|
||||
t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_sheet.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "sheet" {
|
||||
t.Fatalf("service=%q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "sheet" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := registered[item.Command]; duplicate {
|
||||
t.Fatalf("duplicate Sheet Shortcut %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 2 || len(source.Shortcuts) != 2 {
|
||||
t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("sheet", command) {
|
||||
t.Errorf("%s is not delivered as reviewed public", command)
|
||||
}
|
||||
if item.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk {
|
||||
t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command)
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) != 0 || len(stale) != 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_whiteboard.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "whiteboard" {
|
||||
t.Fatalf("service=%q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "whiteboard" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := registered[item.Command]; duplicate {
|
||||
t.Fatalf("duplicate Whiteboard Shortcut %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 2 || len(source.Shortcuts) != 2 {
|
||||
t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
public, unavailable, hidden := 0, 0, 0
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("whiteboard", command) {
|
||||
t.Errorf("%s did not enter the public surface", command)
|
||||
}
|
||||
if !record.Public {
|
||||
hidden++
|
||||
} else {
|
||||
public++
|
||||
}
|
||||
if item.Availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
}
|
||||
if item.Availability != shortcut.AvailabilityAvailable || record.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk {
|
||||
t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Interface == nil || item.Contract.Interface.Availability != "available" || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
|
||||
t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command)
|
||||
}
|
||||
}
|
||||
if public != 2 || unavailable != 0 || hidden != 0 {
|
||||
t.Fatalf("Whiteboard source/public/unavailable/hidden=2/%d/%d/%d, want 2/2/0/0", public, unavailable, hidden)
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) != 0 || len(stale) != 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
}
|
||||
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
|
||||
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
|
||||
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
|
||||
}
|
||||
var serviceEmptySentinel map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, page, err = eventListProject(serviceEmptySentinel)
|
||||
if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
|
||||
t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err)
|
||||
}
|
||||
|
||||
for name, payload := range map[string]string{
|
||||
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"bad collection": `{"success":true,"result":{"events":{}}}`,
|
||||
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
|
||||
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
|
||||
"missing pagination": `{"success":true,"result":{"events":[]}}`,
|
||||
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
|
||||
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"bad collection": `{"success":true,"result":{"events":{}}}`,
|
||||
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
|
||||
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
|
||||
"unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
|
||||
"sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
|
||||
"missing pagination": `{"success":true,"result":{"events":[]}}`,
|
||||
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var data map[string]any
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
|
||||
)
|
||||
|
||||
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
||||
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
|
||||
if !ok {
|
||||
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
||||
}
|
||||
if key == "events" {
|
||||
items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
|
||||
}
|
||||
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package calendarcompat contains narrow compatibility adapters for observed
|
||||
// Calendar wire shapes shared by the calendar and smart Shortcut packages.
|
||||
package calendarcompat
|
||||
|
||||
// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
|
||||
// page sentinel. Some deployments encode an exhausted empty page as one object
|
||||
// whose collection fields are all null instead of events:[]. Only that exact
|
||||
// terminal shape is normalized; arbitrary id-less or mixed event rows remain
|
||||
// available to the strict caller validation and must fail closed.
|
||||
func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
|
||||
if len(items) != 1 || container == nil {
|
||||
return items, false
|
||||
}
|
||||
hasMore, ok := container["hasMore"].(bool)
|
||||
if !ok || hasMore || hasNonEmptyCursor(container) {
|
||||
return items, false
|
||||
}
|
||||
placeholder, ok := items[0].(map[string]any)
|
||||
knownNullFields := map[string]struct{}{
|
||||
"attendees": {},
|
||||
"categories": {},
|
||||
"meetingRooms": {},
|
||||
"reminders": {},
|
||||
}
|
||||
if !ok || len(placeholder) != len(knownNullFields) {
|
||||
return items, false
|
||||
}
|
||||
for key, value := range placeholder {
|
||||
if _, known := knownNullFields[key]; !known || value != nil {
|
||||
return items, false
|
||||
}
|
||||
}
|
||||
return []any{}, true
|
||||
}
|
||||
|
||||
func hasNonEmptyCursor(container map[string]any) bool {
|
||||
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
|
||||
value, present := container[key]
|
||||
if !present || value == nil || value == "" {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package calendarcompat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
|
||||
sentinel := []any{map[string]any{
|
||||
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
|
||||
}}
|
||||
if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
|
||||
t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
|
||||
}
|
||||
|
||||
for name, items := range map[string][]any{
|
||||
"empty object": {map[string]any{}},
|
||||
"missing fields": {map[string]any{"attendees": nil}},
|
||||
"unknown field": {map[string]any{
|
||||
"attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
|
||||
}},
|
||||
"non-null field": {map[string]any{
|
||||
"attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
|
||||
}},
|
||||
"mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
|
||||
t.Fatalf("invalid sentinel accepted: %#v", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, container := range map[string]map[string]any{
|
||||
"missing terminal evidence": {},
|
||||
"more pages": {"hasMore": true},
|
||||
"cursor": {"hasMore": false, "nextCursor": "next"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
|
||||
t.Fatalf("non-terminal sentinel accepted with %#v", container)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package ding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const (
|
||||
dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection."
|
||||
dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete."
|
||||
dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts."
|
||||
)
|
||||
|
||||
type dingPageEvidence struct {
|
||||
HasMore bool
|
||||
Next string
|
||||
}
|
||||
|
||||
func dingReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func dingWriteSafety(destructive bool) contract.SafetySpec {
|
||||
effect, risk := "write", "medium"
|
||||
if destructive {
|
||||
effect, risk = "destructive", "high"
|
||||
}
|
||||
return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"}
|
||||
}
|
||||
|
||||
func dingListResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`),
|
||||
SensitivePaths: []string{"messages.content", "messages.senderNick"},
|
||||
}
|
||||
}
|
||||
|
||||
func dingReceiverResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`),
|
||||
SensitivePaths: []string{"receivers.receiverNick"},
|
||||
}
|
||||
}
|
||||
|
||||
func dingPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "ding " + command
|
||||
availability, reason := contract.InterfaceAvailable, dingCompositeReason
|
||||
if !available {
|
||||
availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason
|
||||
}
|
||||
return corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Parameters: params,
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "ding", Name: name, CanonicalPath: "ding." + name,
|
||||
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{intent},
|
||||
AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"},
|
||||
Examples: examples,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dingResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
func dingRequireResult(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空")
|
||||
}
|
||||
success, present := data["success"]
|
||||
if !present {
|
||||
return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态")
|
||||
}
|
||||
succeeded, ok := success.(bool)
|
||||
if !ok {
|
||||
return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success))
|
||||
}
|
||||
if !succeeded {
|
||||
message := "DING 服务明确返回失败"
|
||||
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
message = strings.TrimSpace(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, dingResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok || result == nil {
|
||||
return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) {
|
||||
raw, present := result[field]
|
||||
if !present {
|
||||
return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field))
|
||||
}
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw))
|
||||
}
|
||||
items := make([]map[string]any, 0, len(values))
|
||||
for index, value := range values {
|
||||
item, ok := value.(map[string]any)
|
||||
if !ok || len(item) == 0 {
|
||||
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index))
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) {
|
||||
value, ok := item[field].(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func dingOptionalString(item map[string]any, field string) (string, error) {
|
||||
value, present := item[field]
|
||||
if !present || value == nil {
|
||||
return "", nil
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value)
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
func dingInteger(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(typed), true
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) {
|
||||
result, err := dingRequireResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, dingPageEvidence{}, err
|
||||
}
|
||||
items, err := dingRequireObjectCollection(result, operation, "dingMessages")
|
||||
if err != nil {
|
||||
return nil, dingPageEvidence{}, err
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
|
||||
if identityErr != nil {
|
||||
return nil, dingPageEvidence{}, identityErr
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
projected := map[string]any{"openDingId": id}
|
||||
for source, target := range map[string]string{
|
||||
"dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId",
|
||||
"sendTime": "sendTime", "senderNick": "senderNick",
|
||||
} {
|
||||
value, valueErr := dingOptionalString(item, source)
|
||||
if valueErr != nil {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error()))
|
||||
}
|
||||
if value != "" {
|
||||
projected[target] = value
|
||||
}
|
||||
}
|
||||
messages = append(messages, projected)
|
||||
}
|
||||
hasMoreValue, present := result["hasMore"]
|
||||
if !present {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整")
|
||||
}
|
||||
hasMore, ok := hasMoreValue.(bool)
|
||||
if !ok {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值")
|
||||
}
|
||||
page := dingPageEvidence{HasMore: hasMore}
|
||||
if !hasMore {
|
||||
if next, exists := result["nextCursor"]; exists && next != nil {
|
||||
if numeric, valid := dingInteger(next); !valid || numeric != 0 {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页")
|
||||
}
|
||||
}
|
||||
return messages, page, nil
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进")
|
||||
}
|
||||
next, valid := dingInteger(result["nextCursor"])
|
||||
if !valid || next <= 0 {
|
||||
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor")
|
||||
}
|
||||
page.Next = strconv.FormatInt(next, 10)
|
||||
return messages, page, nil
|
||||
}
|
||||
|
||||
func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) {
|
||||
result, err := dingRequireResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := dingRequireObjectCollection(result, operation, "receivers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态")
|
||||
}
|
||||
receivers := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
|
||||
if identityErr != nil {
|
||||
return nil, identityErr
|
||||
}
|
||||
if id != expectedID {
|
||||
return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致")
|
||||
}
|
||||
status, ok := dingInteger(item["confirmedStatus"])
|
||||
if !ok {
|
||||
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index))
|
||||
}
|
||||
nick, nickErr := dingRequiredString(item, operation, "receiverNick", index)
|
||||
if nickErr != nil {
|
||||
return nil, nickErr
|
||||
}
|
||||
receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick})
|
||||
}
|
||||
return receivers, nil
|
||||
}
|
||||
|
||||
func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error {
|
||||
payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore}
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
if page.HasMore {
|
||||
payload["nextCursor"] = page.Next
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
pagination, err := output.NewPagination(!page.HasMore, page.Next)
|
||||
if err != nil {
|
||||
return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error())
|
||||
}
|
||||
meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func dingUnavailable(operation string) error {
|
||||
return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("shortcut_registry"),
|
||||
apperrors.WithFailureStage("capability_gate"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason("write_fixture_unavailable"),
|
||||
)
|
||||
}
|
||||
+154
-175
@@ -1,226 +1,205 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are
|
||||
// copied verbatim from internal/helpers/ding.go. Some tools route to the "im"
|
||||
// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product.
|
||||
// Package ding registers strict declarative shortcuts for DingTalk DING.
|
||||
package ding
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var List = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+list", Product: "im",
|
||||
Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "ding",
|
||||
Name: "shortcut_list",
|
||||
CanonicalPath: "ding.shortcut_list",
|
||||
CLIPath: "ding +list",
|
||||
PrimaryCLIPath: "ding +list",
|
||||
},
|
||||
Description: "查询 DING 消息列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询 DING 消息列表",
|
||||
UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws ding +list"},
|
||||
},
|
||||
},
|
||||
Description: "查询 DING 消息列表",
|
||||
Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: dingReadSafety(),
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Contract: dingContract(
|
||||
"+list", "查询 DING 消息列表",
|
||||
"需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
|
||||
true, dingListResult(), dingPagination(),
|
||||
[]contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}},
|
||||
"dws ding +list --type ALL", "dws ding +list --type DELETED",
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}},
|
||||
Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("cursor") < 0 {
|
||||
return apperrors.NewValidation("--cursor 不能小于 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。
|
||||
msgType := rt.Str("type")
|
||||
if msgType == "" {
|
||||
msgType = "ALL"
|
||||
}
|
||||
params := map[string]any{"type": msgType}
|
||||
params := map[string]any{"type": rt.Str("type")}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Int("cursor")
|
||||
}
|
||||
return rt.CallMCP("list_ding_messages", params)
|
||||
data, err := rt.CallMCPData("im", "list_ding_messages", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages, page, err := dingProjectMessages(data, "im/list_ding_messages")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if page.HasMore {
|
||||
current := int64(rt.Int("cursor"))
|
||||
next, parseErr := strconv.ParseInt(page.Next, 10, 64)
|
||||
if parseErr != nil || next <= current {
|
||||
return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进")
|
||||
}
|
||||
}
|
||||
return outputDingPage(rt, messages, page)
|
||||
},
|
||||
}
|
||||
|
||||
var ReceiverStatus = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+receiver-status", Product: "im",
|
||||
Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "ding",
|
||||
Name: "shortcut_receiver_status",
|
||||
CanonicalPath: "ding.shortcut_receiver_status",
|
||||
CLIPath: "ding +receiver-status",
|
||||
PrimaryCLIPath: "ding +receiver-status",
|
||||
},
|
||||
Description: "查询 DING 消息接收人已读状态",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询 DING 消息接收人已读状态",
|
||||
UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
|
||||
},
|
||||
Description: "查询 DING 消息接收人已读状态",
|
||||
Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: dingReadSafety(),
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Contract: dingContract(
|
||||
"+receiver-status", "查询 DING 消息接收人已读状态",
|
||||
"已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
|
||||
true, dingReceiverResult(), nil,
|
||||
[]contract.ParamDecl{{Name: "ding-id", Property: "dingId"}},
|
||||
"dws ding +receiver-status --ding-id <DING_ID>",
|
||||
),
|
||||
Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
|
||||
Tips: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("list_ding_receiver_status", map[string]any{
|
||||
"openDingId": rt.Str("ding-id"),
|
||||
})
|
||||
const operation = "im/list_ding_receiver_status"
|
||||
data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"count": len(receivers), "receivers": receivers}
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
meta := &output.Meta{Count: output.NewCount(len(receivers))}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
|
||||
},
|
||||
}
|
||||
|
||||
var SendPersonal = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+send-personal", Product: "im",
|
||||
Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "ding",
|
||||
Name: "shortcut_send_personal",
|
||||
CanonicalPath: "ding.shortcut_send_personal",
|
||||
CLIPath: "ding +send-personal",
|
||||
PrimaryCLIPath: "ding +send-personal",
|
||||
},
|
||||
Description: "以本人身份发送 DING 给指定人",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以本人身份发送 DING 给指定人",
|
||||
UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws ding +send-personal --users <VALUES> --content <CONTENT>"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
var SendPersonal = compatibilityDingWrite(
|
||||
"+send-personal", "以本人身份发送 DING 给指定人",
|
||||
"仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。",
|
||||
shortcut.RiskWrite, false,
|
||||
[]shortcut.Flag{
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
[]contract.ParamDecl{
|
||||
{Name: "users", Property: "users", InterfaceType: "array"},
|
||||
{Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"},
|
||||
},
|
||||
"dws ding +send-personal --users <VALUES> --content <CONTENT>",
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
remindType, err := dingPersonalRemindType(rt.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"receiverOpenDingTalkIds": rt.StrSlice("users"),
|
||||
"content": rt.Str("content"),
|
||||
"remindType": rt.Str("type"),
|
||||
"remindType": remindType,
|
||||
}
|
||||
if rt.Changed("uuid") {
|
||||
params["uuid"] = rt.Str("uuid")
|
||||
}
|
||||
return rt.CallMCP("send_personal_ding", params)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var SendByMessage = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+send-by-message", Product: "im",
|
||||
Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
var SendByMessage = unavailableDingWrite(
|
||||
"+send-by-message", "针对某条消息发起 DING 提醒",
|
||||
"需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。",
|
||||
shortcut.RiskWrite, false,
|
||||
[]shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"openMessageId": rt.Str("message-id"),
|
||||
"receiverOpenDingTalkIds": rt.StrSlice("users"),
|
||||
"remindType": rt.Str("type"),
|
||||
}
|
||||
if rt.Changed("uuid") {
|
||||
params["uuid"] = rt.Str("uuid")
|
||||
}
|
||||
return rt.CallMCP("send_ding_by_message", params)
|
||||
[]contract.ParamDecl{
|
||||
{Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"},
|
||||
{Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"},
|
||||
{Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"},
|
||||
},
|
||||
"dws ding +send-by-message --group <GROUP_ID> --message-id <MESSAGE_ID> --users <VALUES>",
|
||||
)
|
||||
|
||||
var RecallPersonal = compatibilityDingWrite(
|
||||
"+recall-personal", "撤回本人发起的 DING",
|
||||
"仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。",
|
||||
shortcut.RiskHighWrite, true,
|
||||
[]shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
|
||||
[]contract.ParamDecl{{Name: "id", Property: "id"}},
|
||||
"dws ding +recall-personal --id <DING_ID>",
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")})
|
||||
},
|
||||
)
|
||||
|
||||
func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
declaration := dingContract(command, description, intent, true, nil, nil, params, example)
|
||||
declaration.Interface.Reason = dingCompatibilityWriteReason
|
||||
return shortcut.Shortcut{
|
||||
Service: "ding", Command: command, Product: "im",
|
||||
Description: description, Intent: intent, Risk: risk,
|
||||
Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
|
||||
Contract: declaration,
|
||||
Flags: flags, Tips: []string{example}, Execute: execute,
|
||||
}
|
||||
}
|
||||
|
||||
var RecallPersonal = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+recall-personal", Product: "im",
|
||||
Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "ding",
|
||||
Name: "shortcut_recall_personal",
|
||||
CanonicalPath: "ding.shortcut_recall_personal",
|
||||
CLIPath: "ding +recall-personal",
|
||||
PrimaryCLIPath: "ding +recall-personal",
|
||||
func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{
|
||||
Service: "ding", Command: command, Product: "im",
|
||||
Description: description, Intent: intent, Risk: risk,
|
||||
Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
|
||||
Contract: dingContract(command, description, intent, false, nil, nil, params, example),
|
||||
Flags: flags, Tips: []string{example},
|
||||
Execute: func(*shortcut.RuntimeContext) error {
|
||||
return dingUnavailable("ding/" + command)
|
||||
},
|
||||
Description: "撤回本人发起的 DING",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "撤回本人发起的 DING",
|
||||
UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws ding +recall-personal --id <ID>"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("recall_personal_ding", map[string]any{
|
||||
"openDingId": rt.Str("id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dingPersonalRemindType(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "app":
|
||||
return "APP", nil
|
||||
case "sms":
|
||||
return "SMS", nil
|
||||
case "call":
|
||||
return "PHONE", nil
|
||||
default:
|
||||
return "", apperrors.NewValidation("--type 必须是 app、sms 或 call")
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
List,
|
||||
ReceiverStatus,
|
||||
SendPersonal,
|
||||
SendByMessage,
|
||||
RecallPersonal,
|
||||
)
|
||||
shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package ding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type dingCoverageCaller struct {
|
||||
responses map[string][]string
|
||||
failures map[string]error
|
||||
history []string
|
||||
arguments []map[string]any
|
||||
}
|
||||
|
||||
func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
|
||||
caller.history = append(caller.history, tool)
|
||||
caller.arguments = append(caller.arguments, arguments)
|
||||
if err := caller.failures[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queue := caller.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("missing DING fake response for " + tool)
|
||||
}
|
||||
caller.responses[tool] = queue[1:]
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
|
||||
}
|
||||
|
||||
func (*dingCoverageCaller) Format() string { return "json" }
|
||||
func (*dingCoverageCaller) DryRun() bool { return false }
|
||||
func (*dingCoverageCaller) Fields() string { return "" }
|
||||
func (*dingCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd, cmd.Execute()
|
||||
}
|
||||
|
||||
func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.Bytes(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) {
|
||||
for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} {
|
||||
if declaration.Contract.Empty() {
|
||||
t.Errorf("%s lacks Contract", declaration.Command)
|
||||
}
|
||||
if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
|
||||
t.Errorf("%s lacks Safety", declaration.Command)
|
||||
}
|
||||
if declaration.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} {
|
||||
if declaration.Contract.Result == nil {
|
||||
t.Errorf("%s lacks Result", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
|
||||
t.Errorf("%s is not interface-available", declaration.Command)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} {
|
||||
if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
|
||||
t.Errorf("%s compatibility write safety/interface drift", declaration.Command)
|
||||
}
|
||||
}
|
||||
if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" {
|
||||
t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command)
|
||||
}
|
||||
if List.Contract.Pagination == nil {
|
||||
t.Fatal("+list lacks cursor pagination")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) {
|
||||
valid := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}},
|
||||
"hasMore": true,
|
||||
"nextCursor": float64(2),
|
||||
},
|
||||
}
|
||||
messages, page, err := dingProjectMessages(valid, "im/list_ding_messages")
|
||||
if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" {
|
||||
t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err)
|
||||
}
|
||||
empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}}
|
||||
if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore {
|
||||
t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err)
|
||||
}
|
||||
fixtures := map[string]map[string]any{
|
||||
"empty": {},
|
||||
"missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
|
||||
"wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
|
||||
"false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
|
||||
"missing result": {"success": true},
|
||||
"missing collection": {"success": true, "result": map[string]any{"hasMore": false}},
|
||||
"wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}},
|
||||
"bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}},
|
||||
"missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}},
|
||||
"duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}},
|
||||
"missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}},
|
||||
"wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}},
|
||||
"empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}},
|
||||
"missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}},
|
||||
"wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}},
|
||||
"conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}},
|
||||
"wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}},
|
||||
}
|
||||
for name, fixture := range fixtures {
|
||||
if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil {
|
||||
t.Errorf("%s returned success: %#v", name, projected)
|
||||
}
|
||||
}
|
||||
if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") {
|
||||
t.Fatalf("remote failure message was not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) {
|
||||
for name, test := range map[string]struct {
|
||||
value any
|
||||
want int64
|
||||
}{
|
||||
"float64": {value: float64(1), want: 1},
|
||||
"int": {value: int(2), want: 2},
|
||||
"int64": {value: int64(3), want: 3},
|
||||
"json number": {value: json.Number("4"), want: 4},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got, ok := dingInteger(test.value); !ok || got != test.want {
|
||||
t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, value := range map[string]any{
|
||||
"nan": math.NaN(),
|
||||
"fraction": 1.5,
|
||||
"overflow": math.MaxFloat64,
|
||||
"bad json number": json.Number("not-an-integer"),
|
||||
"wrong type": "1",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got, ok := dingInteger(value); ok {
|
||||
t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) {
|
||||
valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}
|
||||
items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1")
|
||||
if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) {
|
||||
t.Fatalf("valid receiver projection=%#v err=%v", items, err)
|
||||
}
|
||||
for name, fixture := range map[string]map[string]any{
|
||||
"empty": {},
|
||||
"missing collection": {"success": true, "result": map[string]any{}},
|
||||
"empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}},
|
||||
"bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}},
|
||||
"missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
|
||||
"identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
|
||||
"wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}},
|
||||
"missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}},
|
||||
} {
|
||||
if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil {
|
||||
t.Errorf("%s returned success: %#v", name, projected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) {
|
||||
t.Run("negative cursor validates before call", func(t *testing.T) {
|
||||
caller := &dingCoverageCaller{responses: map[string][]string{}}
|
||||
if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 {
|
||||
t.Fatalf("negative cursor error=%v calls=%v", err, caller.history)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transport failure", func(t *testing.T) {
|
||||
caller := &dingCoverageCaller{
|
||||
responses: map[string][]string{},
|
||||
failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")},
|
||||
}
|
||||
if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" {
|
||||
t.Fatalf("transport error=%v calls=%v", err, caller.history)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projection failure", func(t *testing.T) {
|
||||
caller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`},
|
||||
}}
|
||||
if _, err := runDingCoverage(t, List, caller); err == nil {
|
||||
t.Fatal("malformed empty continuation returned success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stalled cursor", func(t *testing.T) {
|
||||
caller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`},
|
||||
}}
|
||||
if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") {
|
||||
t.Fatalf("stalled cursor error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("advancing cursor legacy output", func(t *testing.T) {
|
||||
legacy := List
|
||||
legacy.OutputRollout = output.RolloutLegacyOnly
|
||||
caller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`},
|
||||
}}
|
||||
stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy advancing page: %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false {
|
||||
t.Fatalf("legacy payload=%#v decode=%v", payload, err)
|
||||
}
|
||||
if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 {
|
||||
t.Fatalf("legacy cursor arguments=%#v", caller.arguments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid unified page evidence", func(t *testing.T) {
|
||||
cmd := corecmd.New(shortcut.FromShortcut(List))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, List)
|
||||
if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil {
|
||||
t.Fatal("continuation without next cursor returned success")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) {
|
||||
t.Run("transport failure", func(t *testing.T) {
|
||||
caller := &dingCoverageCaller{
|
||||
responses: map[string][]string{},
|
||||
failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")},
|
||||
}
|
||||
if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
|
||||
t.Fatal("receiver transport failure returned success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projection failure", func(t *testing.T) {
|
||||
caller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`},
|
||||
}}
|
||||
if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
|
||||
t.Fatal("empty receiver response returned success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy output", func(t *testing.T) {
|
||||
legacy := ReceiverStatus
|
||||
legacy.OutputRollout = output.RolloutLegacyOnly
|
||||
caller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
|
||||
}}
|
||||
stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1")
|
||||
if err != nil {
|
||||
t.Fatalf("receiver legacy output: %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) {
|
||||
t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err)
|
||||
}
|
||||
if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" {
|
||||
t.Fatalf("receiver arguments=%#v", caller.arguments)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) {
|
||||
listCaller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`},
|
||||
}}
|
||||
cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
|
||||
t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := envelope["data"].(map[string]any)
|
||||
if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true {
|
||||
t.Fatalf("list unified projection=%#v", data)
|
||||
}
|
||||
if strings.Join(listCaller.history, ",") != "list_ding_messages" {
|
||||
t.Fatalf("list call history=%v", listCaller.history)
|
||||
}
|
||||
|
||||
receiverCaller := &dingCoverageCaller{responses: map[string][]string{
|
||||
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
|
||||
}}
|
||||
if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" {
|
||||
t.Fatalf("receiver call history=%v", receiverCaller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) {
|
||||
args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}
|
||||
unconfirmed := &dingCoverageCaller{responses: map[string][]string{}}
|
||||
err := runDingRoot(t, SendByMessage, unconfirmed, false, args...)
|
||||
if err == nil || len(unconfirmed.history) != 0 {
|
||||
t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history)
|
||||
}
|
||||
confirmed := &dingCoverageCaller{responses: map[string][]string{}}
|
||||
err = runDingRoot(t, SendByMessage, confirmed, true, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 {
|
||||
t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) {
|
||||
for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} {
|
||||
got, err := dingPersonalRemindType(input)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"}
|
||||
unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 {
|
||||
t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history)
|
||||
}
|
||||
confirmedSend := &dingCoverageCaller{responses: map[string][]string{
|
||||
"send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`},
|
||||
}}
|
||||
if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil {
|
||||
t.Fatalf("confirmed send failed: %v", err)
|
||||
}
|
||||
if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 {
|
||||
t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments)
|
||||
}
|
||||
sendParams := confirmedSend.arguments[0]
|
||||
users, _ := sendParams["receiverOpenDingTalkIds"].([]string)
|
||||
if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" {
|
||||
t.Fatalf("confirmed send params=%#v", sendParams)
|
||||
}
|
||||
|
||||
invalidType := &dingCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 {
|
||||
t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history)
|
||||
}
|
||||
|
||||
recallArgs := []string{"--id", "ding-fixture"}
|
||||
unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 {
|
||||
t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history)
|
||||
}
|
||||
confirmedRecall := &dingCoverageCaller{responses: map[string][]string{
|
||||
"recall_personal_ding": {`{"success":true,"result":true}`},
|
||||
}}
|
||||
if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil {
|
||||
t.Fatalf("confirmed recall failed: %v", err)
|
||||
}
|
||||
if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" {
|
||||
t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
service := &cobra.Command{Use: "ding"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
argv := []string{"ding", declaration.Command}
|
||||
argv = append(argv, args...)
|
||||
if yes {
|
||||
argv = append(argv, "--yes")
|
||||
}
|
||||
root.SetArgs(argv)
|
||||
return root.Execute()
|
||||
}
|
||||
@@ -176,15 +176,17 @@ var Fetch = shortcut.Shortcut{
|
||||
Command: "+fetch",
|
||||
Product: productDoc,
|
||||
Description: "读取完整或局部文档内容,并按 detail 控制保真度",
|
||||
Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。",
|
||||
Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: docContract(
|
||||
"+fetch", "读取完整或局部文档内容,并按 detail 控制保真度",
|
||||
"当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。",
|
||||
"当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。",
|
||||
[]string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`},
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
contract.ParamDecl{Name: "query", Property: "keyword"},
|
||||
contract.ParamDecl{Name: "password", Property: "password"},
|
||||
contract.ParamDecl{Name: "version", Property: "historyVersion"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL;" + fetchTargetConstraint},
|
||||
@@ -198,12 +200,17 @@ var Fetch = shortcut.Shortcut{
|
||||
{Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"},
|
||||
{Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"},
|
||||
{Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"},
|
||||
{Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"},
|
||||
{Name: "password", Type: shortcut.FlagString, Desc: "互联网公开文档开启密码保护时的访问密码;普通文档无需传入"},
|
||||
{Name: "revision", Type: shortcut.FlagInt, Desc: "不支持;revision 是文档编辑版本号(JSONML 读取响应返回、供 +update --expected-revision 条件写使用),不是历史版本号"},
|
||||
{Name: "version", Type: shortcut.FlagInt, Desc: "读取指定历史版本(版本号从 doc +version-list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版"},
|
||||
},
|
||||
Tips: []string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Changed("revision") {
|
||||
return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision")
|
||||
return apperrors.NewValidation("--revision 不支持:revision 是文档编辑版本号(doc read --content-format jsonml 响应返回,供 doc +update --expected-revision 条件写使用),不是历史版本号;读历史版本请用 --version")
|
||||
}
|
||||
if rt.Changed("version") && rt.Int("version") < 0 {
|
||||
return apperrors.NewValidation("--version 必须为非负整数历史版本号(0 表示初始版本,从 doc +version-list 获取)")
|
||||
}
|
||||
if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" {
|
||||
return apperrors.NewValidation("--scope keyword 时必须提供 --keyword")
|
||||
@@ -240,6 +247,12 @@ var Fetch = shortcut.Shortcut{
|
||||
if rt.Changed("max-depth") {
|
||||
params["maxDepth"] = rt.Int("max-depth")
|
||||
}
|
||||
if rt.Changed("version") {
|
||||
params["historyVersion"] = rt.Int("version")
|
||||
}
|
||||
if value := rt.Str("password"); value != "" {
|
||||
params["password"] = value
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, "get_document_content", params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user