Compare commits

...
Author SHA1 Message Date
Dennis 446f27d96e fix(doc): trust only explicit inserted block IDs 2026-08-14 10:14:20 +08:00
Dennis ef81a1a59c test(doc): complete readback coverage evidence 2026-08-14 10:14:17 +08:00
Dennis d8ed8c2147 fix(doc): verify inline media from jsonml readback 2026-08-14 10:14:14 +08:00
Dennis f3ae03373e fix(doc): harden mutation readback verification 2026-08-14 10:14:11 +08:00
github-actions[bot] f55f9bc3a6 Merge pull request #998 from DingTalk-Real-AI/codex/open-dingtalk-id-format-routing
fix(chat): harden openDingTalkId target routing
2026-08-14 01:48:08 +08:00
栩朝 be001949e4 test(chat): complete sender routing coverage 2026-08-14 01:31:16 +08:00
栩朝 bcd91aca1f test(chat): align time defaults with current open ID format 2026-08-14 01:10:17 +08:00
栩朝 12e6632692 fix(chat): preserve sender identity uncertainty 2026-08-14 01:01:53 +08:00
栩朝 a5111f486b fix(chat): harden openDingTalkId target routing 2026-08-14 01:01:53 +08:00
github-actions[bot] 7a9348f9aa Merge pull request #973 from xlb1130/feat/85378080-chat-message-time-defaults
feat(chat): default message query time ranges
2026-08-14 00:01:32 +08:00
长真 6c78db7467 fix(chat): document Shanghai time message default 2026-08-13 23:37:29 +08:00
xlb1130 b539e15e6d Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 23:29:03 +08:00
github-actions[bot] c9426622f0 Merge pull request #985 from xlb1130/chore/85411130-idempotency-key-ledger
chore(policy): add chat message send idempotency flag ledger
2026-08-13 23:13:51 +08:00
长真 5b01f29f2f Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 22:35:02 +08:00
xlb1130 5efb6210b0 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 22:32:50 +08:00
长真 113e084a8d fix(chat): align default message time timezone 2026-08-13 22:31:57 +08:00
xlb1130 a76492e16e Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 22:16:29 +08:00
github-actions[bot] a0cc9b4b51 Merge pull request #942 from avicii-chen/feat/list-filter
feat(drive): add drive list --type/--start/--end client-side filtering
2026-08-13 14:06:12 +00:00
juanxincai 45df573d0e Merge branch 'main' into feat/list-filter 2026-08-13 21:30:04 +08:00
github-actions[bot] 608edfa309 Merge pull request #974 from DingTalk-Real-AI/fix/param-hallucination
feat(cli): standardize Doc and Drive parameter aliases
2026-08-13 13:17:11 +00:00
长真 e8ef510d3a Merge remote-tracking branch 'origin/chore/85411130-idempotency-key-ledger' into chore/85411130-idempotency-key-ledger 2026-08-13 21:09:19 +08:00
长真 8c6266f158 chore(policy): consume idempotency flag migration 2026-08-13 21:06:44 +08:00
长真 91f0fb7b11 fix(chat): declare idempotency key alias 2026-08-13 21:03:03 +08:00
xlb1130 570d2e6756 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 20:59:30 +08:00
长真 c3ffb9c831 fix(chat): validate list-all time defaults 2026-08-13 20:57:40 +08:00
juanxincai e6821176a4 Merge branch 'main' into feat/list-filter 2026-08-13 20:55:23 +08:00
长真 44857449d6 Merge remote-tracking branch 'upstream/main' into chore/85411130-idempotency-key-ledger 2026-08-13 20:50:03 +08:00
克谨 29f2f1c813 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 20:48:23 +08:00
xlb1130 d21f18af04 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 20:48:16 +08:00
github-actions[bot] dd604455cc Merge pull request #990 from xlb1130/chore/85411130-idempotency-key-ledger-only
chore(policy): add idempotency flag migration ledger
2026-08-13 12:46:25 +00:00
克谨 26049a158a Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 20:41:21 +08:00
xlb1130 95f9d168f1 Merge branch 'main' into chore/85411130-idempotency-key-ledger-only 2026-08-13 20:26:51 +08:00
juanxincai 6d58520f57 Merge branch 'main' into feat/list-filter 2026-08-13 20:18:35 +08:00
github-actions[bot] 91090a13b9 chore: update formula for v1.0.58 [skip ci] 2026-08-13 11:51:41 +00:00
juanxincai 395712490d Merge branch 'main' into feat/list-filter 2026-08-13 19:38:56 +08:00
chichuan 29c00341fa Merge pull request #997 from DingTalk-Real-AI/codex/fix-sealed-stable-compat
fix(ci): preserve delivered stable compatibility baseline
2026-08-13 19:26:10 +08:00
juanxincai 2eef6fdaa2 Merge branch 'main' into feat/list-filter 2026-08-13 19:14:48 +08:00
chichuan 14a2175434 fix(ci): preserve delivered stable compatibility baseline 2026-08-13 19:04:57 +08:00
卷心菜 973671bdf1 chore: trigger auto CR re-review 2026-08-13 18:36:38 +08:00
xlb1130 2d24f74980 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 18:03:28 +08:00
长真 90278ab2fc test(chat): cover message default end window 2026-08-13 18:02:50 +08:00
chichuan 1b06d0105a Merge pull request #995 from DingTalk-Real-AI/codex/changelog-v1.0.58
docs: seal changelog for v1.0.58
2026-08-13 17:53:40 +08:00
chichuan 18fad57bbe docs: seal changelog for v1.0.58 2026-08-13 17:44:56 +08:00
xlb1130 658f1e8e34 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 17:40:20 +08:00
长真 a32608f964 fix(chat): use local time for message defaults 2026-08-13 17:39:32 +08:00
github-actions[bot] c3ef04988b chore: update beta formula for v1.0.58-beta.6 [skip ci] 2026-08-13 09:10:23 +00:00
xlb1130 1f2fbca4de Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 16:34:19 +08:00
john 76d54d6df6 Merge pull request #993 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.6
docs: seal v1.0.58-beta.6 changelog
2026-08-13 16:33:34 +08:00
xlb1130 58a8dddf31 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 16:33:03 +08:00
克谨 0dc6735da2 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 16:25:39 +08:00
chichuan a36189d31e docs: seal v1.0.58-beta.6 changelog 2026-08-13 16:19:04 +08:00
长真 e46c4d0d71 Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 16:16:39 +08:00
长真 35f399e2cf fix(chat): use Shanghai time for message defaults 2026-08-13 16:16:00 +08:00
chichuan d52d16dba4 Merge pull request #987 from DingTalk-Real-AI/codex/fix-release-seal-ci-path
ci: fast-path release seal fragment archival
2026-08-13 16:15:00 +08:00
xlb1130 9ff74c852a Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 15:56:17 +08:00
克谨 9be59ddfec Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 15:50:15 +08:00
chichuan d77fa91c69 Merge remote-tracking branch 'origin/main' into codex/fix-release-seal-ci-path 2026-08-13 15:37:08 +08:00
长真 9eeb0681ff test(chat): cover list-all time defaults in platform gate 2026-08-13 15:31:35 +08:00
chichuan 9ea527a7c4 ci: reject truncated release seal file lists 2026-08-13 15:25:11 +08:00
chichuan f78f1b83e7 Merge pull request #991 from typefield/agent/fix-release-validator
fix: align package verifier with Agent skill roots
2026-08-13 15:24:10 +08:00
卷心菜 75bd518447 fix(drive): honor --type folder in --latest top-N and harden filter mutexes 2026-08-13 15:19:46 +08:00
玉澜 3a6fa9a00c Merge remote-tracking branch 'origin/agent/fix-release-validator' into agent/fix-release-validator 2026-08-13 15:04:12 +08:00
玉澜 dc43d0d6d4 Merge remote-tracking branch 'upstream/main' into agent/fix-release-validator 2026-08-13 15:02:03 +08:00
chichuan bb69ed76df Merge branch 'main' into agent/fix-release-validator 2026-08-13 15:01:15 +08:00
chichuan a26b16b30e test: scope release seal env assertions 2026-08-13 14:58:44 +08:00
玉澜 e0c9b4910d fix: align package verifier with Agent skill roots 2026-08-13 14:57:51 +08:00
xlb1130 c118a6a795 Merge branch 'main' into chore/85411130-idempotency-key-ledger-only 2026-08-13 14:48:52 +08:00
xlb1130 3d6c93196a Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 14:44:37 +08:00
长真 dc762dc6e3 Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 14:43:07 +08:00
长真 45a80185f6 fix(chat): pass explicit list-all times through 2026-08-13 14:42:26 +08:00
chichuan a1dc997004 Merge branch 'main' into codex/fix-release-seal-ci-path 2026-08-13 14:41:29 +08:00
chichuan b525497da8 fix: pass release seal classification to policy 2026-08-13 14:31:50 +08:00
卷心菜 273a3ab5dd chore: migrate drive list changelog entries to release fragments 2026-08-13 14:12:13 +08:00
卷心菜 647bdb251c test(drive): cover drive list filter/pattern edge branches 2026-08-13 14:12:13 +08:00
卷心菜 9c59206d2f feat(drive): add drive list --type/--start/--end client-side filtering 2026-08-13 14:12:13 +08:00
长真 9edc587e96 chore(policy): to #85411130 add idempotency flag migration ledger 2026-08-13 13:55:53 +08:00
克谨 db2caf6544 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 13:46:08 +08:00
chichuan ea9e31a59f Merge pull request #986 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.5
release: seal v1.0.58-beta.5 changelog
2026-08-13 13:45:14 +08:00
chichuan e58b85ea45 test: cover release seal CI fast path 2026-08-13 13:44:23 +08:00
chichuan e3fef0b6d4 ci: fast-path release seal fragment archival 2026-08-13 13:34:43 +08:00
xlb1130 54535bec11 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 13:29:00 +08:00
长真 d32bbe009d fix(chat): expose idempotency key for message send 2026-08-13 13:28:00 +08:00
chichuan c7236a1844 release: seal v1.0.58-beta.5 changelog 2026-08-13 13:18:21 +08:00
xlb1130 0ea3d9810e Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 13:11:31 +08:00
github-actions[bot] 0a063e3ebd Merge pull request #979 from wxianfeng/feat/85384225-agent-version-ext
feat: forward Agent version and extension context
2026-08-13 05:07:40 +00:00
xlb1130 1e13413f79 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 13:03:01 +08:00
chichuan e19c54f77e Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 12:47:15 +08:00
长真 891dde7d03 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 12:28:59 +08:00
github-actions[bot] fbc34509f8 Merge pull request #970 from DingTalk-Real-AI/codex/im-page-all
feat(chat): unify shortcut auto-pagination controls
2026-08-13 04:18:43 +00:00
长真 def6ed4d2f test(chat): align list-all time expectations 2026-08-13 12:16:16 +08:00
长真 7bf8ce79bd chore(policy): to #85411130 add idempotency flag migration ledger 2026-08-13 12:07:06 +08:00
昊淼 ad0cf639c4 Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 11:49:27 +08:00
Dennis 2f8e136dc0 fix(chat): fail closed on bounded legacy pages 2026-08-13 11:35:39 +08:00
Dennis fdbd11e0ea docs(changelog): add IM pagination release note 2026-08-13 11:35:37 +08:00
Dennis d07bf39586 fix(chat): bound automatic page delays 2026-08-13 11:35:35 +08:00
Dennis eee41a9b45 fix(chat): preserve safe pagination continuations 2026-08-13 11:35:33 +08:00
Dennis 896801634f fix(chat): preserve max-results visibility 2026-08-13 11:35:30 +08:00
Dennis a203572ee3 feat(chat): unify shortcut auto-pagination controls 2026-08-13 11:35:27 +08:00
克谨 ed6e7e493c Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 11:33:07 +08:00
github-actions[bot] 6c0ba91414 Merge pull request #963 from DingTalk-Real-AI/codex/drive-readback-verification
fix(drive): verify upload and move readback
2026-08-13 03:26:54 +00:00
长真 ec4a730287 Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 10:55:22 +08:00
长真 19a21b8f7e fix(chat): avoid explicit zone in list-all formatting 2026-08-13 10:54:51 +08:00
xlb1130 fa00da3507 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 10:53:55 +08:00
昊淼 472d3d321b Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 10:40:21 +08:00
克谨 a7ac4a264e Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 10:40:05 +08:00
john 0b68450709 Merge branch 'main' into codex/drive-readback-verification 2026-08-13 10:38:17 +08:00
john 346444ea38 Merge pull request #981 from typefield/fix/interface-integrity-ledger-validation
fix: restore interface migration ledger compatibility
2026-08-13 10:37:25 +08:00
wxianfeng 54dc8fadb7 feat: forward agent version and extension context 2026-08-13 10:13:04 +08:00
玉澜 b469bb127a docs: clarify hidden canonical promotion 2026-08-13 09:37:22 +08:00
玉澜 c6e810e4d9 fix: restore interface migration ledger compatibility 2026-08-13 09:34:48 +08:00
Dennis 98d03455b1 fix(drive): bind readback to requested objects 2026-08-13 00:12:07 +08:00
Dennis fad41d4d99 fix(drive): verify upload and move readback 2026-08-13 00:12:02 +08:00
xlb1130 1a9945f299 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 23:18:30 +08:00
长真 b92ac4db0f fix(chat): preserve list-all time format 2026-08-12 23:16:33 +08:00
克谨 9a3796c401 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 22:46:16 +08:00
克谨 6bf78f1783 test(ci): isolate app race partitions 2026-08-12 22:46:05 +08:00
github-actions[bot] 5fed80fc0f Merge pull request #966 from wxianfeng/feat/85349380-primary-param-governance
feat: support safe Primary flag rename governance (#85349380)
2026-08-12 14:40:01 +00:00
xlb1130 bb68baf0a9 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 22:39:59 +08:00
长真 657f9ee368 ci(test): extend app race shard timeout 2026-08-12 22:29:22 +08:00
昊淼 1727025f67 Merge branch 'main' into feat/85349380-primary-param-governance 2026-08-12 22:23:32 +08:00
克谨 221e42b103 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 21:23:34 +08:00
github-actions[bot] 715f5346da Merge pull request #975 from DingTalk-Real-AI/dws_optimization
fix(skill): clarify document-space routing in doc/drive/wiki descript…
2026-08-12 13:21:57 +00:00
克谨 bcc324cc8f Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 21:02:57 +08:00
克谨 cf8dd167a4 fix(cli): preserve scoped space aliases 2026-08-12 20:49:57 +08:00
长真 d82e12d09e Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-12 20:44:37 +08:00
长真 30f3273a17 fix(chat): validate message list-all time range 2026-08-12 20:43:56 +08:00
xlb1130 3e362fb3d1 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 20:23:20 +08:00
长真 d40a22aeb0 fix(chat): default start from explicit message end 2026-08-12 20:17:10 +08:00
克谨 b29a12abbf test: harden parameter alias safety gates 2026-08-12 19:05:11 +08:00
克谨 65bedd5f8c Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 18:35:18 +08:00
克谨 388ae0d37b ci: shard parameter alias changes 2026-08-12 17:59:54 +08:00
克谨 07c5d25d55 fix(cli): cover doc search time aliases 2026-08-12 17:14:23 +08:00
克谨 e9bbfdd20c Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 17:00:18 +08:00
长真 1f7d8c16bd Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 16:42:38 +08:00
长真 9c14d9a6e1 fix(chat): repair message time defaults checks 2026-08-12 16:42:27 +08:00
克谨 fd26152141 docs(release): note Doc and Drive parameter aliases 2026-08-12 16:24:03 +08:00
克谨 a53971b146 feat(cli): standardize Doc and Drive parameter aliases 2026-08-12 16:23:17 +08:00
长真 56bb50913b feat(chat): default message query time ranges 2026-08-12 16:23:13 +08:00
wxianfeng bfd836064d feat: support optional flag rename governance to #85349380 2026-08-12 13:59:07 +08:00
118 changed files with 10543 additions and 919 deletions
+12
View File
@@ -0,0 +1,12 @@
---
category: Added
---
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
file|folder`, `--start`, and `--end` for client-side filtering by node type
and modification time on both the pan and workspace routes. Filtering runs
a bounded full scan of the target directory (2000-entry cap, reported via
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
+12
View File
@@ -0,0 +1,12 @@
---
category: Fixed
---
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
single-layer pan route now filters the returned page by name pattern; the
flag was previously accepted but silently ignored.
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
ranks the filtered entries (folders included when `--type folder` is set)
instead of unconditionally dropping folders, so the documented combination
returns the most recently modified folders rather than an empty list.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
@@ -0,0 +1,8 @@
---
category: Added
---
- **Agent version and extended context passthrough** (Aone 85384225) — adds
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
Discovery, or third-party plugins.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
+9
View File
@@ -19,3 +19,12 @@
# Cache directory (optional, defaults to ~/.dws/cache)
# DWS_CACHE_DIR=
# Agent integration metadata (optional; ordinary non-plugin MCP requests only)
# DWS_AGENT_PRODUCT=example-agent
# DWS_AGENT_HOST=cloud
# DWS_AGENT_VER=0.1.5
# DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
# The outer single quotes above are shell syntax and are not part of the value.
# DWS_AGENT_EXT is sensitive caller-declared JSON (max 8 KiB); never put real
# tokens in committed files or use this metadata alone for authentication.
+113 -16
View File
@@ -24,6 +24,7 @@ jobs:
pull-requests: read
outputs:
changelog_only: ${{ steps.classify.outputs.changelog_only }}
release_seal_only: ${{ steps.classify.outputs.release_seal_only }}
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
docs_only: ${{ steps.classify.outputs.docs_only }}
full_suite: ${{ steps.classify.outputs.full_suite }}
@@ -39,6 +40,7 @@ jobs:
with:
script: |
let changelogOnly = false;
let releaseSealOnly = false;
let changelogChanged = false;
let docsOnly = false;
let fullSuite = context.eventName === 'push';
@@ -151,6 +153,12 @@ jobs:
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
// Parameter aliases are reduced against the live command tree.
// Their reverse-dependency set is too large for one focused
// race job, so use the existing full-suite shards.
filename === 'internal/cli/param_concepts.json' ||
filename === 'internal/cli/param_concepts.schema.json' ||
filename === 'internal/cli/param_aliases_generated.go' ||
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
@@ -162,6 +170,43 @@ jobs:
filename === 'go.mod' ||
filename === 'go.sum'
);
const isExactReleaseSeal = (candidates) => {
const changelog = candidates.filter(
({ filename, status, previous_filename }) =>
filename === 'CHANGELOG.md' &&
status === 'modified' &&
!previous_filename
);
if (changelog.length !== 1 || candidates.length < 2) {
return false;
}
let version = '';
return candidates.every((file) => {
if (file.filename === 'CHANGELOG.md') {
return file.status === 'modified' && !file.previous_filename;
}
if (
file.status !== 'renamed' ||
typeof file.filename !== 'string' ||
typeof file.previous_filename !== 'string' ||
file.additions !== 0 ||
file.deletions !== 0
) {
return false;
}
const target = file.filename.match(
/^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/
);
if (!target || file.previous_filename !== `.changes/${target[2]}`) {
return false;
}
if (version && version !== target[1]) {
return false;
}
version = target[1];
return true;
});
};
const classifyFiles = (complete) => {
const paths = files.flatMap(({ filename, previous_filename }) =>
[filename, previous_filename].filter(
@@ -248,19 +293,26 @@ jobs:
);
}
changelogOnly =
const exactChangelogDiff =
files.length === 1 &&
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
releaseSealOnly = isExactReleaseSeal(files);
changelogOnly = exactChangelogDiff || releaseSealOnly;
changelogChanged = files.some(
({ filename, previous_filename }) =>
filename === 'CHANGELOG.md' ||
previous_filename === 'CHANGELOG.md'
);
classifyFiles(true);
if (releaseSealOnly) {
fullSuite = false;
}
fastPathTrust = changelogOnly
? 'exact pull-request revision and synthetic merge policy'
? releaseSealOnly
? 'exact release-seal fragment archival and synthetic merge policy'
: 'exact CHANGELOG-only revision and synthetic merge policy'
: docsOnly
? 'documentation-only focused admission'
: fullSuite
@@ -295,7 +347,8 @@ jobs:
per_page: 100,
});
files = Array.isArray(comparison.files) ? comparison.files : [];
classifyFiles(files.length < 300);
const pushFilesComplete = files.length < 300;
classifyFiles(pushFilesComplete);
const linearFromValidatedTip =
comparison.status === 'ahead' &&
comparison.merge_base_commit?.sha === expectedBefore &&
@@ -307,8 +360,10 @@ jobs:
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
const exactReleaseSealDiff =
pushFilesComplete && isExactReleaseSeal(files);
if (linearFromValidatedTip && exactChangelogDiff) {
if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) {
const requiredContexts = [
'Lint',
'Test',
@@ -359,9 +414,15 @@ jobs:
if (missing.length === 0 && nonSuccess.length === 0) {
changelogOnly = true;
releaseSealOnly = exactReleaseSealDiff;
changelogChanged = true;
if (releaseSealOnly) {
fullSuite = false;
}
fastPathTrust =
`exact CHANGELOG-only successor of validated ${expectedBefore}`;
releaseSealOnly
? `exact release-seal successor of validated ${expectedBefore}`
: `exact CHANGELOG-only successor of validated ${expectedBefore}`;
} else {
fastPathTrust =
'predecessor Code Admission is not fully successful; ' +
@@ -376,6 +437,7 @@ jobs:
}
core.setOutput('changelog_only', String(changelogOnly));
core.setOutput('release_seal_only', String(releaseSealOnly));
core.setOutput('changelog_changed', String(changelogChanged));
core.setOutput('docs_only', String(docsOnly));
core.setOutput('full_suite', String(fullSuite));
@@ -387,7 +449,8 @@ jobs:
await core.summary
.addHeading('Code Admission scope')
.addRaw(`- Event: \`${context.eventName}\`\n`)
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
.addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`)
.addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`)
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
@@ -402,7 +465,14 @@ jobs:
- name: Record CHANGELOG-only fast path
if: steps.classify.outputs.changelog_only == 'true'
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
env:
RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }}
run: |
if [ "$RELEASE_SEAL_ONLY" = true ]; then
echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY"
else
echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Record documentation-only fast path
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
@@ -495,7 +565,8 @@ jobs:
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
runs-on: ubuntu-latest
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
# app runs several independently bounded processes; cli/smoke need headroom
# beyond go test -timeout for setup + assembly.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -531,10 +602,19 @@ jobs:
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
if [ "$TEST_SHARD" = "app" ]; then
# A single long-lived app test process retains every constructed
# command tree in framework registries. Isolate Schema assembly and
# bounded name ranges so each process releases that state on exit.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
exit 0
fi
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
# give them a dedicated package timeout on slower hosted runners.
timeout_budget=12m
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
if [ "$TEST_SHARD" = "cli" ] ||
[ "$TEST_SHARD" = "smoke" ]; then
timeout_budget=15m
fi
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
@@ -1256,6 +1336,7 @@ jobs:
env:
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
@@ -1282,7 +1363,7 @@ jobs:
fi
mode=--content-only
if [ "$CHANGELOG_ONLY" = true ]; then
if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then
mode=--fast-path
fi
./scripts/policy/check-changelog-pr.sh \
@@ -1294,25 +1375,41 @@ jobs:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
- name: Validate trusted main metadata-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_AFTER_SHA: ${{ github.event.after }}
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
run: |
set -eu
test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || {
echo "checked-out push revision does not match event after SHA" >&2
exit 1
}
mode=--fast-path
if [ "$RELEASE_SEAL_ONLY" = true ]; then
mode=--content-only
fi
./scripts/policy/check-changelog-pr.sh \
--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
if [ "$RELEASE_SEAL_ONLY" = true ]; then
./scripts/policy/check-release-fragments.sh \
"$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
fi
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
env:
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
run: |
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
if [ "$RELEASE_SEAL_ONLY" = true ]; then
echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
else
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
fi
- name: Validate scoped policy
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
+34
View File
@@ -2645,6 +2645,40 @@ jobs:
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
# The sealed candidate tag is intentionally visible while its GitHub
# authority is checked above. Compatibility must instead discover the
# previous delivered stable tag, so hide only this verified candidate
# from this isolated runner's local tag namespace.
- name: Prepare delivered-stable compatibility ref view
if: ${{ matrix.check == 'compatibility' }}
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
run: |
set -eu
test -n "$RELEASE_VERSION"
test -n "$RELEASE_COMMIT"
test -n "$RELEASE_TAG_OBJECT"
test -n "$PREVIOUS_STABLE"
test -n "$PREVIOUS_STABLE_COMMIT"
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
exit 2
fi
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
- name: Set up Go
uses: actions/setup-go@v5
with:
+58
View File
@@ -6,6 +6,64 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58] - 2026-08-13
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
### Changed
- **Expanded collaborative workflows** — adds full AI Table, Sheet, Minutes,
approval-event, Drive-comment, document export, and CSV workflow support,
including safer validation, explicit confirmation for writes, and
machine-readable completion receipts.
- **More capable Chat operations** — adds robot image/file messages, toolbar
management, streaming-card mentions, automatic pagination controls, and
clearer post-send ID, Markdown-image, paging, and result-shape guidance.
- **Reliable Agent and CLI contracts** — expands Agent-visible Chat and
Minutes commands, aligns bundled skills, improves schema/result envelopes,
and hardens parameter, pagination, runtime-token, and write-result
verification so ambiguous or incomplete operations fail closed.
- **Multi-skill install and upgrade** — makes the multi-skill layout the
default for fresh installs and upgrades while preserving an explicit legacy
mono option.
- **Safer release delivery** — strengthens release-equivalent compatibility,
sealing, package verification, and evaluation-dispatch checks for more
reliable cross-platform releases.
## [1.0.58-beta.6] - 2026-08-13
### Fixed
- **npm package verification for multi-skill installs** (#991) — aligns the
release verifier with the installer’s concrete Agent skill-root selection,
preventing valid multi-skill package layouts from failing release delivery.
### Changed
- **Release-seal CI classification** (#987) — recognizes the reviewed
CHANGELOG-and-fragment archival shape while retaining release-contract and
lifecycle validation, reducing unrelated CI work for release-seal PRs.
## [1.0.58-beta.5] - 2026-08-13
### Added
- **Agent version and extended context passthrough** (Aone 85384225) — adds
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
Discovery, or third-party plugins.
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
### Changed
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
## [1.0.58-beta.4] - 2026-08-12
### Added
+11 -11
View File
@@ -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.58-beta.4"
version "1.0.58-beta.6"
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.58-beta.4/dws-darwin-arm64.tar.gz"
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57"
version "1.0.58"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
end
def install
+4 -4
View File
@@ -1,6 +1,6 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
@@ -50,7 +50,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
@@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -145,7 +145,7 @@ legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interfa
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
+43
View File
@@ -7,6 +7,8 @@
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_AGENT_VER` | Optional caller-declared Agent version / 可选、由调用方声明的 Agent 版本。After trimming surrounding ASCII spaces/tabs, the value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9._+-]*$`; a non-empty valid value is sent as `x-dws-agent-ver`, while unset or empty values omit the Header. / 去除首尾 ASCII 空格和 Tab 后,值不得超过 64 字节且必须匹配上述格式;合法非空值通过 `x-dws-agent-ver` 发送,未设置或空值则省略请求头 |
| `DWS_AGENT_EXT` | Optional caller-declared Agent extended context / 可选、由调用方声明的 Agent 扩展上下文。The value must be a UTF-8 JSON object no larger than 8 KiB, is compacted before being sent as the sensitive `x-dws-agent-ext` Header, and may use the recommended keys `umt`, `miniwua`, and `ua`; unknown keys remain supported. Unset or empty values omit the Header. / 值必须是 UTF-8 JSON 对象且不得超过 8 KiB,压缩后通过敏感请求头 `x-dws-agent-ext` 发送;推荐使用 `umt`、`miniwua`、`ua`,同时允许未知扩展键。未设置或空值则省略请求头 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -14,6 +16,47 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Version and Extended Context / Agent 版本与扩展上下文
`DWS_AGENT_VER` and `DWS_AGENT_EXT` are sent only on the CLI's ordinary,
non-plugin MCP requests. They do not change the standard HTTP `User-Agent` or
the separate `X-Cli-Version` that identifies the DWS CLI version, and they are
not forwarded to A2A, OAuth, Discovery, or third-party plugin requests.
`DWS_AGENT_EXT` is one JSON-object Header rather than a set of Headers. The
recommended keys are `umt`, `miniwua`, and `ua`, but the open-source CLI keeps
the object extensible and does not enforce a key allowlist. For example, using
fictional, redacted values:
```bash
DWS_AGENT_VER=0.1.5
DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
```
The shell's outer single quotes group the JSON and are not part of the
environment-variable value. The CLI trims surrounding ASCII spaces/tabs,
omits either Header when its value is empty, and compacts EXT to a single-line
JSON object. A representative current payload is about 657 bytes, well below
the 8 KiB limit; integrations must still enforce the limit because values can
grow. EXT may contain sensitive device or runtime signals: the CLI masks it in
configuration and logs, and removes it on a cross-host redirect.
Both values are declared by the caller and are therefore forgeable. They can
support compatibility checks, diagnostics, and observability, but they are not
credentials or attestations and must never be sufficient on their own to
authenticate a caller or authorize access.
`DWS_AGENT_VER` 与 `DWS_AGENT_EXT` 仅随 CLI 发起的普通非插件 MCP 请求发送,不会
改变标准 HTTP `User-Agent`,也不会覆盖标识 DWS CLI 自身版本的 `X-Cli-Version`;
二者不会进入 A2A、OAuth、Discovery 或第三方插件请求。EXT 使用单个 JSON 对象
请求头,不拆成多个子请求头;推荐键为 `umt`、`miniwua`、`ua`,但开源 CLI 不限制
扩展键。Shell 示例中的外层单引号只用于保护 JSON,不属于环境变量值。当前典型负载
约为 657 字节,远低于 8 KiB 上限,但集成方仍须遵守大小限制。EXT 可能包含敏感的
设备或运行时信号,配置展示和日志会对其脱敏,跨主机重定向时也会移除该请求头。
这两个值都由调用方自行声明,可以被伪造;它们可用于兼容性判断、诊断和可观测性,
但不是凭据或可信证明,不能单独用于身份认证或访问授权。
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
+1
View File
@@ -16,6 +16,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.5
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0
+2
View File
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+1 -1
View File
@@ -165,7 +165,7 @@ func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
}
}
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
func TestCrossPlatformCoverageRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT_ECHO"
t.Setenv(envDWSAgentHost, invalidValue)
+248
View File
@@ -0,0 +1,248 @@
// 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 app
import (
"bytes"
"context"
"encoding/json"
"os"
"regexp"
"strings"
"unicode"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
envDWSAgentVersion = "DWS_AGENT_VER"
envDWSAgentExt = "DWS_AGENT_EXT"
maxAgentVersionBytes = 64
maxAgentExtensionBytes = 8 * 1024
)
var agentVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`)
type agentMetadataSnapshot struct {
version string
ext string
versionErr error
extErr error
}
type agentMetadataSnapshotContextKey struct{}
func (snapshot agentMetadataSnapshot) validationError() error {
if snapshot.versionErr != nil {
return snapshot.versionErr
}
return snapshot.extErr
}
func contextWithAgentMetadataSnapshot(ctx context.Context, snapshot agentMetadataSnapshot) context.Context {
return context.WithValue(ctx, agentMetadataSnapshotContextKey{}, snapshot)
}
func agentMetadataSnapshotFromContext(ctx context.Context) (agentMetadataSnapshot, bool) {
if ctx == nil {
return agentMetadataSnapshot{}, false
}
snapshot, ok := ctx.Value(agentMetadataSnapshotContextKey{}).(agentMetadataSnapshot)
return snapshot, ok
}
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentVersion,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 版本;仅作为 x-dws-agent-ver 透传到非插件 MCP 请求",
Example: "1.2.3-beta.1+build.7",
})
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentExt,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 扩展上下文 JSON;仅作为 x-dws-agent-ext 透传到非插件 MCP 请求",
Example: `{"umt":"<token>","miniwua":"<token>","ua":"agent/1.0"}`,
Sensitive: true,
})
}
// parseAgentVersion normalizes and validates the caller-declared Agent
// version. Only surrounding ASCII spaces and tabs are trimmed. An unset or
// ASCII-whitespace-only value means "do not emit".
func parseAgentVersion(raw string) (string, error) {
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentVersionBytes || !agentVersionPattern.MatchString(value) {
return "", invalidAgentVersionError()
}
return value, nil
}
// parseAgentExt validates one generic JSON object and returns its compact
// one-line representation. Raw control characters other than horizontal tab
// are rejected before JSON parsing; escaped JSON control characters remain
// valid because they are safe on the HTTP header wire.
func parseAgentExt(raw string) (string, error) {
if len(raw) > maxAgentExtensionBytes || !utf8.ValidString(raw) {
return "", invalidAgentExtError()
}
for _, r := range raw {
if unicode.IsControl(r) && r != '\t' {
return "", invalidAgentExtError()
}
}
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(value)); err != nil {
return "", invalidAgentExtError()
}
compactBytes := compact.Bytes()
if len(compactBytes) > maxAgentExtensionBytes || len(compactBytes) < 2 || compactBytes[0] != '{' {
return "", invalidAgentExtError()
}
return compact.String(), nil
}
func invalidAgentVersionError() error {
return apperrors.NewValidation(
"DWS_AGENT_VER must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9._+-]*$",
apperrors.WithReason("invalid_agent_version"),
)
}
func invalidAgentExtError() error {
return apperrors.NewValidation(
"DWS_AGENT_EXT must be a UTF-8 JSON object of at most 8192 bytes without raw control characters",
apperrors.WithReason("invalid_agent_ext"),
)
}
// readAgentMetadataSnapshot reads both environment variables from one
// os.Environ snapshot, then parses them once. Normal CLI execution retains the
// validated result through the invocation so hooks and transport observe the
// same pair even in an embedding process that mutates its environment.
func readAgentMetadataSnapshot() agentMetadataSnapshot {
var rawVersion, rawExt string
for _, entry := range os.Environ() {
key, value, _ := strings.Cut(entry, "=")
switch key {
case envDWSAgentVersion:
rawVersion = value
case envDWSAgentExt:
rawExt = value
}
}
version, versionErr := parseAgentVersion(rawVersion)
ext, extErr := parseAgentExt(rawExt)
return agentMetadataSnapshot{
version: version,
ext: ext,
versionErr: versionErr,
extErr: extErr,
}
}
// removeAgentMetadataHeaders removes every case variant so edition or
// credential hooks cannot smuggle MCP-only metadata into shared transports.
func removeAgentMetadataHeaders(headers map[string]string) {
for key := range headers {
if strings.EqualFold(key, transport.HeaderAgentVersion) ||
strings.EqualFold(key, transport.HeaderAgentExt) {
delete(headers, key)
}
}
}
// applyAgentMetadataHeaders applies validated environment values as the final
// authority for non-plugin MCP requests. Invalid values are omitted on
// library paths that bypass root validation; normal CLI execution rejects
// them before hooks or network access.
func applyAgentMetadataHeaders(headers map[string]string) map[string]string {
return applyAgentMetadataSnapshot(headers, readAgentMetadataSnapshot())
}
func applyAgentMetadataSnapshot(headers map[string]string, snapshot agentMetadataSnapshot) map[string]string {
removeAgentMetadataHeaders(headers)
if (snapshot.versionErr != nil || snapshot.version == "") && (snapshot.extErr != nil || snapshot.ext == "") {
return headers
}
if headers == nil {
headers = make(map[string]string)
}
if snapshot.versionErr == nil && snapshot.version != "" {
headers[transport.HeaderAgentVersion] = snapshot.version
}
if snapshot.extErr == nil && snapshot.ext != "" {
headers[transport.HeaderAgentExt] = snapshot.ext
}
return headers
}
// resolveMCPRequestHeaders adds Agent version and extension metadata only to
// the built-in DingTalk MCP request path. Shared identity consumers (notably
// A2A) continue to use resolveIdentityHeaders and never receive these fields.
func resolveMCPRequestHeaders() map[string]string {
return resolveMCPRequestHeadersWithSnapshot(readAgentMetadataSnapshot())
}
func resolveMCPRequestHeadersWithSnapshot(snapshot agentMetadataSnapshot) map[string]string {
return applyAgentMetadataSnapshot(resolveIdentityHeaders(), snapshot)
}
// resolveMCPRequestHeadersForInvocation resolves one immutable Header snapshot
// for an invocation. The helper-only mcp-meta server performs endpoint
// discovery rather than an ordinary MCP product call, so caller-declared
// Agent metadata must not cross that boundary.
func resolveMCPRequestHeadersForInvocation(invocation executor.Invocation, snapshots ...agentMetadataSnapshot) map[string]string {
headers := resolveIdentityHeaders()
if strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), mcpMetaServerID) {
return headers
}
snapshot := readAgentMetadataSnapshot()
if len(snapshots) > 0 {
snapshot = snapshots[0]
}
return applyAgentMetadataSnapshot(headers, snapshot)
}
// pluginRequestHeaders returns a private, sanitized copy of plugin-owned
// Headers. Third-party plugins never receive DWS-owned Agent metadata, even if
// their manifest tries to declare the reserved Header names itself.
func pluginRequestHeaders(pluginAuth *PluginAuth) map[string]string {
if pluginAuth == nil || len(pluginAuth.ExtraHeaders) == 0 {
return nil
}
headers := make(map[string]string, len(pluginAuth.ExtraHeaders))
for key, value := range pluginAuth.ExtraHeaders {
headers[key] = value
}
removeAgentMetadataHeaders(headers)
if len(headers) == 0 {
return nil
}
return headers
}
+743
View File
@@ -0,0 +1,743 @@
// 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 app
import (
"context"
"encoding/json"
"errors"
"io"
"maps"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
outputpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageParseAgentVersion(t *testing.T) {
var nilContext context.Context
if _, ok := agentMetadataSnapshotFromContext(nilContext); ok {
t.Fatal("nil context unexpectedly contained Agent metadata")
}
wantSnapshot := agentMetadataSnapshot{version: "context-version", ext: "{}"}
if got, ok := agentMetadataSnapshotFromContext(contextWithAgentMetadataSnapshot(context.Background(), wantSnapshot)); !ok || got != wantSnapshot {
t.Fatalf("context Agent metadata = %#v, %v; want %#v", got, ok, wantSnapshot)
}
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "semantic version", raw: "1.2.3", want: "1.2.3"},
{name: "pre-release and build", raw: " v1.2.3-rc.1+build_7 ", want: "v1.2.3-rc.1+build_7"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentVersionBytes), want: strings.Repeat("a", maxAgentVersionBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentVersion(tc.raw)
if err != nil {
t.Fatalf("parseAgentVersion() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentVersion() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "leading punctuation", raw: "-1.2.3"},
{name: "internal space", raw: "1.2 3"},
{name: "slash", raw: "1.2/3"},
{name: "line feed", raw: "1.2.3\n"},
{name: "carriage return", raw: "1.2.3\r"},
{name: "NUL", raw: "1.2\x003"},
{name: "Unicode", raw: "版本1"},
{name: "too long", raw: strings.Repeat("a", maxAgentVersionBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentVersion(tc.raw)
if err == nil || got != "" {
t.Fatalf("parseAgentVersion(%q) = %q, %v; want validation error", tc.raw, got, err)
}
assertAgentMetadataValidationError(t, err, "invalid_agent_version", tc.raw)
})
}
}
func TestCrossPlatformCoverageParseAgentExt(t *testing.T) {
boundary := `{"x":"` + strings.Repeat("a", maxAgentExtensionBytes-8) + `"}`
if len(boundary) != maxAgentExtensionBytes {
t.Fatalf("invalid boundary fixture size: %d", len(boundary))
}
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "empty object", raw: "{}", want: "{}"},
{name: "compact generic object", raw: " \t{ \"umt\": \"masked\",\t \"nested\": { \"ok\": true }, \"unknown\": [1, 2] }\t ", want: `{"umt":"masked","nested":{"ok":true},"unknown":[1,2]}`},
{name: "Unicode value", raw: `{"ua":"千问办公/1.0"}`, want: `{"ua":"千问办公/1.0"}`},
{name: "escaped control remains safe", raw: `{"ua":"line\nnext"}`, want: `{"ua":"line\nnext"}`},
{name: "maximum length", raw: boundary, want: boundary},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentExt(tc.raw)
if err != nil {
t.Fatalf("parseAgentExt() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentExt() = %q, want %q", got, tc.want)
}
})
}
invalidUTF8 := string([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'})
invalid := []struct {
name string
raw string
}{
{name: "too long raw input", raw: strings.Repeat(" ", maxAgentExtensionBytes+1)},
{name: "invalid UTF-8", raw: invalidUTF8},
{name: "array", raw: `[]`},
{name: "string", raw: `"value"`},
{name: "number", raw: `1`},
{name: "boolean", raw: `true`},
{name: "null", raw: `null`},
{name: "malformed object", raw: `{"secret":"DO_NOT_ECHO"`},
{name: "trailing value", raw: `{} {}`},
{name: "line feed", raw: "{\n}"},
{name: "carriage return", raw: "{\r}"},
{name: "NUL", raw: "{\x00}"},
{name: "vertical tab", raw: "{\v}"},
{name: "form feed", raw: "{\f}"},
{name: "DEL", raw: "{\x7f}"},
{name: "C1 control", raw: "{\u0085}"},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentExt(tc.raw)
if err == nil || got != "" {
t.Fatalf("parseAgentExt() = %q, %v; want validation error", got, err)
}
assertAgentMetadataValidationError(t, err, "invalid_agent_ext", tc.raw)
})
}
}
func assertAgentMetadataValidationError(t *testing.T, err error, reason, raw string) {
t.Helper()
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != reason {
t.Fatalf("error = category %q reason %q, want validation/%s", appErr.Category, appErr.Reason, reason)
}
if strings.Contains(raw, "DO_NOT_ECHO") && strings.Contains(err.Error(), "DO_NOT_ECHO") {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestCrossPlatformCoverageAgentMetadataConfigRegistrationAndMasking(t *testing.T) {
items := configmeta.All()
var versionItem, extItem *configmeta.ConfigItem
for i := range items {
switch items[i].Name {
case envDWSAgentVersion:
versionItem = &items[i]
case envDWSAgentExt:
extItem = &items[i]
}
}
if versionItem == nil || extItem == nil {
t.Fatalf("Agent metadata config registration missing: version=%v ext=%v", versionItem != nil, extItem != nil)
}
if versionItem.Category != configmeta.CategoryExternal || versionItem.Sensitive {
t.Fatalf("version config metadata = %#v", *versionItem)
}
if extItem.Category != configmeta.CategoryExternal || !extItem.Sensitive {
t.Fatalf("extension config metadata = %#v", *extItem)
}
const canary = `{"umt":"SENSITIVE_CANARY"}`
t.Setenv(envDWSAgentExt, canary)
got, ok := configmeta.Resolve(envDWSAgentExt)
if !ok || got == "" || strings.Contains(got, "SENSITIVE_CANARY") || got == canary {
t.Fatalf("sensitive extension was not masked: value=%q ok=%v", got, ok)
}
t.Setenv(envDWSAgentVersion, "9.8.7")
command := newConfigListCommand()
var output strings.Builder
command.SetOut(&output)
command.SetArgs([]string{"--category", string(configmeta.CategoryExternal), "--show-values", "--json"})
if err := command.Execute(); err != nil {
t.Fatalf("config list failed: %v", err)
}
rawOutput := output.String()
if !json.Valid([]byte(rawOutput)) {
t.Fatalf("config list emitted invalid JSON: %q", rawOutput)
}
if !strings.Contains(rawOutput, envDWSAgentVersion) || !strings.Contains(rawOutput, envDWSAgentExt) {
t.Fatalf("config list omitted Agent metadata variables: %s", rawOutput)
}
if strings.Contains(rawOutput, "SENSITIVE_CANARY") || strings.Contains(rawOutput, canary) {
t.Fatalf("config list leaked Agent extension: %s", rawOutput)
}
}
func TestCrossPlatformCoverageResolveMCPRequestHeadersScopesAndFinalizesAgentMetadata(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, " 1.2.3-rc.1 ")
t.Setenv(envDWSAgentExt, " { \"umt\": \"masked\", \"unknown\": true } ")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers["X-Dws-Agent-Ver"] = "merge-must-not-win"
headers["X-Dws-Agent-Ext"] = `{"source":"merge"}`
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[transport.HeaderAgentVersion] = "credential-must-not-win"
headers[transport.HeaderAgentExt] = `{"source":"credential"}`
return headers
},
})
for name, headers := range map[string]map[string]string{
"shared identity": resolveIdentityHeaders(),
"A2A export": MCPIdentityHeaders(),
} {
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
t.Fatalf("%s leaked MCP-only metadata: %#v", name, headers)
}
}
headers := resolveMCPRequestHeaders()
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
t.Fatalf("%s = %q, want 1.2.3-rc.1", transport.HeaderAgentVersion, got)
}
if got := headers[transport.HeaderAgentExt]; got != `{"umt":"masked","unknown":true}` {
t.Fatalf("%s = %q", transport.HeaderAgentExt, got)
}
if got := headers[transport.HeaderVersion]; got != version {
t.Fatalf("%s = %q, want CLI version %q", transport.HeaderVersion, got, version)
}
if _, ok := headers["User-Agent"]; ok {
t.Fatal("Agent extension must not create or replace the standard User-Agent header")
}
for _, key := range []string{"umt", "miniwua", "ua", "x-dws-agent-umt", "x-dws-agent-miniwua", "x-dws-agent-ua"} {
if hasHeaderFold(headers, key) {
t.Fatalf("Agent extension was split into an extra header %q: %#v", key, headers)
}
}
// Library paths are best-effort: one invalid value is omitted without
// suppressing the other valid field or preserving hook-injected values.
t.Setenv(envDWSAgentExt, `{"secret":"DO_NOT_ECHO"`)
headers = resolveMCPRequestHeaders()
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
t.Fatalf("valid version was suppressed: %q", got)
}
if hasHeaderFold(headers, transport.HeaderAgentExt) {
t.Fatalf("invalid extension or hook value leaked: %#v", headers)
}
// Exercise the nil-map and empty-input library paths. An absent environment
// must not allocate a map, while an EXT-only value must allocate one and
// remain a single compact Header.
t.Setenv(envDWSAgentVersion, "")
t.Setenv(envDWSAgentExt, "")
if got := applyAgentMetadataHeaders(nil); got != nil {
t.Fatalf("empty metadata allocated headers: %#v", got)
}
t.Setenv(envDWSAgentExt, " { } ")
headers = applyAgentMetadataHeaders(nil)
if got := headers[transport.HeaderAgentExt]; got != "{}" {
t.Fatalf("EXT-only metadata = %q, want {}", got)
}
}
func TestCrossPlatformCoverageRootRejectsInvalidAgentMetadataBeforeEditionHook(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
tests := []struct {
name string
env string
value string
reason string
}{
{name: "version", env: envDWSAgentVersion, value: "DO_NOT ECHO", reason: "invalid_agent_version"},
{name: "extension", env: envDWSAgentExt, value: `{"secret":"DO_NOT_ECHO"`, reason: "invalid_agent_ext"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "")
t.Setenv(envDWSAgentExt, "")
t.Setenv(tc.env, tc.value)
headerHookCalled := false
afterHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headerHookCalled = true
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headerHookCalled = true
return headers
},
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
afterHookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatalf("root command accepted invalid %s", tc.env)
}
if headerHookCalled || afterHookCalled {
t.Fatalf("edition hook ran before %s validation", tc.env)
}
assertAgentMetadataValidationError(t, err, tc.reason, tc.value)
})
}
}
func TestCrossPlatformCoverageAgentMetadataProcessEntryValidationPrecedesRootConstruction(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want bool
}{
{name: "default JSON", args: []string{"version"}, want: true},
{name: "long JSON", args: []string{"version", "--format", "JSON"}, want: true},
{name: "long table", args: []string{"--format=table", "version"}, want: false},
{name: "short attached JSON", args: []string{"version", "-fjson"}, want: true},
{name: "short table", args: []string{"version", "-f", "table"}, want: false},
{name: "last wins", args: []string{"--format", "table", "version", "-f=json"}, want: true},
{name: "terminator", args: []string{"version", "--format", "table", "--", "--format", "json"}, want: false},
{name: "missing value", args: []string{"version", "--format"}, want: false},
} {
t.Run("presentation/"+tc.name, func(t *testing.T) {
if got := processArgsRequestJSON(tc.args); got != tc.want {
t.Fatalf("processArgsRequestJSON(%q) = %v, want %v", tc.args, got, tc.want)
}
})
}
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "")
sensitiveRaw := "{\"umt\":\"must-not-leak\"}\n"
t.Setenv(envDWSAgentExt, sensitiveRaw)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
extensionHookCalls := 0
edition.Override(&edition.Hooks{
Name: "presentation-test",
RegisterExtraCommands: func(*cobra.Command, edition.ToolCaller) {
extensionHookCalls++
},
VisibleProducts: func() []string {
extensionHookCalls++
return nil
},
StaticServers: func() []edition.ServerInfo {
extensionHookCalls++
return nil
},
})
oldArgs := os.Args
os.Args = []string{"dws", "version"}
t.Cleanup(func() { os.Args = oldArgs })
rootConstructed := false
preParseCalled := false
testseam.Swap(t, &rootNewRootCommandWithEngine, func(context.Context, *pipeline.Engine) *cobra.Command {
rootConstructed = true
return &cobra.Command{Use: "dws"}
})
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error {
preParseCalled = true
return nil
})
stderrFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stderr-*")
if err != nil {
t.Fatalf("create stderr capture: %v", err)
}
oldStderr := os.Stderr
os.Stderr = stderrFile
t.Cleanup(func() {
os.Stderr = oldStderr
_ = stderrFile.Close()
})
if code := Execute(); code == 0 {
t.Fatal("process entry accepted invalid Agent metadata")
}
if rootConstructed || preParseCalled {
t.Fatalf("invalid Agent metadata reached root hooks: constructed=%v preParse=%v", rootConstructed, preParseCalled)
}
if extensionHookCalls != 0 {
t.Fatalf("invalid Agent metadata executed %d extension hooks", extensionHookCalls)
}
if err := stderrFile.Sync(); err != nil {
t.Fatalf("sync stderr capture: %v", err)
}
stderrOutput, err := os.ReadFile(stderrFile.Name())
if err != nil {
t.Fatalf("read stderr capture: %v", err)
}
if strings.Contains(string(stderrOutput), "must-not-leak") || strings.Contains(string(stderrOutput), sensitiveRaw) {
t.Fatalf("process validation error leaked raw EXT: %q", stderrOutput)
}
if !json.Valid(stderrOutput) || !strings.Contains(string(stderrOutput), `"reason": "invalid_agent_ext"`) {
t.Fatalf("default JSON error presentation = %q", stderrOutput)
}
stdoutFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stdout-*")
if err != nil {
t.Fatalf("create stdout capture: %v", err)
}
oldStdout := os.Stdout
os.Stdout = stdoutFile
t.Cleanup(func() {
os.Stdout = oldStdout
_ = stdoutFile.Close()
})
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
if err := stdoutFile.Sync(); err != nil {
t.Fatalf("sync stdout capture: %v", err)
}
unifiedOutput, err := os.ReadFile(stdoutFile.Name())
if err != nil {
t.Fatalf("read stdout capture: %v", err)
}
if !json.Valid(unifiedOutput) || !strings.Contains(string(unifiedOutput), `"outcome": "failure"`) ||
!strings.Contains(string(unifiedOutput), `"subtype": "invalid_agent_ext"`) {
t.Fatalf("unified JSON error presentation = %q", unifiedOutput)
}
if extensionHookCalls != 0 {
t.Fatalf("presentation-only root executed %d extension hooks", extensionHookCalls)
}
if err := stderrFile.Truncate(0); err != nil {
t.Fatalf("truncate fallback stderr capture: %v", err)
}
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind fallback stderr capture: %v", err)
}
testseam.Swap(t, &rootEmitResult, func(*cobra.Command, outputpkg.CommandResult) (int, error) {
return 0, errors.New("injected result emission failure")
})
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
if err := stderrFile.Sync(); err != nil {
t.Fatalf("sync fallback stderr capture: %v", err)
}
fallbackOutput, err := os.ReadFile(stderrFile.Name())
if err != nil {
t.Fatalf("read fallback stderr capture: %v", err)
}
if !json.Valid(fallbackOutput) || !strings.Contains(string(fallbackOutput), `"reason": "invalid_agent_ext"`) ||
strings.Contains(string(fallbackOutput), "must-not-leak") {
t.Fatalf("fallback validation error presentation = %q", fallbackOutput)
}
if err := stderrFile.Truncate(0); err != nil {
t.Fatalf("truncate stderr capture: %v", err)
}
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind stderr capture: %v", err)
}
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"version", "--format", "table"})
if err := stderrFile.Sync(); err != nil {
t.Fatalf("sync human stderr capture: %v", err)
}
humanOutput, err := os.ReadFile(stderrFile.Name())
if err != nil {
t.Fatalf("read human stderr capture: %v", err)
}
if json.Valid(humanOutput) || !strings.Contains(string(humanOutput), "DWS_AGENT_EXT") ||
strings.Contains(string(humanOutput), "must-not-leak") {
t.Fatalf("human validation error presentation = %q", humanOutput)
}
var capturedRunner *runtimeRunner
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(flags *GlobalFlags) executor.Runner {
capturedRunner = newCommandRunnerWithFlags(flags).(*runtimeRunner)
return capturedRunner
})
cachedSnapshot := agentMetadataSnapshot{version: "9.8.7", ext: `{"ua":"cached"}`}
_ = newRootCommandWithMode(
contextWithAgentMetadataSnapshot(context.Background(), cachedSnapshot),
nil,
false,
true,
true,
)
if capturedRunner == nil || capturedRunner.agentMetadata == nil || *capturedRunner.agentMetadata != cachedSnapshot {
t.Fatalf("root runner Agent metadata = %#v, want %#v", capturedRunner, cachedSnapshot)
}
}
func TestCrossPlatformCoverageAgentMetadataExcludedFromServiceDiscovery(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "3.0.0")
t.Setenv(envDWSAgentExt, `{"umt":"test-value"}`)
headers := resolveMCPRequestHeadersForInvocation(executor.Invocation{
CanonicalProduct: mcpMetaServerID,
Tool: mcpMetaURLTool,
})
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
t.Fatalf("service-discovery request leaked Agent metadata: %#v", headers)
}
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"})
if headers[transport.HeaderAgentVersion] != "3.0.0" || headers[transport.HeaderAgentExt] == "" {
t.Fatalf("ordinary MCP request omitted Agent metadata: %#v", headers)
}
cached := agentMetadataSnapshot{version: "3.1.0", ext: "{}"}
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"}, cached)
if headers[transport.HeaderAgentVersion] != "3.1.0" || headers[transport.HeaderAgentExt] != "{}" {
t.Fatalf("ordinary MCP request ignored its validated snapshot: %#v", headers)
}
}
func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "2.0.0")
t.Setenv(envDWSAgentExt, `{"ua":"test-agent/2.0"}`)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{})
pluginAuthMu.Lock()
oldPluginRegistry := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
pluginAuthMu.Lock()
pluginAuthRegistry = oldPluginRegistry
pluginAuthMu.Unlock()
})
dynamicMu.Lock()
oldDynamicEndpoints := dynamicEndpoints
oldDynamicProducts := dynamicProducts
oldDynamicAliases := dynamicAliases
oldDynamicToolEndpoints := dynamicToolEndpoints
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = oldDynamicEndpoints
dynamicProducts = oldDynamicProducts
dynamicAliases = oldDynamicAliases
dynamicToolEndpoints = oldDynamicToolEndpoints
dynamicMu.Unlock()
})
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
})
type capturedRequest struct {
headers map[string]string
token string
}
var captured []capturedRequest
testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
copyHeaders := make(map[string]string, len(client.ExtraHeaders))
for key, value := range client.ExtraHeaders {
copyHeaders[key] = value
}
captured = append(captured, capturedRequest{headers: copyHeaders, token: client.AuthToken})
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
})
created := newCommandRunnerWithFlags(&GlobalFlags{}).(*runtimeRunner)
if hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentVersion) ||
hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentExt) {
t.Fatalf("new runner resolved Agent metadata before invocation validation: %#v", created.transport.ExtraHeaders)
}
// runSingle must not cache ambient MCP metadata on the shared base transport.
// Use mock mode to exercise the path without authentication or network I/O.
t.Setenv(envDWSAgentVersion, "2.0.1")
refreshRunner := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{Mock: true},
auditSink: audit.NopSink{},
}
refreshInvocation := executor.Invocation{CanonicalProduct: "refresh", Tool: "tool", Params: map[string]any{}}
if _, err := refreshRunner.runSingle(context.Background(), refreshInvocation, false); err != nil {
t.Fatalf("mock runSingle failed: %v", err)
}
if hasHeaderFold(refreshRunner.transport.ExtraHeaders, transport.HeaderAgentVersion) {
t.Fatalf("runSingle mutated the shared transport Header map: %#v", refreshRunner.transport.ExtraHeaders)
}
t.Setenv(envDWSAgentVersion, "2.0.0")
r := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{Token: "test-token"},
auditSink: audit.NopSink{},
agentMetadata: &agentMetadataSnapshot{
version: "2.0.0",
ext: `{"ua":"test-agent/2.0"}`,
},
}
builtIn := executor.Invocation{CanonicalProduct: "built-in", Tool: "tool", Params: map[string]any{}}
if _, err := r.executeInvocation(context.Background(), "https://example.test", builtIn); err != nil {
t.Fatalf("built-in invocation failed: %v", err)
}
pluginDescriptor := mcptypes.ServerDescriptor{
Key: "third-party",
Endpoint: "https://plugin.example.test",
CLI: mcptypes.CLIOverlay{ID: "third-party"},
AuthHeaders: map[string]string{
"X-Plugin": "yes",
"X-Dws-Agent-Ver": "plugin-must-not-forge-version",
"X-Dws-Agent-Ext": `{"source":"plugin"}`,
},
}
registerPluginHTTPServer(pluginDescriptor)
registeredPlugin, pluginOwned := LookupPluginAuth("third-party")
if !pluginOwned || registeredPlugin == nil || registeredPlugin.Token != "" {
t.Fatalf("anonymous HTTP plugin ownership = %#v, %v", registeredPlugin, pluginOwned)
}
registerPluginHTTPServer(mcptypes.ServerDescriptor{
Key: "anonymous-empty",
Endpoint: "https://anonymous.example.test",
CLI: mcptypes.CLIOverlay{ID: "anonymous-empty"},
})
if emptyPlugin, owned := LookupPluginAuth("anonymous-empty"); !owned || emptyPlugin == nil || emptyPlugin.Token != "" || len(emptyPlugin.ExtraHeaders) != 0 {
t.Fatalf("headerless HTTP plugin ownership = %#v, %v", emptyPlugin, owned)
}
originalPluginHeaders := maps.Clone(registeredPlugin.ExtraHeaders)
pluginInvocation := executor.Invocation{CanonicalProduct: "third-party", Tool: "tool", Params: map[string]any{}}
if _, err := r.executeInvocation(context.Background(), "https://plugin.example.test", pluginInvocation); err != nil {
t.Fatalf("plugin invocation failed: %v", err)
}
if len(captured) != 2 {
t.Fatalf("captured %d calls, want 2", len(captured))
}
if captured[0].headers[transport.HeaderAgentVersion] != "2.0.0" || captured[0].headers[transport.HeaderAgentExt] != `{"ua":"test-agent/2.0"}` {
t.Fatalf("built-in MCP metadata = %#v", captured[0].headers)
}
if hasHeaderFold(captured[1].headers, transport.HeaderAgentVersion) || hasHeaderFold(captured[1].headers, transport.HeaderAgentExt) {
t.Fatalf("plugin request leaked Agent metadata: %#v", captured[1].headers)
}
if got := captured[1].headers["X-Plugin"]; got != "yes" {
t.Fatalf("plugin-owned header = %q, want yes", got)
}
if captured[1].token != "" {
t.Fatalf("anonymous plugin unexpectedly received default OAuth token")
}
if !maps.Equal(registeredPlugin.ExtraHeaders, originalPluginHeaders) {
t.Fatalf("plugin Header sanitization mutated registry state: got %#v want %#v", registeredPlugin.ExtraHeaders, originalPluginHeaders)
}
if got := pluginRequestHeaders(nil); got != nil {
t.Fatalf("nil plugin auth produced Headers: %#v", got)
}
if got := pluginRequestHeaders(&PluginAuth{ExtraHeaders: map[string]string{
"X-DWS-AGENT-VER": "forged",
"X-DWS-AGENT-EXT": `{"forged":true}`,
}}); got != nil {
t.Fatalf("reserved-only plugin Headers survived sanitization: %#v", got)
}
// Keep the execution-boundary auth guard independently testable: even if a
// future token provider returns an empty token without an error, built-in MCP
// calls must fail before preflight or transport while anonymous plugins remain
// valid above.
resolveCalled := false
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
resolveCalled = true
return "", nil
})
callsBefore := len(captured)
unauthenticated := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{},
auditSink: audit.NopSink{},
}
if _, err := unauthenticated.executeInvocation(context.Background(), "https://example.test", executor.Invocation{CanonicalProduct: "built-in-unauthenticated", Tool: "tool"}); err == nil || !isAuthError(err) {
t.Fatalf("unauthenticated built-in request = %v, want auth error", err)
}
if !resolveCalled {
t.Fatal("unauthenticated request did not exercise the token resolver")
}
if len(captured) != callsBefore {
t.Fatalf("unauthenticated built-in request reached transport: calls %d -> %d", callsBefore, len(captured))
}
}
func hasHeaderFold(headers map[string]string, want string) bool {
for key := range headers {
if strings.EqualFold(key, want) {
return true
}
}
return false
}
+1 -1
View File
@@ -158,7 +158,7 @@ func TestApplyAgentProductHeader(t *testing.T) {
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
func TestCrossPlatformCoverageRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
+11 -14
View File
@@ -15,11 +15,10 @@ package app
import "sync"
// PluginAuth holds authentication credentials for a plugin-owned
// streamable-http MCP server. Each server is keyed by its canonical
// product ID (CLI.ID) so that different servers can use independent
// tokens without interfering with each other or with the default
// DingTalk OAuth token.
// PluginAuth marks ownership of a plugin-owned streamable-http MCP server and
// holds its optional authentication credentials. Every accepted HTTP plugin,
// including an anonymous one, has a non-nil record keyed by canonical product
// ID (CLI.ID) so execution never falls back to built-in DingTalk OAuth.
type PluginAuth struct {
// Token is the Bearer token extracted from the plugin's
// "Authorization" header (e.g. a third-party API key).
@@ -39,27 +38,25 @@ var (
pluginAuthRegistry = make(map[string]*PluginAuth)
)
// RegisterPluginAuth stores authentication credentials for a plugin
// server keyed by its canonical product ID. The runner looks up these
// credentials at execution time to inject the correct Bearer token
// instead of the default DingTalk OAuth token.
// RegisterPluginAuth stores ownership and optional authentication credentials
// for a plugin server keyed by its canonical product ID.
func RegisterPluginAuth(productID string, auth *PluginAuth) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
pluginAuthRegistry[productID] = auth
}
// ClearPluginAuth removes credentials for a plugin product. Registration uses
// this before applying an accepted descriptor so a descriptor without custom
// auth cannot inherit stale credentials from an earlier root construction.
// ClearPluginAuth removes the ownership and credential record for a plugin
// product.
func ClearPluginAuth(productID string) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
delete(pluginAuthRegistry, productID)
}
// LookupPluginAuth returns the authentication credentials registered
// for the given product ID, or nil if none exists.
// LookupPluginAuth returns plugin ownership and optional authentication
// credentials for the product ID. The bool denotes ownership, not whether a
// Bearer token is present.
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
pluginAuthMu.RLock()
defer pluginAuthMu.RUnlock()
+10 -5
View File
@@ -61,11 +61,16 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
}
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
fallback := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
if fallback == nil || fallback.Token != "token" || len(fallback.TrustedDomains) != 0 {
t.Fatalf("fallback plugin auth = %#v", fallback)
}
got := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
if got == nil || got.Token != "token" || got.ExtraHeaders["X"] != "Y" || len(got.TrustedDomains) != 2 {
t.Fatalf("plugin auth = %#v", got)
}
if anonymous := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "none"}); anonymous == nil || anonymous.Token != "" {
t.Fatalf("anonymous plugin ownership = %#v", anonymous)
}
}
+3 -3
View File
@@ -13,9 +13,9 @@
package app
// MCPIdentityHeaders returns the same header map used for MCP HTTP requests
// (agent identity, env trace headers, edition MergeHeaders). Intended for
// non-MCP transports such as the A2A gateway client.
// MCPIdentityHeaders returns the shared identity header map used by non-MCP
// transports such as the A2A gateway client. MCP-only Agent version and
// extension metadata are intentionally excluded.
func MCPIdentityHeaders() map[string]string {
return resolveIdentityHeaders()
}
+67 -5
View File
@@ -68,6 +68,45 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "list_files":
return `{"success":true,"result":{"files":[],"hasMore":false}}`
case "list_recycle_items":
return `{"success":true,"result":{"recycleItems":[{"recycleItemId":"recycle-1","originalName":"Fixture Node"}],"hasMore":false}}`
case "get_star_list":
return `{"success":true,"result":{"starList":[],"hasMore":false}}`
case "list_file_versions":
return `{"success":true,"result":{"versions":[{"version":3,"name":"Fixture Version"}],"hasMore":false}}`
case "get_file_info":
name := "Fixture Node"
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
switch call.tool {
case "create_folder":
if value, ok := call.args["name"].(string); ok {
name = value
}
index = -1
case "rename_document":
if value, ok := call.args["newName"].(string); ok {
name = value
}
index = -1
}
}
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"fileId": "node-1", "name": name}})
return string(encoded)
case "get_cover", "get_node_stats":
return `{"success":true,"result":{"nodeId":"node-1"}}`
case "get_file_publish_status":
return `{"success":true,"result":{"fileId":"node-1","published":false}}`
case "create_folder", "create_shortcut":
return `{"success":true,"fileId":"node-1"}`
case "delete_document", "mark_star", "unmark_star", "restore_recycle_item", "rename_document", "revert_file_version":
return `{"success":true,"fileId":"node-1"}`
case "set_file_publish":
return `{"success":true}`
case "download_file", "download_file_version":
return `{"success":true,"result":{"downloadUrl":"http://invalid.test/fixture.bin","fileName":"fixture.bin"}}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
@@ -322,9 +361,9 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--to-user", appFixtureCurrentDOpenID,
"--text", "hello alias",
"--uuid", "alias-e2e",
"--idempotency-key", "alias-e2e",
)
if err != nil {
t.Fatalf("chat write alias E2E error = %v", err)
@@ -336,7 +375,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
@@ -350,6 +389,29 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatMessageSendLegacyUUIDAliasFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
_, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--group", "fixture-conversation",
"--text", "hello legacy uuid",
"--uuid", "legacy-alias-e2e",
)
if err != nil {
t.Fatalf("chat message send legacy uuid error = %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["uuid"] != "legacy-alias-e2e" || payload["openConversationId"] != "fixture-conversation" {
t.Fatalf("chat legacy uuid payload = %#v", payload)
}
if _, exists := payload["idempotency-key"]; exists {
t.Fatalf("chat payload leaked CLI-only idempotency-key: %#v", payload)
}
}
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
@@ -627,11 +689,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
@@ -4,14 +4,21 @@
package app
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
const (
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
@@ -32,12 +39,12 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
@@ -55,7 +62,7 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
@@ -68,10 +75,10 @@ var paramAliasCompleteCommands = map[string][]string{
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
@@ -86,9 +93,9 @@ var paramAliasCompleteCommands = map[string][]string{
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
@@ -124,6 +131,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -131,9 +139,43 @@ var paramAliasCompleteCommands = map[string][]string{
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
"doc create": {"doc", "create", "--name", "Fixture Document", "--workspace", "workspace-1"},
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
"drive +cover": {"drive", "+cover", "--node", "node-1"},
"drive +create-folder": {"drive", "+create-folder", "--name", "Fixture Folder", "--space-id", "space-1", "--folder", "folder-1"},
"drive +create-shortcut": {"drive", "+create-shortcut", "--node", "node-1", "--folder", "folder-1", "--workspace", "workspace-1"},
"drive +delete": {"drive", "+delete", "--node", "node-1", "--yes"},
"drive +download": {"drive", "+download", "--node", "node-1", "--space-id", "space-1", "--output", "downloads/fixture.bin"},
"drive +info": {"drive", "+info", "--node", "node-1", "--space-id", "space-1"},
"drive +inspect": {"drive", "+inspect", "--node", "node-1", "--space-id", "space-1", "--include-stats"},
"drive +list": {"drive", "+list", "--space-id", "space-1", "--folder", "folder-1", "--limit", "7", "--cursor", "cursor-1", "--order-by", "name", "--order", "asc"},
"drive +publish-get": {"drive", "+publish-get", "--node", "node-1"},
"drive +publish-unset": {"drive", "+publish-unset", "--node", "node-1", "--yes"},
"drive +recycle-list": {"drive", "+recycle-list", "--space-id", "space-1", "--limit", "7", "--cursor", "cursor-1"},
"drive +recycle-restore": {"drive", "+recycle-restore", "--id", "recycle-1", "--yes"},
"drive +rename": {"drive", "+rename", "--node", "node-1", "--name", "Fixture Renamed", "--yes"},
"drive +search": {"drive", "+search", "--query", "fixture"},
"drive +star-add": {"drive", "+star-add", "--node", "node-1"},
"drive +star-list": {"drive", "+star-list", "--limit", "7", "--cursor", "cursor-1"},
"drive +star-remove": {"drive", "+star-remove", "--node", "node-1"},
"drive +stats": {"drive", "+stats", "--node", "node-1"},
"drive +upload": {"drive", "+upload", "--file", "param_alias_payload_equivalence_test.go", "--file-name", "fixture.txt", "--mime-type", "text/plain", "--space-id", "space-1", "--node", "node-1", "--yes"},
"drive +version-download": {"drive", "+version-download", "--node", "node-1", "--version", "3", "--output", "downloads/fixture-v3.bin"},
"drive +version-get": {"drive", "+version-get", "--node", "node-1", "--version", "3"},
"drive +version-history": {"drive", "+version-history", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"drive +version-revert": {"drive", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"drive commit": {"drive", "commit", "--file-name", "fixture.txt", "--file-size", "7", "--upload-id", "upload-1", "--space-id", "space-1"},
"drive copy": {"drive", "copy", "--node", "node-1", "--folder", "folder-1"},
"drive download": {"drive", "download", "--node", "node-1", "--output", "downloads/fixture.bin", "--version", "3", "--space-id", "space-1"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"drive mkdir": {"drive", "mkdir", "--name", "Fixture Folder", "--space-id", "space-1"},
"drive permission add": {"drive", "permission", "add", "--node", "node-1", "--users", "user-1,user-2", "--role", "READER"},
"drive recycle list": {"drive", "recycle", "list", "--space-id", "space-1", "--limit", "7"},
"drive recycle restore": {"drive", "recycle", "restore", "--id", "recycle-1"},
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
@@ -156,15 +198,29 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"doc +inspect": {
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
},
"doc +search": {
"created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"},
"created-to": {"doc", "+search", "--query", "fixture", "--created-to", "2"},
"creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"},
},
"drive list": {
"workspace": {"drive", "list", "--workspace", "workspace-1", "--limit", "7"},
"order-by": {"drive", "list", "--folder", "folder-1", "--order-by", "name", "--limit", "7"},
"space-id": {"drive", "list", "--space-id", "space-1", "--limit", "7"},
"order": {"drive", "list", "--folder", "folder-1", "--order", "asc", "--limit", "7"},
},
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
@@ -241,12 +297,105 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
}
// paramAliasNewDriveCases is the exact executable-alias set introduced by the
// reviewed Drive expansion. Guard fixtures are covered separately by the
// exhaustive runtime-contract tests; every entry here must preserve the final
// transport payload of its canonical spelling.
var paramAliasNewDriveCases = []struct {
command string
emitted string
canonical string
}{
{command: "drive +cover", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +create-folder", emitted: "folder-name", canonical: "name"},
{command: "drive +create-folder", emitted: "storage-space-id", canonical: "space-id"},
{command: "drive +create-shortcut", emitted: "source-file-id", canonical: "node"},
{command: "drive +create-shortcut", emitted: "target-folder-id", canonical: "folder"},
{command: "drive +create-shortcut", emitted: "target-workspace-id", canonical: "workspace"},
{command: "drive +delete", emitted: "file-id", canonical: "node"},
{command: "drive +download", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +download", emitted: "destination-path", canonical: "output"},
{command: "drive +inspect", emitted: "include-statistics", canonical: "include-stats"},
{command: "drive +list", emitted: "folder-id", canonical: "folder"},
{command: "drive +list", emitted: "page-size", canonical: "limit"},
{command: "drive +list", emitted: "next-token", canonical: "cursor"},
{command: "drive +list", emitted: "sort-direction", canonical: "order"},
{command: "drive +list", emitted: "sort-by", canonical: "order-by"},
{command: "drive +publish-get", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +publish-unset", emitted: "file-id", canonical: "node"},
{command: "drive +recycle-list", emitted: "storage-space-id", canonical: "space-id"},
{command: "drive +recycle-list", emitted: "page-token", canonical: "cursor"},
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
{command: "drive +rename", emitted: "file-id", canonical: "node"},
{command: "drive +rename", emitted: "new-name", canonical: "name"},
{command: "drive +star-add", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +star-list", emitted: "max-results", canonical: "limit"},
{command: "drive +star-remove", emitted: "file-id", canonical: "node"},
{command: "drive +stats", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +upload", emitted: "source-file", canonical: "file"},
{command: "drive +upload", emitted: "name", canonical: "file-name"},
{command: "drive +upload", emitted: "overwrite-node-id", canonical: "node"},
{command: "drive +version-download", emitted: "version-number", canonical: "version"},
{command: "drive +version-download", emitted: "save-path", canonical: "output"},
{command: "drive +version-get", emitted: "version-no", canonical: "version"},
{command: "drive +version-history", emitted: "next-cursor", canonical: "cursor"},
{command: "drive +version-history", emitted: "page-size", canonical: "limit"},
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
{command: "drive +cover", emitted: "node-id", canonical: "node"},
{command: "drive +create-shortcut", emitted: "node-id", canonical: "node"},
{command: "drive +delete", emitted: "node-id", canonical: "node"},
{command: "drive +download", emitted: "node-id", canonical: "node"},
{command: "drive +inspect", emitted: "node-id", canonical: "node"},
{command: "drive +publish-get", emitted: "node-id", canonical: "node"},
{command: "drive +publish-unset", emitted: "node-id", canonical: "node"},
{command: "drive +rename", emitted: "node-id", canonical: "node"},
{command: "drive +star-add", emitted: "node-id", canonical: "node"},
{command: "drive +star-remove", emitted: "node-id", canonical: "node"},
{command: "drive +stats", emitted: "node-id", canonical: "node"},
{command: "drive +upload", emitted: "node-id", canonical: "node"},
{command: "drive +version-download", emitted: "node-id", canonical: "node"},
{command: "drive +version-get", emitted: "node-id", canonical: "node"},
{command: "drive +version-history", emitted: "node-id", canonical: "node"},
{command: "drive +version-revert", emitted: "node-id", canonical: "node"},
{command: "drive +cover", emitted: "url", canonical: "node"},
{command: "drive +create-shortcut", emitted: "document-id", canonical: "node"},
{command: "drive +delete", emitted: "folder-id", canonical: "node"},
{command: "drive +inspect", emitted: "document-id", canonical: "node"},
{command: "drive +inspect", emitted: "folder-id", canonical: "node"},
{command: "drive +publish-get", emitted: "url", canonical: "node"},
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
{command: "drive +rename", emitted: "document-id", canonical: "node"},
{command: "drive +rename", emitted: "folder-id", canonical: "node"},
{command: "drive +star-add", emitted: "url", canonical: "node"},
{command: "drive +star-remove", emitted: "doc-id", canonical: "node"},
{command: "drive +stats", emitted: "document-url", canonical: "node"},
{command: "drive +upload", emitted: "file-id", canonical: "node"},
}
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
// every Drive command in the expansion whose declared runtime safety requires
// confirmation. The full matrix below proves all spellings preserve the
// confirmed payload; this smaller matrix proves aliases cannot cross the
// confirmation boundary before any transport call is made.
var paramAliasNewDriveConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "drive +delete", emitted: "file-id", canonical: "node"},
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
{command: "drive +rename", emitted: "new-name", canonical: "name"},
{command: "drive +upload", emitted: "source-file", canonical: "file"},
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
// checked through the embedded PreParse delivery path and against a complete
// business-valid command template. The separate IM gate below continues to
// execute every alias introduced by the current IM optimization.
// business-valid command template. The dedicated product gates below continue
// to execute every alias introduced by the reviewed IM and Drive expansions.
//
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
// executing the complete 800+ command Root twice per spelling under -race.
@@ -261,6 +410,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
paramAliasPayloadCaseKey("doc create", "space-id"): true, // published Doc workspace compatibility remains payload-equivalent
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
@@ -269,6 +419,8 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
paramAliasPayloadCaseKey("doc +search", "create-time-start"): true, // observed lower-bound spelling preserves milliseconds
paramAliasPayloadCaseKey("doc +search", "create-time-end"): true, // observed upper-bound spelling preserves milliseconds
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
@@ -278,6 +430,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
paramAliasPayloadCaseKey("drive info", "workspace"): true, // published numeric storage-space compatibility remains payload-equivalent
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
@@ -421,14 +574,118 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// Resource download deliberately continues from the transport call into a
// local HTTPS download. The generic capture caller returns an empty object, so
// this command's stable post-transport validation error is the expected test
// boundary; canonical and alias calls must still produce the same request and
// the same error.
func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewDriveCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed Drive alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active {
return
}
if target != test.canonical {
t.Fatalf("active reviewed Drive alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeAliases != len(paramAliasNewDriveCases) {
t.Fatalf("new Drive aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewDriveCases))
}
}
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewDriveConfirmationCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
}
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, complete)
}
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
if removals != 1 {
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active || target != test.canonical {
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed alias command skipped PreParse")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed alias command crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
})
}
}
// Some artifact commands deliberately continue past the final captured
// transport call into local download/upload handling. Deterministic invalid
// resource responses form their expected post-transport test boundary;
// canonical and alias calls must still produce the same request and error.
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
return command == "chat +messages-resource-download" && err != nil &&
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
if err == nil {
return false
}
switch command {
case "chat +messages-resource-download":
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
case "drive +download", "drive +version-download":
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
case "drive +upload":
return strings.Contains(err.Error(), "incomplete drive upload credentials")
default:
return false
}
}
// +chat-messages supplies the current wall-clock time when callers omit
@@ -483,3 +740,16 @@ func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
}
return out, replacements
}
func removeExactArg(args []string, target string) ([]string, int) {
out := make([]string, 0, len(args))
removals := 0
for _, arg := range args {
if arg == target {
removals++
continue
}
out = append(out, arg)
}
return out, removals
}
+11
View File
@@ -30,6 +30,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
@@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) {
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
// GetMCPBaseURL() returns the test server address.
func stubPATPollAccessToken(t *testing.T) {
t.Helper()
testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
})
}
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
t.Helper()
stubPATPollAccessToken(t)
var callCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
}
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
stubPATPollAccessToken(t)
// When server returns 302 (SSO redirect), poll should continue until real response.
var callCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor
// It responds to device poll requests with the given status after the first poll.
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
t.Helper()
stubPATPollAccessToken(t)
var pollCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+128 -24
View File
@@ -79,6 +79,7 @@ var (
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
rootEmitResult = output.EmitResult
)
// Execute runs the root command and returns the process exit code.
@@ -136,6 +137,16 @@ func Execute() (exitCode int) {
restoreArgs := rootNormalizeProcessProfileArgs()
defer restoreArgs()
// Validate MCP Agent metadata before constructing the command tree.
// Construction may invoke edition registration/static-server hooks and load
// plugin PreParse handlers, so PersistentPreRunE alone is too late for the
// process entry point. Retain this exact pair for the eventual invocation.
agentMetadata := readAgentMetadataSnapshot()
if err := agentMetadata.validationError(); err != nil {
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
return apperrors.ExitCode(err)
}
timing := NewTimingCollector()
defer func() {
rootStopAllStdioClients() // Ensure child processes are terminated on exit
@@ -147,6 +158,7 @@ func Execute() (exitCode int) {
// Attach timing collector to context for use by child components
ctx := WithTimingCollector(context.Background(), timing)
ctx = contextWithAgentMetadataSnapshot(ctx, agentMetadata)
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
@@ -260,6 +272,70 @@ func Execute() (exitCode int) {
return 0
}
// emitEarlyAgentMetadataValidationError preserves each built-in command's
// legacy-vs-unified output contract without running extension hooks. The
// presentation-only tree contains reviewed open-source commands and flags but
// deliberately omits edition registration, plugin loading, and visibility
// hooks; callers therefore still fail before any external hook executes.
func emitEarlyAgentMetadataValidationError(err error, args []string) {
format := processArgsFormat(args)
presentationRoot := newRootPresentationCommand()
_ = presentationRoot.PersistentFlags().Set("format", format)
if target, _, findErr := presentationRoot.Find(args); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
target.SetOut(os.Stdout)
target.SetErr(os.Stderr)
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
if _, emitErr := rootEmitResult(target, result); emitErr == nil {
return
}
}
if strings.EqualFold(strings.TrimSpace(format), "json") {
_ = apperrors.PrintJSON(os.Stderr, err)
return
}
_ = apperrors.PrintHumanAt(os.Stderr, err, apperrors.VerbosityNormal)
}
// processArgsRequestJSON preserves the CLI's machine-readable error contract
// for validation that must occur before Cobra and its presentation flags exist.
// The global format defaults to JSON; an explicit non-JSON format switches to
// the human diagnostic path. Last occurrence wins, matching pflag semantics.
func processArgsRequestJSON(args []string) bool {
return strings.EqualFold(strings.TrimSpace(processArgsFormat(args)), "json")
}
func processArgsFormat(args []string) string {
format := "json"
for index := 0; index < len(args); index++ {
arg := args[index]
if arg == "--" {
break
}
if value, ok := strings.CutPrefix(arg, "--format="); ok {
format = value
continue
}
if value, ok := strings.CutPrefix(arg, "-f="); ok {
format = value
continue
}
if strings.HasPrefix(arg, "-f") && len(arg) > len("-f") {
format = strings.TrimPrefix(arg, "-f")
continue
}
if arg != "--format" && arg != "-f" {
continue
}
if index+1 >= len(args) {
format = ""
break
}
index++
format = args[index]
}
return format
}
// errorInfoFromExecutionError projects the repository error model into the unified
// failure body. Exit code and category are derived from the same error value,
// preventing the wire and process status from drifting apart.
@@ -633,12 +709,25 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
return newRootCommandWithMode(rootCtx, engine, loadRuntimeExtensions, declarationOnly, false)
}
func newRootPresentationCommand() *cobra.Command {
return newRootCommandWithMode(context.Background(), nil, false, true, true)
}
func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool, presentationOnly bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
runner := rootNewCommandRunnerWithFlags(flags)
if snapshot, ok := agentMetadataSnapshotFromContext(rootCtx); ok {
if runtime, ok := runner.(*runtimeRunner); ok {
runtime.agentMetadata = &snapshot
}
}
root := &cobra.Command{
Use: "dws",
@@ -672,15 +761,29 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
// Validate caller-provided identity and MCP metadata before command
// execution hooks or network activity. The process entry point additionally
// validates Agent metadata before command-tree construction; direct Cobra
// embedding retains this execution-boundary guard.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
agentMetadata, cached := agentMetadataSnapshotFromContext(cmd.Context())
if !cached {
agentMetadata = readAgentMetadataSnapshot()
}
if err := agentMetadata.validationError(); err != nil {
return err
}
if runtime, ok := runner.(*runtimeRunner); ok {
// Retain the exact validated pair for this command execution so a
// concurrently mutating embedding environment cannot change what is
// later applied after edition and credential hooks.
runtime.agentMetadata = &agentMetadata
}
if shouldDetectNestedSkillLayout(cmd) {
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
@@ -777,10 +880,12 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
if !presentationOnly {
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
}
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
@@ -791,7 +896,9 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
if !presentationOnly {
hideNonDirectRuntimeCommands(root)
}
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
@@ -1755,17 +1862,16 @@ func distributionRootOwns(root *cobra.Command, name string) bool {
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
// Register ownership for every accepted HTTP plugin, including anonymous
// plugins. Execution must never fall back to the built-in DingTalk OAuth or
// Agent-metadata path merely because a plugin has no Authorization Header.
RegisterPluginAuth(productID, pluginAuthFromServerDescriptor(srv))
}
// registerPluginAuthFromHeaders extracts authentication credentials from
// a server descriptor's AuthHeaders and registers them in the global
// PluginAuth registry. The runner uses this registry at execution time
// to inject the correct Bearer token for third-party MCP servers.
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// pluginAuthFromServerDescriptor extracts plugin-owned credentials and custom
// Headers. A non-nil result also acts as the HTTP plugin ownership marker for
// anonymous plugins.
func pluginAuthFromServerDescriptor(srv mcptypes.ServerDescriptor) *PluginAuth {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1776,20 +1882,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
extraHeaders[key] = value
}
}
if authToken == "" {
return
}
var trustedDomains []string
if parsed, err := url.Parse(srv.Endpoint); err == nil {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
if host != "" {
trustedDomains = []string{host, "*." + host}
}
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
return &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
TrustedDomains: trustedDomains,
})
}
}
// newPipelineEngine creates and configures the pipeline engine with
+15
View File
@@ -22,6 +22,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -369,6 +370,20 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
t.Fatalf("chat message send missing --%s", flag)
}
}
idempotencyKey := send.Flags().Lookup("idempotency-key")
if idempotencyKey == nil {
t.Fatal("chat message send missing --idempotency-key")
}
legacyUUID := send.Flags().Lookup("uuid")
if legacyUUID == nil || !legacyUUID.Hidden {
t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID)
}
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "idempotency-key" {
t.Fatalf("chat message send --uuid alias_of = %#v, want idempotency-key", got)
}
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("chat message send --uuid alias_origin = %#v, want %s", got, runtimeannotate.FlagAliasOriginCorecmdV1)
}
got, err := executeRootCaptureStdout(t, []string{
"chat", "file", "upload",
+26 -13
View File
@@ -137,7 +137,6 @@ func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
transportClient := transport.NewClient(httpClient)
transportClient.ExtraHeaders = resolveIdentityHeaders()
transportClient.FileLogger = FileLoggerInstance()
return &runtimeRunner{
transport: transportClient,
@@ -156,12 +155,14 @@ type runtimeRunner struct {
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
agentMetadata *agentMetadataSnapshot
}
var (
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
runnerResolveProfile = authpkg.ResolveProfile
runnerGetCachedRuntimeToken = getCachedRuntimeToken
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
@@ -237,7 +238,6 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
if r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
r.transport.ExtraHeaders = resolveIdentityHeaders()
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
if r.globalFlags != nil && r.globalFlags.Mock {
@@ -543,9 +543,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
}()
// Check if this product has plugin-level auth credentials registered.
// If so, use the plugin's token instead of the default DingTalk OAuth token.
// This allows third-party MCP servers (e.g. Bailian) to use their own API keys.
// Check whether this product belongs to an HTTP plugin. Every accepted
// plugin has an ownership record; credentials within that record are
// optional. Plugin requests never fall back to the default DingTalk OAuth.
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
authToken := ""
@@ -553,7 +553,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
authToken = pluginAuth.Token
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
var tokenErr error
authToken, tokenErr = r.resolveAuthToken(ctx)
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
if tokenErr != nil {
return executor.Result{}, tokenResolutionError(tokenErr)
}
@@ -603,9 +603,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
// Fail-fast: reject unauthenticated requests before making network calls.
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
if strings.TrimSpace(authToken) == "" {
// Preserve a final execution-boundary guard even though the built-in token
// resolver normally returns either a non-empty token or an error. HTTP
// plugins are ownership-scoped separately and may intentionally be anonymous.
if !hasPluginAuth && strings.TrimSpace(authToken) == "" {
return executor.Result{}, apperrors.NewAuth(
"未登录,请先执行 dws auth login",
apperrors.WithReason("not_authenticated"),
@@ -616,12 +617,20 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
var tc *transport.Client
if hasPluginAuth {
// Use plugin-level auth: inject the plugin's token and trust its domains.
tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders)
// Plugin ownership is authoritative even when the plugin is anonymous.
// Copy and sanitize manifest headers so plugins cannot opt themselves into
// DWS-owned Agent metadata by declaring the reserved names directly.
tc = r.transport.WithAuth(authToken, pluginRequestHeaders(pluginAuth))
tc.TrustedDomains = pluginAuth.TrustedDomains
} else {
// Default path: use DingTalk OAuth token with identity headers.
tc = r.transport.WithAuth(authToken, resolveIdentityHeaders())
// Default path: use DingTalk OAuth token with identity headers. Agent
// metadata is resolved exactly once per invocation and is excluded from
// helper-only service-discovery requests.
if r.agentMetadata != nil {
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation, *r.agentMetadata))
} else {
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation))
}
}
callCtx := ctx
@@ -1069,6 +1078,10 @@ func resolveIdentityHeaders() map[string]string {
} else {
delete(headers, agentproduct.HeaderName)
}
// Agent version and extension are intentionally MCP-request-only. Remove
// every case variant potentially supplied by an edition or credential hook
// so shared consumers such as A2A cannot inherit them.
removeAgentMetadataHeaders(headers)
return headers
}
+4
View File
@@ -83,6 +83,10 @@ func TestMain(m *testing.M) {
// remove a TempDir while the audit lock is still open.
setupAuditSink()
openBrowserFunc = func(string) error { return nil }
// App tests may run in filtered CI processes. Install the same lazy Schema
// source factory as NewRootCommand without constructing a root so ResolveMeta
// tests never depend on an earlier test having initialized process state.
registerSchemaRuntimeDelivery()
code := m.Run()
StopAllStdioClients()
CloseAuditSink()
@@ -0,0 +1,80 @@
// 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"
"io"
"net/http"
"strings"
"testing"
)
type agentMetadataHeaderRoundTripFunc func(*http.Request) (*http.Response, error)
func (f agentMetadataHeaderRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestCrossPlatformCoverageOAuthRequestsExcludeAgentMetadataHeaders(t *testing.T) {
t.Setenv("DWS_AGENT_VER", "1.2.3-test")
t.Setenv("DWS_AGENT_EXT", `{"umt":"test-umt","miniwua":"test-wua","ua":"test-agent"}`)
assertExcluded := func(t *testing.T, header http.Header) {
t.Helper()
for _, name := range []string{"x-dws-agent-ver", "x-dws-agent-ext"} {
if values := header.Values(name); len(values) != 0 {
t.Fatalf("OAuth request header %q = %q, want absent", name, values)
}
}
}
newCaptureClient := func(responseBody string) (*http.Client, <-chan http.Header) {
seen := make(chan http.Header, 1)
client := &http.Client{Transport: agentMetadataHeaderRoundTripFunc(func(req *http.Request) (*http.Response, error) {
seen <- req.Header.Clone()
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(responseBody)),
Request: req,
}, nil
})}
return client, seen
}
t.Run("JSON token request", func(t *testing.T) {
client, seen := newCaptureClient(`{}`)
provider := &OAuthProvider{httpClient: client}
if _, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{
"code": "test-code",
"grantType": "authorization_code",
}); err != nil {
t.Fatalf("postJSON() error = %v", err)
}
assertExcluded(t, <-seen)
})
t.Run("device code request", func(t *testing.T) {
client, seen := newCaptureClient(`{"success":true,"result":{"deviceCode":"test-device-code","userCode":"TEST-CODE","verificationUri":"https://example.test/device","expiresIn":900,"interval":1}}`)
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.clientID = "test-client-id"
provider.httpClient = client
provider.SetBaseURL("https://oauth.test")
if _, err := provider.requestDeviceCode(context.Background()); err != nil {
t.Fatalf("requestDeviceCode() error = %v", err)
}
assertExcluded(t, <-seen)
})
}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -515,3 +515,68 @@ func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
}
}
}
func TestCrossPlatformCoveragePublishedSpaceWorkspaceAliasesRemainExecutable(t *testing.T) {
docCommands := []string{
"doc +access-change",
"doc +access-grant",
"doc +access-revoke",
"doc +copy",
"doc +create",
"doc +create-from-template",
"doc +grant-and-share",
"doc +import",
"doc +list",
"doc +move",
"doc create",
"doc file create",
"doc import",
"doc template apply",
}
for _, command := range docCommands {
entry, ok := LookupParamAlias(command)
if !ok {
t.Errorf("published Doc command %q has no generated parameter-alias entry", command)
continue
}
for _, emitted := range []string{"space", "space-id"} {
target, active := entry.ResolveAlias(emitted)
if !active || target != "workspace" {
t.Errorf("%s --%s resolution = active:%v target:%q, want --workspace", command, emitted, active, target)
}
if entry.IsBlocked(emitted) || entry.IsAmbiguous(emitted) {
t.Errorf("%s --%s remains protected after restoring its published alias", command, emitted)
}
}
}
driveInfo, ok := LookupParamAlias("drive info")
if !ok {
t.Fatal("published drive info command has no generated parameter-alias entry")
}
for _, emitted := range []string{"space", "workspace", "workspace-id"} {
target, active := driveInfo.ResolveAlias(emitted)
if !active || target != "space-id" {
t.Errorf("drive info --%s resolution = active:%v target:%q, want --space-id", emitted, active, target)
}
if driveInfo.IsBlocked(emitted) || driveInfo.IsAmbiguous(emitted) {
t.Errorf("drive info --%s remains protected after restoring its published alias", emitted)
}
}
// Compatibility remains exact-path scoped. Strong type spellings introduced
// after the split continue to guard the two value domains elsewhere.
for _, test := range []struct {
command string
emitted string
}{
{command: "doc create", emitted: "storage-space-id"},
{command: "drive +upload", emitted: "workspace-id"},
{command: "drive info", emitted: "knowledge-base-id"},
} {
entry, ok := LookupParamAlias(test.command)
if !ok || !entry.IsBlocked(test.emitted) {
t.Errorf("%s --%s must remain blocked outside the published compatibility set", test.command, test.emitted)
}
}
}
+249 -30
View File
@@ -10,10 +10,10 @@
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "drive +find-file", "drive +search", "drive +search-docs", "drive search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +search-docs", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive permission list", "drive recent", "drive recycle list", "drive search", "drive star list", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive recent", "drive recycle list", "drive search", "drive star list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
@@ -31,20 +31,31 @@
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
"drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
"doc_version_number": {"denotes": "single document or Drive ordinary-file historical version number with unchanged positive integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive revert"], "risk": "yellow"},
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"},
"drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"},
"drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
"creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
@@ -54,8 +65,8 @@
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
@@ -101,9 +112,9 @@
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
@@ -114,7 +125,7 @@
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
"doc update": {"block": ["stdin", "version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
@@ -158,21 +169,86 @@
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
"doc +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."},
"doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."},
"doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."},
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "block": ["after-block-id", "before-block-id"], "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."},
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."},
"drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
"drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]},
"drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
"drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
"drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]},
"drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]},
"drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
"drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]},
"drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
"drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]},
"drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]},
"drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
"drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."},
"drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
"drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."},
"drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]},
"drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."},
"drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."},
"drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
"drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."},
"drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."},
"drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
"drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."},
"drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."},
"drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."},
"drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."},
"drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."},
"drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."},
"drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
"drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
"drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
"drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
"drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
"drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
"drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."},
"drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."},
"drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."},
"drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."},
"drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."},
"drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}},
"drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
"drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
"drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."},
"drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}
},
"validation_fixture": {
@@ -243,7 +319,7 @@
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2},
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
@@ -325,7 +401,7 @@
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
@@ -415,9 +491,9 @@
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion", "occ": 1},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5},
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
@@ -425,11 +501,154 @@
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
{"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
{"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"},
{"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"},
{"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"},
{"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
{"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"},
{"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"},
{"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
{"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"},
{"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"},
{"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"},
{"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
{"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
{"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"},
{"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
{"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
{"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1},
{"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1},
{"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
{"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
{"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"},
{"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"},
{"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
{"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6},
{"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1},
{"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
{"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
{"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
{"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
{"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3},
{"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1},
{"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1},
{"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3},
{"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1},
{"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2},
{"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2},
{"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1},
{"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
{"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
{"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1},
{"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2},
{"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1},
{"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1},
{"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"},
{"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1},
{"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1},
{"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1},
{"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
{"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
{"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
{"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1},
{"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
{"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"},
{"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"},
{"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"},
{"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"},
{"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
{"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"},
{"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"},
{"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"},
{"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"},
{"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
{"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"},
{"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"},
{"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"},
{"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"},
{"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"},
{"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
{"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"},
{"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"},
{"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"},
{"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"},
{"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
{"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"},
{"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
{"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
{"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"}
]
}
}
+9
View File
@@ -1356,6 +1356,7 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message send", "idempotency-key", "uuid", false, ""},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
@@ -1407,6 +1408,14 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden)
}
}
sendLeaf, err = queryDeliverySchemaPayload([]string{"chat message send"})
if err != nil {
t.Fatal(err)
}
sendParams = schemaMap(sendLeaf["parameters"])
if _, ok := sendParams["uuid"]; ok {
t.Fatal("chat message send unexpectedly publishes hidden alias --uuid")
}
listByConv, err := queryDeliverySchemaPayload([]string{"chat category list-by-conv"})
if err != nil {
t.Fatal(err)
+144 -61
View File
@@ -18,6 +18,7 @@ import (
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
@@ -467,22 +468,50 @@ func validateNativeSearchConversationScope(conversationIDs []string) error {
}
func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
startRaw, endRaw, err := defaultChatMessageListAllTimeRange(cmd, 24*time.Hour)
if err != nil {
return nil, err
}
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return nil, err
}
if err := validateTimeRange(startMs, endMs); err != nil {
return nil, err
}
cursor, _ := cmd.Flags().GetString("cursor")
return map[string]any{
"startTime": mustGetFlag(cmd, "start"),
"endTime": mustGetFlag(cmd, "end"),
"startTime": startRaw,
"endTime": endRaw,
"limit": chatIntFlagOrFallback(cmd, "limit", "size"),
"cursor": cursor,
}, nil
}
func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "start"); err != nil {
return nil, err
func defaultChatMessageListAllTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) {
startRaw := mustGetFlag(cmd, "start")
endRaw := mustGetFlag(cmd, "end")
anchor := time.Now()
if endRaw == "" {
endRaw = formatChatMessageListAllTime(anchor.UnixMilli())
} else if startRaw == "" {
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return "", "", err
}
anchor = time.UnixMilli(endMs)
}
if startRaw == "" {
startRaw = formatChatMessageListAllTime(anchor.Add(-lookback).UnixMilli())
}
return startRaw, endRaw, nil
}
func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
senderUserID := flagOrFallback(cmd, "sender-user-id", "sender")
senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id")
if senderUserID != "" && senderOpenDingTalkID != "" {
@@ -491,13 +520,18 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
if senderUserID == "" && senderOpenDingTalkID == "" {
return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if senderOpenDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil {
return nil, err
}
}
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
if err != nil {
return nil, err
}
endRaw, _ := cmd.Flags().GetString("end")
if strings.TrimSpace(endRaw) == "" {
endRaw = time.Now().Format(time.RFC3339)
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
@@ -522,14 +556,15 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
}
func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
return nil, err
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end"))
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return nil, err
}
@@ -564,14 +599,15 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil {
return nil, err
}
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
return nil, err
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end"))
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return nil, err
}
@@ -592,6 +628,33 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) {
return toolArgs, nil
}
func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) {
startRaw := mustGetFlag(cmd, "start")
endRaw := mustGetFlag(cmd, "end")
anchor := time.Now()
if endRaw == "" {
endRaw = anchor.Format(time.RFC3339)
} else if startRaw == "" {
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return "", "", err
}
anchor = time.UnixMilli(endMs)
}
if startRaw == "" {
startRaw = anchor.Add(-lookback).Format(time.RFC3339)
}
return startRaw, endRaw, nil
}
func formatChatMessageListAllTime(ms int64) string {
return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05")
}
func defaultChatMessageListTime() string {
return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05")
}
func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) {
toolArgs := map[string]any{}
if v := flagOrFallback(cmd, "query", "keyword"); v != "" {
@@ -993,8 +1056,7 @@ func isNumericUserID(value string) bool {
}
func isOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(value)
}
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
@@ -2605,8 +2667,9 @@ func newChatCommand() *cobra.Command {
chatMessageListCmd := &cobra.Command{
Use: "list",
Short: "拉取会话消息内容",
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Example: ` dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Example: ` dws chat message list --group <openconversation_id>
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
@@ -2614,9 +2677,6 @@ func newChatCommand() *cobra.Command {
# 查询群 ID: dws chat search --query "群名"
# 查询 userId: dws contact user search --query "姓名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "time"); err != nil {
return err
}
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
@@ -2636,15 +2696,25 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
}
forward, err := resolveMessageForward(cmd, true)
timeVal := mustGetFlag(cmd, "time")
defaultForward := true
if timeVal == "" {
timeVal = defaultChatMessageListTime()
defaultForward = false
}
forward, err := resolveMessageForward(cmd, defaultForward)
if err != nil {
return err
}
timeVal := mustGetFlag(cmd, "time")
if groupID != "" {
toolArgs := map[string]any{
"openconversation_id": groupID,
@@ -2695,7 +2765,7 @@ func newChatCommand() *cobra.Command {
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
Examples: []string{
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
"dws chat message list --group <openConversationId> --limit 50",
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
},
},
@@ -2723,6 +2793,11 @@ func newChatCommand() *cobra.Command {
if userID == "" && openDingTalkID == "" {
return fmt.Errorf("--user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -2730,7 +2805,7 @@ func newChatCommand() *cobra.Command {
timeVal, _ := cmd.Flags().GetString("time")
defaultForward := true
if strings.TrimSpace(timeVal) == "" {
timeVal = time.Now().Format("2006-01-02 15:04:05")
timeVal = defaultChatMessageListTime()
defaultForward = false
}
forward, err := resolveMessageForward(cmd, defaultForward)
@@ -2835,7 +2910,7 @@ func newChatCommand() *cobra.Command {
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
msgUuid, _ := cmd.Flags().GetString("uuid")
msgUuid := flagOrFallback(cmd, "idempotency-key", "uuid")
specified := 0
if groupID != "" {
specified++
@@ -2852,6 +2927,11 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -3050,6 +3130,7 @@ func newChatCommand() *cobra.Command {
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
{Name: "group", Property: "openConversationId"},
{Name: "idempotency-key", Property: "uuid"},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"},
},
},
@@ -3487,7 +3568,7 @@ func newChatCommand() *cobra.Command {
chatMessageListAllCmd := &cobra.Command{
Use: "list-all",
Short: "拉取指定时间范围内当前用户的所有会话消息",
Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`,
Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 可选,不传时默认最近 1 天到当前时间,避免跨全部会话范围过大;--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`,
Example: ` dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`,
@@ -3534,7 +3615,7 @@ func newChatCommand() *cobra.Command {
chatMessageListBySenderCmd := &cobra.Command{
Use: "list-by-sender",
Short: "拉取指定发送者的消息(包含单聊和群聊)",
Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。--start 和 --end 可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0
@@ -3586,7 +3667,7 @@ func newChatCommand() *cobra.Command {
chatMessageListMentionsCmd := &cobra.Command{
Use: "list-mentions",
Short: "拉取 @我 的消息",
Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
@@ -3774,7 +3855,7 @@ func newChatCommand() *cobra.Command {
chatMessageSearchCmd := &cobra.Command{
Use: "search",
Short: "按关键词搜索消息",
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
@@ -4332,9 +4413,9 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageListCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)")
chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)")
chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)")
chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(推荐)")
chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older")
chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)")
chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)")
chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)")
_ = chatMessageListCmd.Flags().MarkHidden("forward")
chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制")
chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名")
@@ -4385,7 +4466,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
_ = chatMessageSendCmd.Flags().MarkHidden("file-type")
_ = chatMessageSendCmd.Flags().MarkHidden("file-size")
chatMessageSendCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageSendCmd.Flags().String("uuid", "", "幂等 UUID,相同 uuid 在 24h 内不会重复发送(可选)")
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
Name: "idempotency-key",
Usage: "幂等键,相同 key 在 24h 内不会重复发送(可选)",
Aliases: []string{"uuid"},
}})
cli.AttachRuntimeSchema(chatMessageSendCmd, "chat", "send_personal_message", "hardcoded:chat")
cli.AnnotateRuntimeConstraints(chatMessageSendCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}},
@@ -4448,10 +4533,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageListTopicRepliesCmd.Flags().String("forward", "false", "true 等价 --direction newer,false 等价 --direction older(默认 false)")
_ = chatMessageListTopicRepliesCmd.Flags().MarkHidden("forward")
chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
_ = chatMessageListAllCmd.MarkFlagRequired("start")
chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
_ = chatMessageListAllCmd.MarkFlagRequired("end")
chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间前 1 天)")
chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)")
chatMessageListAllCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageListAllCmd.Flags().MarkHidden("size")
@@ -4465,9 +4548,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageListBySenderCmd.Flags().String("sender-open-dingtalk-id", "", "发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)")
chatMessageListBySenderCmd.Flags().String("user", "", "")
_ = chatMessageListBySenderCmd.Flags().MarkHidden("user")
chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
_ = chatMessageListBySenderCmd.MarkFlagRequired("start")
chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
chatMessageListBySenderCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageListBySenderCmd.Flags().MarkHidden("size")
@@ -4476,10 +4558,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
// list-mentions flags
chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)")
chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
_ = chatMessageListMentionsCmd.MarkFlagRequired("start")
chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
_ = chatMessageListMentionsCmd.MarkFlagRequired("end")
chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageListMentionsCmd.Flags().MarkHidden("size")
@@ -4504,10 +4584,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名")
_ = chatMessageSearchCmd.Flags().MarkHidden("keyword")
chatMessageSearchCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则搜索所有会话)")
chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
_ = chatMessageSearchCmd.MarkFlagRequired("start")
chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
_ = chatMessageSearchCmd.MarkFlagRequired("end")
chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)")
chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageSearchCmd.Flags().MarkHidden("size")
@@ -4667,9 +4745,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
userID := rawUserID
openDingTalkID := rawOpenDingTalkID
if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) {
userID = openDingTalkID
openDingTalkID = ""
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
@@ -5917,6 +5996,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Short: "下载消息中的资源(图片/视频/语音等)到本地",
Long: `下载聊天消息中的图片、视频、语音等资源到本地文件。
本命令只支持聊天消息 mediaId 下载,不支持钉盘 fileId。
如需用 fileId 下载,请使用钉盘/drive 下载命令。
流程:
1. 根据 resourceType + resource-id + message-id + open-conversation-id 向服务端获取下载 URL
2. HTTP GET 下载文件到本地
@@ -5927,6 +6009,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./photo.jpg
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
# 不支持钉盘 fileId;fileId 下载请使用钉盘/drive 下载命令
# message-id: 从 dws chat message list 返回的 openMessageId
# open-conversation-id: 从 dws chat search 获取 openConversationId`,
PreRunE: func(cmd *cobra.Command, args []string) error {
@@ -6056,16 +6139,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Selection: contract.SelectionSpec{
AgentSummary: "下载消息中的媒体资源到本地",
UseWhen: []string{"已知消息、会话和资源 ID,需要保存媒体文件时"},
AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令"},
AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令", "资源是钉盘 fileId 时使用钉盘/drive 下载命令"},
Examples: []string{"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."},
},
},
})
// download-media flags
chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId (必填)")
chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId(必填;仅支持聊天消息 mediaId,不支持钉盘 fileId)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("type")
chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)")
chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值(必填;不是钉盘 fileId)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("resource-id")
chatMessageDownloadMediaCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id")
@@ -160,6 +160,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &productExampleCaller{}
if got := formatChatMessageListAllTime(0); got == "" {
t.Fatal("formatChatMessageListAllTime returned empty string")
}
commands := [][]string{
{"chmod", "chat.read", "--ttl="},
@@ -168,9 +171,17 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"},
{"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"},
{"message", "list-direct", "--user=D-user", "--limit=1"},
{"message", "list-all"},
{"message", "list-all", "--end=2026-01-02T00:00:00Z"},
{"message", "list-all", "--end=bad"},
{"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"},
{"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"},
{"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"},
{"message", "list-all", "--start=2027-01-01 00:00:00"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
{"message", "list-by-sender", "--sender-user-id=u1", "--end=2026-01-02T00:00:00Z"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"},
{"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"},
{"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"},
+25 -4
View File
@@ -14,6 +14,11 @@ import (
"github.com/spf13/cobra"
)
const (
helperCurrentDOpenID = "DAAAAAAAAAAAiE"
helperCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func newChatFlagTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "chat"}
cmd.Flags().String("forward", "", "")
@@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command {
return cmd
}
func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) {
userIDs, openIDs := splitChatIDValues([]string{
helperCurrentDOpenID,
"D-prefix-fixture-user",
"d-prefix-fixture-user",
"D-invalid",
"fixture-user-id",
})
if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID {
t.Fatalf("open IDs=%#v", openIDs)
}
if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" {
t.Fatalf("user IDs=%#v", userIDs)
}
}
func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
search := &cobra.Command{Use: "search"}
search.Flags().String("nicks", "", "")
@@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) {
if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID {
t.Fatalf("direct ID = %q, %v", id, err)
}
if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
t.Fatal("empty ID unexpectedly resolved")
}
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID {
t.Fatalf("direct IDs = %#v, %v", ids, err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}}
installScriptedCaller(t, caller)
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 {
t.Fatalf("resolved IDs = %#v, %v", ids, err)
}
caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
+4 -4
View File
@@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
caller := &chatFilePathCaller{}
commandArgs := []string{
"message", "send",
"--open-dingtalk-id=D-target",
"--open-dingtalk-id=" + helperCurrentDOpenID,
"--msg-type=file",
"--file-path=" + filePath,
}
@@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "init_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
"md5": fileMD5,
@@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "commit_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"uploadKey": "upload-key",
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
@@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
t.Fatalf("send direct target call = %#v", send)
}
if send.args["receiverOpenDingTalkId"] != "D-target" {
if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID {
t.Fatalf("send direct target = %#v", send.args)
}
if _, ok := send.args["openDingTalkId"]; ok {
@@ -90,3 +90,96 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
})
}
}
func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *testing.T) {
tests := []struct {
name string
args []string
contains []string
absent []string
}{
{
name: "list",
args: []string{"message", "list", "--help"},
contains: []string{
"--time 可选,不传时默认上海时间当前时间并向旧消息拉取",
"默认上海时间当前时间",
"未传 --time 时默认 older",
},
absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
},
{
name: "search",
args: []string{"message", "search", "--help"},
contains: []string{
"可选,不传时默认最近 7 天到当前时间",
"默认当前时间前 7 天",
"默认当前时间",
},
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
},
{
name: "list-by-sender",
args: []string{"message", "list-by-sender", "--help"},
contains: []string{
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
"默认当前时间前 7 天",
"默认当前时间",
},
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
},
{
name: "list-mentions",
args: []string{"message", "list-mentions", "--help"},
contains: []string{
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
"默认当前时间前 7 天",
"默认当前时间",
},
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
},
{
name: "list-all",
args: []string{"message", "list-all", "--help"},
contains: []string{
"--start 和 --end 可选,不传时默认最近 1 天到当前时间",
"默认当前时间前 1 天",
"默认当前时间",
},
absent: []string{"起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
},
{
name: "download-media",
args: []string{"message", "download-media", "--help"},
contains: []string{
"只支持聊天消息 mediaId 下载,不支持钉盘 fileId",
"fileId 下载请使用钉盘/drive 下载命令",
"仅支持聊天消息 mediaId,不支持钉盘 fileId",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs(test.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("dws chat %s: %v\n%s", strings.Join(test.args, " "), err, output.String())
}
help := output.String()
for _, want := range test.contains {
if !strings.Contains(help, want) {
t.Errorf("chat message %s help missing %q:\n%s", test.name, want, help)
}
}
for _, unwanted := range test.absent {
if strings.Contains(help, unwanted) {
t.Errorf("chat message %s help still contains %q:\n%s", test.name, unwanted, help)
}
}
})
}
}
@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"time"
@@ -148,6 +149,356 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
}
}
func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) {
tests := []struct {
name string
args []string
wantTool string
wantLookback time.Duration
}{
{
name: "list-all defaults to one day",
args: []string{"message", "list-all"},
wantTool: "search_messages_by_time_range",
wantLookback: 24 * time.Hour,
},
{
name: "list-by-sender defaults to seven days",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1"},
wantTool: "search_messages_by_sender",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "list-mentions defaults to seven days",
args: []string{"message", "list-mentions"},
wantTool: "search_at_me_message",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "search defaults to seven days",
args: []string{"message", "search", "--query", "发布"},
wantTool: "search_messages_by_keyword",
wantLookback: 7 * 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
before := time.Now()
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
if got.tool != tt.wantTool {
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
}
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
wantEndMin := before.Truncate(time.Second).UnixMilli()
wantEndMax := after.Add(time.Second).UnixMilli()
if endMs < wantEndMin || endMs > wantEndMax {
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
}
wantStartMin := before.Add(-tt.wantLookback).Truncate(time.Second).UnixMilli()
wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli()
if startMs < wantStartMin || startMs > wantStartMax {
t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax)
}
if tt.wantTool == "search_messages_by_time_range" {
assertChatMessageListAllTimeFormat(t, got.args["startTime"])
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
}
})
}
}
func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) {
endRaw := "2026-01-01T00:00:00+08:00"
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
wantTool string
wantLookback time.Duration
}{
{
name: "list-all defaults start one day before explicit end",
args: []string{"message", "list-all", "--end", endRaw},
wantTool: "search_messages_by_time_range",
wantLookback: 24 * time.Hour,
},
{
name: "list-by-sender defaults start seven days before explicit end",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--end", endRaw},
wantTool: "search_messages_by_sender",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "list-mentions defaults start seven days before explicit end",
args: []string{"message", "list-mentions", "--end", endRaw},
wantTool: "search_at_me_message",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "search defaults start seven days before explicit end",
args: []string{"message", "search", "--query", "发布", "--end", endRaw},
wantTool: "search_messages_by_keyword",
wantLookback: 7 * 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
if got.tool != tt.wantTool {
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
}
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
gotEndMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
if gotEndMs != endMs {
t.Fatalf("endTime = %d, want %d", gotEndMs, endMs)
}
wantStartMs := time.UnixMilli(endMs).Add(-tt.wantLookback).UnixMilli()
if startMs != wantStartMs {
t.Fatalf("startTime = %d, want %d", startMs, wantStartMs)
}
if tt.wantTool == "search_messages_by_time_range" {
assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs))
assertStringArg(t, got.args["endTime"], endRaw)
}
})
}
}
func TestChatMessageListAllDefaultStartFromTimezoneLessEndUsesShanghai(t *testing.T) {
previousLocal := time.Local
t.Cleanup(func() { time.Local = previousLocal })
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
t.Run(loc.String(), func(t *testing.T) {
time.Local = loc
caller := &chatMessagePaginationCaller{}
_, err := executeChatMessagePaginationCommand(t, caller, "message", "list-all", "--end", "2026-03-01 00:00:00")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
assertStringArg(t, got.args["endTime"], "2026-03-01 00:00:00")
assertStringArg(t, got.args["startTime"], "2026-02-28 00:00:00")
startMs, err := parseISOTimeToMillis("start", got.args["startTime"].(string))
if err != nil {
t.Fatal(err)
}
endMs, err := parseISOTimeToMillis("end", got.args["endTime"].(string))
if err != nil {
t.Fatal(err)
}
if gotWindow := time.Duration(endMs-startMs) * time.Millisecond; gotWindow != 24*time.Hour {
t.Fatalf("window = %v, want 24h", gotWindow)
}
})
}
}
func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) {
startRaw := "2026-01-01T00:00:00+08:00"
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
wantTool string
}{
{
name: "list-all defaults end to now",
args: []string{"message", "list-all", "--start", startRaw},
wantTool: "search_messages_by_time_range",
},
{
name: "list-by-sender defaults end to now",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", startRaw},
wantTool: "search_messages_by_sender",
},
{
name: "list-mentions defaults end to now",
args: []string{"message", "list-mentions", "--start", startRaw},
wantTool: "search_at_me_message",
},
{
name: "search defaults end to now",
args: []string{"message", "search", "--query", "发布", "--start", startRaw},
wantTool: "search_messages_by_keyword",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
before := time.Now()
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
if got.tool != tt.wantTool {
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
}
gotStartMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
if gotStartMs != startMs {
t.Fatalf("startTime = %d, want %d", gotStartMs, startMs)
}
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
wantEndMin := before.Truncate(time.Second).UnixMilli()
wantEndMax := after.Add(time.Second).UnixMilli()
if endMs < wantEndMin || endMs > wantEndMax {
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
}
if tt.wantTool == "search_messages_by_time_range" {
assertStringArg(t, got.args["startTime"], startRaw)
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
}
})
}
}
func TestChatMessageListAllRejectsInvalidTimeBounds(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "invalid start",
args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"},
wantErr: "cannot parse time for --start",
},
{
name: "end before start",
args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"},
wantErr: "--end must be after --start",
},
{
name: "default end before future start",
args: []string{"message", "list-all", "--start", "2027-01-01 00:00:00"},
wantErr: "--end must be after --start",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want contains %q", err.Error(), tt.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want no MCP call", caller.calls)
}
})
}
}
func numericArgAsInt64(t *testing.T, value any) int64 {
t.Helper()
switch v := value.(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
case string:
parsed, err := parseISOTimeToMillis("time", v)
if err != nil {
t.Fatalf("parse time arg %q: %v", v, err)
}
return parsed
default:
t.Fatalf("unsupported numeric arg type %T (%#v)", value, value)
return 0
}
}
func chatMessageTimeArgAsMillis(t *testing.T, value any, tool string) int64 {
t.Helper()
if tool == "search_messages_by_time_range" {
return parseChatMessageListAllTimeArg(t, value).UnixMilli()
}
return numericArgAsInt64(t, value)
}
func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time {
t.Helper()
raw, ok := value.(string)
if !ok {
t.Fatalf("time arg = %#v, want time string", value)
}
if strings.Contains(raw, "T") {
parsedMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
return time.UnixMilli(parsedMs)
}
parsedMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
return time.UnixMilli(parsedMs)
}
func assertChatMessageListAllTimeFormat(t *testing.T, value any) {
t.Helper()
raw, ok := value.(string)
if !ok {
t.Fatalf("time arg = %#v, want time string", value)
}
if strings.Contains(raw, "T") {
t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw)
}
if _, err := parseISOTimeToMillis("time", raw); err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
}
func assertStringArg(t *testing.T, value any, want string) {
t.Helper()
got, ok := value.(string)
if !ok || got != want {
t.Fatalf("arg = %#v, want %q", value, want)
}
}
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
tests := []struct {
name string
+119 -26
View File
@@ -724,6 +724,99 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.
}
}
func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) {
previousLocal := time.Local
t.Cleanup(func() { time.Local = previousLocal })
tests := []struct {
name string
args []string
wantTool string
wantTarget map[string]any
}{
{
name: "group",
args: []string{"message", "list", "--group", "cid-1", "--limit", "50"},
wantTool: "list_conversation_message_v2",
wantTarget: map[string]any{"openconversation_id": "cid-1"},
},
{
name: "user",
args: []string{"message", "list", "--user", "user-1", "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"userId": "user-1"},
},
{
name: "user open DingTalk ID fallback",
args: []string{"message", "list", "--user", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
{
name: "open DingTalk ID",
args: []string{"message", "list", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
{
name: "direct user",
args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"userId": "user-1"},
},
{
name: "direct open DingTalk ID",
args: []string{"message", "list-direct", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
}
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
t.Run(loc.String(), func(t *testing.T) {
time.Local = loc
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
before := time.Now()
err := executeChatChangedContract(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one MCP call", caller.calls)
}
if caller.calls[0].toolName != tt.wantTool {
t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool)
}
for key, want := range tt.wantTarget {
if got := caller.calls[0].args[key]; got != want {
t.Fatalf("arg %s = %#v, want %#v", key, got, want)
}
}
raw, ok := caller.calls[0].args["time"].(string)
if !ok || raw == "" {
t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"])
}
gotMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
wantMin := before.Add(-time.Second).UnixMilli()
wantMax := after.Add(time.Second).UnixMilli()
if gotMs < wantMin || gotMs > wantMax {
t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax)
}
if caller.calls[0].args["forward"] != false {
t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"])
}
})
}
})
}
}
func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
@@ -786,11 +879,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
},
}
@@ -819,11 +912,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
}
@@ -857,25 +950,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply",
@@ -883,15 +976,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--ref-sender", helperCurrentDOpenID,
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "reply adds missing member placeholders",
@@ -899,13 +992,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
"--ref-sender", helperCurrentDOpenID,
"--text", "DWS synthetic reply mention test",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
},
{
name: "reply adds missing member placeholders after at-all",
@@ -913,15 +1006,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply at-all preserves alliance word",
@@ -929,7 +1022,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
"--at-all",
},
@@ -943,7 +1036,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
},
contentField: "content",
+214 -6
View File
@@ -492,10 +492,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
return nil
}
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
// 2. HTTP PUT file content to OSS
// 3. insert_document_block with attachment element
// 4. list_document_blocks → prove the uploaded resource is visible in the document
func runMediaInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -551,7 +552,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -569,7 +570,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 2: HTTP PUT file to OSS
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -592,7 +593,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 3: insert block into document
deps.Out.PrintInfo("[3/3] 插入块到文档...")
deps.Out.PrintInfo("[3/4] 插入块到文档...")
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
@@ -644,7 +645,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
insertArgs["referenceBlockId"] = v
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
if err != nil {
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
@@ -665,6 +667,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
apperrors.WithCause(err),
)
}
insertResult := map[string]any{}
if strings.TrimSpace(insertText) != "" {
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
}
}
insertedBlockID := insertedDocBlockID(insertResult)
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
if verifyErr != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
}
if insertedBlockID == "" {
insertedBlockID = verifiedBlockID
}
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
@@ -674,16 +693,205 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
"inserted": true, "verified": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "success"},
},
})
}
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
return apperrors.NewAPI(
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_verification_failed"),
apperrors.WithFailureStage("verify"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "failed"},
},
}),
apperrors.WithCause(cause),
)
}
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
if err != nil {
lastErr = err
} else {
lastErr = nil
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
return found, nil
}
}
if attempt < len(delays) {
helperSleep(delays[attempt])
}
}
if lastErr != nil {
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
}
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
}
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
const pageSize = 50
const maxItems = 5000
all := make([]any, 0, pageSize)
seen := map[string]bool{}
for start := 0; start < maxItems; start += pageSize {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
}
payload = nestedDocMap(payload)
blocks, ok := payload["blocks"].([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
encoded, _ := json.Marshal(blocks)
key := string(encoded)
if key != "[]" && seen[key] {
return nil, fmt.Errorf("list_document_blocks 分页停滞")
}
seen[key] = true
all = append(all, blocks...)
hasMore, hasMoreKnown := payload["hasMore"].(bool)
if hasMoreKnown && !hasMore {
return all, nil
}
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
return all, nil
}
if !hasMoreKnown && len(blocks) < pageSize {
return all, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
}
}
return nil, fmt.Errorf("文档块超过安全回读上限")
}
func nestedDocMap(data map[string]any) map[string]any {
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
return nestedDocMap(nested)
}
}
return data
}
func nestedDocString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
case []any:
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
}
return ""
}
// insertedDocBlockID only accepts explicit block IDs from the insert result or
// known response wrappers. Arbitrary IDs may belong to the document, operator,
// or request and must not become a hard constraint for the media readback.
func insertedDocBlockID(data map[string]any) string {
for _, key := range []string{"blockId", "elementId"} {
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if text := insertedDocBlockID(inner); text != "" {
return text
}
}
}
return ""
}
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
for _, value := range blocks {
candidateID := nestedDocString(value, "blockId", "id", "uuid")
if candidateID == "" || (blockID != "" && candidateID != blockID) {
continue
}
mediaValue := docMediaReadbackValue(value)
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
return candidateID
}
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
return candidateID
}
}
return ""
}
func docMediaReadbackValue(value any) any {
block, ok := value.(map[string]any)
if !ok {
return value
}
encoded, ok := block["jsonml"].(string)
if !ok || strings.TrimSpace(encoded) == "" {
return value
}
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) != nil {
return value
}
return decoded
}
func docNumberAsInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
if typed >= 0 && typed == float64(int(typed)) {
return int(typed), true
}
case int:
return typed, typed >= 0
}
return 0, false
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
var data map[string]any
+158 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +14,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -74,6 +76,7 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Cleanup(func() { os.Args = oldArgs })
oldPut, oldGet := httpPutFile, httpGetFile
t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
testseam.Swap(t, &helperSleep, func(time.Duration) {})
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
@@ -193,6 +196,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Fatal("media insert failure returned nil")
}
})
t.Run("media insert response parse failure", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`},
{text: `{`},
}}
if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
t.Fatal("invalid media insert response returned nil")
}
})
t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"blockId":"media-block"}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
if caller.args["format"] != "jsonml" {
t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"])
}
})
t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
})
t.Run("large image becomes attachment", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
large := filepath.Join(t.TempDir(), "large.png")
@@ -202,7 +246,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
if err := os.Truncate(large, 21*1024*1024); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{}`},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, large, "image/png"); err != nil {
t.Fatal(err)
}
@@ -240,6 +288,115 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) {
testseam.Swap(t, &helperSleep, func(time.Duration) {})
ctx := context.Background()
for _, tc := range []struct {
name string
steps []scriptedToolStep
}{
{"call failure", []scriptedToolStep{{err: errors.New("read")}}},
{"invalid json", []scriptedToolStep{{text: `{`}}},
{"missing blocks", []scriptedToolStep{{text: `{}`}}},
{"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}},
{"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}},
} {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("defensive readback returned nil")
}
})
}
t.Run("total count and nested payload", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}})
blocks, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("unknown pagination short page", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}})
if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("bounded retry failure", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil {
t.Fatal("missing media unexpectedly verified")
}
})
t.Run("transient read failure retries", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{err: errors.New("temporary read failure")},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
installScriptedCaller(t, caller)
blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "")
if err != nil || blockID != "media-block" || caller.calls != 2 {
t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err)
}
})
t.Run("block identity alone does not verify media", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil {
t.Fatal("matching block ID with a different resource unexpectedly verified")
}
})
t.Run("block read safety limit", func(t *testing.T) {
steps := make([]scriptedToolStep, 100)
for index := range steps {
steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)}
}
installScriptedCaller(t, &scriptedToolCaller{steps: steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("oversized block read returned nil")
}
})
if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true {
t.Fatalf("nested map=%#v", got)
}
if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" {
t.Fatal("nested string traversal failed")
}
if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" {
t.Fatalf("trusted inserted block ID = %q, want block", got)
}
if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" {
t.Fatalf("untrusted inserted block ID = %q, want empty", got)
}
blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}}
if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" {
t.Fatal("media block matching failed")
}
if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" {
t.Fatal("invalid JSONML unexpectedly verified")
}
if got := docMediaReadbackValue("plain"); got != "plain" {
t.Fatalf("non-object media readback = %#v, want unchanged value", got)
}
for _, tc := range []struct {
value any
want bool
}{
{float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false},
} {
_, ok := docNumberAsInt(tc.value)
if ok != tc.want {
t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
}
}
}
func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
t.Cleanup(func() {
+24 -4
View File
@@ -413,6 +413,13 @@ func newDriveCommand() *cobra.Command {
}
}
// --type/--start/--end 客户端过滤:激活即切 BFS 全量拉取后筛(两路由统一);
// 未启用返回零值,存量分支字节级不变。互斥/非法值/start>end 在此拒绝。
filter, err := parseDriveListFilter(cmd)
if err != nil {
return err
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
@@ -454,7 +461,8 @@ func newDriveCommand() *cobra.Command {
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 || latest > 0 {
// filter 激活时 depth==1 也走 BFS 退化态(全量拉取→CLI 侧筛)。
if depth > 1 || latest > 0 || filter.active() {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -463,7 +471,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
}
if pattern != "" {
return &CLIError{
@@ -487,7 +495,9 @@ func newDriveCommand() *cobra.Command {
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
}
if depth > 1 {
// 钉盘:filter 激活即 depth=1 单层退化态(全量翻完当前目录后 CLI 侧筛);
// filter+latest 单层同走 BFS(先筛后排,不走 runDriveListLatest 凑够即停)。
if depth > 1 || filter.active() {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
}
// 默认路由:钉盘文件列表
@@ -563,6 +573,11 @@ func newDriveCommand() *cobra.Command {
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
argsMap["withThumbnail"] = true
}
// --pattern 页内过滤(现状缺口附带修复:透传即打印无法夹过滤,取回解析后筛);
// 不带 pattern 时保持 callMCPTool 纯透传,存量行为不变。
if pattern != "" {
return callDriveListPageWithPattern(argsMap, pattern)
}
return callMCPTool("list_files", argsMap)
},
}
@@ -591,11 +606,13 @@ func newDriveCommand() *cobra.Command {
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
"要在已知目录内按类型/修改时间做无关键词筛选时:--type file --start 7d(钉盘与知识库路由均可,CLI 侧过滤)",
},
AvoidWhen: []string{
"只记得关键词、不知道所在目录时改用 dws drive search",
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
"要找最近打开/编辑过的文档改用 dws drive recent",
"带关键词的过滤改用 dws drive search(--extensions/--modified-from 等已可用)",
},
Examples: []string{
"dws drive list --limit 20 --format json",
@@ -1211,6 +1228,9 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
driveListCmd.Flags().String("end", "", "按修改时间过滤·截止: 语法同 --start(客户端过滤,互斥同 --type)(可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
+17 -11
View File
@@ -178,7 +178,8 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
// filter 零值 = 未启用;启用时由 emitDriveDepthResult 管线在 pattern 之后、latest 之前筛。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int, filter driveListFilter) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +210,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +221,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +234,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
}
if err != nil {
folderErr = err
@@ -297,7 +298,7 @@ bfs:
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
return emitErr
}
return folderErr
@@ -338,18 +339,18 @@ bfs:
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -364,8 +365,13 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
if filter.active() {
// 类型(route.isFolder 反判)+ 时间(直读收集段注入的 sortTime 毫秒)区间筛
items = applyDriveListFilter(items, route, filter)
}
if latest > 0 {
items = applyDriveListLatest(items, latest)
// --type folder 时 latest 对过滤后的目录取 Top-N,避免「先留目录再剔目录」的空结果。
items = applyDriveListLatest(items, latest, filter.nodeType == "folder")
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
@@ -383,7 +389,7 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
maxDepth = d
}
}
if latest > 0 && reqDepth == 1 {
if (latest > 0 || filter.active()) && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
if items == nil {
+17 -17
View File
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err == nil {
t.Fatal("failing writer returned nil")
}
}
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0, driveListFilter{})
return decodeDepthResult(t, out), err
}
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0, driveListFilter{}); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 3 {
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil {
t.Fatal("root failure returned nil")
}
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil {
t.Fatal("unrecoverable returned nil")
}
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0, driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
+3 -2
View File
@@ -49,10 +49,11 @@ func driveLatestExclusiveError(flag string, latest int) error {
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
// foldersOnly=true 时对目录取 Top-N(--type folder --latest 组合),否则对文件取 Top-N。
func applyDriveListLatest(items []map[string]any, latest int, foldersOnly bool) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
if (isDriveDepthFolder(item) || isDocDepthFolder(item)) != foldersOnly {
continue
}
files = append(files, item)
+232
View File
@@ -0,0 +1,232 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// drive list --type/--start/--end 客户端过滤(CLI 侧筛)
//
// 两路由统一(拍板⑤):钉盘与知识库均为全量拉取后在进程内筛——
// 复用 depth/latest 的 BFS 基建(钉盘单层 = depth=1 退化态,全量翻完当前目录不下钻),
// 过滤挂在 emitDriveDepthResult 管线内(pattern 名称筛之后、latest Top-N 之前)。
// 类型判定复用 route.isFolder 反判;时间比较直读 BFS 收集段注入的 sortTime 毫秒。
// ──────────────────────────────────────────────────────────
type driveListFilter struct {
nodeType string // ""=不过滤 / "file" / "folder"
startMs int64
endMs int64
hasStart bool
hasEnd bool
}
func (f driveListFilter) active() bool {
return f.nodeType != "" || f.hasStart || f.hasEnd
}
// parseDriveListFilter 读取并校验 --type/--start/--end。
// 三 flag 均未给值时返回零值(active()=false,调用方走存量分支)。
// 非法值 / start>end / 互斥组合在此拒绝(退出码 3,风格对齐 drive_latest.go 互斥条款)。
func parseDriveListFilter(cmd *cobra.Command) (driveListFilter, error) {
var filter driveListFilter
nodeType, _ := cmd.Flags().GetString("type")
startRaw, _ := cmd.Flags().GetString("start")
endRaw, _ := cmd.Flags().GetString("end")
nodeType = strings.TrimSpace(nodeType)
startRaw = strings.TrimSpace(startRaw)
endRaw = strings.TrimSpace(endRaw)
if nodeType == "" && startRaw == "" && endRaw == "" {
return filter, nil
}
if nodeType != "" {
switch strings.ToLower(nodeType) {
case "file", "folder":
filter.nodeType = strings.ToLower(nodeType)
default:
return filter, &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--type 取值非法: %q(合法值: file|folder)", nodeType),
}
}
}
if startRaw != "" {
ms, err := parseDriveListTime(startRaw)
if err != nil {
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--start %s", err)}
}
filter.startMs = ms
filter.hasStart = true
}
if endRaw != "" {
ms, err := parseDriveListTime(endRaw)
if err != nil {
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--end %s", err)}
}
filter.endMs = ms
filter.hasEnd = true
}
if filter.hasStart && filter.hasEnd && filter.startMs > filter.endMs {
return filter, &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--start %q 晚于 --end %q,请修正时间范围", startRaw, endRaw),
}
}
// 互斥:filter 为 CLI 侧全量扫描模式,服务端分页/排序语义无法保持。
if cmd.Flags().Changed("versions") {
return filter, driveListFilterExclusiveError("versions", "--versions 为独立的版本列表模式,请去掉 --versions 或过滤条件")
}
// 用 Changed 判定而非值非空:显式 --cursor= 空值同样视为启用游标分页。
for _, f := range []string{"cursor", "next-token", "page-token"} {
if fl := cmd.Flags().Lookup(f); fl != nil && fl.Changed {
return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件")
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return filter, driveListFilterExclusiveError(f, "过滤模式输出由客户端筛选后重排,服务端排序语义无法保持;如需服务端排序请去掉过滤条件")
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return filter, driveListFilterExclusiveError("limit", "过滤模式为全量扫描,数据量由全局上限 2000 与目录范围控制;如需控制单页条数请去掉过滤条件")
}
return filter, nil
}
func driveListFilterExclusiveError(flag, guidance string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--type/--start/--end 不能与 --%s 同时使用:%s", flag, guidance),
}
}
// parseDriveListTime 单一时间解析入口:先试相对时间语法,失败回落 ISO8601
// (agoal.go parseISO8601ToMillis,RFC3339/无时区默认 Asia/Shanghai/仅日期)。
// 两 flag 共用同一入口;不接受毫秒时间戳(规约红线,与 search 历史遗留切割)。
func parseDriveListTime(value string) (int64, error) {
if ms, ok := parseDriveRelativeTimeAgo(value); ok {
return ms, nil
}
if ms, err := parseISO8601ToMillis(value); err == nil {
return ms, nil
}
return 0, fmt.Errorf("时间格式不支持: %q(支持: 相对时间如 24h/7d/2w、RFC3339 如 2026-08-01T00:00:00+08:00、无时区 ISO8601 如 2026-08-01 08:00:00(默认 Asia/Shanghai)、仅日期 2026-08-01;不支持毫秒时间戳与 m 单位)", value)
}
// parseDriveRelativeTimeAgo 相对时间 Nh/Nd/Nw(小时/天/周),按本机时钟换算为绝对毫秒。
// 不支持 m 单位(lark 双解析器 m=分钟/月语义打架的教训,直接规避);
// ok=false 表示非相对时间语法,由调用方回落 ISO8601 解析。
func parseDriveRelativeTimeAgo(value string) (int64, bool) {
s := strings.TrimSpace(value)
if len(s) < 2 {
return 0, false
}
var unit time.Duration
switch s[len(s)-1] {
case 'h':
unit = time.Hour
case 'd':
unit = 24 * time.Hour
case 'w':
unit = 7 * 24 * time.Hour
default:
return 0, false
}
n, err := strconv.Atoi(s[:len(s)-1])
if err != nil || n <= 0 {
return 0, false
}
return time.Now().Add(-time.Duration(n) * unit).UnixMilli(), true
}
// applyDriveListFilter 在 emit 管线内做类型/时间筛(pattern 之后、latest Top-N 之前)。
func applyDriveListFilter(items []map[string]any, route driveDepthRoute, filter driveListFilter) []map[string]any {
filtered := make([]map[string]any, 0, len(items))
for _, item := range items {
if filter.nodeType != "" {
folder := route.isFolder(item)
if filter.nodeType == "folder" && !folder {
continue
}
if filter.nodeType == "file" && folder {
continue
}
}
if filter.hasStart || filter.hasEnd {
// sortTime 由 BFS 收集段无条件注入(drive_depth.go 时间戳归一);
// 无时间信息的条目(sortTime<=0)在时间条件下不可判定,保守滤除。
ts, _ := item["sortTime"].(int64)
if ts <= 0 {
continue
}
if filter.hasStart && ts < filter.startMs {
continue
}
if filter.hasEnd && ts > filter.endMs {
continue
}
}
filtered = append(filtered, item)
}
return filtered
}
// callDriveListPageWithPattern 单层钉盘 --pattern 页内过滤(现状缺口附带修复:
// 纯透传分支此前未消费 pattern,flag 文案承诺的客户端过滤静默失效)。
// callMCPTool 透传即打印、无夹过滤的钩子,故取回解析后页内筛再输出;
// 输出保留原 body 形态(nextToken 等分页字段不动),仅条目数组被筛。
func callDriveListPageWithPattern(argsMap map[string]any, pattern string) error {
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": "list_files",
"arguments": argsMap,
"pattern": pattern,
"note": "pattern 为客户端过滤,仅作用于本页返回条目",
})
}
text, err := callMCPToolReturnText(context.Background(), "list_files", argsMap)
if err != nil {
return err
}
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
// 非 JSON 返回无法筛,原样输出(与透传分支的兜底形态一致)。
deps.Out.PrintRaw(text)
return nil
}
target := body
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
target = inner
}
if key := driveDepthListItemsKey(target); key != "" {
arr, _ := target[key].([]any)
filtered := make([]any, 0, len(arr))
for _, entry := range arr {
item, ok := entry.(map[string]any)
if !ok {
filtered = append(filtered, entry)
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if matchDriveNamePattern(name, pattern) {
filtered = append(filtered, entry)
}
}
target[key] = filtered
}
return deps.Out.PrintJSON(body)
}
+587
View File
@@ -0,0 +1,587 @@
package helpers
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// drive list --type/--start/--end 客户端过滤测试矩阵(设计稿 §9.3)。
// 命令级用例统一经 executeDriveListCapture 捕获 stdout JSON;
// 零值 filter 的存量分支行为由 drive_depth_test.go / drive_list_modes_test.go 既有用例兜底。
// 钉盘路由(serverID 空)经 resolveProductID 扫 os.Args 推断产品,命令级测试需伪装命令行。
func useDriveListArgs(t *testing.T) {
t.Helper()
old := os.Args
os.Args = []string{"dws", "drive", "list"}
t.Cleanup(func() { os.Args = old })
}
func executeDriveListCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
buf := &bytes.Buffer{}
deps.Out.w = buf
deps.Out.errW = io.Discard
root := newDriveCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return buf, err
}
// 1. 正向:--workspace --type file → list_nodes 全量拉取后仅 file,单层剥装饰字段。
func TestCrossPlatformCoverageDriveListFilterWorkspaceTypeFile(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"n1","name":"doc1","nodeType":"doc","updateTime":1754000000000},
{"nodeId":"n2","name":"sub","nodeType":"folder","updateTime":1754000000000}
],"hasMore":false}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
}
result := decodeDepthResult(t, buf)
items := result["items"].([]any)
if len(items) != 1 {
t.Fatalf("filtered items = %#v", items)
}
item := items[0].(map[string]any)
if item["nodeId"] != "n1" {
t.Fatalf("item = %#v", item)
}
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
if _, ok := item[key]; ok {
t.Fatalf("decoration %q not stripped: %#v", key, item)
}
}
if result["truncated"] != false || result["maxDepth"] != float64(1) {
t.Fatalf("result = %#v", result)
}
}
// 2. 正向:--workspace --start 7d --end <RFC3339> → sortTime 区间断言。
func TestCrossPlatformCoverageDriveListFilterWorkspaceTimeRange(t *testing.T) {
now := time.Now()
within := now.Add(-24 * time.Hour).UnixMilli()
before := now.Add(-30 * 24 * time.Hour).UnixMilli()
after := now.Add(24 * time.Hour).UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"nodes":[
{"nodeId":"in","name":"within","nodeType":"doc","updateTime":%d},
{"nodeId":"old","name":"before","nodeType":"doc","updateTime":%d},
{"nodeId":"new","name":"after","nodeType":"doc","updateTime":%d}
],"hasMore":false}`, within, before, after)},
}}
buf, err := executeDriveListCapture(t, caller,
"list", "--workspace", "ws-1", "--start", "7d", "--end", now.Format(time.RFC3339))
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["nodeId"] != "in" {
t.Fatalf("time-range items = %#v", items)
}
}
// 3. 组合:--workspace --depth 2 --type file → BFS 路径 filter 生效,装饰字段保留。
func TestCrossPlatformCoverageDriveListFilterWithDepthKeepsDecorations(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"fA","name":"dirA","nodeType":"folder"},
{"nodeId":"n1","name":"doc1","nodeType":"doc"}
],"hasMore":false}`},
{text: `{"nodes":[{"nodeId":"n2","name":"doc2","nodeType":"doc"}],"hasMore":false}`},
}}
buf, err := executeDriveListCapture(t, caller,
"list", "--workspace", "ws-1", "--depth", "2", "--type", "file")
if err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2(depth=2 下钻 dirA)", caller.calls)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 2 {
t.Fatalf("filtered items = %#v(folder 应被滤除)", items)
}
for _, raw := range items {
item := raw.(map[string]any)
if _, ok := item["depth"]; !ok {
t.Fatalf("depth>1 装饰字段应保留: %#v", item)
}
}
}
// 4. 组合:--workspace --type file --latest 3 → filter 先筛后 Top-N;
// 触顶时 LATEST_SCAN_TRUNCATED 拒绝优先于 filter 的 partial 语义。
func TestCrossPlatformCoverageDriveListFilterWithLatestTopN(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"d1","name":"dir","nodeType":"folder","updateTime":400},
{"nodeId":"f1","name":"a","nodeType":"doc","updateTime":100},
{"nodeId":"f2","name":"b","nodeType":"doc","updateTime":300},
{"nodeId":"f3","name":"c","nodeType":"doc","updateTime":200},
{"nodeId":"f4","name":"d","nodeType":"doc","updateTime":50}
],"hasMore":false}`},
}}
buf, err := executeDriveListCapture(t, caller,
"list", "--workspace", "ws-1", "--type", "file", "--latest", "2")
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 2 {
t.Fatalf("top-n items = %#v", items)
}
// folder(updateTime 最大)不得进入 Top-N 排序基;筛选后按 sortTime 倒序。
if items[0].(map[string]any)["nodeId"] != "f2" || items[1].(map[string]any)["nodeId"] != "f3" {
t.Fatalf("top-n order = %#v", items)
}
}
func TestCrossPlatformCoverageDriveListFilterLatestTruncatedRejected(t *testing.T) {
useDriveListArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
_, err := executeDriveListCapture(t, caller, "list", "--type", "file", "--latest", "2")
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err = %v, want LATEST_SCAN_TRUNCATED", err)
}
}
// 5. 钉盘路由:--folder + --type file 单层退化态全量翻页后仅 file;
// --start 7d --latest 3 走 BFS(depth=1);filter 与 cursor/order-by/order/limit/versions 互斥退出码 3。
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFile(t *testing.T) {
useDriveListArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"d1","name":"sub","type":"FOLDER"},
{"fileId":"f1","name":"a.txt","type":"FILE"}
],"nextToken":"p2"}`},
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "file")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want 2(翻完当前目录两页,sub 不下钻)", len(caller.calls))
}
if caller.calls[0]["parentId"] != "root-1" || caller.calls[0]["maxResults"] != float64(driveDepthPageSize) {
t.Fatalf("page args = %#v", caller.calls[0])
}
if caller.calls[1]["nextToken"] != "p2" {
t.Fatalf("page2 args = %#v", caller.calls[1])
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 2 {
t.Fatalf("filtered items = %#v", items)
}
for _, raw := range items {
item := raw.(map[string]any)
if item["type"] != "FILE" {
t.Fatalf("folder leaked: %#v", item)
}
if _, ok := item["depth"]; ok {
t.Fatalf("single-layer decorations not stripped: %#v", item)
}
}
}
func TestCrossPlatformCoverageDriveListFilterPanStartWithLatest(t *testing.T) {
useDriveListArgs(t)
now := time.Now()
within := now.Add(-24 * time.Hour).UnixMilli()
stale := now.Add(-30 * 24 * time.Hour).UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
{"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":%d},
{"fileId":"f3","name":"c.txt","type":"FILE","modifyTime":%d}
]}`, within-1000, within, stale)},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d", "--latest", "1")
if err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, buf)
if _, ok := result["truncated"]; !ok {
t.Fatalf("filter+latest 应走 BFS 聚合形态: %#v", result)
}
items := result["items"].([]any)
// --start 7d 先筛掉 stale,Top-1 取 sortTime 最大的 f2。
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f2" {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageDriveListFilterExclusiveFlags(t *testing.T) {
cases := [][]string{
{"list", "--type", "file", "--cursor", "c1"},
{"list", "--type", "file", "--cursor", ""},
{"list", "--type", "file", "--order-by", "name"},
{"list", "--start", "7d", "--order", "asc"},
{"list", "--type", "file", "--limit", "5"},
{"list", "--end", "2026-08-01", "--max", "5"},
{"list", "--versions", "--node", "n1", "--type", "file"},
}
for _, args := range cases {
caller := &scriptedToolCaller{}
_, err := executeDriveListCapture(t, caller, args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("args %v: err = %v, want CLIError exit 3", args, err)
}
if !strings.Contains(err.Error(), "不能与") || !strings.Contains(err.Error(), "同时使用") {
t.Fatalf("args %v: err = %v, want exclusivity message", args, err)
}
if caller.calls != 0 {
t.Fatalf("args %v: calls = %d, want 0", args, caller.calls)
}
}
}
// 6. 边界:--type 非法值列合法值;start>end 退出码 3;相对时间解析与 m/毫秒拒绝。
func TestCrossPlatformCoverageDriveListFilterInvalidValues(t *testing.T) {
caller := &scriptedToolCaller{}
_, err := executeDriveListCapture(t, caller, "list", "--type", "dir")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("--type dir err = %v, want exit 3", err)
}
if !strings.Contains(err.Error(), "file|folder") {
t.Fatalf("--type dir err = %v, want valid values listed", err)
}
_, err = executeDriveListCapture(t, caller, "list", "--start", "2026-08-10", "--end", "2026-08-01")
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("start>end err = %v, want exit 3", err)
}
if !strings.Contains(err.Error(), "晚于") {
t.Fatalf("start>end err = %v", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListFilterTimeParsing(t *testing.T) {
// 相对时间按本机时钟换算为绝对毫秒。
for _, tc := range []struct {
raw string
unit time.Duration
}{
{"24h", time.Hour},
{"7d", 24 * time.Hour},
{"2w", 7 * 24 * time.Hour},
} {
before := time.Now()
ms, err := parseDriveListTime(tc.raw)
after := time.Now()
if err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
expected := time.Duration(mustAtoi(t, tc.raw[:len(tc.raw)-1])) * tc.unit
lo := before.Add(-expected).UnixMilli()
hi := after.Add(-expected).UnixMilli()
if ms < lo || ms > hi {
t.Fatalf("%s: ms = %d, want in [%d, %d]", tc.raw, ms, lo, hi)
}
}
// RFC3339 / 无时区 ISO8601(默认 Asia/Shanghai)/ 仅日期。
shanghai := time.FixedZone("CST", 8*3600)
for _, tc := range []struct {
raw string
want int64
}{
{"2026-08-01T00:00:00+08:00", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
{"2026-08-01 08:00:00", time.Date(2026, 8, 1, 8, 0, 0, 0, shanghai).UnixMilli()},
{"2026-08-01", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
} {
ms, err := parseDriveListTime(tc.raw)
if err != nil || ms != tc.want {
t.Fatalf("%s: ms = %d err = %v, want %d", tc.raw, ms, err, tc.want)
}
}
// m 单位 / 毫秒时间戳 / 零与负值一律拒绝。
for _, raw := range []string{"7m", "30m", "1754000000000", "0d", "-3d", "abc"} {
if _, err := parseDriveListTime(raw); err == nil {
t.Fatalf("%q accepted, want rejection", raw)
}
}
}
func mustAtoi(t *testing.T, s string) int {
t.Helper()
n := 0
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
t.Fatalf("atoi %q: %v", s, err)
}
return n
}
// 7. dry-run:--workspace --type file --dry-run → printDriveDepthDryRun 路径,不发起调用。
func TestCrossPlatformCoverageDriveListFilterDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run calls = %d", caller.calls)
}
result := decodeDepthResult(t, buf)
if result["dry_run"] != true || result["tool"] != "list_nodes" || result["maxDepth"] != float64(1) {
t.Fatalf("dry-run payload = %#v", result)
}
}
// 8. 触顶:2000 条上限 → partial + truncated=true + 退出码 0(filter 触顶结果不全但不会错)。
func TestCrossPlatformCoverageDriveListFilterTruncatedPartial(t *testing.T) {
useDriveListArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
buf, err := executeDriveListCapture(t, caller, "list", "--type", "file")
if err != nil {
t.Fatalf("filter 触顶应放行(退出码 0): %v", err)
}
result := decodeDepthResult(t, buf)
if result["truncated"] != true {
t.Fatalf("truncated = %#v", result["truncated"])
}
if got := len(result["items"].([]any)); got != driveDepthMaxItems {
t.Fatalf("items len = %d, want %d(全 FILE 类型筛全保留)", got, driveDepthMaxItems)
}
}
// 9. 回归:不带 filter 的存量分支行为不变;--pattern 单层钉盘页内过滤为本次有意修复。
func TestCrossPlatformCoverageDriveListFilterRegressionUnaffectedPaths(t *testing.T) {
useDriveListArgs(t)
// 单层纯透传(无 filter/pattern):body 原样输出,args 语义不变。
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt"}],"nextToken":"nt"}`},
}}
buf, err := executeDriveListCapture(t, caller, "list")
if err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, buf)
if result["nextToken"] != "nt" || len(result["items"].([]any)) != 1 {
t.Fatalf("透传 body 被改变: %#v", result)
}
if _, ok := result["truncated"]; ok {
t.Fatalf("透传形态不应含 BFS 聚合字段: %#v", result)
}
if caller.args["maxResults"] != float64(20) {
t.Fatalf("args = %#v", caller.args)
}
// workspace 单层透传:无 filter/depth/latest 时仍走单页 list_nodes。
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`},
}}
buf2, err := executeDriveListCapture(t, caller2, "list", "--workspace", "ws-1")
if err != nil {
t.Fatal(err)
}
result2 := decodeDepthResult(t, buf2)
if _, ok := result2["nodes"]; !ok {
t.Fatalf("workspace 单层透传形态被改变: %#v", result2)
}
if caller2.args["pageSize"] != 20 || caller2.args["workspaceId"] != "ws-1" {
t.Fatalf("args = %#v", caller2.args)
}
// 钉盘单层 --latest(无 filter):仍走 runDriveListLatest(非 BFS 聚合形态)。
now := time.Now().UnixMilli()
caller3 := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}],"nextToken":""}`, now)},
}}
buf3, err := executeDriveListCapture(t, caller3, "list", "--latest", "1")
if err != nil {
t.Fatal(err)
}
result3 := decodeDepthResult(t, buf3)
if _, ok := result3["truncated"]; ok {
t.Fatalf("单层 latest 不应切到 BFS 聚合形态: %#v", result3)
}
if len(result3["items"].([]any)) != 1 {
t.Fatalf("items = %#v", result3["items"])
}
if caller3.calls[0]["orderBy"] != "modifyTime" || caller3.calls[0]["order"] != "desc" {
t.Fatalf("latest 扫描参数被改变: %#v", caller3.calls[0])
}
}
func TestCrossPlatformCoverageDriveListPatternSingleLayerFixed(t *testing.T) {
useDriveListArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"f1","name":"a.txt"},
{"fileId":"f2","name":"b.md"}
],"nextToken":"nt"}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--pattern", "*.txt")
if err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, buf)
items := result["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
t.Fatalf("pattern 页内过滤未生效: %#v", items)
}
if result["nextToken"] != "nt" {
t.Fatalf("分页字段应保留: %#v", result)
}
}
// 10. 钉盘路由:--type folder 反向筛(FILE 被滤除,仅 FOLDER 保留)。
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFolder(t *testing.T) {
useDriveListArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"d1","name":"sub","type":"FOLDER"},
{"fileId":"f1","name":"a.txt","type":"FILE"}
]}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "folder")
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d1" {
t.Fatalf("folder filter items = %#v", items)
}
}
// 11. 钉盘路由:--start 下无时间信息的条目(sortTime<=0)不可判定,保守滤除。
func TestCrossPlatformCoverageDriveListFilterPanMissingTimeDropped(t *testing.T) {
useDriveListArgs(t)
now := time.Now().UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
{"fileId":"f2","name":"no-time.txt","type":"FILE"}
]}`, now)},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d")
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
t.Fatalf("missing-time 条目应被滤除: %#v", items)
}
}
// 12. 单层钉盘 --pattern 页内过滤边界:dry-run 预览 / MCP 错误透传 / 非 JSON 原样输出 /
// 非 map 条目保留 + name 缺失时 fileName 兜底匹配。
func TestCrossPlatformCoverageDriveListPatternPassthroughEdges(t *testing.T) {
useDriveListArgs(t)
// dry-run:打印预览,不发起调用。
dryCaller := &scriptedToolCaller{dry: true}
buf, err := executeDriveListCapture(t, dryCaller, "list", "--pattern", "*.txt")
if err != nil {
t.Fatal(err)
}
if dryCaller.calls != 0 {
t.Fatalf("dry-run calls = %d", dryCaller.calls)
}
preview := decodeDepthResult(t, buf)
if preview["dry_run"] != true || preview["tool"] != "list_files" || preview["pattern"] != "*.txt" {
t.Fatalf("dry-run payload = %#v", preview)
}
// MCP 错误:原样返回。
errCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
if _, err := executeDriveListCapture(t, errCaller, "list", "--pattern", "*.txt"); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("err = %v, want boom", err)
}
// 非 JSON 返回:无法筛,原样输出(与透传分支兜底形态一致)。
rawCaller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "plain-text-result"}}}
rawBuf, err := executeDriveListCapture(t, rawCaller, "list", "--pattern", "*.txt")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rawBuf.String(), "plain-text-result") {
t.Fatalf("raw output = %q", rawBuf.String())
}
// 非 map 条目原样保留;name 缺失时 fileName 兜底参与匹配。
mixedCaller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
"raw-entry",
{"fileId":"f1","fileName":"日报模板.doc"},
{"fileId":"f2","name":"其他.txt"}
],"nextToken":"nt"}`},
}}
mixedBuf, err := executeDriveListCapture(t, mixedCaller, "list", "--pattern", "*模板*")
if err != nil {
t.Fatal(err)
}
mixed := decodeDepthResult(t, mixedBuf)["items"].([]any)
if len(mixed) != 2 || mixed[0] != "raw-entry" || mixed[1].(map[string]any)["fileId"] != "f1" {
t.Fatalf("items = %#v", mixed)
}
}
// 13. --type folder --latest 回归(P1 CR):latest 对过滤后的目录按时间取 Top-N,
// 不再「先留目录再剔目录」返回空列表。
func TestCrossPlatformCoverageDriveListFilterFolderLatest(t *testing.T) {
useDriveListArgs(t)
now := time.Now().UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[
{"fileId":"d1","name":"old-dir","type":"FOLDER","modifyTime":%d},
{"fileId":"d2","name":"new-dir","type":"FOLDER","modifyTime":%d},
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}
]}`, now-5000, now, now)},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--type", "folder", "--latest", "1")
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d2" {
t.Fatalf("folder latest items = %#v", items)
}
}
@@ -21,7 +21,9 @@ import (
"io"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -39,11 +41,16 @@ type imReadResultCaller struct {
results map[string]*edition.ToolResult
errors map[string]error
calls []imReadResultCall
args map[string]any
dryRun bool
}
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName})
c.args = map[string]any{}
for k, v := range args {
c.args[k] = v
}
if err := c.errors[toolName]; err != nil {
return nil, err
}
@@ -203,6 +210,37 @@ func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLeg
}
}
func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) {
payload := `{"result":{"messages":[],"hasMore":false}}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
loc := shanghaiLocation()
before := time.Now().In(loc)
_, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--limit", "50")
after := time.Now().In(loc)
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
}
if got := caller.args["forward"]; got != false {
t.Fatalf("forward = %#v, want false when --time is omitted", got)
}
timeRaw, ok := caller.args["time"].(string)
if !ok || strings.TrimSpace(timeRaw) == "" {
t.Fatalf("time arg = %#v, want generated current time", caller.args["time"])
}
gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, loc)
if err != nil {
t.Fatalf("parse generated time %q: %v", timeRaw, err)
}
if gotTime.Before(before.Add(-time.Second)) || gotTime.After(after.Add(time.Second)) {
t.Fatalf("time = %s, want between %s and %s", gotTime, before, after)
}
}
func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
payload := `{
"result": {
+2 -2
View File
@@ -611,7 +611,7 @@ func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
{"nodeType": "Folder", "name": "folder"},
}
got := applyDriveListLatest(items, 10)
got := applyDriveListLatest(items, 10, false)
if len(got) != 3 {
t.Fatalf("len=%d", len(got))
}
@@ -674,7 +674,7 @@ func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
}
@@ -322,7 +322,7 @@ func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
}
got := applyDriveListLatest(items, 1)
got := applyDriveListLatest(items, 1, false)
if len(got) != 1 {
t.Fatalf("latest len=%d", len(got))
}
+17 -4
View File
@@ -324,11 +324,23 @@ func (m FlagMigration) validate() error {
if m.Canonical.After.Hidden {
return fmt.Errorf("canonical flag must remain visible")
}
if !m.Canonical.After.Required {
return fmt.Errorf("canonical flag must be required after migration")
// Requiredness belongs to the one logical parameter. The hidden legacy
// spelling must not remain independently required, while the canonical
// spelling inherits the exact before-state contract. An already-visible
// canonical flag cannot change requiredness; an existing hidden canonical
// placeholder may inherit it when promoted to the public spelling.
if m.Legacy.After.Required {
return fmt.Errorf("legacy compatibility alias must not remain independently required after migration")
}
if m.Canonical.Before.Present && m.Canonical.Before.Required {
return fmt.Errorf("canonical flag must be absent or optional before migration")
if m.Legacy.Before.Required != m.Canonical.After.Required {
return fmt.Errorf(
"flag requiredness must be preserved from legacy before to canonical after",
)
}
if m.Canonical.Before.Present &&
!m.Canonical.Before.Hidden &&
m.Canonical.Before.Required != m.Canonical.After.Required {
return fmt.Errorf("canonical flag requiredness must remain unchanged when already present")
}
if m.Legacy.After.AliasOf != m.Canonical.Name {
return fmt.Errorf(
@@ -682,6 +694,7 @@ func flagMigrationAuthorizesChange(
return true
}
if migration.Canonical.Before.Present &&
migration.Canonical.Before.Hidden &&
!migration.Canonical.Before.Required &&
migration.Canonical.After.Required &&
change.Kind == "flag_became_required" {
@@ -188,7 +188,7 @@ func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
optionalCanonical := func() FlagMigrationManifest {
manifest := coverageManifest(FlagMigrationPending)
manifest := coverageOptionalManifest(FlagMigrationPending)
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
@@ -269,6 +269,36 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
wantErr: "canonical flag must be present after migration",
},
{
name: "required legacy cannot become optional canonical",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Required = false },
wantErr: "requiredness must be preserved from legacy before to canonical after",
},
{
name: "optional legacy cannot become required canonical",
make: func() FlagMigrationManifest { return coverageOptionalManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Required = true },
wantErr: "requiredness must be preserved from legacy before to canonical after",
},
{
name: "existing canonical cannot change requiredness",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(manifest *FlagMigrationManifest) {
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
},
wantErr: "canonical flag requiredness must remain unchanged when already present",
},
{
name: "hidden legacy alias is not independently required",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Required = true },
wantErr: "legacy compatibility alias must not remain independently required",
},
{
name: "legacy after declares alias target",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
@@ -711,8 +741,32 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
}
})
t.Run("existing optional canonical becomes required", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
t.Run("optional canonical is introduced without becoming required", func(t *testing.T) {
pending := coverageOptionalManifest(FlagMigrationPending)
consumed := coverageOptionalManifest(FlagMigrationConsumed)
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", pending.Migrations[0].Command, pending.Migrations[0].Legacy.Name) {
t.Fatalf("fixture did not create flag_became_hidden: %#v", ordinary.Blocking)
}
if hasFlagChange(ordinary.Blocking, "required_flag_added", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("optional canonical was treated as 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("optional rename = (%#v, %v), want compatible", report, err)
}
})
t.Run("existing optional canonical remains optional", func(t *testing.T) {
pending := coverageOptionalManifest(FlagMigrationPending)
pending.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
@@ -725,8 +779,8 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
if hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("fixture changed canonical requiredness: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
@@ -735,7 +789,83 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
t.Fatalf("existing optional canonical migration = (%#v, %v), want compatible", report, err)
}
})
t.Run("hidden canonical inherits requiredness when promoted", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Scope: "local",
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("fixture did not change canonical requiredness: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("hidden canonical promotion = (%#v, %v), want compatible", report, err)
}
})
}
func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *testing.T) {
pending := coverageOptionalManifest(FlagMigrationPending)
consumed := coverageOptionalManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
t.Run("candidate cannot self authorize", func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
)
if err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error = %v", err)
}
})
t.Run("partial application remains rejected", func(t *testing.T) {
partial := coverageMigrationSnapshot(migration, false, false)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
_, err := CompareAllWithFlagMigrations(
partial,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "partially applied flag migration") {
t.Fatalf("partial optional migration error = %v", err)
}
})
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
_, 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)
}
})
}
@@ -886,6 +1016,13 @@ func coverageManifest(state string) FlagMigrationManifest {
}
}
func coverageOptionalManifest(state string) FlagMigrationManifest {
manifest := coverageManifest(state)
manifest.Migrations[0].Legacy.Before.Required = false
manifest.Migrations[0].Canonical.After.Required = false
return manifest
}
func coverageEmptyManifest() FlagMigrationManifest {
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
}
+62 -8
View File
@@ -5,6 +5,7 @@ package interfacesnapshot
import (
"errors"
"os"
"reflect"
"strings"
"testing"
@@ -37,6 +38,42 @@ const validFlagMigrationManifestJSON = `{
]
}`
func optionalFlagMigrationManifestJSON() string {
manifest := strings.Replace(
validFlagMigrationManifestJSON,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"before": {"present": true, "type": "string", "scope": "local"}`,
1,
)
return strings.Replace(
manifest,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
)
}
func hiddenCanonicalFlagMigrationManifestJSON() string {
return strings.Replace(
validFlagMigrationManifestJSON,
`"before": {"present": false}`,
`"before": {"present": true, "type": "string", "hidden": true, "scope": "local"}`,
1,
)
}
func TestApprovedFlagMigrationManifestRemainsValid(t *testing.T) {
manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-flag-migrations-v1.json")
if err != nil {
t.Fatalf("open approved flag migration manifest: %v", err)
}
defer manifest.Close()
if _, err := ReadFlagMigrationManifest(manifest); err != nil {
t.Fatalf("approved flag migration manifest is invalid: %v", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
"version": 1,
@@ -110,6 +147,12 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
}
if _, err := ReadFlagMigrationManifest(strings.NewReader(optionalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(optional rename) error = %v", err)
}
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
}
tests := []struct {
name string
@@ -162,24 +205,34 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
wantErr: "canonical flag must remain visible",
},
{
name: "canonical remains optional",
name: "required legacy becomes optional canonical",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "canonical flag must be required after migration",
wantErr: "requiredness must be preserved from legacy before to canonical after",
},
{
name: "canonical was already required",
name: "optional legacy becomes required canonical",
input: strings.Replace(
optionalFlagMigrationManifestJSON(),
`"after": {"present": true, "type": "string", "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "requiredness must be preserved from legacy before to canonical after",
},
{
name: "existing canonical changes requiredness",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"before": {"present": false}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"before": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "canonical flag must be absent or optional before migration",
wantErr: "canonical flag requiredness must remain unchanged when already present",
},
}
@@ -215,9 +268,10 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
canonicalDrift := manifest
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
Present: true,
Type: "string",
Required: true,
Scope: "local",
}
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
+2
View File
@@ -26,6 +26,8 @@ import (
var sensitiveKeys = map[string]bool{
"authorization": true,
"x-user-access-token": true,
"dws_agent_ext": true,
"x-dws-agent-ext": true,
"client_secret": true,
"client-secret": true,
"token": true,
+21 -8
View File
@@ -30,6 +30,12 @@ func TestIsSensitiveKey(t *testing.T) {
{"authorization", true},
{"x-user-access-token", true},
{"X-User-Access-Token", true},
{"DWS_AGENT_EXT", true},
{"dws_agent_ext", true},
{"x-dws-agent-ext", true},
{"X-Dws-Agent-Ext", true},
{"DWS_AGENT_VER", false},
{"x-dws-agent-ver", false},
{"client_secret", true},
{"client-secret", true},
{"token", true},
@@ -128,17 +134,24 @@ func TestSanitizeArguments_Empty(t *testing.T) {
func TestRedactHeaders(t *testing.T) {
t.Parallel()
headers := http.Header{
"Authorization": {"Bearer token123456"},
"Content-Type": {"application/json"},
}
headers := make(http.Header)
headers.Set("Authorization", "Bearer test-credential-value")
headers.Set("Content-Type", "application/json")
headers.Set("DWS_AGENT_EXT", `{"umt":"test-umt-value"}`)
headers.Set("x-dws-agent-ext", `{"ua":"test-agent-value"}`)
attrs := RedactHeaders(headers)
if len(attrs) != 2 {
t.Fatalf("expected 2 attrs, got %d", len(attrs))
if len(attrs) != 4 {
t.Fatalf("expected 4 attrs, got %d", len(attrs))
}
for _, attr := range attrs {
if attr.Key == "header.authorization" && !strings.Contains(attr.Value.String(), "***") {
t.Fatalf("authorization should be redacted: %s", attr.Value.String())
switch attr.Key {
case "header.authorization", "header.dws_agent_ext", "header.x-dws-agent-ext":
if !strings.Contains(attr.Value.String(), "***") {
t.Fatalf("%s should be redacted: %s", attr.Key, attr.Value.String())
}
if strings.Contains(attr.Value.String(), "test-") {
t.Fatalf("%s leaked its original value: %s", attr.Key, attr.Value.String())
}
}
if attr.Key == "header.content-type" && attr.Value.String() != "application/json" {
t.Fatalf("content-type should not be redacted: %s", attr.Value.String())
+17 -4
View File
@@ -24,13 +24,14 @@ import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
// isOpenID reports whether value looks like an openDingTalkId (starts with D/d),
// mirroring helpers.isOpenDingTalkID.
// isOpenID classifies mixed userId/openDingTalkId inputs using the canonical
// current-D wire format shared with helpers and smart shortcuts.
func isOpenID(v string) bool {
v = strings.TrimSpace(v)
return len(v) > 0 && (v[0] == 'D' || v[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(v)
}
// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring
@@ -50,6 +51,18 @@ func splitIDs(vals []string) (userIDs, openIDs []string) {
return userIDs, openIDs
}
func validateExplicitOpenIDs(flagName string, values []string) error {
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
if err := targetresolver.ValidateExplicitOpenDingTalkID(flagName, value); err != nil {
return err
}
}
return nil
}
// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64.
func toInt64Slice(vals []string) ([]int64, error) {
out := make([]int64, 0, len(vals))
+80 -19
View File
@@ -25,6 +25,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
// ConversationInfo gets conversation info (get_conversation_info, chat server).
@@ -71,6 +72,9 @@ var ConversationInfo = shortcut.Shortcut{
params["openConversationId"] = rt.Str("group")
}
if rt.Str("open-dingtalk-id") != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
return err
}
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
}
if len(params) == 0 {
@@ -313,17 +317,17 @@ var ConversationList = shortcut.Shortcut{
Examples: []string{"dws chat +conversation-list --limit 50"},
},
},
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +conversation-list --limit 50`,
`dws chat +conversation-list --page-all --limit 100`,
@@ -338,6 +342,9 @@ var ConversationList = shortcut.Shortcut{
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -353,9 +360,20 @@ var ConversationList = shortcut.Shortcut{
complete := false
hasMore := false
nextCursor := int64(0)
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
unsafeResultContinuation := false
failures := make([]map[string]any, 0)
for pagesFetched < pageLimit {
params := map[string]any{"limit": rt.Int("limit")}
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{"stage": "conversation-page-delay", "cursor": cursor, "error": err.Error()})
stopReason = "delay_interrupted"
break
}
}
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, rt.Int("limit"), len(convs))}
if cursor > 0 {
params["cursor"] = cursor
}
@@ -371,6 +389,7 @@ var ConversationList = shortcut.Shortcut{
break
}
pagesFetched++
overflowOnPage := false
for _, conversation := range conversationListProject(data) {
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
if id != "" && id != "<nil>" {
@@ -379,6 +398,11 @@ var ConversationList = shortcut.Shortcut{
}
seenConversations[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
convs = append(convs, conversation)
}
page := chatmsg.Pagination(data)
@@ -388,6 +412,14 @@ var ConversationList = shortcut.Shortcut{
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
break
}
if overflowOnPage {
hasMore = true
nextCursor = 0
unsafeResultContinuation = true
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标"})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
break
@@ -398,27 +430,56 @@ var ConversationList = shortcut.Shortcut{
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := map[string]any{
"count": len(convs),
"conversations": convs,
"pagesFetched": pagesFetched,
"complete": complete,
"hasMore": hasMore,
"nextCursor": nextCursor,
"paginationKnown": len(failures) == 0 || hasMore,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0,
"count": len(convs),
"conversations": convs,
"pagesFetched": pagesFetched,
"complete": complete,
"hasMore": hasMore,
"nextCursor": nextCursor,
"paginationKnown": len(failures) == 0 || hasMore,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"truncatedByResultLimit": truncatedByResultLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0,
}
return rt.Output(payload)
chatmsg.ApplyTruncation(payload)
if err := rt.Output(payload); err != nil {
return err
}
if stopReason == "delay_interrupted" && rt.Command().Context().Err() != nil {
return rt.Command().Context().Err()
}
if unsafeResultContinuation {
return apperrors.NewAPI(
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_all_conversations"),
apperrors.WithReason("conversation_list_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
},
}
@@ -15,7 +15,9 @@ package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"reflect"
"testing"
@@ -148,6 +150,96 @@ func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *
}
}
func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(2) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageConversationListRejectsOversizedLimitPage(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a safe continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil || err != context.Canceled {
t.Fatalf("delay cancellation error = %v, want context.Canceled", err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageConversationListAutoPageValidationAndOutputFailure(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+conversation-list", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("max-items without page-all unexpectedly succeeded")
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root = newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+conversation-list"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
}
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
+48 -8
View File
@@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--users", rt.StrSlice("users")); err != nil {
return err
}
return rt.CallMCP("list_group_member_by_ids", map[string]any{
"openConversationId": rt.Str("id"),
"cid": rt.Str("id"),
@@ -878,16 +881,16 @@ var ChatListAll = shortcut.Shortcut{
Examples: []string{"dws chat +chat-list-all --limit 50"},
},
},
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +chat-list-all --limit 50`,
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
@@ -908,6 +911,9 @@ func validateChatListAll(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -958,10 +964,21 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
pageSize, _ := baseParams["limit"].(int)
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, len(allGroups))}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
@@ -978,6 +995,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
}
pagesFetched++
pageGroups := chatListAllProject(data)
overflowOnPage := false
for _, group := range pageGroups {
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
if id == "<nil>" {
@@ -989,6 +1007,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
if id != "" {
seenGroups[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
allGroups = append(allGroups, group)
}
@@ -1003,6 +1026,16 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
break
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = nil
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "已加入群列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = nil
@@ -1022,8 +1055,13 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
@@ -1033,9 +1071,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
"pagesFetched": pagesFetched, "paginationKnown": true,
"complete": complete && len(failures) == 0, "hasMore": hasMore,
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures), "failures": failures,
"truncatedByResultLimit": truncatedByResultLimit,
"failedCount": len(failures), "failures": failures,
"partial": len(failures) > 0 && len(allGroups) > 0,
}
chatmsg.ApplyTruncation(payload)
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
+19
View File
@@ -29,6 +29,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const directMessagesHardPageLimit = 500
@@ -55,6 +56,9 @@ var MessagesSendByBot = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
}
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"openConversationId": rt.Str("group"),
@@ -93,6 +97,9 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
return err
}
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": rt.Str("title"),
@@ -493,6 +500,9 @@ func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
}
switch {
case rt.Str("open-dingtalk-id") != "":
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
return err
}
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
@@ -634,6 +644,7 @@ func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
chatmsg.ApplyTruncation(payload)
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(messages) > 0
@@ -1514,6 +1525,14 @@ var MessagesSendCard = shortcut.Shortcut{
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if receiverOpenID := rt.Str("receiver-open-dingtalk-id"); receiverOpenID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--receiver-open-dingtalk-id", receiverOpenID); err != nil {
return err
}
}
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
}
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
@@ -26,6 +26,11 @@ import (
"github.com/spf13/cobra"
)
const (
fixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
fixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
type platformCoverageCaller struct {
product string
tool string
@@ -53,7 +58,7 @@ func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool s
case "contact/get_user_info_by_user_ids":
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}`
case "chat/get_group_members":
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}`
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"` + fixtureCurrentDOpenID2 + `"}]}}`
case "im/set_group_member_mute_list":
text = `{"success":true}`
default:
@@ -235,8 +240,10 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
value string
want bool
}{
{value: "DingTalk-open-id", want: true},
{value: " dingtalk-open-id ", want: true},
{value: "DAAAAAAAAAAAiE", want: true},
{value: " DAAAAAAAAAAAiE ", want: true},
{value: "D-prefix-fixture-user", want: false},
{value: "d-prefix-fixture-user", want: false},
{value: "user-id", want: false},
{value: " ", want: false},
}
@@ -248,11 +255,11 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
})
t.Run("split mixed IDs", func(t *testing.T) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"})
if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "DAAAAAAAAAAAiE", "d-open-2", "D-prefix-fixture-user", "user-2"})
if want := []string{"user-1", "d-open-2", "D-prefix-fixture-user", "user-2"}; !reflect.DeepEqual(userIDs, want) {
t.Fatalf("user IDs = %#v, want %#v", userIDs, want)
}
if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) {
if want := []string{"DAAAAAAAAAAAiE"}; !reflect.DeepEqual(openIDs, want) {
t.Fatalf("open IDs = %#v, want %#v", openIDs, want)
}
})
@@ -281,7 +288,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
root.SetArgs([]string{
"chat", "+chat-mute-member",
"--group", "cid-1",
"--users", "user-2,D-open-2",
"--users", "user-2," + fixtureCurrentDOpenID2,
"--mute-time", "300000",
"--yes",
})
@@ -298,7 +305,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
if _, ok := final.args["uids"]; ok {
t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args)
}
if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) {
if got, want := final.args["openDingTalkIds"], []string{fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
t.Fatalf("openDingTalkIds = %#v, want %#v", got, want)
}
}
@@ -225,7 +225,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
{
name: "reply write",
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"},
wantError: "fixture lower call failed",
},
}
@@ -259,7 +259,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
}}
helpers.InitDeps(external)
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -400,17 +400,17 @@ func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) {
cases := [][]string{
{"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "user", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--at-all", "--text", "x"},
{"--identity", "bot", "--group", "cid", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"},
{"--identity", "webhook", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"},
}
for _, tail := range cases {
@@ -437,23 +437,23 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true},
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
name: "user direct",
args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": "D1"},
args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID},
},
{
name: "bot group mentions",
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}},
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}},
},
{
name: "bot direct targets",
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"},
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "isAtAll": "true"},
},
{
name: "webhook mentions",
@@ -580,7 +580,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
root.SetArgs([]string{
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
"--at-open-dingtalk-ids", "D1", "--at-all", "--yes",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -590,7 +590,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
}
for _, call := range fake.calls {
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) ||
call.args["isAtAll"] != "true" {
t.Fatalf("batch mention args = %#v", call.args)
}
@@ -5,6 +5,7 @@ package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
@@ -73,11 +74,115 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" || payload["nextCursor"] != float64(9) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageFlagListLegacyFullRemainingPageFailsClosed(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_message_favorites": {
`{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":7}}`,
`{"result":{"items":[{"openMessageId":"m3"}]}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "2", "--page-all", "--max-items", "3"})
if err := root.Execute(); err == nil {
t.Fatal("full remaining-budget legacy page unexpectedly declared a complete result")
}
if len(fake.calls) != 2 || fake.calls[0].args["size"] != "2" || fake.calls[1].args["size"] != "1" {
t.Fatalf("request sizes = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(3) || payload["complete"] != false ||
payload["paginationKnown"] != false || payload["stopReason"] != "pagination_error" ||
payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
t.Run("oversized lower page", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("canceled delay", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
t.Run("later read failure keeps partial result", func(t *testing.T) {
fake := &larkAlignmentCaller{
@@ -137,7 +242,11 @@ func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
{"--size", "31"},
{"--page-token", "not-a-number"},
{"--page-limit", "2"},
{"--max-items", "1"},
{"--page-delay", "1"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--max-items", "-1"},
{"--page-all", "--page-delay", "-1"},
{"--page-all", "--page-limit", "501"},
{"--cursor", "1", "--page-token", "2"},
} {
+49 -20
View File
@@ -417,8 +417,8 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1",
"--owner-open-dingtalk-id", "D-owner", "--yes",
"chat", "+chat-create", "--name", "测试群", "--users", fixtureCurrentDOpenID + ",user-1",
"--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -427,24 +427,24 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
t.Fatalf("explicit owner calls = %#v", fake.calls)
}
create := fake.calls[0].args
if create["ownerOpenDingTalkId"] != "D-owner" {
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`,
"contact/search_contact_by_key_word": `{"result":[{"name":"测试用户甲","userId":"owner-user","openDingTalkId":"` + fixtureCurrentDOpenID + `"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
"--owner-query", "张三", "--yes",
"--owner-query", "测试用户甲", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -454,10 +454,10 @@ func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *
t.Fatalf("owner query calls = %#v", fake.calls)
}
create := fake.calls[1].args
if create["ownerOpenDingTalkId"] != "D-owner" {
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
@@ -601,7 +601,7 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--open-dingtalk-id", "D-target",
"--open-dingtalk-id", fixtureCurrentDOpenID,
"--file", "./fixture.bin",
"--dry-run",
"--yes",
@@ -773,7 +773,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2 + "," + fixtureCurrentDOpenID,
"--at-all",
"--content", "完成",
"--flow-status", "3",
@@ -786,7 +786,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
}
if fake.calls[0].args["atAll"] != true {
@@ -842,7 +842,7 @@ func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *te
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
"--content", "正文",
"--yes",
})
@@ -891,7 +891,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver-open-dingtalk-id", "D-direct",
"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2,
"--yes",
})
if err := root.Execute(); err != nil {
@@ -902,7 +902,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card open receiver calls = %#v", fake.calls)
}
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != fixtureCurrentDOpenID2 {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
}
}
@@ -969,7 +969,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
"--at-all",
"--content", "处理中",
"--dry-run",
@@ -990,7 +990,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
createArguments, _ := create["arguments"].(map[string]any)
update, _ := actions[1].(map[string]any)
updateArguments, _ := update["arguments"].(map[string]any)
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{fixtureCurrentDOpenID}) || createArguments["atAll"] != true {
t.Fatalf("card create dry-run mentions = %#v", createArguments)
}
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
@@ -1090,10 +1090,10 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
{"--group", "cid", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
{"--receiver", "user-id", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
{"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--at-all"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
@@ -1104,6 +1104,35 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageExplicitOpenIDValidationEdges(t *testing.T) {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{" ", fixtureCurrentDOpenID}); err != nil {
t.Fatalf("blank and valid IDs: %v", err)
}
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{fixtureCurrentDOpenID, "not-an-open-id"}); err == nil {
t.Fatal("invalid explicit open ID unexpectedly accepted")
}
for _, args := range [][]string{
{"chat", "+messages-send", "--open-dingtalk-id", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send", "--open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send-card", "--receiver-open-dingtalk-id", "not-an-open-id", "--yes"},
{"chat", "+messages-send-card", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+conversation-info", "--open-dingtalk-id", "not-an-open-id"},
{"chat", "+chat-members-get", "--id", "cid", "--users", "not-an-open-id"},
{"chat", "+messages-send-by-bot", "--robot-code", "robot", "--group", "cid", "--title", "title", "--text", "text", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+messages-batch-send-by-bot", "--robot-code", "robot", "--title", "title", "--text", "text", "--open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+messages-list-direct", "--open-dingtalk-id", "not-an-open-id", "--time", "2026-01-01 00:00:00"},
{"chat", "+chat-create", "--name", "fixture", "--users", "user-1", "--owner-open-dingtalk-id", "not-an-open-id", "--yes"},
} {
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid explicit open ID unexpectedly accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
+64 -22
View File
@@ -52,6 +52,11 @@ var ChatCreate = shortcut.Shortcut{
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
if ownerOpenID := rt.Str("owner-open-dingtalk-id"); ownerOpenID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--owner-open-dingtalk-id", ownerOpenID); err != nil {
return err
}
}
resolvedMembers, err := targetresolver.ResolveUsers(
rt,
rt.StrSlice("member-query"),
@@ -339,7 +344,7 @@ func findOpenDingTalkID(value any) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
@@ -374,7 +379,7 @@ func findMessageSenderOpenDingTalkID(message map[string]any) string {
"senderOpenDingtalkId",
"senderOpenId",
} {
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) {
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
@@ -510,21 +515,21 @@ var FlagList = shortcut.Shortcut{
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +flag-list --cursor 0 --page-size 20`,
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
@@ -548,6 +553,9 @@ func validateFlagList(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -598,9 +606,20 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
var cursorErr error
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
for pagesFetched < pageLimit {
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
requestPageSize := shortcut.AutoPageRequestSize(rt, pageSize, len(items))
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, requestPageSize))
if callErr != nil {
if pagesFetched == 0 {
return callErr
@@ -613,6 +632,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
}
pagesFetched++
pageItems := flagListItems(data)
overflowOnPage := false
for _, item := range pageItems {
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
if messageID != "" && seenMessages[messageID] {
@@ -621,6 +641,11 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
if messageID != "" {
seenMessages[messageID] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
items = append(items, item)
}
@@ -631,7 +656,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
switch {
case nextCursor > 0:
pageHasMore = true
case len(pageItems) < pageSize:
case len(pageItems) < requestPageSize:
paginationKnown = false
complete = true
hasMore = false
@@ -649,6 +674,16 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
}
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = 0
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "收藏列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = 0
@@ -669,28 +704,35 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
}
seenCursors[nextCursor] = true
cursor = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
payload := map[string]any{
"count": len(items),
"items": items,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(items) > 0,
"count": len(items),
"items": items,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"truncatedByResultLimit": truncatedByResultLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(items) > 0,
}
chatmsg.ApplyTruncation(payload)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
@@ -289,12 +289,12 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}{
{
name: "user",
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @" + fixtureCurrentDOpenID, "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--idempotency-key", "u1", "--yes"},
product: "chat",
tool: "send_personal_message",
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
bodyKey: "content",
body: "<@all> hello <@D1>",
body: "<@all> hello <@" + fixtureCurrentDOpenID + ">",
},
{
name: "bot",
@@ -471,7 +471,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--ref-sender", fixtureCurrentDOpenID,
"--text", "收到",
"--idempotency-key", "reply-uuid",
"--yes",
@@ -494,7 +494,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
t.Fatal(err)
}
if content["referenceOpenMessageId"] != "msg" ||
content["srcMsgSendOpenDingTalkId"] != "D-sender" ||
content["srcMsgSendOpenDingTalkId"] != fixtureCurrentDOpenID ||
content["replyMsgType"] != "text" ||
content["content"] != "收到" {
t.Fatalf("reply content = %#v", content)
@@ -525,7 +525,7 @@ func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T)
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--ref-sender", fixtureCurrentDOpenID,
"--text", "收到",
"--dry-run",
"--yes",
@@ -5,12 +5,14 @@ package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
@@ -54,7 +56,7 @@ func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *test
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
"chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID,
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
"--page-all", "--page-limit", "5",
})
@@ -95,11 +97,86 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1", "--page-delay", "0"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(88) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageChatListAllFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
t.Run("oversized lower page", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != nil {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
})
t.Run("canceled delay", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
@@ -163,6 +240,17 @@ func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
t.Fatalf("invalid args succeeded: %v", args)
}
}
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-list-all"})
if err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("max-items", "1"); err != nil {
t.Fatal(err)
}
if err := validateChatListAll(shortcut.RuntimeContextForTest(cmd, ChatListAll)); err == nil {
t.Fatal("direct auto-page validation unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
+11
View File
@@ -142,6 +142,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
if openID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
return err
}
}
if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil {
return err
}
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil {
return err
}
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
+13
View File
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
"hasMore",
"nextPage",
"stopReason",
"truncated",
"truncatedByPageLimit",
"truncatedByResultLimit",
"failedCount",
@@ -122,9 +123,21 @@ func NewMessageListPayload(messages []map[string]any) map[string]any {
"failedCount": 0,
"failures": []map[string]any{},
"partial": false,
"truncated": false,
}
}
// ApplyTruncation publishes the stable aggregate bit while preserving the
// established reason-specific fields for compatibility and diagnosis.
func ApplyTruncation(payload map[string]any) {
if payload == nil {
return
}
byPage, _ := payload["truncatedByPageLimit"].(bool)
byItems, _ := payload["truncatedByResultLimit"].(bool)
payload["truncated"] = byPage || byItems
}
// ListMessageItems returns message rows from the common list response envelopes.
func ListMessageItems(data map[string]any) []map[string]any {
if data == nil {
@@ -441,6 +441,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T)
}
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
ApplyTruncation(nil)
payload := map[string]any{"count": 98}
ApplyPagination(payload, map[string]any{
"result": map[string]any{
+301 -33
View File
@@ -4,8 +4,10 @@
package doc
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -18,6 +20,9 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
var (
@@ -28,6 +33,21 @@ var (
docMkdirTemp = os.MkdirTemp
docRemoveAll = os.RemoveAll
docDownload = localio.Download
docVerifySleep = time.Sleep
docVerifyDelays = []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
docMarkdown = goldmark.New(
goldmark.WithExtensions(extension.Table),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
docMarkdownConvert = func(source []byte, writer io.Writer) error {
return docMarkdown.Convert(source, writer)
}
)
const (
docBlockReadPageSize = 50
docBlockReadMaxItems = 5000
docMarkdownVerifyMax = 2 * 1024 * 1024
)
var Create = shortcut.Shortcut{
@@ -133,7 +153,9 @@ var Create = shortcut.Shortcut{
verifyTool = "get_document_content"
verifyParams["format"] = format
}
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format)
})
if err != nil {
return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
@@ -422,7 +444,9 @@ var CheckpointUpdate = shortcut.Shortcut{
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
}
steps = append(steps, map[string]any{"name": "update", "status": "success"})
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool {
return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "markdown")
})
if err != nil {
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
append(steps, map[string]any{"name": "verify", "status": "failed"}))
@@ -539,7 +563,7 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
}
referenceBlockID := rt.Str("after-block-id")
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node,
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat},
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
func(result, data map[string]any) bool {
return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format"))
})
@@ -553,14 +577,14 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node,
"list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat},
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
func(_, data map[string]any) bool {
return blockContentEquals(data, blockID, content, rt.Str("doc-format"))
})
case "block_delete":
blockID := rt.Str("block-id")
return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node,
"list_document_blocks", map[string]any{"nodeId": node, "format": "element"},
"list_document_blocks", map[string]any{"nodeId": node, "format": "element", "__allBlocks": true},
func(_, data map[string]any) bool { return findBlock(data, blockID) == nil })
case "str_replace":
return executePlainTextReplace(rt, node)
@@ -609,7 +633,7 @@ func nestedRevision(value any) (int, bool) {
}
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
if err != nil {
return err
}
@@ -643,12 +667,12 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
blockID := matches[0].blockID
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block",
map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID,
"list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"},
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") })
}
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
if err != nil {
return err
}
@@ -664,7 +688,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
referenceBlockID := rt.Str("after-block-id")
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block",
map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID,
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"},
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
func(result, data map[string]any) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown")
})
@@ -684,7 +708,9 @@ func executeVerifiedDocMutation(
return docUnknownWriteError(operation, tool, nodeID, err)
}
steps[0]["status"] = "success"
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
return verify == nil || verify(result, data)
})
if err != nil {
return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
@@ -732,7 +758,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
}
steps = append(steps, map[string]any{"name": stepName, "status": "success"})
}
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format})
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool {
return verifyUpdatedDocumentContent(data, content, mode, format)
})
if err != nil {
return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
@@ -745,6 +773,134 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
}, steps...))
}
func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) {
params := cloneMap(rawParams)
allBlocks, _ := params["__allBlocks"].(bool)
delete(params, "__allBlocks")
var last map[string]any
var lastErr error
for attempt := 0; attempt <= len(docVerifyDelays); attempt++ {
var data map[string]any
var err error
if allBlocks && tool == "list_document_blocks" {
data, err = readAllDocumentBlocks(rt, params)
} else {
data, err = rt.CallMCPData(productDoc, tool, params)
}
if err != nil {
lastErr = err
} else {
last = data
lastErr = nil
if verify == nil || verify(data) {
return data, nil
}
}
if attempt < len(docVerifyDelays) {
docVerifySleep(docVerifyDelays[attempt])
}
}
if lastErr != nil {
return nil, lastErr
}
return last, nil
}
func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) {
all := make([]any, 0, docBlockReadPageSize)
seenPages := map[string]bool{}
for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize {
params := cloneMap(base)
params["startIndex"] = start
params["endIndex"] = start + docBlockReadPageSize - 1
page, err := rt.CallMCPData(productDoc, "list_document_blocks", params)
if err != nil {
return nil, err
}
blocks, ok := documentBlockEntries(page)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
encoded, _ := json.Marshal(blocks)
pageKey := string(encoded)
if pageKey != "[]" && seenPages[pageKey] {
return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整")
}
seenPages[pageKey] = true
all = append(all, blocks...)
hasMore, known, _ := docPageState(page)
if known && !hasMore {
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
}
if total, ok := nestedNonNegativeInt(page, "totalCount", "total_count"); ok && len(all) >= total {
return map[string]any{"blocks": all, "hasMore": false, "totalCount": total}, nil
}
if !known && len(blocks) < docBlockReadPageSize {
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空,无法证明回读完整")
}
}
return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems)
}
func documentBlockEntries(value any) ([]any, bool) {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"blocks", "items"} {
if blocks, ok := typed[key].([]any); ok {
return blocks, true
}
}
if encoded, ok := typed["jsonml"].(string); ok {
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) == nil {
blocks := orderedJSONMLBlocks(decoded)
values := make([]any, len(blocks))
for index := range blocks {
values[index] = blocks[index]
}
return values, true
}
}
for _, key := range []string{"result", "data"} {
if nested, ok := typed[key]; ok {
if blocks, found := documentBlockEntries(nested); found {
return blocks, true
}
}
}
}
return nil, false
}
func nestedNonNegativeInt(value any, keys ...string) (int, bool) {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if raw, ok := typed[key]; ok {
switch number := raw.(type) {
case float64:
if number >= 0 && number == float64(int(number)) {
return int(number), true
}
case int:
if number >= 0 {
return number, true
}
}
}
}
for _, key := range []string{"result", "data"} {
if result, ok := nestedNonNegativeInt(typed[key], keys...); ok {
return result, true
}
}
}
return 0, false
}
func splitDocMarkdown(content string, maxRunes int) []string {
if maxRunes <= 0 {
return []string{content}
@@ -796,11 +952,17 @@ func containsText(value any, needle string) bool {
}
func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool {
expected = normalizeDocumentContentForVerification(expected, format)
expectedRaw := expected
expected = normalizeDocumentContentForVerification(expectedRaw, format)
for _, candidate := range documentContentCandidates(value, format) {
actual := normalizeDocumentContentForVerification(candidate, format)
actualRaw := candidate
actual := normalizeDocumentContentForVerification(actualRaw, format)
if mode == "overwrite" {
if actual == expected {
if actual == expected || (format == "markdown" && stripReadbackDocumentTitle(actual) == expected) {
return true
}
if format == "markdown" && (markdownSemanticallyEquivalent(actualRaw, expectedRaw) ||
markdownSemanticallyEquivalent(stripReadbackDocumentTitle(actualRaw), expectedRaw)) {
return true
}
continue
@@ -808,10 +970,48 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool
if actual == expected || strings.HasSuffix(actual, "\n"+expected) {
return true
}
if format == "markdown" && markdownSemanticallyEndsWith(actualRaw, expectedRaw) {
return true
}
}
return false
}
func markdownSemanticallyEquivalent(left, right string) bool {
leftFingerprint, leftOK := markdownSemanticFingerprint(left)
rightFingerprint, rightOK := markdownSemanticFingerprint(right)
return leftOK && rightOK && leftFingerprint == rightFingerprint
}
func markdownSemanticallyEndsWith(content, suffix string) bool {
contentFingerprint, contentOK := markdownSemanticFingerprint(content)
suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix)
return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint)
}
func markdownSemanticFingerprint(source string) (string, bool) {
if len(source) > docMarkdownVerifyMax {
return "", false
}
var rendered bytes.Buffer
if err := docMarkdownConvert([]byte(source), &rendered); err != nil {
return "", false
}
return rendered.String(), true
}
func stripReadbackDocumentTitle(content string) string {
lines := strings.Split(content, "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") {
return content
}
lines = lines[1:]
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
return strings.Join(lines, "\n")
}
func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format)
}
@@ -848,6 +1048,11 @@ func blockContentEquals(data map[string]any, blockID, expected, format string) b
}
func canonicalBlockContent(value any, format string) string {
if values, ok := value.(map[string]any); ok {
if element, ok := values["element"].(map[string]any); ok {
value = element
}
}
if format == "jsonml" {
if values, ok := value.(map[string]any); ok {
if encoded, ok := values["jsonml"].(string); ok {
@@ -871,7 +1076,7 @@ func canonicalBlockContent(value any, format string) string {
return
}
for key, child := range typed {
if key == "id" || key == "blockId" || key == "uuid" {
if key == "id" || key == "blockId" || key == "uuid" || key == "blockType" {
continue
}
walk(child)
@@ -1005,6 +1210,10 @@ func orderedDocumentBlocks(value any) []map[string]any {
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
if element, ok := typed["element"].(map[string]any); ok && blockIdentity(element, "") != "" {
blocks = append(blocks, element)
return
}
if blockIdentity(typed, "") != "" {
blocks = append(blocks, typed)
return
@@ -1065,9 +1274,23 @@ func normalizeDocumentContentForVerification(raw, format string) string {
func normalizeMarkdownForVerification(raw string) string {
raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")
lines := make([]string, 0, strings.Count(raw, "\n")+1)
inFence := false
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
inFence = !inFence
lines = append(lines, trimmed)
continue
}
if inFence {
lines = append(lines, strings.TrimRight(line, " \t"))
continue
}
line = trimmed
if line == "" {
if len(lines) > 0 && lines[len(lines)-1] != "" {
lines = append(lines, "")
}
continue
}
if strings.Contains(line, "|") {
@@ -1081,6 +1304,9 @@ func normalizeMarkdownForVerification(raw string) string {
}
lines = append(lines, line)
}
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return strings.Join(lines, "\n")
}
@@ -1089,33 +1315,75 @@ func normalizeJSONMLForVerification(raw string) string {
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return normalizeMarkdownForVerification(raw)
}
var tokens []string
var walk func(any)
walk = func(current any) {
var normalize func(any) any
normalize = func(current any) any {
switch typed := current.(type) {
case []any:
start := 0
if len(typed) > 0 {
if tag, ok := typed[0].(string); ok {
tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">")
start = 1
if len(typed) == 0 {
return []any{}
}
tag, isElement := typed[0].(string)
if !isElement {
out := make([]any, 0, len(typed))
for _, child := range typed {
out = append(out, normalize(child))
}
return out
}
start := 1
attrs := map[string]any{}
if len(typed) > 1 {
if declared, ok := typed[1].(map[string]any); ok {
attrs, _ = normalize(declared).(map[string]any)
start = 2
}
}
children := make([]any, 0, len(typed)-start)
for _, child := range typed[start:] {
walk(child)
normalized := normalize(child)
if normalized != nil {
children = append(children, normalized)
}
}
if strings.EqualFold(tag, "span") && isGeneratedTextSpan(attrs) {
if len(children) == 1 {
return children[0]
}
return children
}
out := []any{strings.ToLower(tag), attrs}
out = append(out, children...)
return out
case map[string]any:
// JSONML maps contain element attributes. Server-generated UUIDs and
// default attributes do not change the authored document content.
return
case string:
if text := normalizeMarkdownForVerification(typed); text != "" {
tokens = append(tokens, text)
out := make(map[string]any, len(typed))
for key, child := range typed {
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if normalizedKey == "uuid" || normalizedKey == "blockid" || normalizedKey == "elementid" || normalizedKey == "index" {
continue
}
out[key] = normalize(child)
}
return out
case string:
return strings.ReplaceAll(strings.ReplaceAll(typed, "\r\n", "\n"), "\r", "\n")
}
return current
}
walk(value)
return strings.Join(tokens, "\n")
// normalize only receives values decoded by encoding/json, so the resulting
// tree is always JSON-marshalable.
encoded, _ := json.Marshal(normalize(value))
return string(encoded)
}
func isGeneratedTextSpan(attrs map[string]any) bool {
if len(attrs) == 0 {
return true
}
if len(attrs) != 1 {
return false
}
value, ok := attrs["data-type"].(string)
return ok && (value == "text" || value == "leaf")
}
func executeExport(rt *shortcut.RuntimeContext) error {
@@ -0,0 +1,275 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) {
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "old"}, {"markdown": "old\nnew"}},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "append", "--content", "new", "--yes"); err != nil {
t.Fatal(err)
}
reads := 0
for _, call := range caller.history {
if call.tool == "get_document_content" {
reads++
}
}
if reads != 2 {
t.Fatalf("readback calls = %d, want 2; history=%#v", reads, caller.history)
}
}
func TestCrossPlatformCoverageDocDeleteReadbackConsumesEveryPage(t *testing.T) {
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
firstPage := make([]any, 50)
for index := range firstPage {
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": "body"}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
{"blocks": []any{map[string]any{"id": "target", "text": "stale"}}, "hasMore": false, "totalCount": 51},
{"blocks": firstPage, "hasMore": false, "totalCount": 50},
},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
t.Fatal(err)
}
starts := []int{}
for _, call := range caller.history {
if call.tool == "list_document_blocks" {
starts = append(starts, call.params["startIndex"].(int))
}
}
if fmt.Sprint(starts) != "[0 50 0]" {
t.Fatalf("pagination starts = %v, want [0 50 0]", starts)
}
}
func TestCrossPlatformCoverageDocReplacePreflightIsGloballyUnique(t *testing.T) {
firstPage := make([]any, 50)
for index := range firstPage {
text := "body"
if index == 0 {
text = "unique needle"
}
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": text}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
{"blocks": []any{map[string]any{"id": "block-50", "text": "another needle"}}, "hasMore": false, "totalCount": 51},
},
}}
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "str_replace", "--old", "needle", "--new", "changed", "--yes")
if err == nil {
t.Fatal("str_replace accepted a second match on a later page")
}
for _, call := range caller.history {
if call.tool == "update_document_block" {
t.Fatalf("ambiguous replace executed a write: %#v", caller.history)
}
}
}
func TestCrossPlatformCoverageDocVerificationPreservesMeaning(t *testing.T) {
expected := "[\"p\",{},\"text\"]"
serverExpanded := "[\"p\",{\"uuid\":\"generated\"},[\"span\",{\"data-type\":\"text\"},[\"span\",{\"data-type\":\"leaf\"},\"text\"]]]"
if normalizeJSONMLForVerification(expected) != normalizeJSONMLForVerification(serverExpanded) {
t.Fatal("generated JSONML text wrappers should not change document meaning")
}
linkA := "[\"a\",{\"href\":\"https://example.com/a\"},\"text\"]"
linkB := "[\"a\",{\"href\":\"https://example.com/b\"},\"text\"]"
if normalizeJSONMLForVerification(linkA) == normalizeJSONMLForVerification(linkB) {
t.Fatal("semantic JSONML attributes were ignored")
}
codeA := "~~~go\n return nil\n~~~"
codeB := "~~~go\nreturn nil\n~~~"
if normalizeMarkdownForVerification(codeA) == normalizeMarkdownForVerification(codeB) {
t.Fatal("fenced code indentation was ignored")
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "# Server title\n\nbody"}, "body", "overwrite", "markdown") {
t.Fatal("server-generated document title prevented body verification")
}
}
func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) {
input := strings.Join([]string{
"sales_data.xlsx",
"### 1.",
"+10.22%",
"| name | value |",
"| -------- | -------- |",
"| sales_data.xlsx | +10.22% |",
}, "\n")
server := strings.Join([]string{
`sales\_data.xlsx`,
`### 1\.`,
`\+10.22%`,
"|name|value|",
"|---|---|",
`|sales\_data.xlsx|\+10.22%|`,
}, "\n")
if !markdownSemanticallyEquivalent(input, server) {
t.Fatal("server Markdown escaping and table delimiter normalization changed the semantic fingerprint")
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") {
t.Fatal("equivalent server Markdown failed overwrite verification")
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "existing\n\n" + server}, input, "append", "markdown") {
t.Fatal("equivalent server Markdown failed append verification")
}
}
func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) {
for _, test := range []struct {
name string
left string
right string
}{
{name: "emphasis", left: `*important*`, right: `\*important\*`},
{name: "inline code", left: "`sales_data`", right: "`sales\\_data`"},
{name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"},
{name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"},
{name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"},
{name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"},
} {
t.Run(test.name, func(t *testing.T) {
if markdownSemanticallyEquivalent(test.left, test.right) {
t.Fatal("meaningful Markdown difference was ignored")
}
})
}
oversized := strings.Repeat("x", docMarkdownVerifyMax+1)
if _, ok := markdownSemanticFingerprint(oversized); ok {
t.Fatal("oversized Markdown entered semantic verification")
}
testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") })
if _, ok := markdownSemanticFingerprint("body"); ok {
t.Fatal("failed Markdown render produced a semantic fingerprint")
}
}
func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) {
wrapper := map[string]any{
"blockType": "paragraph",
"element": map[string]any{"id": "inserted", "blockType": "paragraph", "paragraph": map[string]any{"text": "body"}},
}
if got := canonicalBlockContent(wrapper, "markdown"); got != "body" {
t.Fatalf("nested element content = %q, want body", got)
}
blocks := orderedDocumentBlocks(map[string]any{"blocks": []any{wrapper}})
if len(blocks) != 1 || blockIdentity(blocks[0], "") != "inserted" {
t.Fatalf("nested element blocks = %#v", blocks)
}
}
func TestCrossPlatformCoverageVersionRevertRequiresTargetEvidence(t *testing.T) {
if revertResultMatchesVersion(map[string]any{"ok": true}, 3) || currentDocumentMatchesRestoredVersion(map[string]any{"version": 99}, 3) {
t.Fatal("readability or an unrelated current version must not prove a revert")
}
if !revertResultMatchesVersion(map[string]any{"revertedToVersion": 3}, 3) {
t.Fatal("explicit target-version acknowledgement was not accepted")
}
}
func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) {
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
for _, tc := range []struct {
name string
responses []map[string]any
failAt int
}{
{"call failure", nil, 1},
{"missing blocks", []map[string]any{{"ok": true}}, 0},
{"stalled page", []map[string]any{{"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}, {"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}}, 0},
{"empty continued page", []map[string]any{{"blocks": []any{}, "hasMore": true}}, 0},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{failAt: tc.failAt, responses: map[string][]map[string]any{"list_document_blocks": tc.responses}}
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes")
if err == nil {
t.Fatal("defensive readback unexpectedly succeeded")
}
})
}
t.Run("total count terminates pagination", func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"blocks": []any{map[string]any{"id": "other"}}, "totalCount": 1}}}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
t.Fatal(err)
}
})
t.Run("block read safety limit", func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
pages := make([]map[string]any, docBlockReadMaxItems/docBlockReadPageSize)
for index := range pages {
pages[index] = map[string]any{"blocks": []any{map[string]any{"id": fmt.Sprintf("block-%d", index)}}, "hasMore": true}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": pages}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err == nil {
t.Fatal("oversized block read returned nil")
}
})
if blocks, ok := documentBlockEntries(map[string]any{"jsonml": `["root",{},["p",{"uuid":"a"},"x"]]`}); !ok || len(blocks) == 0 {
t.Fatalf("jsonml blocks=%#v ok=%v", blocks, ok)
}
if _, ok := documentBlockEntries(map[string]any{"jsonml": `{`}); ok {
t.Fatal("invalid jsonml produced blocks")
}
if blocks, ok := documentBlockEntries(map[string]any{"data": map[string]any{"items": []any{"x"}}}); !ok || len(blocks) != 1 {
t.Fatalf("nested items=%#v ok=%v", blocks, ok)
}
if _, ok := documentBlockEntries(nil); ok {
t.Fatal("nil produced blocks")
}
for _, tc := range []struct {
value any
want bool
}{
{map[string]any{"totalCount": float64(2)}, true},
{map[string]any{"totalCount": float64(-1)}, false},
{map[string]any{"totalCount": 2.5}, false},
{map[string]any{"data": map[string]any{"total_count": 2}}, true},
{map[string]any{"totalCount": -1}, false},
{nil, false},
} {
_, ok := nestedNonNegativeInt(tc.value, "totalCount", "total_count")
if ok != tc.want {
t.Fatalf("nestedNonNegativeInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
}
}
for _, raw := range []string{
`[]`, `[1,["p",{},"x"]]`, `["span",{},"a","b"]`,
`["p",{"block_id":"x","custom":true},"x"]`, `true`,
} {
if normalizeJSONMLForVerification(raw) == "" {
t.Fatalf("empty normalized JSONML for %s", raw)
}
}
if !isGeneratedTextSpan(nil) || isGeneratedTextSpan(map[string]any{"a": 1, "b": 2}) || isGeneratedTextSpan(map[string]any{"data-type": 3}) || isGeneratedTextSpan(map[string]any{"data-type": "other"}) {
t.Fatal("generated span classification failed")
}
}
+13 -2
View File
@@ -15,6 +15,7 @@ import (
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -112,6 +113,7 @@ func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *do
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
t.Helper()
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
@@ -491,6 +493,7 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
}
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
var typed *apperrors.Error
@@ -512,6 +515,7 @@ func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification
}
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "truncated"}},
}}
@@ -577,8 +581,15 @@ func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testi
"revert_doc_version": {{}},
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.FailureStage != "verify" || typed.Details["status"] != "partial_success" {
t.Fatalf("unproven revert error = %#v", err)
}
data, _ := typed.Details["data"].(map[string]any)
steps, _ := typed.Details["steps"].([]map[string]any)
if data["verified"] != false || len(steps) != 3 || steps[2]["status"] != "failed" {
t.Fatalf("unproven revert details = %#v", typed.Details)
}
})
@@ -178,15 +178,47 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
verified := revertResultMatchesVersion(reverted, target) || currentDocumentMatchesRestoredVersion(current, target)
if !verified {
return docPartialWriteError(
"doc.history_revert", "doc_history_revert_target_unproven", "verify",
fmt.Sprintf("版本 %d 的回滚请求已执行且文档可读,但响应没有提供目标版本证据;不要直接重试回滚", target),
fmt.Errorf("回读缺少目标版本 %d 的明确证据", target),
map[string]any{
"nodeId": nodeID, "version": target, "reverted": true, "verified": false,
"revertResult": reverted, "current": current,
},
[]map[string]any{
{"name": "preflight", "status": "success"},
{"name": "revert", "status": "success"},
{"name": "verify", "status": "failed"},
},
map[string]any{"available": false, "reason": "the revert may have completed; inspect version history before any further revert"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
"version": target, "revertResult": reverted, "current": current, "verified": true,
"verification": "revert_acknowledged_and_document_readable",
"verification": "target_version_proven",
},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func revertResultMatchesVersion(value map[string]any, target int) bool {
return versionEvidenceMatches(value, target, map[string]bool{
"version": true, "targetversion": true, "appliedversion": true,
"restoredversion": true, "revertedversion": true, "revertedtoversion": true, "sourceversion": true,
})
}
func currentDocumentMatchesRestoredVersion(value map[string]any, target int) bool {
return versionEvidenceMatches(value, target, map[string]bool{
"restoredfromversion": true, "revertedfromversion": true,
"sourceversion": true, "appliedversion": true, "targetversion": true,
})
}
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
const maxPages = 20
cursor := ""
+15 -1
View File
@@ -486,9 +486,23 @@ var Upload = shortcut.Shortcut{
if err != nil {
return err
}
if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) {
remoteID := firstString(verified, "fileId", "dentryUuid", "nodeId", "id")
if remoteID == "" {
return driveResponseError("drive/commit_upload", "readback_missing_id", "上传后读回缺少文件 ID;无法证明读回的是已提交文件")
}
if remoteID != nodeID {
return driveResponseError("drive/commit_upload", "readback_id_mismatch", fmt.Sprintf("上传后读回文件 ID %q 与提交 ID %q 不一致", remoteID, nodeID))
}
if remoteName := firstString(verified, "name", "fileName"); !driveReadbackNameMatches(verified, name) {
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
}
remoteSize, ok := firstInt64(verified, "fileSize", "size", "byteSize", "length")
if !ok {
return driveResponseError("drive/commit_upload", "readback_missing_size", "上传后读回缺少有效文件大小;无法证明远端文件完整")
}
if remoteSize != info.Size() {
return driveResponseError("drive/commit_upload", "readback_size_mismatch", fmt.Sprintf("上传后读回大小 %d 与本地文件大小 %d 不一致", remoteSize, info.Size()))
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
},
}
+43
View File
@@ -6,6 +6,8 @@ package drive
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -216,6 +218,47 @@ func nestedString(data map[string]any, keys ...string) string {
return ""
}
func driveReadbackNameMatches(data map[string]any, requested string) bool {
remoteName := firstString(data, "name", "fileName")
if remoteName == requested {
return true
}
extension := strings.TrimLeft(firstString(data, "extension", "fileExtension", "ext"), ".")
return extension != "" && remoteName+"."+extension == requested
}
func firstInt64(data map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
value, present := data[key]
if !present {
continue
}
switch typed := value.(type) {
case int:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case float64:
if !math.IsNaN(typed) && !math.IsInf(typed, 0) && typed == math.Trunc(typed) && typed >= math.MinInt64 && typed < math.MaxInt64 {
return int64(typed), true
}
case json.Number:
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
if err == nil {
return parsed, true
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed, true
}
}
}
return 0, false
}
func driveResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
+26
View File
@@ -20,6 +20,7 @@
package drive
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -546,6 +547,31 @@ var Move = shortcut.Shortcut{
if err != nil {
return err
}
remoteID := firstString(verified, "nodeId", "fileId", "dentryUuid", "id")
if remoteID == "" {
return driveResponseError("doc/move_document", "readback_missing_id", "移动后读回缺少节点 ID;无法证明读回的是已移动节点")
}
if remoteID != rt.Str("node") {
return driveResponseError("doc/move_document", "readback_id_mismatch", fmt.Sprintf("移动后读回节点 %q 与请求节点 %q 不一致", remoteID, rt.Str("node")))
}
if rt.Changed("folder") {
remoteFolder := firstString(verified, "folderId", "targetFolderId", "parentId")
if remoteFolder == "" {
return driveResponseError("doc/move_document", "readback_missing_folder", "移动后读回缺少目标文件夹 ID;无法证明移动已到达请求位置")
}
if remoteFolder != rt.Str("folder") {
return driveResponseError("doc/move_document", "readback_folder_mismatch", fmt.Sprintf("移动后读回文件夹 %q 与请求 %q 不一致", remoteFolder, rt.Str("folder")))
}
}
if rt.Changed("workspace") {
remoteWorkspace := firstString(verified, "workspaceId", "spaceId")
if remoteWorkspace == "" {
return driveResponseError("doc/move_document", "readback_missing_workspace", "移动后读回缺少目标知识库 ID;无法证明移动已到达请求位置")
}
if remoteWorkspace != rt.Str("workspace") {
return driveResponseError("doc/move_document", "readback_workspace_mismatch", fmt.Sprintf("移动后读回知识库 %q 与请求 %q 不一致", remoteWorkspace, rt.Str("workspace")))
}
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
},
}
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
@@ -176,6 +177,40 @@ func TestCrossPlatformCoverageDriveDownloadAndUploadRequireArtifactsAndReadback(
if _, _, err := resolveDriveUploadInput("../escape.bin"); err == nil {
t.Fatal("upload path escape was accepted")
}
for _, tc := range []struct {
name string
committedID string
readback string
want string
}{
{"missing remote id", "uploaded-2", `{"success":true,"result":{"name":"input.bin","fileSize":18}}`, "缺少文件 ID"},
{"mismatched remote id", "uploaded-3", `{"success":true,"result":{"fileId":"other","name":"input.bin","fileSize":18}}`, "与提交 ID"},
{"prefix-only remote name", "uploaded-4", `{"success":true,"result":{"fileId":"uploaded-4","name":"input.bin-old","fileSize":18}}`, "读回名称"},
{"missing remote size", "uploaded-5", `{"success":true,"result":{"fileId":"uploaded-5","name":"input.bin"}}`, "缺少有效文件大小"},
{"mismatched remote size", "uploaded-6", `{"success":true,"result":{"fileId":"uploaded-6","name":"input.bin","fileSize":17}}`, "与本地文件大小 18 不一致"},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) {
return map[string]any{"success": true, "result": map[string]any{"fileId": tc.committedID}}, nil
})
caller := &driveCoverageCaller{responses: map[string][]string{"get_file_info": {tc.readback}}}
err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
})
}
t.Run("split remote extension", func(t *testing.T) {
testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) {
return map[string]any{"success": true, "result": map[string]any{"fileId": "uploaded-7"}}, nil
})
caller := &driveCoverageCaller{responses: map[string][]string{
"get_file_info": {`{"success":true,"result":{"fileId":"uploaded-7","name":"input","extension":"bin","fileSize":18}}`},
}}
if err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes"); err != nil {
t.Fatal(err)
}
})
testseam.Swap(t, &driveDownload, func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
if options.Output != "downloads/file.bin" || options.Headers["x-token"] != "secret" {
@@ -218,6 +253,42 @@ func TestCrossPlatformCoverageDriveCopyPreservesSchemaProperties(t *testing.T) {
}
}
func TestCrossPlatformCoverageDriveFirstInt64(t *testing.T) {
for _, tc := range []struct {
name string
value any
want int64
ok bool
}{
{"int", int(1), 1, true},
{"int32", int32(2), 2, true},
{"int64", int64(3), 3, true},
{"float", float64(4), 4, true},
{"json number", json.Number("5"), 5, true},
{"string", " 6 ", 6, true},
{"fraction", 1.5, 0, false},
{"nan", math.NaN(), 0, false},
{"infinity", math.Inf(1), 0, false},
{"overflow", float64(math.MaxInt64), 0, false},
{"bad json number", json.Number("bad"), 0, false},
{"bad string", "bad", 0, false},
{"unsupported", true, 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
got, ok := firstInt64(map[string]any{"size": tc.value}, "missing", "size")
if ok != tc.ok || got != tc.want {
t.Fatalf("firstInt64(%#v) = (%d, %t), want (%d, %t)", tc.value, got, ok, tc.want, tc.ok)
}
})
}
if got, ok := firstInt64(map[string]any{}, "size"); ok || got != 0 {
t.Fatalf("missing firstInt64 = (%d, %t), want (0, false)", got, ok)
}
if got, ok := firstInt64(map[string]any{"fileSize": nil, "size": "7"}, "fileSize", "size"); !ok || got != 7 {
t.Fatalf("fallback firstInt64 = (%d, %t), want (7, true)", got, ok)
}
}
func TestCrossPlatformCoverageDriveVersionAndPublishContracts(t *testing.T) {
versionPayload := `{"success":true,"versions":[{"version":1,"fileSize":3},{"versionNumber":"2","fileSize":4}],"hasMore":false}`
caller := &driveCoverageCaller{responses: map[string][]string{"list_file_versions": {versionPayload}}}
@@ -449,11 +520,34 @@ func TestCrossPlatformCoverageDriveCreateRestoreCopyMoveRename(t *testing.T) {
move := &driveCoverageCaller{responses: map[string][]string{
"move_document": {`{"success":true}`},
"get_document_info": {`{"success":true,"result":{"nodeId":"n1"}}`},
"get_document_info": {`{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"space"}}`},
}}
if err := runDriveCoverage(t, Move, move, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
readback string
want string
}{
{"missing node id", `{"success":true,"result":{"folderId":"target","workspaceId":"space"}}`, "缺少节点 ID"},
{"wrong node id", `{"success":true,"result":{"nodeId":"other","folderId":"target","workspaceId":"space"}}`, "与请求节点 \"n1\" 不一致"},
{"missing folder", `{"success":true,"result":{"nodeId":"n1","workspaceId":"space"}}`, "缺少目标文件夹 ID"},
{"wrong folder", `{"success":true,"result":{"nodeId":"n1","folderId":"other","workspaceId":"space"}}`, "与请求 \"target\" 不一致"},
{"missing workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target"}}`, "缺少目标知识库 ID"},
{"wrong workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"other"}}`, "与请求 \"space\" 不一致"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &driveCoverageCaller{responses: map[string][]string{
"move_document": {`{"success":true}`},
"get_document_info": {tc.readback},
}}
err := runDriveCoverage(t, Move, caller, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
})
}
rename := &driveCoverageCaller{responses: map[string][]string{
"get_file_info": {`{"success":true,"result":{"fileId":"n1","type":"FILE","extension":"md","name":"old.md"}}`, `{"success":true,"result":{"fileId":"n1","name":"new.md"}}`},
+97
View File
@@ -0,0 +1,97 @@
// 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 shortcut
import (
"fmt"
"math"
"time"
)
const maxAutoPageDelayMS = int64(math.MaxInt64) / int64(time.Millisecond)
// AutoPageControlFlags returns the shared item and pacing controls used by
// cursor-based shortcuts. Callers retain ownership of --page-all and their
// product-specific --page-limit defaults.
func AutoPageControlFlags() []Flag {
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
return []Flag{
{Name: "max-items", Type: FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制)。" + evidence},
{Name: "page-delay", Type: FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待)。" + evidence},
}
}
// AutoPageControlConstraints publishes the runtime-only relationship between
// the pagination switch and its shared controls into Help and Schema.
func AutoPageControlConstraints() []Constraint {
return []Constraint{{
Kind: ConstraintCustom,
Flags: []string{"page-all", "max-items", "page-delay"},
Description: "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0",
}}
}
// ValidateAutoPageControls enforces the shared contract while preserving the
// historical behavior that defaulted controls do not activate pagination.
func ValidateAutoPageControls(rt *RuntimeContext) error {
if !rt.Bool("page-all") {
if rt.Changed("max-items") || rt.Changed("page-delay") {
return fmt.Errorf("--max-items/--page-delay 仅与 --page-all 一起使用")
}
return nil
}
if rt.Int("max-items") < 0 {
return fmt.Errorf("--max-items 必须大于等于 0")
}
delayMS := rt.Int("page-delay")
if delayMS < 0 {
return fmt.Errorf("--page-delay 必须大于等于 0")
}
if int64(delayMS) > maxAutoPageDelayMS {
return fmt.Errorf("--page-delay 不能大于 %d 毫秒", maxAutoPageDelayMS)
}
return nil
}
// AutoPageRequestSize caps the next lower-page request to the remaining item
// budget. A cursor returned for that request is therefore safe to resume from:
// the CLI did not intentionally discard a suffix of the lower page.
func AutoPageRequestSize(rt *RuntimeContext, pageSize, itemCount int) int {
maxItems := rt.Int("max-items")
if maxItems <= 0 {
return pageSize
}
remaining := maxItems - itemCount
if remaining > 0 && remaining < pageSize {
return remaining
}
return pageSize
}
// WaitAutoPageDelay waits between successful pages and remains cancellable so
// a throttled pagination run cannot ignore command cancellation.
func WaitAutoPageDelay(rt *RuntimeContext) error {
delayMS := rt.Int("page-delay")
if delayMS <= 0 {
return nil
}
timer := time.NewTimer(time.Duration(int64(delayMS)) * time.Millisecond)
defer timer.Stop()
select {
case <-rt.Command().Context().Done():
return rt.Command().Context().Err()
case <-timer.C:
return nil
}
}
+91
View File
@@ -0,0 +1,91 @@
// 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 shortcut
import (
"context"
"strconv"
"testing"
"github.com/spf13/cobra"
)
func autoPageRuntimeForTest(t *testing.T, pageAll bool, maxItems, pageDelay string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "page"}
cmd.SetContext(context.Background())
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("max-items", 0, "")
cmd.Flags().Int("page-delay", 0, "")
if pageAll {
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
}
for name, value := range map[string]string{"max-items": maxItems, "page-delay": pageDelay} {
if value != "" {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatal(err)
}
}
}
return RuntimeContextForTest(cmd, Shortcut{})
}
func TestCrossPlatformCoverageAutoPageControlsBoundDelayAndRequestSize(t *testing.T) {
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, false, "1", "")); err == nil {
t.Fatal("max-items without page-all unexpectedly succeeded")
}
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "-1", "")); err == nil {
t.Fatal("negative max-items unexpectedly succeeded")
}
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", "-1")); err == nil {
t.Fatal("negative page-delay unexpectedly succeeded")
}
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", strconv.FormatInt(maxAutoPageDelayMS, 10))); err != nil {
t.Fatalf("maximum safe page-delay failed: %v", err)
}
if strconv.IntSize == 64 {
tooLarge := strconv.FormatInt(maxAutoPageDelayMS+1, 10)
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", tooLarge)); err == nil {
t.Fatal("overflowing page-delay unexpectedly succeeded")
}
}
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "0", ""), 100, 40); got != 100 {
t.Fatalf("unlimited request size = %d", got)
}
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 40); got != 10 {
t.Fatalf("remaining request size = %d", got)
}
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 50); got != 100 {
t.Fatalf("exhausted request size = %d", got)
}
}
func TestCrossPlatformCoverageWaitAutoPageDelayNoopAndCancellation(t *testing.T) {
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "0")); err != nil {
t.Fatalf("zero delay = %v", err)
}
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "1")); err != nil {
t.Fatalf("elapsed delay = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
rt := autoPageRuntimeForTest(t, true, "", "1")
rt.Command().SetContext(ctx)
if err := WaitAutoPageDelay(rt); err != context.Canceled {
t.Fatalf("canceled delay = %v, want context.Canceled", err)
}
}
+44 -6
View File
@@ -93,23 +93,23 @@ var AtMe = shortcut.Shortcut{
},
},
},
Flags: append([]shortcut.Flag{
Flags: append(append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...), chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append(append([]shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
}, chatshortcut.MessageResourceDownloadConstraints()...),
}, shortcut.AutoPageControlConstraints()...), chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
@@ -211,6 +211,9 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -240,12 +243,24 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
nextCursor := ""
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
for pagesFetched < pageLimit {
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
params := make(map[string]any, len(baseParams))
for key, value := range baseParams {
params[key] = value
}
pageSize, _ := baseParams["limit"].(int)
params["limit"] = shortcut.AutoPageRequestSize(rt, pageSize, len(allItems))
params["cursor"] = cursor
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
if err != nil {
@@ -260,6 +275,7 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
}
pagesFetched++
pageItems := atMeMessageItems(data)
overflowOnPage := false
for _, item := range pageItems {
id := chatmsg.StableMessageID(item)
if id != "" && seenMessages[id] {
@@ -268,6 +284,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
if id != "" {
seenMessages[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
allItems = append(allItems, item)
}
@@ -282,6 +303,16 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
break
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = ""
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "@我消息下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = ""
@@ -299,8 +330,13 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
}
seenCursors[nextCursor] = true
cursor = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
@@ -312,9 +348,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["truncatedByResultLimit"] = truncatedByResultLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
chatmsg.ApplyTruncation(payload)
if hasMore && nextCursor != "" {
payload["nextCursor"] = nextCursor
}
+279 -51
View File
@@ -57,10 +57,10 @@ var ChatMessages = shortcut.Shortcut{
Command: "+chat-messages",
Product: "chat",
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
@@ -83,10 +83,10 @@ var ChatMessages = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
Examples: []string{
@@ -104,7 +104,8 @@ var ChatMessages = shortcut.Shortcut{
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 筛选"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
@@ -117,9 +118,11 @@ var ChatMessages = shortcut.Shortcut{
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--max-results 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-results 仅与 --page-all 一起使用且不能为负数;0 表示仅受页数上限约束"},
{Name: "max-items", Type: shortcut.FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制);--max-items 仅与 --page-all 一起使用且不能为负数"},
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的公开兼容别名;--max-results 仅与 --page-all 一起使用且不能为负数"},
{Name: "page-delay", Type: shortcut.FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待);--page-delay 仅与 --page-all 一起使用且不能为负数"},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
@@ -131,6 +134,7 @@ var ChatMessages = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"sender", "sender-query"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
@@ -138,7 +142,10 @@ var ChatMessages = shortcut.Shortcut{
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-items"}, Description: "--max-items 仅与 --page-all 一起使用且不能为负数"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"max-items", "max-results"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-delay"}, Description: "--page-delay 仅与 --page-all 一起使用且不能为负数"},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output", "overwrite"},
@@ -179,16 +186,25 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
}
}
if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) {
return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用")
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if !rt.Bool("page-all") && rt.Changed("max-results") {
return apperrors.NewValidation("--max-results 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if rt.Int("max-results") < 0 {
return apperrors.NewValidation("--max-results 不能小于 0")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
if rt.Int("max-results") < 0 {
return apperrors.NewValidation("--max-results 不能小于 0")
}
if rt.Changed("max-items") && rt.Changed("max-results") {
return apperrors.NewValidation("--max-items 与 --max-results 不能同时使用")
}
if rt.Changed("output") {
if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil {
@@ -209,105 +225,285 @@ type chatMessagesRequest struct {
}
type chatMessagesSenderFilter struct {
requested bool
applied bool
stableIDs map[string]bool
resolutions []targetresolver.UserResolution
failure map[string]any
requested bool
applied bool
inputs []string
inputMode string
stableIDs map[string]bool
resolutions []targetresolver.UserResolution
resolutionFailure map[string]any
resolutionErr error
scopeErr error
}
// resolveOptionalChatMessagesSenderFilter is deliberately local to
// +chat-messages. Sender resolution is an optional post-read filter here: an
// unresolved name must not suppress the command's primary conversation-list
// result. +search-msg keeps its stricter semantics because sender identity is
// part of the lower search request there.
// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target
// as +search-msg while retaining --sender-query as a compatibility entry. It
// resolves only explicitly requested senders; it never enumerates every member
// or tries to infer a person from message display names.
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
queries := rt.StrSlice("sender-query")
direct := uniqueChatMessageTargets(rt.StrSlice("sender"))
queries := uniqueChatMessageTargets(rt.StrSlice("sender-query"))
inputs := direct
inputMode := "sender"
if len(inputs) == 0 {
inputs = queries
inputMode = "sender-query"
}
filter := chatMessagesSenderFilter{
requested: len(queries) > 0,
requested: len(inputs) > 0,
inputs: inputs,
inputMode: inputMode,
stableIDs: map[string]bool{},
}
if !filter.requested {
return filter
}
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
var resolutions []targetresolver.UserResolution
var err error
if len(direct) > 0 {
for _, value := range direct {
var resolved targetresolver.UserResolution
resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny)
if err != nil {
break
}
resolutions = append(resolutions, resolved)
}
} else {
resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
}
if err != nil {
failure := map[string]any{
"stage": "sender_resolution",
"queries": queries,
"error": err.Error(),
"stage": "sender_resolution",
"inputs": inputs,
"mode": inputMode,
"error": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
if typed.Reason != "" {
failure["reason"] = typed.Reason
}
if typed.Hint != "" {
failure["hint"] = typed.Hint
}
if len(typed.Details) > 0 {
failure["details"] = typed.Details
}
}
filter.failure = failure
filter.resolutionFailure = failure
filter.resolutionErr = err
return filter
}
filter.resolutions = resolutions
for _, resolution := range resolutions {
for _, identity := range []string{
resolution.Selected.UserID,
resolution.Selected.OpenDingTalkID,
} {
if identity = strings.TrimSpace(identity); identity != "" {
filter.stableIDs[identity] = true
}
}
}
// ResolveUsers(IdentityAny) only returns users extracted with at least one
// stable userId/openDingTalkId and fails when no resolvable user remains.
addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions)
filter.applied = true
return filter
}
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
// when optional resolution fails. Successful resolution filters both the
func uniqueChatMessageTargets(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) {
for _, resolution := range resolutions {
if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" {
ids[identity] = true
}
if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" {
ids[identity] = true
}
}
}
// applyOptionalChatMessagesSenderFilter fails closed when an explicitly
// requested sender cannot be resolved. Successful resolution filters both the
// public projection and raw rows so exports and resource downloads cannot
// accidentally include messages outside the requested sender set.
func applyOptionalChatMessagesSenderFilter(
rt *shortcut.RuntimeContext,
payload map[string]any,
rawItems []map[string]any,
filter chatMessagesSenderFilter,
filter *chatMessagesSenderFilter,
) []map[string]any {
if !filter.requested || payload == nil {
if filter == nil || !filter.requested || payload == nil {
return rawItems
}
if !filter.applied {
failures, _ := payload["failures"].([]map[string]any)
priorFailures := len(failures)
failures = append(failures, filter.failure)
failure := filter.resolutionFailure
if failure == nil {
failure = map[string]any{
"stage": "sender_resolution",
"inputs": filter.inputs,
"mode": filter.inputMode,
"error": "无法把发送者目标唯一解析为稳定身份",
}
}
failures = append(failures, failure)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(rawItems) > 0
payload["partial"] = false
payload["messages"] = []map[string]any{}
payload["count"] = 0
payload["senderFilter"] = map[string]any{
"requested": true,
"inputs": filter.inputs,
"mode": filter.inputMode,
"applied": false,
"status": "resolution_failed",
"suppressedCount": len(rawItems),
}
payload["identityResult"] = map[string]any{
"status": "resolution_failed",
"nameComparisonAllowed": false,
"stableIdField": "senderId",
"hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。",
}
if priorFailures == 0 {
payload["stopReason"] = "sender_resolution_failed"
}
return rawItems
return nil
}
filtered := make([]map[string]any, 0, len(rawItems))
unverifiableMessageIDs := make([]string, 0)
for _, item := range rawItems {
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
if identity == "" || identity == "<nil>" {
messageID := chatmsg.StableMessageID(item)
if messageID == "" {
messageID = "<unknown>"
}
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
continue
}
if filter.stableIDs[identity] {
filtered = append(filtered, item)
}
}
if len(unverifiableMessageIDs) > 0 {
filter.scopeErr = apperrors.NewAPI(
"部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出",
apperrors.WithReason("chat_messages_sender_scope_unverified"),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"),
apperrors.WithDetails(map[string]any{
"requestedSenders": filter.inputs,
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
}),
)
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, map[string]any{
"stage": "sender_scope_verification",
"error": filter.scopeErr.Error(),
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
})
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(filtered) > 0
}
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filtered, filter.resolutions)
if len(unverifiedSenderInputs) > 0 {
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, map[string]any{
"stage": "sender_identity_verification",
"inputs": unverifiedSenderInputs,
"error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 过滤,但不能据此作完整否定结论",
})
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(filtered) > 0
}
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
payload["count"] = len(filtered)
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
filterStatus := "applied"
if filter.scopeErr != nil {
filterStatus = "scope_unverified"
} else if len(unverifiedSenderInputs) > 0 {
filterStatus = "identity_unverified"
}
payload["senderFilter"] = map[string]any{
"requested": true,
"inputs": filter.inputs,
"mode": filter.inputMode,
"applied": true,
"status": filterStatus,
}
if len(unverifiedSenderInputs) > 0 {
payload["senderFilter"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs
}
identityResult := map[string]any{
"status": "evaluated",
"nameComparisonAllowed": false,
"negativeConclusionAllowed": true,
"stableIdField": "senderId",
}
if filter.scopeErr != nil {
identityResult["status"] = "scope_unverified"
identityResult["negativeConclusionAllowed"] = false
identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。"
} else if len(unverifiedSenderInputs) > 0 {
identityResult["status"] = "identity_unverified"
identityResult["negativeConclusionAllowed"] = false
identityResult["hint"] = "当前混合发送者参数未能经通讯录确认;已按原值 userId 精确比较,若无命中仍不能断言该人员没有发过消息。"
}
payload["identityResult"] = identityResult
return filtered
}
func chatMessagesUnverifiedSenderInputs(
messages []map[string]any,
resolutions []targetresolver.UserResolution,
) []string {
observed := map[string]bool{}
for _, message := range messages {
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
observed[senderID] = true
}
}
values := make([]string, 0)
for _, resolution := range resolutions {
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
values = append(values, resolution.Query)
}
}
return uniqueChatMessageTargets(values)
}
func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) {
if payload == nil || filter.requested {
return
}
payload["senderFilter"] = map[string]any{
"requested": false,
"applied": false,
"status": "not_requested",
}
payload["identityResult"] = map[string]any{
"status": "requires_query_check",
"negativeConclusionAllowed": false,
"hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。",
}
}
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
userID := rt.Str("user")
@@ -317,6 +513,11 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
)
}
if openID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
return chatMessagesRequest{}, err
}
}
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
if err != nil {
@@ -394,7 +595,8 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
return err
}
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, &senderFilter)
attachUnfilteredSenderIdentitySemantics(payload, senderFilter)
if err != nil {
// Full-page collection returns its failure ledger together with a
// non-zero error. Publish that ledger for diagnosis, but stop before
@@ -406,6 +608,22 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
}
return err
}
if senderFilter.requested && !senderFilter.applied {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return senderFilter.resolutionErr
}
if senderFilter.scopeErr != nil {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return senderFilter.scopeErr
}
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
@@ -477,7 +695,7 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
maxResults := rt.Int("max-results")
maxResults := rt.IntFirst("max-items", "max-results")
basePageSize, _ := request.params["limit"].(int)
if basePageSize <= 0 {
basePageSize = chatMessagesAllPageSize
@@ -496,6 +714,15 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
var nextPage map[string]any
for pagesFetched < pageLimit {
if pagesFetched > 0 {
if delayErr := shortcut.WaitAutoPageDelay(rt); delayErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "error": delayErr.Error(),
})
stopReason = "delay_interrupted"
break
}
}
request.params["limit"] = basePageSize
if maxResults > 0 {
remaining := maxResults - len(allItems)
@@ -656,6 +883,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["truncatedByResultLimit"] = truncatedByResultLimit
chatmsg.ApplyTruncation(payload)
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(results) > 0
+306 -12
View File
@@ -20,12 +20,14 @@ import (
stderrors "errors"
"math"
"os"
"reflect"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -503,7 +505,29 @@ func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcu
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
}
func TestCrossPlatformCoverageChatMessagesKeepsMaxResultsPublic(t *testing.T) {
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-messages"})
if err != nil {
t.Fatal(err)
}
flag := cmd.Flags().Lookup("max-results")
if flag == nil || flag.Hidden {
t.Fatalf("--max-results must remain a visible compatibility flag: %#v", flag)
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) {
for _, values := range []map[string]string{
{"max-results": "1"},
{"max-items": "1"},
{"page-all": "true", "max-results": "-1"},
{"page-all": "true", "max-items": "1", "max-results": "1"},
} {
if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil {
t.Fatalf("pagination validation unexpectedly accepted %#v", values)
}
}
for _, values := range []map[string]string{
{"time": "2026-01-01", "start": "2026-01-01"},
{"direction": "older", "start": "2026-01-01"},
@@ -524,7 +548,7 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender-query": "测试用户甲",
}))
if filter.applied || filter.failure == nil {
if filter.applied || filter.resolutionFailure == nil {
t.Fatalf("identity-free sender resolution = %#v", filter)
}
@@ -555,6 +579,257 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
}
}
func TestChatMessagesSenderScopeIgnoresNonMatchingIdentityFamily(t *testing.T) {
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"DAAAAAAAAAAAiE"},
inputMode: "sender",
stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true},
}
payload := map[string]any{
"complete": true,
"failures": []map[string]any{},
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil),
payload,
[]map[string]any{
{"openMessageId": "wanted", "senderOpenDingTalkId": "DAAAAAAAAAAAiE"},
{"openMessageId": "other-family", "senderUserId": "other-user"},
},
&filter,
)
if len(filtered) != 1 || filter.scopeErr != nil || payload["complete"] != true || payload["count"] != 1 {
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
}
}
func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) {
t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[{"userId":"other-user","name":"其他用户"}],"hasMore":false}`,
}
helpers.InitDeps(fake)
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender": "fixture-user-id",
}))
if !filter.applied || !filter.stableIDs["fixture-user-id"] || filter.stableIDs["other-user"] {
t.Fatalf("filter=%#v", filter)
}
})
t.Run("direct user ids continue when directory is unavailable", func(t *testing.T) {
fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender": testCurrentDOpenID + ",fixture-name,ignored-name",
}))
if !filter.applied || filter.resolutionErr != nil || len(fake.calls) != 2 ||
!filter.stableIDs["fixture-name"] || !filter.stableIDs["ignored-name"] {
t.Fatalf("filter=%#v calls=%#v", filter, fake.calls)
}
})
t.Run("unverified mixed sender becomes verified by an exact sender id match", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
resolution := targetresolver.UserResolution{
Status: targetresolver.StatusResolved,
EntityType: "user",
Query: "stable-user-id",
MatchType: "unverified_user_id",
Selected: targetresolver.User{UserID: "stable-user-id"},
}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"stable-user-id"},
inputMode: "sender",
stableIDs: map[string]bool{"stable-user-id": true},
resolutions: []targetresolver.UserResolution{resolution},
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload,
[]map[string]any{
{"openMessageId": "wanted", "senderUserId": "stable-user-id"},
{"openMessageId": "other", "senderUserId": "other-user"},
}, &filter,
)
if len(filtered) != 1 || payload["complete"] != true || len(payload["failures"].([]map[string]any)) != 0 {
t.Fatalf("filtered=%#v payload=%#v", filtered, payload)
}
identity := payload["identityResult"].(map[string]any)
if identity["status"] != "evaluated" || identity["negativeConclusionAllowed"] != true {
t.Fatalf("identityResult=%#v", identity)
}
})
t.Run("unverified mixed sender without a match blocks complete negative conclusion", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
resolution := targetresolver.UserResolution{
Status: targetresolver.StatusResolved,
EntityType: "user",
Query: "possibly-a-name",
MatchType: "unverified_user_id",
Selected: targetresolver.User{UserID: "possibly-a-name"},
}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"possibly-a-name"},
inputMode: "sender",
stableIDs: map[string]bool{"possibly-a-name": true},
resolutions: []targetresolver.UserResolution{resolution},
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload,
[]map[string]any{{"openMessageId": "other", "senderUserId": "other-user"}}, &filter,
)
if len(filtered) != 0 || payload["complete"] != false || payload["failedCount"] != 1 {
t.Fatalf("filtered=%#v payload=%#v", filtered, payload)
}
identity := payload["identityResult"].(map[string]any)
if identity["status"] != "identity_unverified" || identity["negativeConclusionAllowed"] != false {
t.Fatalf("identityResult=%#v", identity)
}
})
t.Run("dedupes blanks and repeated values", func(t *testing.T) {
got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "})
if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) {
t.Fatalf("targets=%#v want=%#v", got, want)
}
})
t.Run("synthesizes failure when none was supplied", func(t *testing.T) {
payload := map[string]any{"failures": []map[string]any{{"stage": "existing"}}}
filter := chatMessagesSenderFilter{requested: true, inputs: []string{"fixture"}, inputMode: "sender"}
if got := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"openMessageId": "hidden"}}, &filter,
); got != nil {
t.Fatalf("filtered=%#v", got)
}
if _, exists := payload["stopReason"]; exists {
t.Fatalf("existing failure should retain stop reason ownership: %#v", payload)
}
})
t.Run("rejects only missing identities", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"fixture"},
inputMode: "sender",
stableIDs: map[string]bool{"wanted-user": true},
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil),
payload,
[]map[string]any{
{"content": "missing sender and id"},
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
&filter,
)
if len(filtered) != 1 || filter.scopeErr == nil {
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
}
})
t.Run("ignores user identity when only open ids were resolved", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{testCurrentDOpenID},
inputMode: "sender",
stableIDs: map[string]bool{testCurrentDOpenID: true},
}
applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload,
[]map[string]any{{"senderUserId": "other-user"}}, &filter,
)
if filter.scopeErr != nil || payload["complete"] != true {
t.Fatalf("filter=%#v", filter)
}
})
t.Run("ignores open identity when only user ids were resolved", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"wanted-user"},
inputMode: "sender",
stableIDs: map[string]bool{"wanted-user": true},
}
applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload,
[]map[string]any{{"senderOpenDingTalkId": testCurrentDOpenID}}, &filter,
)
if filter.scopeErr != nil || payload["complete"] != true {
t.Fatalf("filter=%#v", filter)
}
})
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
"open-dingtalk-id": "not-an-open-id",
})); err == nil {
t.Fatal("invalid explicit open ID unexpectedly accepted")
}
}
func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) {
t.Run("ambiguous direct sender prioritizes output error", func(t *testing.T) {
caller := &platformCoverageCaller{contactSearchResult: `{"result":[
{"userId":"fixture-user-1","name":"测试同名发送者"},
{"userId":"fixture-user-2","name":"测试同名发送者"}
],"hasMore":false}`}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", "测试同名发送者"})
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
for _, tc := range []struct {
name string
sender string
}{
{name: "resolution", sender: "fixture-name"},
{name: "scope", sender: testCurrentDOpenID},
} {
t.Run(tc.name+" returns domain error", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
if err := root.Execute(); err == nil {
t.Fatal("sender failure unexpectedly succeeded")
}
})
t.Run(tc.name+" prioritizes output error", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
@@ -613,21 +888,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T
})
t.Run("terminal result limit and unsafe continuation", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
for _, flag := range []string{"max-items", "max-results"} {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, map[string]string{flag: "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["truncated"] != true || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("%s payload=%#v err=%v", flag, payload, err)
}
}
caller = &chatMessagesPagingCaller{responses: []string{
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`,
}}
payload, _, err = collectAllChatMessages(
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
@@ -650,6 +927,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T
}
})
t.Run("canceled delay", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`,
}}
rt := runtimeWith(t, caller, map[string]string{"page-delay": "1"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
rt.Command().SetContext(ctx)
payload, _, err := collectAllChatMessages(
rt,
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err == nil || payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != 1 {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("first failure ledger output error", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
root := newPlatformCoverageRoot()
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", testCurrentDOpenID, "--limit", "1", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
+57 -7
View File
@@ -84,17 +84,17 @@ var MyGroups = shortcut.Shortcut{
},
},
},
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +my-groups`,
`dws chat +my-groups --type group`,
@@ -116,6 +116,9 @@ func validateMyGroups(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -180,10 +183,22 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
eligibleCount := 0
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
pageSize, _ := baseParams["limit"].(int)
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, eligibleCount)}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
@@ -200,6 +215,7 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
}
pagesFetched++
pageGroups := myGroupsExtract(data)
overflowOnPage := false
for _, group := range pageGroups {
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
if id != "" && seenGroups[id] {
@@ -208,7 +224,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
if id != "" {
seenGroups[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && myGroupsMatchesFilter(rt, group) && eligibleCount >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
allGroups = append(allGroups, group)
if myGroupsMatchesFilter(rt, group) {
eligibleCount++
}
}
page := chatmsg.Pagination(data)
@@ -222,6 +246,16 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
break
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = nil
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "我的群列表下层返回的匹配条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = nil
@@ -241,8 +275,13 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && eligibleCount >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
@@ -254,9 +293,11 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["truncatedByResultLimit"] = truncatedByResultLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
chatmsg.ApplyTruncation(payload)
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
@@ -275,6 +316,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
)
}
func myGroupsMatchesFilter(rt *shortcut.RuntimeContext, group map[string]any) bool {
typeFilter := strings.TrimSpace(rt.Str("type"))
if typeFilter == "" {
return true
}
groupType, _ := myGroupsProject(group)["type"].(string)
return strings.EqualFold(strings.TrimSpace(groupType), typeFilter)
}
func myGroupsCursorString(value any) string {
if value == nil {
return ""
@@ -22,7 +22,7 @@ func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *t
want []string
}{
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender", "sender-query"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -160,7 +160,7 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "测试用户甲", "--page-all",
"--sender", "测试用户甲", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -201,7 +201,64 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
func TestChatMessagesFormatValidSenderSkipsDirectoryAndFiltersStableID(t *testing.T) {
fake := &platformCoverageCaller{
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"wanted","sender":"目标展示名","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"保留"},
{"openMessageId":"other","sender":"其他人","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"过滤"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender", "DAAAAAAAAAAAiE", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
t.Fatalf("format-valid sender unexpectedly preflighted: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != true {
t.Fatalf("payload=%#v", payload)
}
}
func TestChatMessagesWithoutSenderDoesNotResolveEveryMessageIdentity(t *testing.T) {
fake := &platformCoverageCaller{chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m1","sender":"群昵称一","senderOpenDingTalkId":"D1","content":"一"},
{"openMessageId":"m2","sender":"群昵称二","senderOpenDingTalkId":"D2","content":"二"}
]}}`}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
t.Fatalf("calls=%#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
identity := payload["identityResult"].(map[string]any)
if payload["count"] != float64(2) || identity["status"] != "requires_query_check" ||
identity["negativeConclusionAllowed"] != false {
t.Fatalf("payload=%#v", payload)
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureStopsWithoutUnfilteredMessages(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
@@ -216,22 +273,26 @@ func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfiltered
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "不存在的人", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatalf("optional sender failure stopped message read: %v", err)
if err := root.Execute(); err == nil {
t.Fatal("sender resolution failure unexpectedly succeeded")
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
t.Fatalf("calls = %#v, want message read followed by failed resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["count"] != float64(1) || payload["complete"] != false ||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
if payload["count"] != float64(0) || payload["complete"] != false ||
payload["partial"] != false || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "sender_resolution_failed" {
t.Fatalf("unfiltered fallback payload = %#v", payload)
t.Fatalf("fail-closed payload = %#v", payload)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 0 {
t.Fatalf("failed resolution leaked unfiltered messages: %#v", payload["messages"])
}
if _, exists := payload["resolvedFilters"]; exists {
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
@@ -5,6 +5,7 @@ package smart
import (
"bytes"
"context"
"encoding/json"
"testing"
@@ -83,6 +84,31 @@ func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t
}
}
func TestCrossPlatformCoverageAtMeMaxItemsPublishesStableTruncation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--page-all", "--max-items", "1", "--page-delay", "0"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != "cursor-2" {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload)
}
}
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
@@ -109,12 +135,119 @@ func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMer
}
}
func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"p1","groupType":"p2p"}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":3}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--max-items", "1", "--page-delay", "0"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(caller.args) != 2 || caller.args[0]["limit"] != 1 || caller.args[1]["limit"] != 1 || payload["nextCursor"] != float64(3) {
t.Fatalf("unsafe filtered continuation: calls=%#v payload=%#v", caller.args, payload)
}
}
func TestCrossPlatformCoverageRemainingListsFailClosedOnOversizeAndCanceledDelay(t *testing.T) {
tests := []struct {
name string
command string
response string
extra []string
}{
{
name: "at-me",
command: "+at-me",
response: `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}]}],` +
`"hasMore":true,"nextCursor":"next"}}`,
},
{
name: "my-groups",
command: "+my-groups",
response: `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"},{"openConversationId":"g2","groupType":"group"}],"hasMore":true,"nextCursor":2}}`,
extra: []string{"--type", "group"},
},
}
for _, tc := range tests {
t.Run(tc.name+" oversized lower page", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{tc.response}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{"chat", tc.command}, tc.extra...)
args = append(args, "--page-all", "--max-items", "1")
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != nil {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload)
}
})
t.Run(tc.name+" canceled delay", func(t *testing.T) {
continuing := tc.response
if tc.command == "+at-me" {
continuing = `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`
} else {
continuing = `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":2}}`
}
caller := &chatMessagesPagingCaller{responses: []string{continuing}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{"chat", tc.command}, tc.extra...)
args = append(args, "--page-all", "--page-delay", "1")
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+at-me", "--page-limit", "2"},
{"chat", "+at-me", "--max-items", "1"},
{"chat", "+at-me", "--page-delay", "1"},
{"chat", "+at-me", "--page-all", "--page-limit", "501"},
{"chat", "+at-me", "--page-all", "--max-items", "-1"},
{"chat", "+my-groups", "--limit", "201"},
{"chat", "+my-groups", "--page-limit", "2"},
{"chat", "+my-groups", "--max-items", "1"},
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&chatMessagesPagingCaller{})
+242 -30
View File
@@ -35,7 +35,7 @@ import (
// search hits through list_messages_by_ids in chunks of 50. A later-page or
// enrichment failure never turns a partial result into a false success: the
// output carries an explicit failure ledger and complete=false.
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 执行,但无稳定 senderId 命中不得作完整否定结论。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
@@ -70,8 +70,8 @@ var SearchMsg = shortcut.Shortcut{
UseWhen: []string{searchMsgIntent},
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --group \"项目群\" --sender \"测试用户甲\" --page-all",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
},
},
},
@@ -80,15 +80,15 @@ var SearchMsg = shortcut.Shortcut{
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
{Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "group", Type: shortcut.FlagString, Desc: "单个群名或 openConversationId;自动唯一解析并校验"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 查询"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名,保留同样的三态解析与安全降级语义"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
@@ -140,6 +140,7 @@ var SearchMsg = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group "项目群" --sender "测试用户甲" --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree" --jq '.messages[] | {messageId, text}'`,
@@ -270,6 +271,22 @@ var SearchMsg = shortcut.Shortcut{
}
messages = validatedMessages
}
if len(resolvedFilters.Senders) > 0 {
var unverifiableMessageIDs []string
messages, unverifiableMessageIDs = filterSearchSenderScope(messages, resolvedFilters.Senders)
if len(unverifiableMessageIDs) > 0 {
return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs)
}
}
unverifiedSenderInputs := searchUnverifiedSenderInputs(messages, resolvedFilters.Senders)
if len(unverifiedSenderInputs) > 0 {
failures = append(failures, map[string]any{
"stage": "sender_identity_verification",
"inputs": unverifiedSenderInputs,
"error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 执行,但不能据此作完整否定结论",
})
complete = false
}
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
if order == "" {
@@ -293,9 +310,23 @@ var SearchMsg = shortcut.Shortcut{
"failedCount": len(failures),
"failures": failures,
"queryRange": searchMessageQueryRange(params, order),
"timeCoverage": searchMessageTimeCoverage(rt),
"conclusionGuard": searchMessageConclusionGuard(rt, complete, len(results)),
}
if len(resolvedFilters.Chats) > 0 || len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
}
if len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
payload["senderScope"] = map[string]any{
"targetsResolved": len(unverifiedSenderInputs) == 0,
"filterApplied": true,
"filterMode": "server_and_client",
"resultsWithinScope": true,
}
if len(unverifiedSenderInputs) > 0 {
payload["senderScope"].(map[string]any)["status"] = "identity_unverified"
payload["senderScope"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs
}
}
if scopedSearch {
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
@@ -313,6 +344,39 @@ var SearchMsg = shortcut.Shortcut{
},
}
func searchMessageTimeCoverage(rt *shortcut.RuntimeContext) map[string]any {
source := "implicit_default"
if rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") || rt.Changed("end-time") {
source = "explicit_range"
} else if rt.Changed("days") {
source = "explicit_days"
}
coverage := map[string]any{
"source": source,
"allHistory": false,
}
if source != "explicit_range" {
coverage["days"] = rt.Int("days")
}
return coverage
}
func searchMessageConclusionGuard(rt *shortcut.RuntimeContext, complete bool, resultCount int) map[string]any {
coverage := searchMessageTimeCoverage(rt)
guard := map[string]any{
"absenceWithinQueryRangeAllowed": complete,
"absenceAcrossAllHistoryAllowed": false,
"allHistoryCountAllowed": false,
"requiredActionForAllHistory": "widen_time_range_or_reuse_complete_message_ledger",
}
if coverage["source"] == "implicit_default" && resultCount == 0 {
guard["requiredAction"] = "widen_time_range_or_reuse_complete_message_ledger"
guard["hint"] = "当前只证明默认 7 天时间窗内没有命中,不能据此判断全部历史没有。若已有完整会话消息,请解析稳定身份后复用现有消息;否则扩大时间范围。"
}
return guard
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
@@ -350,6 +414,39 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 {
return apperrors.NewValidation("--page-limit 必须在 1-40 之间")
}
if err := validateSearchMsgStrictConversationAliases(rt); err != nil {
return err
}
return nil
}
// The conventional --group/--groups flags are intentionally dual-form. Only
// the explicit ID aliases remain strict so scripts that select them retain a
// deterministic contract.
func validateSearchMsgStrictConversationAliases(rt *shortcut.RuntimeContext) error {
values := append([]string{}, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("conversation-id", "id"); value != "" {
values = append(values, value)
}
for _, value := range uniqueSearchStrings(values) {
if strings.HasPrefix(strings.ToLower(value), "cid") {
continue
}
return apperrors.NewValidation(
fmt.Sprintf("显式会话 ID 参数收到非 openConversationId 值 %q;已停止消息搜索", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint(fmt.Sprintf("--chat-id/--conversation-id/--id 只接受 cid 开头的 openConversationId;群名请使用 --group %q 或 --groups %q。", value, value)),
apperrors.WithDetails(map[string]any{
"flags": []string{"chat-id", "conversation-id", "id"},
"expectedType": "openConversationId",
"providedValue": value,
}),
)
}
return nil
}
@@ -358,6 +455,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
// the directory name, so consumers must join the selected stable identity to
// each projected message's senderId instead of comparing those two names.
type searchResolvedFilters struct {
Chats []targetresolver.ChatResolution `json:"chats,omitempty"`
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
}
@@ -367,41 +465,58 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
params["keyword"] = value
}
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
conversationIDs := append([]string{}, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("conversation-id", "id"); value != "" {
conversationIDs = append(conversationIDs, value)
}
groupTargets := append([]string{}, rt.StrSlice("groups")...)
if value := rt.Str("group"); value != "" {
groupTargets = append(groupTargets, value)
}
for _, target := range uniqueSearchStrings(groupTargets) {
if strings.HasPrefix(strings.ToLower(target), "cid") {
conversationIDs = append(conversationIDs, target)
continue
}
resolved, err := targetresolver.ResolveChatTarget(rt, target, "")
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
if queries := rt.StrSlice("chat-query"); len(queries) > 0 {
for _, query := range queries {
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
}
if values := uniqueSearchStrings(conversationIDs); len(values) > 0 {
params["openConversationIds"] = values
}
senders := append([]string{}, rt.StrSlice("senders")...)
senders = append(senders, rt.StrSlice("sender")...)
senderTargets := append([]string{}, rt.StrSlice("senders")...)
senderTargets = append(senderTargets, rt.StrSlice("sender")...)
for _, target := range uniqueSearchStrings(senderTargets) {
resolved, err := targetresolver.ResolveSenderTarget(rt, target, targetresolver.IdentityAny)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolved)
}
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
for _, resolved := range resolvedUsers {
identity := resolved.Selected.OpenDingTalkID
if identity == "" {
identity = resolved.Selected.UserID
}
senders = append(senders, identity)
}
}
appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds")
appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds")
appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds")
atUsers := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids"))
appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds")
if rt.Bool("at-me") || rt.Bool("is-at-me") {
params["atMe"] = true
}
@@ -439,15 +554,40 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
return params, resolvedFilters, nil
}
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
var userIDs, openIDs []string
func resolveSearchStableActorTargets(
rt *shortcut.RuntimeContext,
values []string,
) []targetresolver.UserResolution {
resolvedUsers := make([]targetresolver.UserResolution, 0, len(values))
for _, value := range uniqueSearchStrings(values) {
if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') {
openIDs = append(openIDs, value)
} else {
userIDs = append(userIDs, value)
// IdentityAny accepts every non-empty value as exactly one stable ID
// family, and uniqueSearchStrings has already removed empty values.
resolved, _ := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny)
resolvedUsers = append(resolvedUsers, resolved)
}
return resolvedUsers
}
// appendResolvedSearchActorIDs prefers openDingTalkId whenever directory
// resolution supplies it because the current message-search backend reliably
// applies the open-ID field but may ignore senderUserIds.
func appendResolvedSearchActorIDs(
params map[string]any,
resolutions []targetresolver.UserResolution,
userKey, openIDKey string,
) {
var userIDs, openIDs []string
for _, resolved := range resolutions {
openID := strings.TrimSpace(resolved.Selected.OpenDingTalkID)
userID := strings.TrimSpace(resolved.Selected.UserID)
if openID != "" {
openIDs = append(openIDs, openID)
} else if userID != "" {
userIDs = append(userIDs, userID)
}
}
userIDs = uniqueSearchStrings(userIDs)
openIDs = uniqueSearchStrings(openIDs)
if len(userIDs) > 0 {
params[userKey] = userIDs
}
@@ -527,6 +667,78 @@ func searchScopePayload(conversationIDs []string, sourceComplete bool) map[strin
}
}
func filterSearchSenderScope(
messages []map[string]any,
resolutions []targetresolver.UserResolution,
) ([]map[string]any, []string) {
allowed := map[string]bool{}
for _, resolution := range resolutions {
if value := strings.TrimSpace(resolution.Selected.UserID); value != "" {
allowed[value] = true
}
if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" {
allowed[value] = true
}
}
filtered := make([]map[string]any, 0, len(messages))
unverifiable := make([]string, 0)
for _, message := range messages {
senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message)))
if senderID == "" || senderID == "<nil>" {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID == "" || messageID == "<nil>" {
messageID = "<unknown>"
}
unverifiable = append(unverifiable, messageID)
continue
}
if allowed[senderID] {
filtered = append(filtered, message)
}
}
return filtered, uniqueSearchStrings(unverifiable)
}
func searchUnverifiedSenderInputs(
messages []map[string]any,
resolutions []targetresolver.UserResolution,
) []string {
observed := map[string]bool{}
for _, message := range messages {
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
observed[senderID] = true
}
}
values := make([]string, 0)
for _, resolution := range resolutions {
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
values = append(values, resolution.Query)
}
}
return uniqueSearchStrings(values)
}
func searchSenderScopeUnverifiedError(
resolutions []targetresolver.UserResolution,
messageIDs []string,
) error {
queries := make([]string, 0, len(resolutions))
for _, resolution := range resolutions {
queries = append(queries, resolution.Query)
}
return apperrors.NewAPI(
"搜索结果缺少 senderId,无法证明发送者过滤范围;已停止输出",
apperrors.WithReason("search_sender_scope_unverified"),
apperrors.WithDetails(map[string]any{
"requestedSenders": uniqueSearchStrings(queries),
"unverifiableMessageIds": messageIDs,
}),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回稳定 senderId"),
)
}
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
detailsByID := map[string]map[string]any{}
failures := make([]map[string]any, 0)
@@ -15,25 +15,35 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type searchMsgExecutionCaller struct {
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
contactResponse string
groupResponse string
failContactKeyword string
failGroupKeyword string
}
const (
testCurrentDOpenID = "DAAAAAAAAAAAiE"
testCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
if product == "chat" && tool == "get_conversation_info" {
@@ -45,10 +55,28 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
}
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
}
if product == "contact" && tool == "search_contact_by_key_word" {
if f.failContactKeyword != "" && args["keyword"] == f.failContactKeyword {
return nil, errors.New("fixture contact failure")
}
if f.contactResponse != "" {
return searchMsgToolResult(f.contactResponse), nil
}
keyword := args["keyword"].(string)
return searchMsgToolResult(`{"result":[{"userId":"user-` + keyword + `","openDingTalkId":"` + testCurrentDOpenID + `","name":"` + keyword + `"}],"hasMore":false}`), nil
}
if product != "im" {
return nil, errors.New("unexpected product")
}
switch tool {
case "search_groups":
if f.failGroupKeyword != "" && args["keyword"] == f.failGroupKeyword {
return nil, errors.New("fixture group failure")
}
if f.groupResponse != "" {
return searchMsgToolResult(f.groupResponse), nil
}
return searchMsgToolResult(`{"result":[{"openConversationId":"cid-resolved-group","title":"` + args["keyword"].(string) + `"}],"hasMore":false}`), nil
case "search_messages":
if f.searchResponse != "" {
return searchMsgToolResult(f.searchResponse), nil
@@ -63,12 +91,12 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
if f.failSecondPage {
return nil, errors.New("second page unavailable")
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-2"}],"hasMore":false}}`), nil
}
if f.firstResponse != "" {
return searchMsgToolResult(f.firstResponse), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
return nil, errors.New("mget unavailable")
@@ -129,13 +157,264 @@ func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (m
return payload, nil
}
func TestSearchMsgMixedSenderClassifiesFormatWithoutIDPreflight(t *testing.T) {
tests := []string{"D-prefix-fixture-user", "d-prefix-fixture-user", "测试用户甲", "fixture-user-id"}
for _, target := range tests {
t.Run(target, func(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", target, "--no-enrich")
if payload["complete"] != true || len(caller.calls) != 2 {
t.Fatalf("payload=%#v calls=%#v", payload, caller.calls)
}
if caller.calls[0].product != "contact" || caller.calls[0].tool != "search_contact_by_key_word" ||
caller.calls[1].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
if got, want := caller.calls[1].args["senderOpenDingTakIds"], []string{testCurrentDOpenID}; !reflect.DeepEqual(got, want) {
t.Fatalf("senderOpenDingTakIds=%#v want=%#v", got, want)
}
})
}
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
t.Fatalf("format-valid ID unexpectedly preflighted: %#v", caller.calls)
}
}
func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
{"openMessageId":"other","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"drop"}
],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if payload["count"] != float64(1) {
t.Fatalf("payload=%#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "wanted" {
t.Fatalf("messages=%#v", messages)
}
}
func TestCrossPlatformCoverageSearchMsgSenderScopeIgnoresNonMatchingIdentityFamily(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
{"openMessageId":"other-family","senderUserId":"other-user","content":"drop"}
],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if payload["complete"] != true || payload["count"] != float64(1) || payload["failedCount"] != float64(0) {
t.Fatalf("payload=%#v", payload)
}
filtered, unverifiable := filterSearchSenderScope(
[]map[string]any{
{"openMessageId": "wanted-user", "senderUserId": "wanted-user"},
{"openMessageId": "other-open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
[]targetresolver.UserResolution{{Selected: targetresolver.User{UserID: "wanted-user"}}},
)
if len(filtered) != 1 || len(unverifiable) != 0 {
t.Fatalf("reverse family filtered=%#v unverifiable=%#v", filtered, unverifiable)
}
}
func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) {
for _, tc := range []struct {
name string
caller *searchMsgExecutionCaller
args []string
}{
{
name: "group target",
caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"},
args: []string{"--group", "missing-group", "--no-enrich"},
},
{
name: "sender query",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"},
args: []string{"--sender-query", "missing-query", "--no-enrich"},
},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil {
t.Fatal("resolution failure unexpectedly succeeded")
}
})
}
}
func TestCrossPlatformCoverageSearchMsgRejectsAmbiguousDirectSender(t *testing.T) {
caller := &searchMsgExecutionCaller{contactResponse: `{"result":[
{"userId":"fixture-user-1","name":"测试同名发送者"},
{"userId":"fixture-user-2","name":"测试同名发送者"}
],"hasMore":false}`}
_, err := executeSearchMsgResult(caller, "--sender", "测试同名发送者", "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("error=%#v, want resolution_ambiguous", err)
}
}
func TestCrossPlatformCoverageSearchMsgStableUserIDContinuesWhenDirectoryIsUnavailable(t *testing.T) {
t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) {
caller := &searchMsgExecutionCaller{
contactResponse: `{"result":[{"userId":"other-user","name":"其他用户"}],"hasMore":false}`,
searchResponse: `{"result":{"messages":[
{"openMessageId":"wanted","senderUserId":"fixture-user-id","content":"keep"},
{"openMessageId":"other","senderUserId":"other-user","content":"drop"}
],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller, "--sender", "fixture-user-id", "--no-enrich")
if len(caller.calls) != 2 || caller.calls[0].tool != "search_contact_by_key_word" ||
caller.calls[1].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"fixture-user-id"}) {
t.Fatalf("senderUserIds=%#v args=%#v", got, caller.calls[1].args)
}
if _, exists := caller.calls[1].args["senderOpenDingTakIds"]; exists {
t.Fatalf("unrelated candidate leaked into request: %#v", caller.calls[1].args)
}
if payload["complete"] != true || payload["count"] != float64(1) {
t.Fatalf("payload=%#v", payload)
}
})
t.Run("mixed sender preserves positive matches and blocks complete negative conclusion", func(t *testing.T) {
caller := &searchMsgExecutionCaller{
failContactKeyword: "stable-user-id",
searchResponse: `{"result":{"messages":[
{"openMessageId":"wanted","senderUserId":"stable-user-id","content":"keep"},
{"openMessageId":"other","senderUserId":"other-user","content":"drop"}
],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller, "--sender", "stable-user-id", "--no-enrich")
if len(caller.calls) != 2 || caller.calls[0].tool != "search_contact_by_key_word" ||
caller.calls[1].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"stable-user-id"}) {
t.Fatalf("senderUserIds=%#v", got)
}
if payload["count"] != float64(1) || payload["complete"] != true || payload["failedCount"] != float64(0) {
t.Fatalf("payload=%#v", payload)
}
scope := payload["senderScope"].(map[string]any)
if _, exists := scope["status"]; exists || scope["targetsResolved"] != true {
t.Fatalf("senderScope=%#v", scope)
}
})
t.Run("mixed sender without a stable id match cannot produce a complete negative conclusion", func(t *testing.T) {
caller := &searchMsgExecutionCaller{
failContactKeyword: "possibly-a-name",
searchResponse: `{"result":{"messages":[],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller, "--sender", "possibly-a-name", "--no-enrich")
if payload["count"] != float64(0) || payload["complete"] != false || payload["failedCount"] != float64(1) {
t.Fatalf("payload=%#v", payload)
}
scope := payload["senderScope"].(map[string]any)
if scope["status"] != "identity_unverified" || scope["targetsResolved"] != false {
t.Fatalf("senderScope=%#v", scope)
}
})
t.Run("id-only at target bypasses directory", func(t *testing.T) {
caller := &searchMsgExecutionCaller{
failContactKeyword: "stable-at-user-id",
searchResponse: `{"result":{"messages":[],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller, "--at-ids", "stable-at-user-id", "--no-enrich")
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
if got := caller.calls[0].args["atUserIds"]; !reflect.DeepEqual(got, []string{"stable-at-user-id"}) {
t.Fatalf("atUserIds=%#v", got)
}
if payload["complete"] != true {
t.Fatalf("payload=%#v", payload)
}
})
}
func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) {
t.Run("execute rejects senderless backend rows", func(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`}
_, err := executeSearchMsgResult(caller, "--sender", testCurrentDOpenID, "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
t.Fatalf("error=%#v", err)
}
})
resolutions := []targetresolver.UserResolution{{
Query: "fixture-user",
Selected: targetresolver.User{UserID: "wanted-user"},
}}
filtered, unverifiable := filterSearchSenderScope(
[]map[string]any{
{"content": "missing identity"},
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
resolutions,
)
if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"<unknown>"}) {
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
}
err := searchSenderScopeUnverifiedError(
append(resolutions, resolutions...),
[]string{"message-1"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
t.Fatalf("error=%#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgExplicitDaysMetadata(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--query", "fixture", "--days", "3", "--no-enrich")
coverage := payload["timeCoverage"].(map[string]any)
if coverage["source"] != "explicit_days" || coverage["days"] != float64(3) {
t.Fatalf("coverage=%#v", coverage)
}
}
func TestCrossPlatformCoverageSearchMsgRejectsNonConversationIDAlias(t *testing.T) {
caller := &searchMsgExecutionCaller{}
_, err := executeSearchMsgResult(caller, "--query", "fixture", "--chat-id", "group-name", "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("error=%#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgGroupNameAndDefaultWindowMetadata(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--group", "项目讨论群", "--no-enrich")
if len(caller.calls) != 3 || caller.calls[0].tool != "search_groups" ||
caller.calls[1].tool != "get_conversation_info" || caller.calls[2].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
coverage := payload["timeCoverage"].(map[string]any)
guard := payload["conclusionGuard"].(map[string]any)
if coverage["source"] != "implicit_default" || coverage["days"] != float64(7) ||
guard["absenceAcrossAllHistoryAllowed"] != false {
t.Fatalf("coverage=%#v guard=%#v", coverage, guard)
}
}
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
caller := &searchMsgExecutionCaller{}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--chat-id", "cid-1,cid-2",
"--sender", "42,Dsender",
"--at-ids", "43,Dat",
"--sender", testCurrentDOpenID,
"--at-ids", testCurrentDOpenID2,
"--is-at-me",
"--message-type", "text",
"--only-robot",
@@ -160,10 +439,8 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
t.Fatalf("first call = %#v", first)
}
for key, want := range map[string]any{
"senderUserIds": []string{"42"},
"senderOpenDingTakIds": []string{"Dsender"},
"atUserIds": []string{"43"},
"atOpenDingTakIds": []string{"Dat"},
"senderOpenDingTakIds": []string{testCurrentDOpenID},
"atOpenDingTakIds": []string{testCurrentDOpenID2},
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
@@ -463,6 +463,7 @@ func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any)
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
chatmsg.ApplyTruncation(payload)
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
@@ -9,6 +9,7 @@
package targetresolver
import (
"encoding/base64"
stderrors "errors"
"fmt"
"strings"
@@ -69,6 +70,16 @@ type UserResolution struct {
Profile string `json:"profile,omitempty"`
}
const matchTypeUnverifiedUserID = "unverified_user_id"
// IsUnverifiedUserIDResolution reports a mixed sender value that could not be
// classified by the directory and is therefore being tried as an exact userId.
// Consumers may use positive senderId equality, but must not claim a negative
// result is complete because the original value may instead have been a name.
func IsUnverifiedUserIDResolution(resolution UserResolution) bool {
return resolution.MatchType == matchTypeUnverifiedUserID
}
// Chat is the public identity returned by group resolution.
type Chat struct {
OpenConversationID string `json:"openConversationId"`
@@ -105,6 +116,292 @@ func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (User
return resolveUserCandidates(ExtractUsers(data), query, requirement)
}
// ResolveUserTarget accepts the mixed user target used by public convenience
// flags: a natural name, userId, or current-version openDingTalkId. The local
// format check is deliberately a classifier rather than an existence check.
// Format-valid IDs go directly to the downstream business API; all other
// values use the directory resolver, which can match names and exact userIds.
func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" {
return UserResolution{}, apperrors.NewValidation(
"必须指定用户姓名、userId 或 openDingTalkId",
apperrors.WithReason("missing_target"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
)
}
if !LooksLikeCurrentDOpenDingTalkID(value) {
return ResolveUser(rt, value, requirement)
}
if requirement == IdentityUserID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{OpenDingTalkID: value},
Profile: profilectx.Get(),
}, nil
}
// ResolveSenderTarget resolves the mixed identity accepted specifically by
// sender convenience flags. Unlike the shared name resolver, it may select a
// unique candidate whose stable userId/openDingTalkId exactly equals the
// supplied value. Natural-name resolution accepts only exact names and retains
// fail-closed ambiguity behavior; a lone unrelated directory candidate must
// never replace the supplied value. If the directory cannot classify a value
// and userId is accepted downstream, the original value is retained as an
// unverified userId candidate so a valid stable ID is not blocked by directory
// availability.
func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" || LooksLikeCurrentDOpenDingTalkID(value) {
return ResolveUserTarget(rt, value, requirement)
}
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": value,
})
if err != nil {
if requirement == IdentityOpenDingTalkID {
return UserResolution{}, err
}
return unverifiedSenderUserID(value), nil
}
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
stableMatches := make([]User, 0, 1)
for _, user := range users {
if strings.TrimSpace(user.UserID) == value ||
strings.TrimSpace(user.OpenDingTalkID) == value {
stableMatches = append(stableMatches, user)
}
}
if len(stableMatches) == 1 {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: stableMatches[0],
Profile: profilectx.Get(),
}, nil
}
if len(stableMatches) > 1 {
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
}
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
if requirement == IdentityOpenDingTalkID {
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
}
return unverifiedSenderUserID(value), nil
}
exactNameMatches := make([]User, 0, 1)
for _, user := range users {
if strings.EqualFold(strings.TrimSpace(user.Name), value) {
exactNameMatches = append(exactNameMatches, user)
}
}
if len(exactNameMatches) == 1 {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "exact",
Selected: exactNameMatches[0],
Profile: profilectx.Get(),
}, nil
}
if len(exactNameMatches) > 1 {
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, exactNameMatches)
}
if requirement == IdentityOpenDingTalkID {
return UserResolution{}, newResolutionError(StatusNotFound, "user", value, users)
}
return unverifiedSenderUserID(value), nil
}
func unverifiedSenderUserID(value string) UserResolution {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: matchTypeUnverifiedUserID,
Selected: User{UserID: value},
Profile: profilectx.Get(),
}
}
// ResolveStableUserTarget accepts only a userId or current-version
// openDingTalkId. Because callers use it only for ID-only flags, a non-D value
// is already explicitly typed as userId and can be passed through without a
// directory availability dependency.
func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" {
return UserResolution{}, apperrors.NewValidation(
"必须指定 userId 或 openDingTalkId",
apperrors.WithReason("missing_target"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
)
}
if LooksLikeCurrentDOpenDingTalkID(value) {
if requirement == IdentityUserID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{OpenDingTalkID: value},
Profile: profilectx.Get(),
}, nil
}
if requirement == IdentityOpenDingTalkID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 是 userId,但当前参数只接受 openDingTalkId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{UserID: value},
Profile: profilectx.Get(),
}, nil
}
// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical
// wire format emitted by the current D-version encoder. It does not decrypt
// the value and therefore does not prove that the identity exists or is
// accessible to the current profile.
func LooksLikeCurrentDOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 2 || value[0] != 'D' {
return false
}
encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:])
if !ok {
return false
}
ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded)
if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 {
return false
}
// Require the canonical Base64 and escape spelling. This rejects alternate
// but decodable strings and keeps the classifier aligned with the encoder.
canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext))
return canonical == value[1:]
}
func unescapeCurrentDOpenDingTalkID(value string) (string, bool) {
var decoded strings.Builder
decoded.Grow(len(value))
for i := 0; i < len(value); i++ {
current := value[i]
if current != 'i' {
if !isASCIIAlphaNumeric(current) {
return "", false
}
decoded.WriteByte(current)
continue
}
if i+1 >= len(value) {
return "", false
}
i++
switch value[i] {
case 'i':
decoded.WriteByte('i')
case 'P':
decoded.WriteByte('+')
case 'S':
decoded.WriteByte('/')
case 'E':
decoded.WriteByte('=')
default:
return "", false
}
}
return decoded.String(), true
}
func escapeCurrentDOpenDingTalkID(value string) string {
var encoded strings.Builder
encoded.Grow(len(value))
for i := 0; i < len(value); i++ {
switch value[i] {
case 'i':
encoded.WriteString("ii")
case '+':
encoded.WriteString("iP")
case '/':
encoded.WriteString("iS")
case '=':
encoded.WriteString("iE")
default:
encoded.WriteByte(value[i])
}
}
return encoded.String()
}
func isASCIIAlphaNumeric(value byte) bool {
return value >= '0' && value <= '9' ||
value >= 'A' && value <= 'Z' ||
value >= 'a' && value <= 'z'
}
// ValidateExplicitOpenDingTalkID applies the current-version format contract
// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID
// must fail locally instead of falling back to a name or userId lookup.
func ValidateExplicitOpenDingTalkID(flagName, value string) error {
if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"),
apperrors.WithDetails(map[string]any{
"flag": flagName,
"expectedType": "current_d_openDingTalkId",
}),
)
}
// ResolveEnterpriseUser resolves an organization member through the same
// name-calibrated enterprise search used by `dws aisearch person`. Unlike the
// contact search, this source understands organization nicknames and aliases.
@@ -686,3 +686,347 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) {
}
})
}
func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "canonical current D ciphertext", value: "DAAAAAAAAAAAiE", want: true},
{name: "synthetic current D ciphertext with escaped plus", value: "DiPiPiPiPiPiPiPiPiPiP8iE", want: true},
{name: "synthetic current D ciphertext with escaped literal i", value: "DYmJiiYmJiiYmIiE", want: true},
{name: "surrounding whitespace", value: " DAAAAAAAAAAAiE ", want: true},
{name: "empty", value: "", want: false},
{name: "prefix only", value: "D", want: false},
{name: "lowercase prefix", value: "dAAAAAAAAAAAiE", want: false},
{name: "natural uppercase D fixture", value: "D-prefix-fixture-user", want: false},
{name: "natural lowercase d fixture", value: "d-prefix-fixture-user", want: false},
{name: "invalid escape", value: "DAAAAAiDAAAAAiE", want: false},
{name: "non alphanumeric body", value: "DAAAAA/AAAAAiE", want: false},
{name: "invalid base64", value: "Dabc", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := LooksLikeCurrentDOpenDingTalkID(tc.value); got != tc.want {
t.Fatalf("LooksLikeCurrentDOpenDingTalkID(%q) = %v, want %v", tc.value, got, tc.want)
}
})
}
}
func TestCrossPlatformCoverageResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected remote call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSenderInputs(t *testing.T) {
calls := 0
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
calls++
if product != "contact" || tool != "search_contact_by_key_word" {
t.Fatalf("unexpected tool %s/%s", product, tool)
}
query := fmt.Sprint(params["keyword"])
switch query {
case "D-prefix-fixture-user":
return map[string]any{"result": []any{map[string]any{
"userId": "fixture-user-d-upper", "openDingTalkId": "D-directory-value", "name": "D-prefix-fixture-user",
}}}, nil
case "d-prefix-fixture-user":
return map[string]any{"result": []any{map[string]any{
"userId": "fixture-user-d-lower", "openDingTalkId": "D-directory-value-2", "name": "d-prefix-fixture-user",
}}}, nil
case "fixture-user-id":
return map[string]any{"result": []any{
map[string]any{"userId": "other", "name": "相似候选"},
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "D-directory-value-3", "name": "测试目标用户"},
}}, nil
default:
t.Fatalf("unexpected query %q", query)
return nil, nil
}
})
for _, query := range []string{"D-prefix-fixture-user", "d-prefix-fixture-user"} {
_, err := ResolveSenderTarget(reader, query, IdentityAny)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
}
_, err := ResolveUser(reader, "fixture-user-id", IdentityAny)
var ambiguous *apperrors.Error
if !stderrors.As(err, &ambiguous) || ambiguous.Reason != "resolution_ambiguous" {
t.Fatalf("shared name resolver error = %#v, want resolution_ambiguous", err)
}
resolved, err := ResolveSenderTarget(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("exact sender userId resolution = %#v", resolved)
}
if calls != 4 {
t.Fatalf("calls=%d", calls)
}
}
func TestCrossPlatformCoverageResolveSenderTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected directory call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestCrossPlatformCoverageResolveSenderTargetNeverSubstitutesUnrelatedUniqueDirectoryCandidate(t *testing.T) {
resolved, err := ResolveSenderTarget(resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
if product != "contact" || tool != "search_contact_by_key_word" || params["keyword"] != "fixture-user-id" {
t.Fatalf("unexpected call %s/%s %#v", product, tool, params)
}
return map[string]any{
"result": []any{map[string]any{
"userId": "other-user",
"name": "其他用户",
}},
"hasMore": false,
}, nil
}), "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("resolution=%#v", resolved)
}
}
func TestCrossPlatformCoverageResolveSenderTargetOpenIDFailureEdges(t *testing.T) {
t.Run("directory failure", func(t *testing.T) {
wantErr := stderrors.New("directory unavailable")
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return nil, wantErr
}), "fixture-sender", IdentityOpenDingTalkID)
if !stderrors.Is(err, wantErr) {
t.Fatalf("error=%#v, want directory error", err)
}
})
t.Run("incomplete directory result", func(t *testing.T) {
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{map[string]any{
"openDingTalkId": "fixture-open-id-1",
"name": "测试候选用户",
}},
"hasMore": true,
}, nil
}), "fixture-sender", IdentityOpenDingTalkID)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
t.Fatalf("error=%#v, want resolution_incomplete", err)
}
})
t.Run("multiple exact names remain ambiguous", func(t *testing.T) {
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{
map[string]any{"openDingTalkId": "fixture-open-id-1", "name": "测试同名用户"},
map[string]any{"openDingTalkId": "fixture-open-id-2", "name": "测试同名用户"},
},
"hasMore": false,
}, nil
}), "测试同名用户", IdentityOpenDingTalkID)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("error=%#v, want resolution_ambiguous", err)
}
})
t.Run("no exact open id or name match", func(t *testing.T) {
_, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{map[string]any{
"openDingTalkId": "fixture-open-id-1",
"name": "其他测试用户",
}},
"hasMore": false,
}, nil
}), "fixture-sender", IdentityOpenDingTalkID)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_not_found" {
t.Fatalf("error=%#v, want resolution_not_found", err)
}
})
}
func TestCrossPlatformCoverageValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) {
if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil {
t.Fatalf("valid format: %v", err)
}
err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "D-prefix-fixture-user")
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("error=%#v", err)
}
err = ValidateExplicitOpenDingTalkID("--open-dingtalk-id", " DAAAAAAAAAAAiE ")
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("whitespace-wrapped explicit ID error=%#v", err)
}
}
func TestCrossPlatformCoverageResolveStableUserTargetPassesExplicitUserIDWithoutDirectory(t *testing.T) {
calls := 0
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("directory unavailable")
})
resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("exact userId resolution = %#v", resolved)
}
}
func TestCrossPlatformCoverageResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected remote call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestStableIdentityExactMatchWinsEvenWhenDirectoryPageIsIncomplete(t *testing.T) {
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试目标用户"},
map[string]any{"userId": "other", "name": "其他候选"},
},
"hasMore": true,
}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable-only", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name, func(t *testing.T) {
resolved, err := resolve.fn(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("resolved=%#v", resolved)
}
})
}
}
func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) {
unexpectedReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return nil, stderrors.New("directory unavailable")
})
for _, tc := range []struct {
name string
resolve func(Reader, string, IdentityRequirement) (UserResolution, error)
value string
requirement IdentityRequirement
wantReason string
}{
{name: "mixed empty", resolve: ResolveUserTarget, wantReason: "missing_target"},
{name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
{name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"},
{name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
{name: "stable user id requires open id", resolve: ResolveStableUserTarget, value: "ordinary-user", requirement: IdentityOpenDingTalkID, wantReason: "target_type_mismatch"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.resolve(unexpectedReader, tc.value, tc.requirement)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != tc.wantReason {
t.Fatalf("error=%#v, want reason %q", err, tc.wantReason)
}
})
}
if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("mixed directory error=%v", err)
}
resolved, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny)
if err != nil || !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "ordinary-user" {
t.Fatalf("sender fallback resolution=%#v error=%v", resolved, err)
}
resolved, err = ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny)
if err != nil || resolved.MatchType != "stable_id" || resolved.Selected.UserID != "ordinary-user" {
t.Fatalf("stable passthrough resolution=%#v error=%v", resolved, err)
}
}
func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testing.T) {
duplicateReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{"result": []any{
map[string]any{"userId": "duplicate", "openDingTalkId": "D-one", "name": "甲"},
map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"},
}}, nil
})
_, err := ResolveSenderTarget(duplicateReader, "duplicate", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguous sender error=%#v", err)
}
incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{map[string]any{"userId": "other", "name": "其他"}},
"hasMore": true,
}, nil
})
resolved, err := ResolveSenderTarget(incompleteReader, "missing", IdentityAny)
if err != nil || !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "missing" {
t.Fatalf("incomplete sender fallback=%#v error=%v", resolved, err)
}
}
func TestCrossPlatformCoverageCurrentDCodecEdges(t *testing.T) {
if _, ok := unescapeCurrentDOpenDingTalkID("abcdi"); ok {
t.Fatal("dangling escape unexpectedly accepted")
}
if decoded, ok := unescapeCurrentDOpenDingTalkID("iS"); !ok || decoded != "/" {
t.Fatalf("slash escape decoded=%q ok=%v", decoded, ok)
}
if got := escapeCurrentDOpenDingTalkID("i+/="); got != "iiiPiSiE" {
t.Fatalf("escaped=%q", got)
}
}

Some files were not shown because too many files have changed in this diff Show More