Compare commits

...
Author SHA1 Message Date
玉澜andCursor 2ea5acc2a3 fix(helpers): align PR868 whiteboard coverage with main API
After merging #861, use prepareWhiteboardCard and --yes so coverage
tests compile and match fail-closed confirmation + soft pending verify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:07:19 +08:00
玉澜andCursor 5e1bac51e5 Merge origin/main into fix/cli-missing-surfaces-from-eval
Keep main/#861 fail-closed whiteboard; retain #868 missing surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:02:27 +08:00
chichuan a37e6e6847 Merge pull request #875 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.1
docs: seal v1.0.57-beta.1 changelog
2026-08-05 16:51:42 +08:00
chichuan 0ceb96c745 docs: seal v1.0.57-beta.1 changelog 2026-08-05 16:47:10 +08:00
github-actions[bot] 114503d52f Merge pull request #872 from lifeihong/feat/addUpdateUserOwnessV2A84934011
feat(contact): add update-ownness command for user personal status
2026-08-05 08:36:50 +00:00
chichuan 9de1c9c304 Merge branch 'main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:25:02 +08:00
github-actions[bot] 840e1d665f Merge pull request #861 from DingTalk-Real-AI/codex/sync-wukong-whiteboard
feat: add document whiteboard workflows
2026-08-05 16:17:14 +08:00
昕卉 f362c8c2a4 Merge remote-tracking branch 'upstream/main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:06:52 +08:00
chichuan e73a1556ce Merge latest main into codex/sync-wukong-whiteboard
冲突仅在 skills/mono/SKILL.md 的意图路由表,两侧改动正交,均保留:
- 本分支新增的 whiteboard 路由行
- main 把 event 行拆成 `event +listen-im` / `event consume` 的新表述
(下文「优先由一个 dws event +listen-im 进程表达目标」已是 main 版本,
保留旧 event 行会自相矛盾)
2026-08-05 15:59:17 +08:00
昕卉 186f2fa474 test(contact): add update-ownness tests and align confirmation with framework gate 2026-08-05 15:39:22 +08:00
github-actions[bot] d91a93c43b Merge pull request #860 from DingTalk-Real-AI/codex/multi-im-optimization
feat(im): harden Multi IM and publish complete Chat Schema
2026-08-05 15:28:29 +08:00
chichuan 08254e2a36 fix(whiteboard): fail closed when insert verification query fails
回查循环原先吞掉全部 queryErr,鉴权失败、MCP 错误与 JSONML 解析失败都
退化成 soft success 返回 whiteboardId: null,Agent 会把硬失败误判成最终
一致性并带着空 partId 继续调用 whiteboard query/update。

- 引入 errWhiteboardBlockPending sentinel,只有「块暂不可见」允许重试;
  其余错误立即返回,并把已插入的 blockId 带进错误消息供复原
- queryWhiteboardCardNode 严格校验 blocks 字段(缺失 / 非数组均为协议
  错误),避免畸形响应伪装成「块暂不可见」
- --ref-block 与 --parent-block、--where 与 --parent-block 显式互斥,
  锚点组装改用 else if 让单一定位分支在代码上自证
- 补全 mono / multi 两份 recipes.md 被截断的开篇句
- doc 根命令的命令结构清单补上 whiteboard insert 与 media upload/download
- 新增 3 个回归测试覆盖 fail-closed、soft success 与锚点互斥
2026-08-05 15:14:27 +08:00
Dennis e2c15fe9c8 fix(schema): reject breaking constraint expansion 2026-08-05 14:56:18 +08:00
Dennis 9fdf0d2cb3 fix(im): preserve incomplete read failures 2026-08-05 14:27:53 +08:00
Dennis b6325a4f8a fix(im): harden user resolution and broadcast lookup 2026-08-05 13:57:27 +08:00
昕卉 fdd9e189d6 add update ownness 2026-08-05 13:32:23 +08:00
玉澜andCursor 65ce71b8d4 fix(helpers): avoid race on markdown diff compute seam
Capture runMarkdownUnifiedDiff/diffJSONMarshalIndent before spawning the
compute goroutine so testseam restores cannot race a late timeout path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:28:43 +08:00
玉澜andCursor 6bfcac4d54 test(helpers): use testseam for PR868 coverage seam swaps
Policy bans manual prev/t.Cleanup restores for package-var injection seams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:08:24 +08:00
玉澜andCursor f819566c63 fix(schema): treat drive download --version as mapping exclusion
Keep the add-only Wukong compat flag, but publish it as a CLI-local
polymorphic dispatch without a download_file property binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:58:47 +08:00
玉澜andCursor 10d9aa3058 test(helpers): raise PR868 changed-line coverage to 100%
Exercise markdown diff, mail export/share, drive latest/depth, whiteboard,
and diff-engine edges via TestCrossPlatformCoverage*; add small injectable
seams only where defensive branches are otherwise unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:50:00 +08:00
chichuan eebdf52da9 fix: classify local whiteboard example precondition 2026-08-05 12:28:46 +08:00
玉澜andCursor a0ca1fdb28 feat(helpers): add minutes missing surfaces and add-only flag aliases
Expose minutes hot-word delete, permission apply, and audio-memo list from
live MCP gaps; add hidden/cross-product flag aliases only (never remove),
with TestCrossPlatformCoverage coverage for the new surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:04:27 +08:00
chichuan 9eaee76a51 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-05 11:51:35 +08:00
Dennis 2588c711a7 Merge origin/main into codex/multi-im-optimization 2026-08-05 11:24:58 +08:00
玉澜andCursor 5a93f80daa fix(ci): align new-surface dry-run preview kinds with Schema
Whiteboard dry-run now stamps preview_kind=plan; mail export/share
drop [DRY-RUN] tags so plan evidence matches declared DryRunSpec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:21:38 +08:00
玉澜andCursor 8260cf7f53 fix(ci): drop --yes from mail share-to-chat examples
Schema Manual examples forbid confirmation bypass via --yes; also stub
whiteboardSleep in product example coverage so race CI does not hang.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:05:54 +08:00
玉澜andCursor 58dfbc5b6e feat: sync missing open CLI surfaces from wukong cli_to_mcp gaps
Port calendar event instances, markdown diff, drive list --latest,
mail calendar/calendar-event/shared-with-me/export/share-to-chat, and
doc whiteboard insert so open edition matches documented Wukong test
command surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:39:56 +08:00
john 000bc13450 Merge pull request #830 from typefield/agent/cmdcore-phase2
feat(corecmd): unify leaf/shortcut command framework onto a shared typed base
2026-08-05 10:03:21 +08:00
Dennis 01782cd9d7 test(profilectx): cover profile selector 2026-08-05 09:35:57 +08:00
玉澜andCursor ba56b7ff78 fix(test): use testseam.Protect in InitDepsForTest coverage
Policy bans manual t.Cleanup deps restores; Protect is the required seam form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 02:03:51 +08:00
玉澜andCursor c5c97e60f3 test(coverage): cover InitDepsForTest and StaticServers visibility
Platform coverage only selects TestCrossPlatformCoverage*; name the new
declaration-only visibility regression accordingly and exercise the ForTest
deps restore helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 01:49:37 +08:00
玉澜andCursor 6780177356 fix(test): assert non-dry-run version preflight via CallTool
Outside --dry-run, list_doc_versions uses the normal CallTool channel;
CallReadTool is reserved for the dry-run read path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 01:30:17 +08:00
玉澜andCursor 5fa40b8ada fix: keep Schema source visibility without clobbering deps
Declaration-only roots skip injectStaticServers; derive product visibility
from StaticServers directly so reverse completeness is not weakened, and
restore helpers deps via pointer snapshot instead of InitDeps(nil).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 01:22:45 +08:00
玉澜andCursor 8a7d4a7027 fix: nil-safe doc deprecation wrappers for declaration-only Schema probes
Homology Execute probes use NewSchemaSourceRootCommand (no InitDeps); skip
deps.Out when unset and InitDeps a throwaway caller on the probe path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:18:46 +08:00
玉澜andCursor 894e550950 fix: keep schema source root from clobbering runtime deps/endpoints
Schema assembly must mount the reviewed command tree without InitDeps or
SetDynamicServers, so a live process keeps its ToolCaller and plugin
endpoints. Also restore doc version revert dry-run short-circuit so
--dry-run skips remote version preflight.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:42:05 +08:00
Dennis ad4ed41559 Merge origin/main into codex/multi-im-optimization 2026-08-04 22:23:31 +08:00
玉澜andCursor bb205c0f5c ci: align macOS auth filter with main
Drop Windows-only DPAPI export/import names from the darwin -run filter;
those tests skip on macOS and already run under the Windows job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:14:48 +08:00
Dennis 86f9054d5b test(chat): cover batch output failures 2026-08-04 22:13:20 +08:00
玉澜andCursor 82c6b631bf Merge origin/main into agent/cmdcore-phase2
Resolve macOS CI auth/keychain job conflicts by keeping main's focused
6m keychain/auth split and unsigned-darwin self-heal filter, while retaining
this branch's portable DPAPI export/import diagnostics coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:11:33 +08:00
github-actions[bot] 306c30ecad Merge pull request #857 from DingTalk-Real-AI/codex/fix-macos-auth-test-scope
ci: avoid duplicate internal/app race suite on macOS
2026-08-04 22:07:20 +08:00
玉澜andCursor f793d980b5 fix(test): align leaf BuildArgs expectations with required-after-transform
TestLeafArgsOmitsEmptyAndNonPositive called BuildArgs with unset required
CSV flags; after rejecting empty required transforms that path correctly
errors. Satisfy required flags in the omit-empty case and cover the new
RequiredError / scalar-transform branches for the macOS coverage gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:54:02 +08:00
玉澜andCursor 706dbb349c fix(corecmd): reject required flags that transform to empty lists
Separator-only inputs like --event-codes ',' passed pre-transform Required
checks, then BuildArgs omitted the key and ConfirmFirst write paths could
still call MCP. Enforce non-empty transform results for Required flags and
add CrossPlatformCoverage regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:38:40 +08:00
chichuan d7c28bcfef fix: complete whiteboard skill examples 2026-08-04 21:19:45 +08:00
玉澜andCursor 45d0d1cd72 test(ci): reject restoring combined macOS app race shard
Keep the darwin workflow contract from re-accepting the merged
keychain+auth+app invocation that main briefly required.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:06:33 +08:00
玉澜andCursor e9a2360d36 fix(ci): align darwin workflow contract with auth/keychain split
Keep the changelog gate asserting the focused macOS auth/keychain job
instead of main's combined keychain/auth/app race command.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:05:17 +08:00
玉澜andCursor 9531dad9c6 Merge origin/main into agent/cmdcore-phase2
Port OA approval form-schema / forecast-process / create-instance from
main via DeclareLeafMetadata and mapping-ledger exclusions; keep retired
schema pin paths deleted and preserve the CI auth/keychain split.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 20:52:53 +08:00
chichuan 20d27f6db9 Merge latest main into codex/fix-macos-auth-test-scope
Resolve the macOS race budget conflict in favour of the focused scope.

c1f96241 on main extended the whole-package macOS race step from 10m to
12m and pinned that budget in the workflow contract. This branch removes
the whole-package run instead: ./internal/app is already covered by the
Ubuntu "race: app" shard, and macOS only needs the natively-gated tests.
With the focused scope the job drops from 10m47s to ~3m, so the 12m
budget is no longer needed and the two step timeouts (6m + 5m) fit inside
the 15m job budget with headroom.

The contract assertion c1f96241 added is superseded rather than dropped:
pinning both focused commands locks the per-step timeouts, and the
existing checks still block a whole-package regression and require
./internal/app to appear exactly once.
2026-08-04 20:14:58 +08:00
chichuan 287b079c18 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 20:12:37 +08:00
github-actions[bot] ca2b8adcb2 Merge pull request #853 from DingTalk-Real-AI/codex/sync-wukong-oa-approval
feat(oa): add approval form workflow commands
2026-08-04 20:05:58 +08:00
Dennis c4d5139a50 fix(chat): fail batch sends on delivery errors 2026-08-04 19:43:22 +08:00
Dennis 867f20abec test(chat): cover unsafe group files on Windows 2026-08-04 19:22:58 +08:00
Dennis 7c07b29de5 ci: retain failed Windows coverage profiles 2026-08-04 19:06:50 +08:00
Dennis 6ee0df8a9c test(chat): cover Windows drive-relative paths 2026-08-04 18:53:24 +08:00
Dennis 33ceab6000 test(chat): keep platform replace alias coverage-neutral 2026-08-04 18:36:39 +08:00
Dennis 26b06fe0ff fix(chat): replace exports atomically on Windows 2026-08-04 18:18:08 +08:00
chichuan a3c7009f9b Merge latest main into codex/fix-macos-auth-test-scope 2026-08-04 18:17:33 +08:00
chichuan 2d3f820f91 ci: keep the darwin-gated upgrade self-heal test reachable on macOS
Narrowing the macOS internal/app -run pattern orphaned
TestValidateNewBinary_RecoversFromUnsignedDarwin: it is the only
runtime.GOOS != "darwin" gated test in the package, the Ubuntu race shard
skips it on Linux, and the platform coverage gate only runs
^(TestAllShortcuts|TestCrossPlatformCoverage). No CI job selected it any
more, so it could never run or fail again.

- Add the self-heal test back to the macOS -run pattern.
- Add the (CrossPlatformCoverage)? group the Windows pattern already has,
  which also recovers TestCrossPlatformCoverageAuthMigrateKeychainRemainingBranches.
- Attribute vacuous runs: the two skip paths now name the branch that went
  unverified, and DWS_REQUIRE_AMFI_SELF_HEAL=1 escalates such a run to a
  hard failure on a host that does enforce amfid. GitHub's hosted macOS
  runners do not reproduce the amfid kill, so this test has been silently
  skipping there all along.
- Restore step-timeout headroom: 10m + 5m exactly equalled the 15m job
  budget, leaving none for setup. The keychain/auth step drops to 6m
  (measured 2m43s).
- Add a contract test that couples the macOS -run pattern to the set of
  darwin-gated tests in internal/app, so the next narrowing fails loudly
  instead of silently orphaning one.
2026-08-04 17:59:59 +08:00
chichuan 50f8ade1d7 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 17:36:59 +08:00
chichuan f5a1b64d7a test(oa): include approval cases in native coverage 2026-08-04 17:36:22 +08:00
chichuan c1f96241af ci: extend macOS race test budget 2026-08-04 17:23:04 +08:00
玉澜andCursor eb63b3933e test(schema): restore interface metadata fallback coverage for overall gate
Cover applyInterfaceMetadataFallback success/audit paths and summary edges
that were lost when interface_metadata_test.go was retired, closing the
aggregate overall non-regression gap (~91.22% → above merge-base).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 17:19:51 +08:00
chichuan b87cad1eb5 docs: fix whiteboard protocol table 2026-08-04 17:14:11 +08:00
chichuan 0f2eec145e docs: add OpenNodes V1 whiteboard protocol 2026-08-04 17:10:15 +08:00
Dennis 7a5582f4f9 fix(ci): make multi IM coverage platform-safe 2026-08-04 17:05:59 +08:00
chichuan eb571e6e73 fix(oa): remove unreachable mode checks 2026-08-04 16:42:57 +08:00
Dennis f050fbdebc Merge latest main into codex/multi-im-optimization 2026-08-04 16:17:01 +08:00
chichuan 4d5a47ac93 Merge latest main into codex/sync-wukong-oa-approval 2026-08-04 16:16:21 +08:00
Dennis e27dc9fe53 fix(schema): close chat runtime contract review 2026-08-04 16:09:54 +08:00
chichuan 6108f51c9d fix(oa): align approval mode schema contracts 2026-08-04 16:08:51 +08:00
Dennis 85587b9b62 fix(schema): preserve published chat constraints 2026-08-04 15:31:10 +08:00
Dennis 10d93f310e fix(schema): restore chat compatibility gates 2026-08-04 15:18:04 +08:00
Dennis 99478c0060 feat(schema): publish complete chat shortcut catalog 2026-08-04 14:24:49 +08:00
chichuan 64c2e8544c test: complete whiteboard branch coverage 2026-08-04 14:11:29 +08:00
chichuan fc31fddd73 test: cover whiteboard error paths 2026-08-04 13:51:37 +08:00
chichuan 4298d0833b test: refresh CLI interface baseline 2026-08-04 11:45:05 +08:00
chichuan 31e3f6bcbc Merge remote-tracking branch 'origin/main' into codex/sync-wukong-oa-approval
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-08-04 11:41:38 +08:00
chichuan 678f108adf docs(oa): complete approval workflow references 2026-08-04 11:39:55 +08:00
Dennis ee943d9b3f Merge remote-tracking branch 'origin/main' into codex/multi-im-optimization
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	test/mock_mcp/mock_mcp_smoke_test.go
2026-08-04 11:25:36 +08:00
chichuan 7e0957d9e8 feat: add document whiteboard workflows 2026-08-04 11:25:31 +08:00
Dennis a3c85a01a8 chore(im): sanitize pull request fixtures 2026-08-04 11:19:38 +08:00
Dennis 81f67c8d7b fix(im): classify failures and normalize chat targets 2026-08-04 11:06:35 +08:00
Raph a240ad2b81 ci: focus macOS auth race tests 2026-08-03 23:09:58 +08:00
chichuan f39a3f5417 Merge branch 'main' into codex/sync-wukong-oa-approval 2026-08-03 21:18:39 +08:00
Dennis 30314311e4 fix(im): harden live audit contracts 2026-08-03 20:39:27 +08:00
Dennis 2a8c6c87cb feat(im): harden multi IM golden routes 2026-08-03 19:50:10 +08:00
chichuan e806e761ad test(oa): cover approval request branches 2026-08-03 19:48:26 +08:00
chichuan a6696fe9e9 fix(schema): map OA request wrappers 2026-08-03 19:26:22 +08:00
chichuan 06af21c7a1 fix(oa): complete approval instance options 2026-08-03 19:09:51 +08:00
chichuan eba2b692ec feat(oa): add approval form workflow commands 2026-08-03 19:02:39 +08:00
Dennis 017258e5b4 feat(im): optimize Multi IM golden routes
Unify natural target resolution and message contracts, add deterministic IM event listening, streamline cold-start skills, and cover the flows with schema gates and end-to-end tests.
2026-08-03 14:54:19 +08:00
213 changed files with 24226 additions and 3866 deletions
+5 -7
View File
@@ -702,14 +702,11 @@ jobs:
with:
go-version-file: go.mod
# Full ./internal/app (including schema --all assembly) already runs in the
# race:app shard. Keep this job focused on native auth/keychain paths so
# heavy Schema completeness tests cannot exhaust the 10m budget.
- name: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth
- name: Test macOS auth and Keychain packages with Race Detection
run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth
- name: Test macOS auth migration and portable auth diagnostics
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
- name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'
test-windows:
name: Test (Windows)
@@ -820,6 +817,7 @@ jobs:
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-windows
+51
View File
@@ -6,6 +6,57 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
### Changed
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
+6 -1
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -33,6 +33,7 @@ help:
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -87,6 +88,7 @@ policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@@ -126,6 +128,9 @@ skill-command-integrity:
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
+6 -3
View File
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
@@ -277,14 +277,17 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
## `dws oa` — OA Approval
_OA approval workflows: list, approve, reject, revoke, records._
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
**9 commands**
**12 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
+1
View File
@@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
+295
View File
@@ -0,0 +1,295 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
type listenIMOptions struct {
Kind string
Events []string
UserID string
OpenDingTalkID string
UserQuery string
ChatID string
ChatQuery string
QueryCSV string
MaxEvents int
Duration time.Duration
DryRun bool
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type listenIMPlan struct {
EventKeys []string
UserID string
OpenDingTalkID string
GroupID string
ResolvedTargets []any
}
type eventTargetReader struct{}
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
if err != nil {
return fmt.Errorf("event +listen-im: %w", err)
}
consumeOpts := personalConsumeOptions{
EventKey: firstArg(plan.EventKeys),
EventKeys: plan.EventKeys,
Flatten: true,
UserID: plan.UserID,
OpenDingTalkID: plan.OpenDingTalkID,
GroupID: plan.GroupID,
QueryCSV: opts.QueryCSV,
ControlBaseURL: opts.ControlBaseURL,
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
Duration: opts.Duration,
DryRun: opts.DryRun,
},
}
return eventRunPersonalConsume(c, consumeOpts)
},
}
f := cmd.Flags()
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "event",
Name: "listen_im",
CanonicalPath: "event.listen_im",
CLIPath: "event +listen-im",
PrimaryCLIPath: "event +listen-im",
},
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
},
},
Parameters: []contract.ParamDecl{
{Name: "chat-id", Property: "chatId"},
{Name: "chat-query", Property: "chatQuery"},
{Name: "dry-run", Property: "dryRun"},
{Name: "duration", Property: "duration"},
{Name: "events", Property: "events"},
{Name: "kind", Property: "kind"},
{Name: "max-events", Property: "maxEvents"},
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
{Name: "query", Property: "query"},
{Name: "user", Property: "user"},
{Name: "user-query", Property: "userQuery"},
},
},
})
return cmd
}
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
if kind == "" {
kind = "at-me"
}
events := uniqueListenIMValues(opts.Events)
if len(events) == 0 {
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
}
if strings.TrimSpace(opts.QueryCSV) != "" {
for _, eventName := range events {
if eventName != "message" {
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
}
}
}
plan := listenIMPlan{}
var err error
switch kind {
case "at-me", "all-direct", "all-group":
if listenIMTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
}
case "sender":
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
}
plan.UserID = strings.TrimSpace(opts.UserID)
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
if query := strings.TrimSpace(opts.UserQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.UserID = resolved.Selected.UserID
if plan.UserID == "" {
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
}
}
case "group":
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
}
plan.GroupID = strings.TrimSpace(opts.ChatID)
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.GroupID = resolved.Selected.OpenConversationID
}
default:
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
}
plan.EventKeys, err = listenIMEventKeys(kind, events)
if err != nil {
return listenIMPlan{}, err
}
return plan, nil
}
func listenIMEventKeys(kind string, events []string) ([]string, error) {
mapping := map[string]map[string]string{
"at-me": {"message": personal.EventMention},
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
"all-direct": {"message": personal.EventAllSingleChat},
"all-group": {"message": personal.EventAllGroupChat},
}
byEvent := mapping[kind]
keys := make([]string, 0, len(events))
for _, eventName := range events {
key := byEvent[eventName]
if key == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
}
keys = append(keys, key)
}
return keys, nil
}
func listenIMUserTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
}
func listenIMChatTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
}
func listenIMTargetCount(opts listenIMOptions) int {
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
}
func nonEmptyListenIMCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func uniqueListenIMValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"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"
"github.com/spf13/cobra"
)
type listenIMFakeReader struct {
responses map[string]map[string]any
calls []string
}
type listenIMErrorReader struct{ err error }
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
return nil, r.err
}
type listenIMHelperCaller struct {
text string
err error
}
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (listenIMHelperCaller) Format() string { return "json" }
func (listenIMHelperCaller) DryRun() bool { return false }
func (listenIMHelperCaller) Fields() string { return "" }
func (listenIMHelperCaller) JQ() string { return "" }
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
key := product + "/" + tool
f.calls = append(f.calls, key)
if response, ok := f.responses[key]; ok {
return response, nil
}
return map[string]any{}, nil
}
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "group",
Events: []string{"message", "reaction", "recall"},
ChatQuery: "项目群",
})
if err != nil {
t.Fatal(err)
}
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{
map[string]any{"name": "张三", "userId": "u1"},
map[string]any{"name": "张三", "userId": "u2"},
},
},
}}
_, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "sender",
Events: []string{"message"},
UserQuery: "张三",
})
if err == nil {
t.Fatal("ambiguous sender unexpectedly compiled")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguity error = %#v", err)
}
}
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
var captured personalConsumeOptions
var calls int
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
calls++
captured = opts
return nil
}
cmd := newEventListenIMCommand()
cmd.SetArgs([]string{
"--kind", "group",
"--events", "message,reaction",
"--chat-query", "项目群",
"--max-events", "2",
"--duration", "30s",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
}
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
t.Fatalf("captured options = %#v", captured)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
reader := &listenIMFakeReader{}
cases := []listenIMOptions{
{Kind: "at-me", Events: []string{"reaction"}},
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
{Kind: "sender", Events: []string{"message"}},
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
}
for _, opts := range cases {
if _, err := compileListenIMPlan(reader, opts); err == nil {
t.Errorf("options unexpectedly accepted: %#v", opts)
}
}
}
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
for _, tc := range []struct {
name string
text string
err error
ok bool
}{
{name: "transport", err: errors.New("transport")},
{name: "empty", text: " ", ok: true},
{name: "invalid json", text: "{invalid"},
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
} {
t.Run("reader "+tc.name, func(t *testing.T) {
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
if (err == nil) != tc.ok {
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
}
})
}
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
t.Fatal("empty event set unexpectedly accepted")
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
t.Fatal("unknown kind unexpectedly accepted")
}
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
}
wantErr := errors.New("resolution failed")
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
t.Fatalf("sender resolution error = %v", err)
}
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
t.Fatalf("group resolution error = %v", err)
}
cmd := newEventListenIMCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--kind", "sender"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
t.Fatalf("command compile error = %v", err)
}
}
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
newReader := func() *listenIMFakeReader {
return &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
}
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
t.Helper()
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
eventRunPersonalConsume = runPersonalEventConsume
}
installLifecycle := func(t *testing.T) {
t.Helper()
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token", CorpID: "corp", UserID: "user",
ClientID: "client", SourceID: "open",
}, nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
}
t.Run("ready then clean every created subscription", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
var created, deleted, removed []string
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
created = append(created, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if len(specs) != 2 || !cfg.Flatten {
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群", "--max-events", "1",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
if !reflect.DeepEqual(created, wantEvents) {
t.Fatalf("created = %#v, want %#v", created, wantEvents)
}
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
}
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
t.Fatalf("missing ready marker: %s", stderr.String())
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
})
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
}
var deleted, removed []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("consumer started after partial subscription failure")
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SilenceUsage = true
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
})
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
@@ -37,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"consume", "list", "schema", "status", "stop"}
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
@@ -48,7 +48,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
}
}
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
+13 -3
View File
@@ -27,9 +27,11 @@ import (
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
// mountLegacyPublicCommands builds the product + shortcut command tree without
// mutating process-global MCP deps or dynamic server endpoints. Used by the
// Schema source root (declaration-only) path so assembly cannot clobber a live
// runtime's InitDeps caller or plugin endpoints.
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
@@ -51,6 +53,14 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller,
return mergeTopLevelCommands(commands)
}
// newLegacyPublicCommands is the executable CLI path: inject static MCP
// endpoints, InitDeps, then mount the public command tree.
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
return mountLegacyPublicCommands(runner, loadUserShortcuts)
}
func injectStaticServers() {
hooks := edition.Get()
var servers []edition.ServerInfo
@@ -29,6 +29,7 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
@@ -37,6 +38,7 @@ var paramAliasCompleteCommands = map[string][]string{
"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-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
@@ -119,6 +121,7 @@ var paramAliasNewIMCases = []struct {
emitted string
canonical string
}{
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
@@ -129,6 +132,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
@@ -169,7 +173,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -220,6 +224,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
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)
}
@@ -252,7 +257,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
}
}
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -292,6 +297,7 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
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)
}
@@ -302,6 +308,23 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
}
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
// alias-dependent.
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
if command != "chat +chat-messages" {
return
}
for _, caller := range callers {
for i := range caller.calls {
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
delete(caller.calls[i].args, "time")
}
}
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
+14 -5
View File
@@ -374,19 +374,21 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, true)
return newRootCommandWithEngine(rootCtx, nil, true, false)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed Schema surface.
// declarationOnly skips injectStaticServers / helpers.InitDeps so Schema
// assembly cannot clobber a live process's ToolCaller or plugin endpoints.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false)
return newRootCommandWithEngine(rootCtx, nil, false, true)
}
// NewRootCommandWithEngine constructs the root CLI command with an
@@ -394,10 +396,10 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery()
return newRootCommandWithEngine(rootCtx, engine, true)
return newRootCommandWithEngine(rootCtx, engine, true, false)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -488,7 +490,14 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
if declarationOnly {
// Schema / surface assembly: mount the reviewed tree only. Do not
// injectStaticServers or InitDeps — those mutate process globals and
// would clobber a live runtime's caller and plugin endpoints.
root.AddCommand(mountLegacyPublicCommands(runner, loadRuntimeExtensions)...)
} else {
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
}
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
+17 -3
View File
@@ -163,9 +163,16 @@ func commandShort(cmd *cobra.Command) string {
// resolveVisibleProducts returns the set of top-level product IDs that should
// be treated as visible. It unions the edition's VisibleProducts hook (when
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
// including plugins loaded via AppendDynamicServer — are never silently hidden
// by a static VisibleProducts list.
// set), StaticServers product IDs, and DirectRuntimeProductIDs(), so
// dynamically-registered products — including plugins loaded via
// AppendDynamicServer — are never silently hidden by a static VisibleProducts
// list.
//
// StaticServers are consulted directly (without injectStaticServers) so the
// declaration-only Schema source root can keep reviewed products visible
// without mutating the process-global dynamic endpoint registry.
// SupplementServers stay out of this set: they are helper-only endpoints and
// must not synthesize top-level product visibility.
func resolveVisibleProducts() map[string]bool {
allowed := map[string]bool{}
if fn := edition.Get().VisibleProducts; fn != nil {
@@ -173,6 +180,13 @@ func resolveVisibleProducts() map[string]bool {
allowed[p] = true
}
}
if fn := edition.Get().StaticServers; fn != nil {
for _, server := range fn() {
if id := strings.TrimSpace(server.ID); id != "" {
allowed[id] = true
}
}
}
for id := range DirectRuntimeProductIDs() {
allowed[id] = true
}
+41 -14
View File
@@ -413,6 +413,33 @@ func multiProfileErrorPayload(err error) map[string]any {
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if typed.Origin != "" {
payload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
payload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
payload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
payload["retryable"] = typed.Retryable
}
if typed.Hint != "" {
payload["hint"] = typed.Hint
}
if len(typed.Actions) > 0 {
payload["actions"] = append([]string(nil), typed.Actions...)
}
if len(typed.Details) > 0 {
payload["details"] = typed.Details
}
if typed.ServerDiag.TraceID != "" {
payload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
@@ -678,7 +705,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
@@ -695,14 +721,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
mcpErr := apperrors.NewAPI(
mcpErr := newServerFailureAPIError(
extractMCPErrorMessage(callResult),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
@@ -720,14 +746,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
classifiedErr := newServerFailureAPIError(
bizErr,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
return executor.Result{}, classifiedErr
}
invocation.Implemented = true
@@ -0,0 +1,76 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
)
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
tests := []struct {
canonical string
optional []string
requireTogether []string
mutuallyExclusive []string
}{
{
canonical: "oa.forecast_process",
optional: []string{"request", "process-code", "dept-id", "form-values"},
requireTogether: []string{"process-code", "dept-id", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values"},
},
{
canonical: "oa.start_process_instance",
optional: []string{"request", "process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
requireTogether: []string{"process-code", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
},
}
for _, test := range tests {
t.Run(test.canonical, func(t *testing.T) {
tool := tools[test.canonical]
parameters := schemaContractMap(tool["parameters"])
for _, name := range test.optional {
if got := parameters[name]["required"]; got != false {
t.Errorf("--%s required = %#v, want false for dual-mode command", name, got)
}
}
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"request", "process-code"})
assertSchemaContractConstraintGroup(t, tool, "require_together", test.requireTogether)
for _, name := range test.mutuallyExclusive {
assertSchemaContractConstraintGroup(t, tool, "mutually_exclusive", []string{"request", name})
}
constraints, _ := tool["constraints"].(map[string]any)
groups, _ := constraints["mutually_exclusive"].([]any)
if len(groups) != len(test.mutuallyExclusive) {
t.Errorf("mutually_exclusive group count = %d, want %d: %#v", len(groups), len(test.mutuallyExclusive), groups)
}
for _, rawGroup := range groups {
group, _ := rawGroup.([]any)
if len(group) != 2 {
t.Errorf("mutually_exclusive contains an over-broad group: %#v", group)
}
}
hasRequestOnlyExample := false
for _, example := range schemaContractStringSlice(tool["examples"]) {
if strings.Contains(example, " --request ") && !strings.Contains(example, " --process-code ") && !strings.Contains(example, " --form-values ") {
hasRequestOnlyExample = true
}
}
if !hasRequestOnlyExample {
t.Errorf("examples do not contain a request-only invocation: %#v", tool["examples"])
}
})
}
create := tools["oa.start_process_instance"]
if got := schemaContractString(create["confirmation"]); got != "user_required" {
t.Errorf("create-instance confirmation = %q, want user_required", got)
}
}
@@ -19,9 +19,9 @@ const (
publicShortcutCount = 266
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 216
schemaPublishedShortcutCount = 267
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 215
publiclyDeliveredShortcutCount = 266
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 129; got != want {
if got, want := int(product["count"].(float64)), 180; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
}
}
@@ -0,0 +1,163 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
// schemaSourceRootMarkerCaller is a recognizable ToolCaller used to detect
// InitDeps clobbering when NewSchemaSourceRootCommand builds the tree.
type schemaSourceRootMarkerCaller struct{}
func (schemaSourceRootMarkerCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (schemaSourceRootMarkerCaller) Format() string { return "json" }
func (schemaSourceRootMarkerCaller) DryRun() bool { return false }
func (schemaSourceRootMarkerCaller) Fields() string { return "" }
func (schemaSourceRootMarkerCaller) JQ() string { return "" }
// TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints ensures Schema
// assembly (NewSchemaSourceRootCommand / ResolveMeta delivery) does not call
// helpers.InitDeps or SetDynamicServers, which would wipe a live process's
// caller and plugin endpoints.
func TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints(t *testing.T) {
const (
pluginID = "schema-source-root-plugin-marker"
pluginEndpoint = "https://schema-source-root-plugin.example/mcp"
)
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
marker := schemaSourceRootMarkerCaller{}
helpers.InitDepsForTest(t, marker)
SetDynamicServers(nil)
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: pluginID,
Endpoint: pluginEndpoint,
CLI: mcptypes.CLIOverlay{
ID: pluginID,
Command: pluginID,
},
})
root := NewSchemaSourceRootCommand()
if root == nil {
t.Fatal("NewSchemaSourceRootCommand returned nil")
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after NewSchemaSourceRootCommand = %T (%p), want marker caller preserved", got, got)
}
gotEndpoint, ok := directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after NewSchemaSourceRootCommand = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild after declaration-only root: %v", err)
}
if resolved.CommandCount() == 0 {
t.Fatal("ResolveSchemaBuild command count is 0")
}
// Delivery path also builds NewSchemaSourceRootCommand via the factory.
registerSchemaRuntimeDelivery()
meta, ok := cli.ResolveMeta("dev app delete")
if !ok || meta.Identity.Canonical == "" {
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after ResolveMeta delivery = %T, want marker caller preserved", got)
}
gotEndpoint, ok = directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after ResolveMeta delivery = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
}
// TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject
// proves the declaration-only Schema source root does not need
// injectStaticServers to keep StaticServers products visible. VisibleProducts
// may be unset; StaticServers alone must still prevent
// hideNonDirectRuntimeCommands from marking those products Hidden — without
// mutating dynamic endpoints.
func TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject(t *testing.T) {
previous := edition.Get()
t.Cleanup(func() { edition.Override(previous) })
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
edition.Override(&edition.Hooks{
Name: "schema-source-root-static-visibility",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "ignored-empty", Endpoint: "https://schema-source-root-static.example/empty"},
{ID: " ", Name: "ignored-blank", Endpoint: "https://schema-source-root-static.example/blank"},
{ID: "doc", Name: "Doc", Endpoint: "https://schema-source-root-static.example/doc"},
}
},
// VisibleProducts intentionally nil: declaration-only visibility must
// still derive from StaticServers without SetDynamicServers.
})
SetDynamicServers(nil)
root := NewSchemaSourceRootCommand()
var doc *cobra.Command
for _, cmd := range root.Commands() {
if cmd.Name() == "doc" {
doc = cmd
break
}
}
if doc == nil {
t.Fatal("doc command missing from Schema source root")
}
if doc.Hidden {
t.Fatal("declaration-only Schema source root hid doc despite StaticServers")
}
// Endpoint resolution may still read StaticServers directly; the invariant
// is that declaration-only construction must not mutate the dynamic registry.
dynamicMu.RLock()
_, injected := dynamicProducts["doc"]
dynamicMu.RUnlock()
if injected {
t.Fatal("declaration-only Schema source root must not inject StaticServers into dynamicProducts")
}
}
+105
View File
@@ -0,0 +1,105 @@
// 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 (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
strings.Contains(detail, "connection refused") {
classified := serverFailureClass{
message: "MCP 后端依赖暂时不可用",
reason: "backend_dependency_unavailable",
origin: "mcp_gateway",
stage: "backend_dependency",
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
actions: []string{
"使用相同参数重试一次",
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
},
}
if strings.Contains(detail, "querytoolmeta") {
classified.message = "MCP 后端元数据服务暂时不可用"
classified.stage = "tool_metadata_lookup"
}
return classified, true
}
if code == "PARAM_ERROR" ||
strings.Contains(text, "opencid or cid is required") ||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
return serverFailureClass{
message: message,
reason: "invalid_request",
origin: "dingtalk_api",
stage: "tool_validation",
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
}, true
}
return serverFailureClass{}, false
}
func newServerFailureAPIError(
message string,
fallbackReason string,
fallbackHint string,
serverKey string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
apperrors.WithOperation("tools/call"),
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
apperrors.WithOrigin(classified.origin),
apperrors.WithFailureStage(classified.stage),
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
}
return apperrors.NewAPI(message, opts...)
}
func serverFailureReason(err error, fallback string) string {
typed, ok := err.(*apperrors.Error)
if ok && strings.TrimSpace(typed.Reason) != "" {
return typed.Reason
}
return fallback
}
@@ -0,0 +1,223 @@
// 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"
"net/http"
"net/http/httptest"
"strings"
"testing"
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"
)
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
ServerRetryable: &retryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
if !typed.RetryableSet || !typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
t.Fatalf("misleading hint = %q", typed.Hint)
}
}
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
err := newServerFailureAPIError(
"openCid or cid is required",
"business_error",
"check parameters",
"chat",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
t.Fatalf("reason = %q", got)
}
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
t.Fatalf("fallback reason = %q", got)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
ServerRetryable: &retryable,
},
)
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "backend_dependency_unavailable",
"origin": "mcp_gateway",
"stage": "tool_metadata_lookup",
"retryable": true,
"trace_id": "trace-multi",
"server_error_code": "NETWORK_ERROR",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must not invent execution_started: %#v", payload)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
err := apperrors.NewValidation(
"群目标不唯一",
apperrors.WithReason("resolution_ambiguous"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint("请选择候选"),
apperrors.WithActions("使用稳定 ID 重试"),
apperrors.WithDetails(map[string]any{
"type": "resolution",
"candidates": []string{"cid-1", "cid-2"},
}),
)
payload := multiProfileErrorPayload(err)
details, ok := payload["details"].(map[string]any)
if !ok || details["type"] != "resolution" {
t.Fatalf("details = %#v", payload["details"])
}
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
t.Fatalf("payload = %#v", payload)
}
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
t.Fatalf("actions = %#v", payload["actions"])
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "NETWORK_ERROR",
"trace_id": "trace-replay",
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
"retryable": true,
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
+19 -3
View File
@@ -543,6 +543,13 @@ func TestIsLikelyAMFIKill(t *testing.T) {
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
// successfully re-executes the binary. We use go itself as a stand-in for the
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
//
// GitHub's hosted macOS runners do not reproduce the amfid kill, so in CI this
// test reports a skip that names the unverified path rather than implying the
// self-heal was exercised. Set DWS_REQUIRE_AMFI_SELF_HEAL=1 on a host that does
// enforce amfid to turn such a vacuous run into a hard failure.
const requireAMFISelfHealEnv = "DWS_REQUIRE_AMFI_SELF_HEAL"
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
if runtime.GOOS != "darwin" {
@@ -573,15 +580,24 @@ func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
t.Fatalf("strip signature: %v\n%s", err, out)
}
// Sanity: confirm direct exec is killed.
// Sanity: confirm direct exec is killed. When it is not, repairDarwinBinary
// never runs and the rest of this test proves nothing — say so.
if _, err := tryExecVersion(bin); err == nil {
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
const unverified = "amfid did not kill the unsigned binary, so repairDarwinBinary was NOT exercised"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1)", unverified, requireAMFISelfHealEnv)
}
t.Skipf("%s — host does not enforce amfid (Intel Mac, SIP disabled, or hosted runner)", unverified)
}
// validateNewBinary should self-heal and succeed.
if err := validateNewBinary(bin, "dev"); err != nil {
if strings.Contains(err.Error(), "signal: killed") {
t.Skipf("host security policy still rejects the ad-hoc signed test binary: %v", err)
const unverified = "host security policy still rejects the ad-hoc signed binary, so the self-heal outcome was NOT verified"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1): %v", unverified, requireAMFISelfHealEnv, err)
}
t.Skipf("%s: %v", unverified, err)
}
t.Fatalf("validateNewBinary did not recover: %v", err)
}
+3 -12
View File
@@ -23,11 +23,11 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -117,23 +117,14 @@ type Profile struct {
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
profilectx.Set(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
return profilectx.Get()
}
// ProfilesPath returns the profile metadata path for a config dir.
@@ -4,6 +4,7 @@
package homology
import (
"context"
"errors"
"fmt"
"io"
@@ -19,8 +20,22 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// homologyProbeCaller is a throwaway ToolCaller for Execute-based confirmation
// probes. Schema source roots stay declaration-only (no InitDeps); the probe
// path initializes deps locally so leaf RunE wrappers do not nil-deref.
type homologyProbeCaller struct{}
func (homologyProbeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (homologyProbeCaller) Format() string { return "json" }
func (homologyProbeCaller) DryRun() bool { return false }
func (homologyProbeCaller) Fields() string { return "" }
func (homologyProbeCaller) JQ() string { return "" }
// TestUserRequiredSafetyHomologyWithRuntimeGate proves Catalog Safety and the
// executable confirmation gate share one source for every live user_required leaf:
//
@@ -29,6 +44,12 @@ import (
// Sheet protect marker, or framework NewCommand/Shortcut RunE (verified by
// closed-stdin Execute → confirmation_required / 用户取消了操作 without --yes)
func TestUserRequiredSafetyHomologyWithRuntimeGate(t *testing.T) {
// Declaration-only Schema roots intentionally skip InitDeps. Probes Execute
// live leaves (including deprecated doc wrappers), so install a local
// throwaway caller without touching SetDynamicServers. Restore the prior
// deps pointer (including nil) — InitDeps(previousCaller) cannot.
helpers.InitDepsForTest(t, homologyProbeCaller{})
root := app.NewSchemaSourceRootCommand()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "")
+12
View File
@@ -158,6 +158,12 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-messages",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +chat-mute",
Aliases: map[string]string{
@@ -336,6 +342,12 @@ var generatedParamAliases = []ParamAliasEntry{
"at-user-ids": "at-users",
},
},
{
CLIPath: "chat +search-msg",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +send-to-group",
Aliases: map[string]string{
+5 -1
View File
@@ -57,6 +57,8 @@
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
@@ -255,7 +257,9 @@
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
]
}
}
+6
View File
@@ -1142,6 +1142,12 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
}
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
// JSON Schema's date-time format means one RFC3339 value. Do not publish
// that narrower wire contract when the CLI also accepts local timestamps
// or date-only values alongside RFC3339.
if strings.Contains(usage, "yyyy-mm-dd") {
return ""
}
return "date-time"
}
if strings.Contains(usage, "a1") {
+9 -18
View File
@@ -30,38 +30,29 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode string
type AgentExampleMode = contract.ExampleDispositionMode
const (
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode string
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
const (
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
+1
View File
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
-58
View File
@@ -171,62 +171,4 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"todo task remove-attachment",
},
},
{
ID: "chat-shortcuts-pending-schema-curation",
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
Reviewed: true,
Commands: []string{
"chat +category-add-conversation",
"chat +category-list-conversations",
"chat +category-remove-conversation",
"chat +chat-add-bot",
"chat +chat-audit-join",
"chat +chat-create",
"chat +chat-list",
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
"chat +chat-role-remove",
"chat +chat-role-remove-user",
"chat +chat-transfer-owner",
"chat +chat-update",
"chat +chat-update-icon",
"chat +chat-update-settings",
"chat +conversation-clear-messages",
"chat +conversation-clear-red-point",
"chat +conversation-hide",
"chat +conversation-mark-read",
"chat +conversation-mark-unread",
"chat +conversation-mute",
"chat +conversation-set-top",
"chat +feed-group-query-item",
"chat +flag-cancel",
"chat +flag-create",
"chat +flag-list",
"chat +messages-add-emoji",
"chat +messages-add-text-emotion",
"chat +messages-batch-recall-by-bot",
"chat +messages-batch-send-by-bot",
"chat +messages-combine-forward",
"chat +messages-create-text-emotion",
"chat +messages-forward",
"chat +messages-forward-topic",
"chat +messages-list",
"chat +messages-recall",
"chat +messages-recall-by-bot",
"chat +messages-remove-emoji",
"chat +messages-remove-text-emotion",
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send-by-bot",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
},
},
}
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
return selection
}
})
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
}}
return selection
}
})
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
@@ -511,6 +512,17 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"minutes.record_resume --session-id": "Reviewed unpinned adapter: minutes.record_resume has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"minutes.record_start --session-id": "Reviewed unpinned adapter: minutes.record_start has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"minutes.record_stop --session-id": "Reviewed unpinned adapter: minutes.record_stop has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"oa.forecast_process --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.forecast_process --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessForecastPopRequest, so it has no independent top-level MCP property.",
"oa.forecast_process --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers": "Conditional request wrapper: --approvers is transformed into an approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers-action-type": "Conditional request wrapper: --approvers-action-type only configures the generated approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-list": "Conditional request wrapper: --cc-list is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-position": "Conditional request wrapper: --cc-position only configures the generated ccList inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --originator-user-id": "Conditional request wrapper: --originator-user-id is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"pat.batch_grant --domain": "aggregate/two-stage wrapper: --domain is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
"pat.batch_grant --domains": "aggregate/two-stage wrapper: --domains is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
"pat.batch_grant --product": "aggregate/two-stage wrapper: --product is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
@@ -644,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
+4
View File
@@ -332,6 +332,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
reviewed := true
selection.Reviewed = &reviewed
}
// Example dispositions control only the policy gate's execution eligibility.
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
+48
View File
@@ -200,6 +200,10 @@ type SelectionSpec struct {
Tips []string
WorkflowRefs []string
Examples []string
// ExampleDispositions narrows an exact example with a reviewed local or
// stateful precondition from dry-run execution to contract validation.
// It does not change the command's declared DryRun capability.
ExampleDispositions []ExampleDisposition
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
// declaration path must not set it: declared selection is final by
// construction, and assembly rejects a declared payload carrying it.
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
out.Tips = stableUniqueStrings(s.Tips)
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
out.Examples = stableUniqueStrings(s.Examples)
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
return out
}
// ExampleDispositionMode controls how an already contract-validated example
// is exercised by the Agent example gate.
type ExampleDispositionMode string
const (
ExampleDispositionModeContract ExampleDispositionMode = "contract"
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
)
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
// contract-only exceptions to an explicit dry-run capability.
type ExampleDispositionReasonCode string
const (
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
)
// ExampleDisposition narrows one exact example to contract-only validation.
// Index is a pointer so a missing index cannot silently select example zero.
type ExampleDisposition struct {
Index *int `json:"index"`
Mode ExampleDispositionMode `json:"mode"`
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
if len(in) == 0 {
return nil
}
out := append([]ExampleDisposition(nil), in...)
for i := range out {
if out[i].Index != nil {
index := *out[i].Index
out[i].Index = &index
}
}
return out
}
// ParamDecl is one parameter-level Schema fact declared on a command. It is
// stored at DeclareLeafMetadata time and applied as annotations at assembly
// time, when all flags are guaranteed to exist on the fully-built command tree.
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
}
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
exampleIndex := 0
normalized := (SelectionSpec{
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
ExampleDispositions: []ExampleDisposition{{
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
}},
SourceRefs: []string{"b", "a", "b"},
}).Normalized()
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
}
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
}
exampleIndex = 1
if *normalized.ExampleDispositions[0].Index != 0 {
t.Fatal("ExampleDispositions index was not cloned")
}
if got := cloneExampleDispositions(nil); got != nil {
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
}
if got := stableUniqueStrings(nil); got != nil {
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
}
+45 -11
View File
@@ -103,11 +103,12 @@ const ValidationShortcut FlagValidationMode = "shortcut"
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
// helpers can alias to it without touching any call site.
type FlagSpec struct {
Name string // flag name (kebab-case)
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
Name string // flag name (kebab-case)
Shorthand string // optional one-character Cobra shorthand
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
// Required, when true, validates a non-empty effective value in RunE. Plain
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
@@ -551,7 +552,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
flag.Name))
}
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage)
// Aliases are registered with the main flag's Kind, otherwise an integer
// alias's value would never be readable (silently dropped).
for _, alias := range flag.Aliases {
@@ -572,6 +573,10 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
// instead of silently degrading to 0 / false.
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
registerFlagP(cmd, kind, name, "", def, usage)
}
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
switch kind {
case KindInt:
defInt := 0
@@ -582,7 +587,7 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
}
defInt = v
}
cmd.Flags().Int(name, defInt, usage)
cmd.Flags().IntP(name, shorthand, defInt, usage)
case KindBool:
defBool := false
if def != "" {
@@ -595,15 +600,15 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
}
}
cmd.Flags().Bool(name, defBool, usage)
cmd.Flags().BoolP(name, shorthand, defBool, usage)
case KindStringSlice:
var defaults []string
if value := strings.TrimSpace(def); value != "" {
defaults = strings.Split(value, ",")
}
cmd.Flags().StringSlice(name, defaults, usage)
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
default:
cmd.Flags().String(name, def, usage)
cmd.Flags().StringP(name, shorthand, def, usage)
}
}
@@ -885,7 +890,21 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
if err != nil {
return nil, err
}
if value == nil {
// Required is checked on the pre-transform string. A transform that
// collapses separator-only input ("," / ";") to nil/empty must still
// fail required flags locally — otherwise BuildArgs would omit the
// key and ConfirmFirst write paths could reach the backend.
if value == nil || emptyTransformResult(value) {
if flag.Required {
message := strings.TrimSpace(flag.RequiredHint)
if message == "" {
message = strings.TrimSpace(flag.RequiredError)
}
if message == "" {
message = fmt.Sprintf("必填参数 --%s 不能为空", flag.Name)
}
return nil, apperrors.NewValidation(message)
}
continue
}
toolArgs[bind] = value
@@ -896,6 +915,21 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
return toolArgs, nil
}
// emptyTransformResult reports whether a Transform produced an empty payload
// that must not satisfy a Required flag (nil is handled by the caller).
func emptyTransformResult(value any) bool {
switch v := value.(type) {
case string:
return strings.TrimSpace(v) == ""
case []string:
return len(v) == 0
case []any:
return len(v) == 0
default:
return false
}
}
// EffectiveValue reads the value by "explicit main flag → alias → env →
// registration default" order (string form, integers uniformly formatted);
// Trim TrimSpace's the result.
+73 -4
View File
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
cmd := newTestCommand()
RegisterFlags(cmd, []FlagSpec{
{Name: "s", Usage: "S", Default: "d"},
{Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "s", Shorthand: "s", Usage: "S", Default: "d"},
{Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "req", Usage: "R", MarkRequired: true},
{Name: "hidden", Usage: "H", Hidden: true},
})
@@ -78,6 +78,11 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" {
t.Fatalf("string flag = %#v", f)
}
for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} {
if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name {
t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name)
}
}
for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} {
f := cmd.Flags().Lookup(name)
if f == nil || f.Value.Type() != wantType {
@@ -458,6 +463,70 @@ func TestCrossPlatformCoverageBuildArgsTransformAndErrors(t *testing.T) {
t.Fatalf("nil transform should skip key: %#v", args)
}
// Required + transform-to-empty must fail (separator-only lists)
requiredEmpty := []FlagSpec{{
Name: "codes", Usage: "C", Required: true, RequiredHint: "--codes 为必填",
Transform: func(string) (any, error) { return nil, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmpty)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmpty); err == nil || !strings.Contains(err.Error(), "--codes 为必填") {
t.Fatalf("required empty transform err = %v", err)
}
requiredEmptySlice := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return []string{}, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptySlice)
_ = cmd.Flags().Set("codes", ";")
if _, err := BuildArgs(cmd, requiredEmptySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty-slice transform err = %v", err)
}
requiredEmptyString := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return " ", nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptyString)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmptyString); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty-string transform err = %v", err)
}
requiredEmptyAnySlice := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return []any{}, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptyAnySlice)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmptyAnySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty []any transform err = %v", err)
}
requiredErrorHint := []FlagSpec{{
Name: "codes", Usage: "C", Required: true, RequiredError: "codes missing after transform",
Transform: func(string) (any, error) { return nil, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredErrorHint)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredErrorHint); err == nil || !strings.Contains(err.Error(), "codes missing after transform") {
t.Fatalf("required RequiredError transform err = %v", err)
}
// Non-empty non-list transform result is kept (covers emptyTransformResult default).
keepScalar := []FlagSpec{{
Name: "n", Usage: "N", Required: true, Bind: "count",
Transform: func(string) (any, error) { return 3, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, keepScalar)
_ = cmd.Flags().Set("n", "x")
args, err = BuildArgs(cmd, keepScalar)
if err != nil || args["count"] != 3 {
t.Fatalf("scalar transform args = %#v err = %v", args, err)
}
// Transform error propagates
boom := errors.New("transform boom")
bad := []FlagSpec{{Name: "y", Usage: "Y", Transform: func(string) (any, error) { return nil, boom }}}
+66
View File
@@ -46,6 +46,9 @@ type Error struct {
Message string
Operation string
ServerKey string
Origin string
FailureStage string
ExecutionStarted *bool
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
@@ -55,6 +58,7 @@ type Error struct {
Actions []string
AvailableFlags []string
Snapshot string
Details map[string]any
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
@@ -107,6 +111,32 @@ func WithServerKey(serverKey string) Option {
}
}
// WithOrigin records the component that produced the failure, such as the
// client, MCP gateway, or DingTalk API. It is independent from Category,
// which remains the stable exit-code contract.
func WithOrigin(origin string) Option {
return func(err *Error) {
err.Origin = strings.TrimSpace(origin)
}
}
// WithFailureStage records the execution stage at which the failure occurred.
func WithFailureStage(stage string) Option {
return func(err *Error) {
err.FailureStage = strings.TrimSpace(stage)
}
}
// WithExecutionStarted records whether the downstream business operation was
// known to have started. Unknown state must be represented by omitting this
// option, which is important for safe retry decisions on write operations.
func WithExecutionStarted(started bool) Option {
return func(err *Error) {
value := started
err.ExecutionStarted = &value
}
}
// WithRetryable marks whether the error can be retried safely.
func WithRetryable(retryable bool) Option {
return func(err *Error) {
@@ -186,6 +216,21 @@ func WithSnapshot(path string) Option {
}
}
// WithDetails records an additive machine-readable payload for errors whose
// recovery needs typed context, such as ambiguous target-resolution
// candidates. Callers must keep credentials and other secrets out of details.
func WithDetails(details map[string]any) Option {
return func(err *Error) {
if len(details) == 0 {
return
}
err.Details = make(map[string]any, len(details))
for key, value := range details {
err.Details[key] = value
}
}
}
// WithRPCCode records the original JSON-RPC error code.
func WithRPCCode(code int) Option {
return func(err *Error) {
@@ -295,6 +340,15 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Origin != "" {
errorPayload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
errorPayload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
errorPayload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
@@ -316,6 +370,9 @@ func PrintJSON(w io.Writer, err error) error {
if typed.Snapshot != "" {
errorPayload["snapshot_path"] = typed.Snapshot
}
if len(typed.Details) > 0 {
errorPayload["details"] = typed.Details
}
if typed.RPCCode != 0 {
errorPayload["rpc_code"] = typed.RPCCode
}
@@ -447,6 +504,15 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if typed.ServerKey != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
}
if typed.Origin != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
}
if typed.FailureStage != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
}
if typed.ExecutionStarted != nil {
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
}
if typed.Snapshot != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
}
+38 -10
View File
@@ -20,7 +20,7 @@ import (
"time"
)
func TestExitCodeByCategory(t *testing.T) {
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -42,17 +42,24 @@ func TestExitCodeByCategory(t *testing.T) {
}
}
func TestPrintJSON(t *testing.T) {
func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"bad flag",
WithReason("missing_required_flag"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("dws schema doc.create_document", "retry command"),
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
WithDetails(map[string]any{
"type": "resolution",
"query": "项目群",
}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
@@ -67,6 +74,11 @@ func TestPrintJSON(t *testing.T) {
if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") {
t.Fatalf("expected reason in output, got %q", got)
}
if !strings.Contains(got, "\"origin\": \"client\"") ||
!strings.Contains(got, "\"stage\": \"request_validation\"") ||
!strings.Contains(got, "\"execution_started\": false") {
t.Fatalf("expected failure provenance in output, got %q", got)
}
if !strings.Contains(got, "\"retryable\": true") {
t.Fatalf("expected retryable in output, got %q", got)
}
@@ -76,6 +88,9 @@ func TestPrintJSON(t *testing.T) {
if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") {
t.Fatalf("expected snapshot path in output, got %q", got)
}
if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") {
t.Fatalf("expected structured details in output, got %q", got)
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
@@ -171,7 +186,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
}
}
func TestPrintJSON_AvailableFlags(t *testing.T) {
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -192,7 +207,7 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestPrintHuman(t *testing.T) {
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -201,6 +216,9 @@ func TestPrintHuman(t *testing.T) {
WithReason("missing_required_flag"),
WithOperation("calendar.list"),
WithServerKey("calendar"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("retry command"),
@@ -228,9 +246,19 @@ func TestPrintHuman(t *testing.T) {
if !strings.Contains(got, "Retryable: true") {
t.Fatalf("expected retryable marker in output, got %q", got)
}
for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q in verbose output, got %q", want, got)
}
}
withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error)
if withoutDetails.Details != nil {
t.Fatalf("empty details were retained: %#v", withoutDetails.Details)
}
}
func TestPrintHuman_NormalMode(t *testing.T) {
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -254,7 +282,7 @@ func TestPrintHuman_NormalMode(t *testing.T) {
}
}
func TestPrintJSONIncludesServerDiag(t *testing.T) {
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -289,7 +317,7 @@ func TestPrintJSONIncludesServerDiag(t *testing.T) {
}
}
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -313,7 +341,7 @@ func TestPrintHumanIncludesServerGuidance(t *testing.T) {
}
}
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -335,7 +363,7 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
}
}
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -356,7 +384,7 @@ func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
}
}
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -69,4 +69,103 @@ func TestCrossPlatformCoverageInterfaceSummaryRemainingEdges(t *testing.T) {
if interfaceIdentifierOnly("含中文") {
t.Fatal("non-ASCII summary classified as identifier-only")
}
if !interfaceIdentifierOnly("search_open_platform-docs.v1") {
t.Fatal("identifier-only summary must be detected")
}
if got := summarizeInterfaceDescription("search_open_platform_docs", 40); got != "" {
t.Fatalf("identifier description = %q, want empty", got)
}
if got := summarizeInterfaceDescription("**查询日历。** 第二句不保留。", 40); got != "查询日历。" {
t.Fatalf("sentence summary = %q", got)
}
if got := summarizeInterfaceDescription("这是一个没有句号而且明显超过允许长度的接口描述文本", 12); got != "这是一个没有句号而..." {
t.Fatalf("truncated summary = %q", got)
}
if got := summarizeInterfaceDescription("First half, then a very long continuation without a terminal mark", 20); !strings.HasSuffix(got, "...") {
t.Fatalf("punctuation-cut summary = %q", got)
}
if got := interfaceSummarySource(interfaceMetadataFile{Source: " mcp-tools ", SourceRevision: "abcdef1234567890"}); got != "mcp-tools@abcdef123456" {
t.Fatalf("interfaceSummarySource = %q", got)
}
if got := interfaceSummarySource(interfaceMetadataFile{}); got != "mcp-interface" {
t.Fatalf("default interfaceSummarySource = %q", got)
}
}
func TestOverallCoverageGapInterfaceMetadataFallbackSuccessPath(t *testing.T) {
if err := applyInterfaceMetadataFallback(&File{}, nil, Options{}, &Stats{}, sourceTracker{}); err != nil {
t.Fatalf("empty InterfaceMetadataPath error = %v", err)
}
if hasSurfaceAgentSummary(File{Tools: map[string]ToolMetadata{
"blank": {AgentSummary: "", agentSummaryPresent: false},
}}, nil, "blank") {
t.Fatal("absent agent summary must not match")
}
if got := summarizeInterfaceDescription("first line\n\nsecond paragraph ignored", 80); got != "first line" {
t.Fatalf("paragraph break summary = %q", got)
}
display := filepath.ToSlash("interface.json")
body := `{
"version": 1,
"source": "mcp-tools-list+cli-registry",
"source_revision": "abcdef1234567890",
"source_hash": "sha256:interface",
"tools": {
"calendar.get": {
"description": "读取指定日历。",
"interface_ref": {"product_id": "calendar", "rpc_name": "get"}
},
"calendar.list": {
"description": "列出当前用户可访问的日历。后续句子不应进入 summary。"
},
"calendar.raw": {"description": "raw_tool_name"},
"outside.tool": {"description": "不在公开命令面"}
}
}`
files := map[string]sourceFile{display: {display: display, data: []byte(body)}}
out := &File{Tools: map[string]ToolMetadata{
"calendar.get": {AgentSummary: "已有摘要", agentSummaryPresent: true},
}}
stats := &Stats{}
if err := applyInterfaceMetadataFallback(out, files, Options{
InterfaceMetadataPath: "interface.json",
ToolPaths: map[string]string{
"calendar.get": "calendar get",
"calendar.list": "calendar list",
"calendar.raw": "calendar raw",
},
}, stats, sourceTracker{}); err != nil {
t.Fatal(err)
}
audit := stats.InterfaceMetadata
if audit == nil || audit.SourceTools != 4 || audit.SurfaceTools != 3 ||
audit.EligibleSummaries != 2 || audit.AppliedSummaries != 1 ||
audit.PreservedSummaries != 1 {
t.Fatalf("interface audit = %#v", audit)
}
if len(audit.RejectedTools) != 1 || audit.RejectedTools[0] != "calendar.raw" ||
len(audit.OutsideSurface) != 1 || audit.OutsideSurface[0] != "outside.tool" {
t.Fatalf("interface audit paths = %#v", audit)
}
list := out.Tools["calendar.list"]
if list.AgentSummary != "列出当前用户可访问的日历。" {
t.Fatalf("applied MCP summary = %q", list.AgentSummary)
}
if list.AgentSummarySource != "mcp-tools-list+cli-registry@abcdef123456" {
t.Fatalf("applied summary source = %q", list.AgentSummarySource)
}
if list.Reviewed == nil || *list.Reviewed {
t.Fatalf("applied reviewed = %#v, want false", list.Reviewed)
}
if list.InterfaceRef != nil {
t.Fatalf("list tool unexpectedly gained interface_ref: %#v", list.InterfaceRef)
}
get := out.Tools["calendar.get"]
if get.InterfaceRef == nil || get.InterfaceRef.ProductID != "calendar" || get.InterfaceRef.RPCName != "get" {
t.Fatalf("preserved tool interface_ref = %#v", get.InterfaceRef)
}
if get.AgentSummary != "已有摘要" {
t.Fatalf("preserved summary overwritten: %#v", get)
}
}
+6
View File
@@ -3,6 +3,7 @@ package helpers
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -78,6 +79,8 @@ func addAisearchPersonFlags(cmd *cobra.Command) {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
_ = cmd.Flags().MarkHidden("type")
}
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
@@ -90,6 +93,9 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
// 的智能 RunE(裸调兜底)共享调用。
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
// 复用原有报错文案("keyword is required")
@@ -28,3 +28,13 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) {
}
}
}
func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil {
t.Fatal(err)
}
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil {
t.Fatal("invalid person type selector unexpectedly succeeded")
}
}
+179 -2
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
eventSuggestCmd.Flags().String("members", "", "")
_ = eventSuggestCmd.Flags().MarkHidden("members")
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
eventInstancesCmd := &cobra.Command{
Use: "instances",
Short: "查询循环日程的实例列表",
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
Example: ` dws calendar event instances --id EVENT_ID
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
dws calendar event instances --id EVENT_ID --limit 50
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
var startTime, endTime int64
var now time.Time
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
startTime, err = parseISOTimeToMillis("start", v)
if err != nil {
return err
}
toolArgs["startTime"] = startTime
} else {
now = time.Now()
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
toolArgs["startTime"] = startTime
}
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
endTime, err = parseISOTimeToMillis("end", v)
if err != nil {
return err
}
toolArgs["endTime"] = endTime
} else {
if now.IsZero() {
now = time.Now()
}
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
toolArgs["endTime"] = endTime
}
if err := validateTimeRange(startTime, endTime); err != nil {
return err
}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
toolArgs["cursor"] = v
}
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
toolArgs["limit"] = lim
}
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
},
}
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "list_event_instances",
CanonicalPath: "calendar.list_event_instances",
CLIPath: "calendar event instances",
PrimaryCLIPath: "calendar event instances",
},
Description: "查询循环日程的实例列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询循环日程在时间范围内展开的实例",
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
AvoidWhen: []string{
"普通非循环日程请用 dws calendar event get / list",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event instances --id <EVENT_ID>",
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
{Name: "calendar-id", Property: "calendarId"},
{Name: "cursor", Property: "cursor"},
{Name: "limit", Property: "limit", InterfaceType: "integer"},
},
},
})
// InstancesEvent flags (aligned with event list aliases)
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
eventInstancesCmd.Flags().String("event", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event")
eventInstancesCmd.Flags().String("event-id", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
eventInstancesCmd.Flags().String("eventId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
eventInstancesCmd.Flags().String("time-min", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
eventInstancesCmd.Flags().String("min-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
eventInstancesCmd.Flags().String("start-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
eventInstancesCmd.Flags().String("startTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
eventInstancesCmd.Flags().String("start_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
eventInstancesCmd.Flags().String("start-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
eventInstancesCmd.Flags().String("startDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
eventInstancesCmd.Flags().String("time-max", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
eventInstancesCmd.Flags().String("max-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
eventInstancesCmd.Flags().String("end-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
eventInstancesCmd.Flags().String("endTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
eventInstancesCmd.Flags().String("end_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
eventInstancesCmd.Flags().String("end-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
eventInstancesCmd.Flags().String("endDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
eventInstancesCmd.Flags().String("calendarId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
eventInstancesCmd.Flags().String("calendar", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
eventInstancesCmd.Flags().String("next-cursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
eventInstancesCmd.Flags().String("nextCursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
eventInstancesCmd.Flags().String("page-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
eventInstancesCmd.Flags().String("pageToken", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
eventInstancesCmd.Flags().String("next-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
eventInstancesCmd.Flags().Int("max-results", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
eventInstancesCmd.Flags().Int("maxResults", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
eventInstancesCmd.Flags().Int("page-size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
eventInstancesCmd.Flags().Int("size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("size")
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+129 -48
View File
@@ -18,6 +18,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -53,6 +54,35 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
}
}
type nativeChatTargetReader struct{}
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
func resolveNativeChatTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if targetresolver.LooksLikeOpenConversationID(raw) {
return raw, nil
}
resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw)
if err != nil {
return "", err
}
return resolved.Selected.OpenConversationID, nil
}
const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
@@ -78,6 +108,61 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string
return v
}
func runChatGroupSearch(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword", "name", "group")
if len(args) == 1 {
if keyword != "" {
return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定")
}
keyword = strings.TrimSpace(args[0])
}
if keyword == "" {
return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
}
func newChatGroupSearchCommand(hidden bool) *cobra.Command {
cmd := &cobra.Command{
Use: "search [query]",
Short: "根据关键词搜索群聊",
Hidden: hidden,
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
Args: cobra.MaximumNArgs(1),
RunE: runChatGroupSearch,
}
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("keyword", "", "--query 的别名")
_ = cmd.Flags().MarkHidden("keyword")
cmd.Flags().String("name", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("name")
cmd.Flags().String("group", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("group")
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
cmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = cmd.Flags().MarkHidden("size")
cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
return cmd
}
func runChatSearchCommon(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlags(cmd, "nicks"); err != nil {
return err
@@ -1277,34 +1362,23 @@ func newChatCommand() *cobra.Command {
},
})
chatSearchCmd := &cobra.Command{
Use: "search",
Short: "根据关键词搜索群聊",
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
chatSearchCmd := newChatGroupSearchCommand(false)
chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true)
cli.AttachRuntimeSchema(
chatGroupSearchCompatibilityCmd,
"chat",
"search_groups",
"reviewed-compatibility:chat-group-search",
)
cli.AnnotateRuntimeCompatibilityEquivalence(
chatSearchCmd,
chatGroupSearchCompatibilityCmd,
cli.RuntimeCompatibilityEquivalence{
ID: "chat-group-search-compatibility-v1",
Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.",
Reviewed: true,
},
}
)
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -3353,15 +3427,6 @@ func newChatCommand() *cobra.Command {
chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)")
chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈")
chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
chatSearchCmd.Flags().String("keyword", "", "--query 的别名")
_ = chatSearchCmd.Flags().MarkHidden("keyword")
chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatSearchCmd.Flags().MarkHidden("size")
chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
_ = chatGroupMembersCmd.MarkFlagRequired("id")
chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始")
@@ -3387,7 +3452,7 @@ func newChatCommand() *cobra.Command {
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
// message 子命令 flags
@@ -3940,6 +4005,7 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3947,6 +4013,14 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -6261,8 +6335,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group"))
if err != nil {
return err
}
return callMCPToolOnServer("bot", "list_group_bots", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openConversationId": groupID,
})
},
}
@@ -6296,7 +6374,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
},
})
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
_ = chatGroupBotsCmd.MarkFlagRequired("group")
chatGroupMembersRemoveBotCmd := &cobra.Command{
@@ -6432,7 +6510,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Use: "dismiss",
Short: "解散群聊",
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
Example: ` dws chat group dismiss --group <openConversationId> --yes
Example: ` dws chat group dismiss --group <openConversationId>
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -7276,7 +7354,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -7288,6 +7366,14 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -7763,8 +7849,8 @@ status 可选值:
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -8080,10 +8166,5 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// hint: dws chat send → dws chat message send
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
// hint: dws chat history → dws chat message list
root.AddCommand(hintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
@@ -43,6 +43,66 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
return command.RunE(command, nil)
}
func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) {
if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" {
t.Fatalf("stable native chat target = %q, %v", got, err)
}
t.Run("group search path accepts query", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("group search accepts positional", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("native bots resolves group name", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`},
{text: `{"result":{"bots":[]}}`},
}}
if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d", caller.calls)
}
})
}
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
for _, tc := range []struct {
path string
flag string
}{
{path: "send", flag: "--group"},
{path: "history", flag: "--group"},
} {
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
}
if strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
}
if caller.calls != 0 {
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
}
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -112,6 +172,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
{"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
{"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
{"category", "delete", "--category-id=1"},
{"category", "rename", "--category-id=1", "--title=renamed"},
{"category", "add-conv", "--group=cid", "--category-ids=1,2"},
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
+80 -6
View File
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
return cmd
}
func newContactUserUpdateOwnnessCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-ownness",
Aliases: []string{"set-ownness"},
Short: "更新用户个人状态",
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
return err
}
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
if userID == "" {
return fmt.Errorf("--user-id 不能为空")
}
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
return err
}
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
if ownnessText == "" {
return fmt.Errorf("--ownness-text 不能为空")
}
return callMCPTool("user_ownness_update", map[string]any{
"userId": userID,
"ownnessText": ownnessText,
})
},
}
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
cmd.Flags().String("id", "", "--user-id 的别名")
cmd.Flags().String("userid", "", "--user-id 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("userid")
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
return cmd
}
func newContactAccountUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
通讯录功能:
- contact user get-self/search/search-mobile/get: 通讯录用户查询
- contact user invite/update/update-self: 邀请与更新员工
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
- contact relation list-my-followings: 特别关注人查询
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的部门、主管、管理员权限 → contact user get
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
- 邀请员工加入企业 → contact user invite
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
},
},
})
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "contact",
Name: "user_ownness_update",
CanonicalPath: "contact.user_ownness_update",
CLIPath: "contact user update-ownness",
PrimaryCLIPath: "contact user update-ownness",
},
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "userId", Required: boolPtr(false)},
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
{Name: "userid", Property: "userId", Required: boolPtr(false)},
},
},
})
// ── flags 注册 ───────────────────────────────────────────────
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
_ = contactUserGetCmd.Flags().MarkHidden("userid")
userCmd.AddCommand(
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserUpdateOwnnessCmd, // 更新用户个人状态
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
)
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
}
for _, tc := range cases {
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
toolName: "self_user_profile_update",
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
},
{
name: "update user ownness",
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
},
{
name: "update user ownness with aliases",
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
},
{
name: "update enterprise account",
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
{"dept", "update", "--dept", "7", "--name", "研发中心"},
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
{"user", "update-self", "--nick", "新昵称"},
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
}
for _, args := range tests {
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
+37 -28
View File
@@ -98,13 +98,8 @@ func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) {
}
}
func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{
dryRun: true,
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
},
}
func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run")
if err != nil {
@@ -113,10 +108,8 @@ func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
if len(caller.calls) != 0 {
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
}
if len(caller.readCalls) != 1 ||
caller.readCalls[0].productID != "doc" ||
caller.readCalls[0].toolName != "list_doc_versions" {
t.Fatalf("dry-run read calls = %#v, want doc/list_doc_versions", caller.readCalls)
if len(caller.readCalls) != 0 {
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
}
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 7`) {
@@ -124,30 +117,46 @@ func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
}
}
func TestDocVersionRevertDryRunRejectsMissingVersionBeforePreview(t *testing.T) {
caller := &contractDefectCaller{
dryRun: true,
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
},
}
func TestDocVersionRevertDryRunDoesNotRejectMissingVersionRemotely(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "999", "--dry-run")
if err == nil || !strings.Contains(err.Error(), "文档版本 999 不存在") {
t.Fatalf("missing version error = %v, want explicit rejection", err)
if err != nil {
t.Fatalf("doc version revert dry-run returned error: %v", err)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "version_not_found" {
t.Fatalf("missing version error = %#v, want typed version_not_found", err)
}
if len(caller.readCalls) != 1 {
t.Fatalf("dry-run read calls = %#v, want one version lookup", caller.readCalls)
if len(caller.readCalls) != 0 {
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
}
if strings.Contains(output, `"tool": "revert_doc_version"`) {
t.Fatalf("missing version emitted a misleading mutation preview: %q", output)
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 999`) {
t.Fatalf("dry-run output = %q, want preview without remote missing-version rejection", output)
}
}
func TestDocVersionRevertNonDryRunPreflightsVersion(t *testing.T) {
caller := &contractDefectCaller{
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
"doc/revert_doc_version": `{}`,
},
}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-live", "--version", "7", "--yes")
if err != nil {
t.Fatalf("doc version revert returned error: %v", err)
}
// Outside --dry-run, list_doc_versions rides the normal CallTool channel
// (CallReadTool is dry-run-only). Expect preflight then mutation.
if len(caller.calls) != 2 ||
caller.calls[0].productID != "doc" || caller.calls[0].toolName != "list_doc_versions" ||
caller.calls[1].productID != "doc" || caller.calls[1].toolName != "revert_doc_version" {
t.Fatalf("non-dry-run calls = %#v, want list_doc_versions then revert_doc_version", caller.calls)
}
if strings.Contains(output, `"dry_run": true`) {
t.Fatalf("non-dry-run output unexpectedly previewed: %q", output)
}
}
+3 -1
View File
@@ -2232,7 +2232,9 @@ func splitDevAppList(raw string) []string {
}
// transformDevAppListParam splits a comma/semicolon list for LeafFlag.Transform.
// Empty input returns nil so the key is omitted from toolArgs.
// Empty input (including separator-only) returns nil so optional flags omit the
// key from toolArgs. Required flags that collapse to empty are rejected by
// corecmd.BuildArgs after transform.
func transformDevAppListParam(raw string) (any, error) {
values := splitDevAppList(raw)
if len(values) == 0 {
+27
View File
@@ -709,6 +709,33 @@ func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
}
}
func TestCrossPlatformCoverageDevAppEventSubscribeRejectsSeparatorOnlyEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"subscribe_comma", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
{"subscribe_semicolon", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ";", "--yes"}},
{"unsubscribe_comma", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") {
t.Fatalf("Execute() error = %v, want --event-codes 为必填", err)
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called, got tool %q", runner.last.Tool)
}
})
}
}
func TestDevAppWebappCommandsBuildParams(t *testing.T) {
cases := []struct {
name string
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This file is adapted from Go standard library's internal/diff package.
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
//
// Modifications:
// - Package changed from "diff" to "products"
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
// - const C replaced with the contextLines parameter
package helpers
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type diffPair struct{ x, y int }
func nonNeg(v int) int {
if v < 0 {
return 0
}
return v
}
// UnifiedDiff returns an anchored diff of the two texts old and new
// in the "unified diff" format. If old and new are identical,
// UnifiedDiff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of "unique" lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an "anchored diff" because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a "patience diff," named for
// the "patience sorting" algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
if bytes.Equal(old, new) {
return nil
}
x := diffLines(old)
y := diffLines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done diffPair // printed up to x[:done.x] and y[:done.y]
chunk diffPair // start lines of current chunk
count diffPair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range diffTgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far as possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitely do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := contextLines
if C < 0 {
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
C = 0
}
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = diffPair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// diffLines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func diffLines(x []byte) []string {
l := strings.SplitAfter(string(x), "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// diffTgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, "A Special Case of the Maximal Common
// Subsequence Problem," Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func diffTgs(x, y []string) []diffPair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]diffPair, 2+k)
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = diffPair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = diffPair{0, 0} // sentinel at start
return seq
}
+93 -24
View File
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
dws doc create 创建文档
dws doc update 更新文档内容
dws doc block [list|insert|update|delete] 块级编辑
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
mediaUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传可复用的文档媒体资源",
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
等后续写入使用;临时 uploadUrl 不会输出。`,
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
RunE: runDocMediaUpload,
}
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "media_upload",
CanonicalPath: "doc.media_upload",
CLIPath: "doc media upload",
PrimaryCLIPath: "doc media upload",
},
Description: "上传可复用的文档媒体资源",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "file", Required: boolPtr(true)},
},
},
})
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
mediaInsertCmd := &cobra.Command{
Use: "insert",
Short: "上传附件并插入文档",
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
// media 子命令的 --node 隐藏别名
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
for _, c := range mediaNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
_ = c.Flags().MarkHidden("file-id")
}
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
@@ -3607,11 +3657,13 @@ CLI 内部自动完成全部流程:
PROCESSING 处理中
SUCCESS 导出成功,返回 downloadUrl
FAILED 导出失败`,
Example: ` dws doc export get --job-id <JOB_ID>`,
Example: ` dws doc export get --job-id <JOB_ID>
dws doc export get --task-id <TASK_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
jobID := mustGetFlag(cmd, "job-id")
if jobID == "" {
return fmt.Errorf("flag --job-id is required")
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
if err != nil {
return err
}
if deps.Caller.DryRun() {
@@ -3678,9 +3730,14 @@ CLI 内部自动完成全部流程:
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "job-id", Property: "jobId"},
},
},
})
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
_ = exportGetCmd.Flags().MarkHidden("task-id")
// --node 的隐藏别名(与 doc 下其他命令保持一致)
exportCmd.Flags().String("url", "", "--node 的别名")
@@ -3911,20 +3968,22 @@ CLI 内部自动完成全部流程:
return fmt.Errorf("flag --version is required")
}
version, _ := cmd.Flags().GetInt("version")
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return apperrors.NewValidation(
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
apperrors.WithReason("version_not_found"),
apperrors.WithHint(fmt.Sprintf(
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
nodeID,
)),
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
)
if !commandDryRun(cmd) {
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return apperrors.NewValidation(
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
apperrors.WithReason("version_not_found"),
apperrors.WithHint(fmt.Sprintf(
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
nodeID,
)),
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
)
}
}
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
"nodeId": nodeID,
@@ -4225,11 +4284,21 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
return root
}
// printDocDeprecationWarning emits a deprecation warning when shared deps are
// initialized. Schema declaration-only roots skip InitDeps; homology and other
// Execute probes must remain nil-safe on that path.
func printDocDeprecationWarning(msg string) {
if deps == nil || deps.Out == nil {
return
}
deps.Out.PrintWarning(msg)
}
// wrapDocDeprecated wraps a doc command's RunE to print a deprecation warning
// directing users to the corresponding drive command. The original command
// continues to function normally during the transition period.
@@ -4237,7 +4306,7 @@ func wrapDocDeprecated(cmd *cobra.Command, driveSubCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
deps.Out.PrintWarning(fmt.Sprintf(
printDocDeprecationWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws drive %s' instead.",
c.CommandPath()[8:], // strip "dws doc " prefix
driveSubCmd,
@@ -4253,7 +4322,7 @@ func wrapDocDeprecatedToWiki(cmd *cobra.Command, wikiSubCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
deps.Out.PrintWarning(fmt.Sprintf(
printDocDeprecationWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
c.CommandPath()[8:],
wikiSubCmd,
@@ -4269,7 +4338,7 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
deps.Out.PrintWarning(fmt.Sprintf(
printDocDeprecationWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
c.CommandPath()[8:],
targetCmd,
@@ -173,6 +173,24 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
_ = cmd.RunE(cmd, nil)
}
}
// Declaration-only Schema roots leave deps nil; wrappers must not panic.
deps = nil
for _, wrap := range []func(*cobra.Command){
func(cmd *cobra.Command) { wrapDocDeprecated(cmd, "drive target") },
func(cmd *cobra.Command) { wrapDocDeprecatedToWiki(cmd, "wiki target") },
func(cmd *cobra.Command) { wrapDocDeprecatedToTarget(cmd, "target") },
} {
cmd := &cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}
wrap(cmd)
root := &cobra.Command{Use: "dws"}
doc := &cobra.Command{Use: "doc"}
root.AddCommand(doc)
doc.AddCommand(cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("nil-deps deprecation wrapper: %v", err)
}
}
}
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName, _ := cmd.Flags().GetString("name")
if fileName == "" {
fileName = filepath.Base(filePath)
} else if filepath.Ext(fileName) == "" {
fileName += filepath.Ext(filePath)
}
mimeType, _ := cmd.Flags().GetString("mime-type")
if mimeType == "" {
mimeType = inferMimeType(fileName)
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
if err != nil {
return err
}
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
if err != nil {
return err
}
if resourceURL == "" {
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
}
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
return fmt.Errorf("document media upload failed: %s", message)
}
return deps.Out.PrintJSON(map[string]any{
"nodeId": nodeID,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"mimeType": mimeType,
"size": fileInfo.Size(),
})
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
whiteboardDefaultHeight = 600
)
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
// 继续调用 whiteboard query/update。
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
var (
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
whiteboardSleep = time.Sleep
whiteboardJSONMarshal = json.Marshal
prepareWhiteboardCard = prepareJsonMLNode
)
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
node := []any{
"card",
map[string]any{
"uuid": blockUUID,
"cardType": "hetu",
"height": whiteboardDefaultHeight,
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
},
[]any{"span", map[string]any{"data-type": "text"},
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
}
out, err := whiteboardJSONMarshal(node)
if err != nil {
return ""
}
return string(out)
}
func extractWhiteboardID(attrs map[string]any) string {
meta, _ := attrs["metadata"].(map[string]any)
if meta == nil {
return ""
}
id, _ := meta["id"].(string)
return id
}
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID,
"blockId": blockID,
"format": "jsonml",
})
if err != nil {
return nil, err
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
blocksField, ok := data["blocks"]
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
}
blocks, ok := blocksField.([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
}
var raw string
for _, block := range blocks {
entry, _ := block.(map[string]any)
if entry == nil || entry["blockId"] != blockID {
continue
}
raw, _ = entry["jsonml"].(string)
break
}
if raw == "" {
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
}
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
return nil, fmt.Errorf("parse block jsonml: %w", err)
}
return node, nil
}
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
if err != nil {
return nil, err
}
if len(node) < 2 {
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
}
attrs, _ := node[1].(map[string]any)
if attrs == nil {
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
}
return attrs, nil
}
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
blockUUID := uuid.New().String()
whiteboardID := uuid.New().String()
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
normalized, err := prepareWhiteboardCard(cmd, element)
if err != nil {
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
}
toolArgs := map[string]any{
"nodeId": nodeID,
"jsonml": normalized,
"format": "jsonml",
}
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
toolArgs["referenceBlockId"] = v
where, _ := cmd.Flags().GetString("where")
if where == "" {
where = "after"
}
toolArgs["where"] = where
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
toolArgs["referenceBlockId"] = v
}
if cmd.Flags().Changed("index") {
index, _ := cmd.Flags().GetInt("index")
toolArgs["index"] = index
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
ctx := cmd.Context()
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
return err
}
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
persistedID := ""
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
switch {
case queryErr == nil:
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
persistedID = extractWhiteboardID(attrs)
case errors.Is(queryErr, errWhiteboardBlockPending):
// 块暂不可见,属于最终一致性,继续重试。
default:
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
return fmt.Errorf(
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
blockUUID, queryErr)
}
if persistedID != "" {
break
}
if attempt < len(whiteboardRetryDelays) {
whiteboardSleep(whiteboardRetryDelays[attempt])
}
}
result := map[string]any{"blockId": blockUUID}
if persistedID == "" {
result["whiteboardId"] = nil
deps.Out.PrintWarning(fmt.Sprintf(
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
nodeID, blockUUID))
} else {
result["whiteboardId"] = persistedID
}
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
insertCmd := &cobra.Command{
Use: "insert",
Short: "插入白板卡片",
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
不能同时使用。`,
Example: ` dws doc whiteboard insert --node DOC_ID
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
RunE: runWhiteboardInsert,
}
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
insertCmd.Flags().String(name, "", "--node 的兼容别名")
_ = insertCmd.Flags().MarkHidden(name)
}
DeclareLeafMetadata(insertCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "whiteboard_insert",
CanonicalPath: "doc.whiteboard_insert",
CLIPath: "doc whiteboard insert",
PrimaryCLIPath: "doc whiteboard insert",
},
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
},
},
})
root.AddCommand(insertCmd)
return root
}
+72 -7
View File
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
Example: ` dws drive list --limit 20
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>`,
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --latest 5
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
if cmd.Flags().Changed("versions") {
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
}
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 {
if depth > 1 || latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
if pattern != "" {
return &CLIError{
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
// 默认路由:钉盘文件列表
if latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
},
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --folder <dentryUuid> --limit 20 --format json",
"dws drive list --latest 5 --format json",
},
},
},
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
// Wukong compat alias: routes to download-version; not a download_file property.
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
},
},
})
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
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 多页扫描时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
// Wukong compat: `drive download --version N` routes to download-version.
origDriveDownloadRunE := driveDownloadCmd.RunE
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("version") {
return driveDownloadVersionCmd.RunE(cmd, args)
}
return origDriveDownloadRunE(cmd, args)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
toolArgs["maxResults"] = limit
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
// ── cross-product hidden aliases ──
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
},
})
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
for _, alias := range []string{"url", "id"} {
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
_ = driveCoverCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveCoverCmd)
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
})
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
for _, alias := range []string{"url", "id"} {
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
_ = driveRevertCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveRevertCmd)
for _, child := range driveStarCmd.Commands() {
for _, alias := range []string{"url", "id"} {
if child.Flags().Lookup(alias) == nil {
child.Flags().String(alias, "", "--node 的别名")
_ = child.Flags().MarkHidden(alias)
}
}
RegisterCrossProductAliases(child)
}
driveCmd.AddCommand(
driveListCmd,
+39 -18
View File
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +209,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)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +220,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +233,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
if err != nil {
folderErr = err
@@ -249,6 +249,12 @@ bfs:
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
if ms, ok := driveItemModifiedMillis(item); ok {
item["sortTime"] = ms
} else {
item["sortTime"] = int64(0)
}
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
@@ -291,7 +297,7 @@ bfs:
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
return emitErr
}
return folderErr
@@ -324,18 +330,26 @@ bfs:
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern)
if truncated && latest > 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
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 {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
if latest > 0 {
items = applyDriveListLatest(items, latest)
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
}
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
if latest > 0 && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
if items == nil {
items = []map[string]any{}
}
+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"); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); 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, ""); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); 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, ""); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); 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, ""); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); 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)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
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); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); 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); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); 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); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); 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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
+199
View File
@@ -0,0 +1,199 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页)。
driveLatestScanMax = 1000
)
// validateDriveListLatest --latest 边界与互斥校验。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveLatestExclusiveError("limit", latest)
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveLatestExclusiveError("cursor", latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
if v, ok := item[k]; ok {
if ms, ok := toMillis(v); ok {
return ms, true
}
}
}
return 0, false
}
func toMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
// runDriveListLatest 钉盘单层 --latest 扫描。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Test helpers for package-level deps. Production code must not call these;
// the ForTest suffix is the boundary.
// InitDepsForTest installs caller for the test duration and restores the prior
// deps pointer (including a prior nil) via testseam. Prefer this over
// InitDeps + InitDeps(previousCaller): restoring through InitDeps(nil) leaves a
// non-nil Deps with a nil Caller, which is not the original unset state.
func InitDepsForTest(t *testing.T, caller edition.ToolCaller) {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type fortestCoverageCaller struct{}
func (fortestCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (fortestCoverageCaller) Format() string { return "json" }
func (fortestCoverageCaller) DryRun() bool { return false }
func (fortestCoverageCaller) Fields() string { return "" }
func (fortestCoverageCaller) JQ() string { return "" }
// TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps covers the
// ForTest deps restore helper used by declaration-only Schema probes.
func TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps(t *testing.T) {
testseam.Protect(t, &deps)
deps = nil
marker := fortestCoverageCaller{}
InitDepsForTest(t, marker)
if got := GetCaller(); got != marker {
t.Fatalf("GetCaller() during InitDepsForTest = %T, want marker", got)
}
if deps == nil {
t.Fatal("InitDepsForTest left deps nil before test cleanup")
}
}
+1
View File
@@ -64,6 +64,7 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"enterprise_person_search": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
+3
View File
@@ -190,6 +190,9 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
if toolName == "enterprise_person_search" {
return true
}
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
+11 -4
View File
@@ -229,6 +229,15 @@ func TestLeafArgs(t *testing.T) {
func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
// Satisfy Required flags first: BuildArgs now rejects Required transforms that
// collapse to empty (unset CSV), matching runtime behavior after ValidateRequired.
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := cmd.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", "hello"); err != nil {
t.Fatal(err)
}
args, err := corecmd.BuildArgs(cmd, leafTestSpec().Flags)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
@@ -239,10 +248,8 @@ func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
if _, present := args["cursor"]; present {
t.Fatalf("cursor present = %v, want omitted when zero", args["cursor"])
}
// 未配置 OmitEmpty 的 flag 即使为空也入参(复现手写语义;Required 校验在
// leafArgs 之前执行,保证真实路径不会发出空值)。
if v, present := args["accessToken"]; !present || v != "" {
t.Fatalf("accessToken = %v/%v, want present-but-empty without OmitEmpty", v, present)
if v, present := args["accessToken"]; !present || v != "tok" {
t.Fatalf("accessToken = %v/%v, want env tok", v, present)
}
// 未设置 OmitEmpty 的字符串即使为空也入参(复现手写 remindType 恒入参语义)。
if v, present := args["remindType"]; !present || v != "app" {
+489 -3
View File
@@ -195,7 +195,72 @@ func newMailCommand() *cobra.Command {
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
mailboxSharedWithMeCmd := &cobra.Command{
Use: "shared-with-me",
Short: "查询共享给我的邮箱",
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
共享关系(relationships)取值:
LOGIN 登录(可登录该共享邮箱)
SEND_AS 代发(以该邮箱身份发送邮件)
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
返回字段:
total 可访问的共享账号总数
targets 可访问的共享账号列表`,
Example: ` dws mail mailbox shared-with-me
dws mail mailbox shared-with-me --limit 20 --offset 0`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if cmd.Flags().Changed("limit") {
limit, _ := cmd.Flags().GetInt("limit")
toolArgs["limit"] = limit
}
if cmd.Flags().Changed("offset") {
offset, _ := cmd.Flags().GetInt("offset")
toolArgs["offset"] = offset
}
if len(toolArgs) == 0 {
return callMCPTool("list_shared_with_me", nil)
}
return callMCPTool("list_shared_with_me", toolArgs)
},
}
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_shared_with_me",
CanonicalPath: "mail.list_shared_with_me",
CLIPath: "mail mailbox shared-with-me",
PrimaryCLIPath: "mail mailbox shared-with-me",
},
Description: "查询共享给我的邮箱",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询他人共享给当前用户的邮箱",
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
Examples: []string{"dws mail mailbox shared-with-me"},
},
Parameters: []contract.ParamDecl{
{Name: "limit", Property: "limit", InterfaceType: "integer"},
{Name: "offset", Property: "offset", InterfaceType: "integer"},
},
},
})
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
@@ -2126,9 +2191,251 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
messageExportCmd := &cobra.Command{
Use: "export",
Short: "导出/备份邮件(EML格式)",
Long: `导出指定邮件为 EML 格式文件并保存到本地。
不指定 --filename 时,默认以邮件主题作为文件名。
文件保存在当前工作目录下,扩展名为 .eml。
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
注意:目前仅支持 100KB 以内的邮件导出。
编排流程:
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
2. 调用 export_message_mime 获取 EML 内容
3. 将 EML 内容原子写入本地文件`,
Example: ` dws mail message export --email user@company.com --id <messageId>
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
email := mustGetFlag(cmd, "email")
messageID := mustGetFlag(cmd, "id")
filename := mustGetFlag(cmd, "filename")
overwrite, _ := cmd.Flags().GetBool("overwrite")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
deps.Out.PrintKeyValue("email", email)
deps.Out.PrintKeyValue("messageId", messageID)
if filename != "" {
deps.Out.PrintKeyValue("filename", filename)
}
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
return nil
}
ctx := cmd.Context()
if filename == "" {
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
"email": email,
"messageId": messageID,
})
if err != nil {
return fmt.Errorf("获取邮件信息失败: %w", err)
}
var msgData map[string]any
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
data := msgData
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if msg, ok := data["message"].(map[string]any); ok {
data = msg
}
if subj, ok := data["subject"].(string); ok && subj != "" {
filename = subj
}
}
if filename == "" {
filename = messageID
}
}
filename = sanitizeMailFilename(filename)
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
"email": email,
"id": messageID,
})
if err != nil {
return fmt.Errorf("导出邮件失败: %w", err)
}
var exportData map[string]any
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
return fmt.Errorf("解析导出结果失败: %w", err)
}
if result, ok := exportData["result"].(map[string]any); ok {
exportData = result
}
emlContent, _ := exportData["emlContent"].(string)
if emlContent == "" {
return fmt.Errorf("导出结果为空: %s", exportText)
}
destPath := filename + ".eml"
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
if os.IsExist(err) {
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
}
return fmt.Errorf("保存文件失败: %w", err)
}
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
return nil
},
}
DeclareLeafMetadata(messageExportCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "export_message_mime",
CanonicalPath: "mail.export_message_mime",
CLIPath: "mail message export",
PrimaryCLIPath: "mail message export",
},
Description: "导出/备份邮件为本地 EML 文件",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
},
Selection: contract.SelectionSpec{
AgentSummary: "导出邮件为本地 EML 备份",
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "filename", Property: "filename"},
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
},
},
})
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
messageShareToChatCmd := &cobra.Command{
Use: "share-to-chat",
Short: "[危险] 分享邮件至IM聊天",
Long: `将指定邮件分享到钉钉单聊。
参数说明:
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
--yes 跳过二次确认,直接执行分享
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
mcpArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
}
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
mcpArgs["uids"] = parseRecipients(users)
}
skipConfirm, _ := cmd.Flags().GetBool("yes")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
deps.Out.PrintKeyValue("users", users)
}
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
return nil
}
ctx := cmd.Context()
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
if err != nil {
return fmt.Errorf("分享邮件失败: %w", err)
}
var firstResult map[string]any
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
return fmt.Errorf("解析分享结果失败: %w", err)
}
if result, ok := firstResult["result"].(map[string]any); ok {
firstResult = result
}
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
riskMsg, _ := firstResult["riskMessage"].(string)
if !skipConfirm {
if riskMsg != "" {
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
}
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
}
mcpArgs["sign"] = sign
return callMCPTool("share_message_to_chat", mcpArgs)
}
// firstResult is already a parsed object (possibly unwrapped from result).
return deps.Out.PrintJSON(firstResult)
},
}
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "share_message_to_chat",
CanonicalPath: "mail.share_message_to_chat",
CLIPath: "mail message share-to-chat",
PrimaryCLIPath: "mail message share-to-chat",
},
Description: "分享邮件至 IM 单聊",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
},
Selection: contract.SelectionSpec{
AgentSummary: "把邮件分享到钉钉单聊",
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "users", Property: "uids"},
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
},
},
})
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
@@ -3400,7 +3707,129 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
Example: ` dws mail calendar list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_calendars", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
DeclareLeafMetadata(calendarListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendars",
CanonicalPath: "mail.list_mailbox_calendars",
CLIPath: "mail calendar list",
PrimaryCLIPath: "mail calendar list",
},
Description: "列出邮箱日历文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
},
Selection: contract.SelectionSpec{
AgentSummary: "列出邮箱可访问的日历文件夹",
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
Examples: []string{"dws mail calendar list --email user@company.com"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
},
},
})
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
return err
}
toolArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": flagOrFallback(cmd, "id", "folder-id"),
"startTime": flagOrFallback(cmd, "start", "start-time"),
"endTime": flagOrFallback(cmd, "end", "end-time"),
}
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
toolArgs["cursor"] = cursor
}
return callMCPTool("list_mailbox_calendar_events", toolArgs)
},
}
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendar_events",
CanonicalPath: "mail.list_mailbox_calendar_events",
CLIPath: "mail calendar-event list",
PrimaryCLIPath: "mail calendar-event list",
},
Description: "查询邮箱日历文件夹时间范围内的日程",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询邮箱日历日程列表",
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "start", Property: "startTime", Required: boolPtr(true)},
{Name: "end", Property: "endTime", Required: boolPtr(true)},
{Name: "cursor", Property: "cursor"},
},
},
})
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
calendarEventCmd.AddCommand(calendarEventListCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
return root
}
@@ -3935,3 +4364,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
return nil
}
func sanitizeMailFilename(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "\x00", "")
if name == "" {
return "mail"
}
return name
}
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
var mailAtomicLink = os.Link
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
dir := filepath.Dir(path)
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = atomicRemove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return fmt.Errorf("设置文件权限失败: %w", err)
}
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("写入数据失败: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("同步磁盘失败: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("关闭临时文件失败: %w", err)
}
if overwrite {
if err := atomicRename(tmpName, path); err != nil {
return fmt.Errorf("重命名文件失败: %w", err)
}
success = true
return nil
}
if err := mailAtomicLink(tmpName, path); err != nil {
return err
}
_ = atomicRemove(tmpName)
success = true
return nil
}
+2 -1
View File
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
root := &cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
newMarkdownDiffCmd(),
newMarkdownOverwriteCmd(),
newMarkdownPatchCmd(),
)
+491
View File
@@ -0,0 +1,491 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws markdown diff
// ──────────────────────────────────────────────────────────
// diffResult 是 diff 命令的输出结构。
type diffResult struct {
Mode string `json:"mode"`
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks int `json:"hunks"`
Diff string `json:"diff"`
}
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
var (
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = computeUnifiedDiff
)
// formatFileSize 返回人类可读的文件大小。
func formatFileSize(size int64) string {
if size >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
}
if size >= 1024 {
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
}
return fmt.Sprintf("%d B", size)
}
// checkFileSize 校验文件大小是否超过限制。
func checkFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxDiffFileSize {
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
}
return nil
}
// downloadRemoteContent 下载远端文件内容并返回其文本。
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
var text string
var err error
if versionNum > 0 {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
} else {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
"fileId": fileID,
})
}
if err != nil {
return "", err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return "", err
}
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
if err != nil {
return "", err
}
return string(content), nil
}
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
// 包级变量以便测试注入。
var diffDownloadLimited = defaultDiffDownloadLimited
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: diffDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
// Content-Length 预检:可在读取 body 前提前拦截超大文件
if resp.ContentLength > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
}
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
}
return data, nil
}
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
if len(out) == 0 {
return "", 0, 0, 0, false
}
text := string(out)
added, deleted, hunks := 0, 0, 0
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
inHunk := false
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "@@") {
hunks++
inHunk = true
continue
}
if !inHunk {
continue
}
if strings.HasPrefix(line, "+") {
added++
} else if strings.HasPrefix(line, "-") {
deleted++
}
}
changed := added > 0 || deleted > 0
return text, added, deleted, hunks, changed
}
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
info := fetchFileInfo(ctx, nodeID)
switch info.extension {
case "", "md", "markdown":
return nil
case "adoc":
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
case "axls":
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
case "amind", "adraw":
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
default:
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
}
}
// newMarkdownDiffCmd 创建 markdown diff 子命令。
func newMarkdownDiffCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "diff",
Short: "比较 Markdown 内容差异",
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
模式:
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
remote_vs_remote: --version V1 (历史版本 vs 最新)
remote_vs_local: --file ./local.md (最新 vs 本地)
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
历史版本号通过 dws drive list --versions 获取。
--file 与 --version2 不能同时使用。
限制:
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
- 单侧文件大小上限: 10 MB
- 下载超时: 10 分钟
- diff 计算超时: 30 秒`,
Example: ` # 比较两个历史版本
dws markdown diff --node <dentryUuid> --version 3 --version2 5
# 历史版本 vs 最新版本
dws markdown diff --node <dentryUuid> --version 3
# 最新版本 vs 本地文件
dws markdown diff --node <dentryUuid> --file ./draft.md
# 历史版本 vs 本地文件
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
version1, _ := cmd.Flags().GetInt("version")
version2, _ := cmd.Flags().GetInt("version2")
localFile, _ := cmd.Flags().GetString("file")
contextLines, _ := cmd.Flags().GetInt("context")
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
if cmd.Flags().Changed("version") && version1 <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
}
if cmd.Flags().Changed("version2") && version2 <= 0 {
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
}
if contextLines < 0 {
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
}
// 互斥校验:--file 与 --version2 不能同时使用
if localFile != "" && version2 > 0 {
return fmt.Errorf("--file 与 --version2 不能同时使用")
}
// 模式判定
var mode string
if localFile != "" {
mode = "remote_vs_local"
} else {
mode = "remote_vs_remote"
}
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
if localFile == "" && version1 == 0 && version2 == 0 {
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
deps.Out.PrintKeyValue("模式", mode)
deps.Out.PrintKeyValue("节点ID", nodeID)
if version1 > 0 {
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
} else {
deps.Out.PrintKeyValue("左侧版本", "最新")
}
if localFile != "" {
deps.Out.PrintKeyValue("右侧", localFile)
} else if version2 > 0 {
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
} else {
deps.Out.PrintKeyValue("右侧版本", "最新")
}
return nil
}
// 下载超时 context(与项目其他下载命令一致:10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
defer cancel()
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
return err
}
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
if localFile != "" {
if err := checkFileSize(localFile); err != nil {
return err
}
}
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
isJSON := deps.Caller.Format() == "json"
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
// 下载左侧内容
progress("[1/3] 获取左侧内容...")
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
if err != nil {
return err
}
// 获取右侧内容
var rightContent string
if localFile != "" {
// remote_vs_local: 读取本地文件(大小已校验)
progress("[2/3] 读取本地文件...")
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("读取本地文件失败: %w", err)
}
rightContent = string(data)
} else {
// remote_vs_remote: 下载右侧远端内容
progress("[2/3] 获取右侧内容...")
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
if err != nil {
return err
}
}
// 计算 diff(带超时保护)
progress("[3/3] 计算差异...")
type diffOutput struct {
text string
added int
deleted int
hunks int
changed bool
}
resultCh := make(chan diffOutput, 1)
// Capture seams before the goroutine so test restorers cannot race
// against a still-running compute after the timeout path returns.
computeDiff := runMarkdownUnifiedDiff
marshalIndent := diffJSONMarshalIndent
go func() {
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
resultCh <- diffOutput{text, added, deleted, hunks, changed}
}()
select {
case res := <-resultCh:
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
result := diffResult{
Mode: mode,
Changed: changed,
AddedLines: added,
DeletedLines: deleted,
Hunks: hunks,
Diff: diffText,
}
// 输出
if isJSON {
data, err := marshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("JSON 序列化失败: %w", err)
}
deps.Out.PrintRaw(string(data))
} else {
deps.Out.PrintKeyValue("模式", result.Mode)
if result.Changed {
deps.Out.PrintKeyValue("是否有变更", "是")
} else {
deps.Out.PrintKeyValue("是否有变更", "否")
}
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
if result.Changed {
deps.Out.PrintRaw("")
deps.Out.PrintRaw(result.Diff)
}
}
case <-time.After(diffComputeTimeout):
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
}
return nil
},
}
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
// --node 隐藏别名(与 version/fetch 子命令一致)
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
_ = cmd.Flags().MarkHidden("url")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("node-id")
_ = cmd.Flags().MarkHidden("doc-id")
_ = cmd.Flags().MarkHidden("file-id")
RegisterCrossProductAliases(cmd)
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "markdown",
Name: "diff",
CanonicalPath: "markdown.diff",
CLIPath: "markdown diff",
PrimaryCLIPath: "markdown diff",
},
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
},
Selection: contract.SelectionSpec{
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "version", Property: "version", InterfaceType: "integer"},
{Name: "version2", Property: "version2", InterfaceType: "integer"},
{Name: "file", Property: "file"},
{Name: "context", Property: "context", InterfaceType: "integer"},
},
},
})
return cmd
}
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
// 探测失败返回零值,由调用方决定是否阻断。
type markdownFileInfo struct {
name string
extension string
}
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
return
}
data := resp
if result, ok := resp["result"].(map[string]any); ok {
data = result
}
if name, ok := data["name"].(string); ok {
info.name = name
}
if ext, ok := data["extension"].(string); ok {
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
}
return
}
func describeDingTalkDocType(ext string) string {
switch strings.ToLower(ext) {
case "adoc":
return "文档"
case "axls":
return "表格"
case "amind":
return "脑图"
case "adraw":
return "画图"
default:
return "文件"
}
}
+260 -10
View File
@@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"strconv"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "minutes",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
UseWhen: []string{
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
},
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
AvoidWhen: []string{
"要查看已有热词时改用 dws minutes hot-word list",
"要删除热词时改用 dws minutes hot-word delete",
},
Examples: []string{
"dws minutes hot-word add --words \"钉钉\"",
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户配置的所有听记热词列表。",
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
Examples: []string{"dws minutes hot-word list"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"要删除热词时改用 hot-word delete",
},
Examples: []string{"dws minutes hot-word list"},
},
},
})
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
hotWordDeleteCmd := &cobra.Command{
Use: "delete",
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
return err
}
return callMCPTool("delete_personal_hotword", map[string]any{
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
})
},
}
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "delete_personal_hotword",
CanonicalPath: "minutes.delete_personal_hotword",
CLIPath: "minutes hot-word delete",
PrimaryCLIPath: "minutes hot-word delete",
},
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "words", Property: "hotWordList"},
},
},
})
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
// ── replace-text 命令 ───────────────────────────────────────
replaceTextCmd := &cobra.Command{
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission。
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
// permission add — 对应 MCP 工具 add_member_permission
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
AvoidWhen: []string{
"要移除成员权限时改用 dws minutes permission remove",
"当前用户自己申请访问权限时改用 dws minutes permission apply",
"成员、权限策略或听记 id 未确认时不要添加",
},
Examples: []string{
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
AvoidWhen: []string{
"要添加权限时改用 permission add",
"当前用户自己申请访问权限时改用 permission apply",
"成员或听记 id 未确认时不要移除",
},
Examples: []string{
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
// permission apply — 对应 MCP 工具 apply_minutes_permission
permissionApplyCmd := &cobra.Command{
Use: "apply",
Short: "为当前用户申请听记权限",
Long: `为当前登录用户申请指定听记的权限。
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
权限类型 (--policy):
2 = 可编辑
3 = 可查看/下载
4 = 仅查看`,
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
dws minutes permission apply --id <taskUuid> --policy 2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
return callMCPTool("apply_minutes_permission", map[string]any{
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
"policyId": float64(policyID),
})
},
}
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "apply_minutes_permission",
CanonicalPath: "minutes.apply_minutes_permission",
CLIPath: "minutes permission apply",
PrimaryCLIPath: "minutes permission apply",
},
Description: "为当前登录用户申请指定听记的权限。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
AvoidWhen: []string{
"所有者批量给他人加权限时改用 permission add",
"要移除他人权限时改用 permission remove",
},
Examples: []string{
"dws minutes permission apply --id <taskUuid> --policy 4",
"dws minutes permission apply --id <taskUuid> --policy 2",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "taskUuid"},
{Name: "policy", Property: "policyId"},
},
},
})
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("url")
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
// ── tag 子组 ────────────────────────────────────────────────
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
// ── audio-memo 子组 ────────────────────────────
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoListCmd := &cobra.Command{
Use: "list",
Short: "查询语音备忘列表",
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
Example: ` dws minutes audio-memo list
dws minutes audio-memo list --max 500
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
dws minutes audio-memo list --cursor 1740000000000`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
max, _ := cmd.Flags().GetFloat64("max")
if max <= 0 || max > 1000 {
return fmt.Errorf("flag --max must be between 1 and 1000")
}
toolArgs["pageSize"] = max
if cmd.Flags().Changed("cursor") {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return fmt.Errorf("flag --cursor must be >= 0")
}
toolArgs["cursor"] = float64(cursor)
}
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
loc := time.FixedZone("Asia/Shanghai", 8*3600)
var startMs, endMs int64
if startStr != "" {
var err error
startMs, err = parseISOTimeToMillis("start", startStr)
if err != nil {
return err
}
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
}
if endStr != "" {
var err error
endMs, err = parseISOTimeToMillis("end", endStr)
if err != nil {
return err
}
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
}
if startStr != "" && endStr != "" {
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
}
return callMCPToolUnescaped("list_audio_memos", toolArgs)
},
}
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "list_audio_memos",
CanonicalPath: "minutes.list_audio_memos",
CLIPath: "minutes audio-memo list",
PrimaryCLIPath: "minutes audio-memo list",
},
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
AvoidWhen: []string{
"要查听记列表改用 minutes list",
"只要某篇听记的音频地址改用 minutes get audio",
},
Examples: []string{
"dws minutes audio-memo list",
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "max", Property: "pageSize"},
{Name: "cursor", Property: "cursor"},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
},
},
})
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}
+267 -1
View File
@@ -1,21 +1,55 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
var request map[string]any
if err := dec.Decode(&request); err != nil || request == nil {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("JSON 请求不能为 null")
}
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("JSON 请求包含多余内容")
}
return request, nil
}
func oaFormValues(raw string) ([]map[string]string, error) {
var values map[string]string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
result := make([]map[string]string, 0, len(values))
for name, value := range values {
result = append(result, map[string]string{"name": name, "value": value})
}
return result, nil
}
// ──────────────────────────────────────────────────────────
// dws oa — OA 审批
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
// approve_processInstance, reject_processInstance, revoke_processInstance,
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
// get_inst_revert_activities
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
@@ -1017,6 +1051,195 @@ func newOaCommand() *cobra.Command {
},
}
approvalFormSchemaCmd := &cobra.Command{
Use: "form-schema", Short: "查询审批模板的表单 Schema",
Example: "dws oa approval form-schema --process-code <processCode>",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "process-code"); err != nil {
return err
}
return callMCPTool("get_process_schema", map[string]any{"processCode": mustGetFlag(cmd, "process-code")})
},
}
DeclareLeafMetadata(approvalFormSchemaCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "get_process_schema",
CanonicalPath: "oa.get_process_schema",
CLIPath: "oa approval form-schema",
PrimaryCLIPath: "oa approval form-schema",
},
Description: "查询审批模板的表单 Schema",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_schema"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询审批模板的表单 Schema",
UseWhen: []string{"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"},
AvoidWhen: []string{"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"},
Examples: []string{"dws oa approval form-schema --process-code <processCode>"},
},
Parameters: []contract.ParamDecl{
{Name: "process-code", Property: "processCode"},
},
},
})
approvalForecastCmd := &cobra.Command{
Use: "forecast-process", Short: "根据表单值预测审批流程与自选节点",
Example: "dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
RunE: func(cmd *cobra.Command, args []string) error {
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
request, err := decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": request})
}
if err := validateRequiredFlags(cmd, "process-code", "dept-id", "form-values"); err != nil {
return err
}
deptID, err := strconv.ParseInt(mustGetFlag(cmd, "dept-id"), 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "deptId": deptID, "formComponentValues": [][]map[string]string{values}}})
},
}
DeclareLeafMetadata(approvalForecastCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "forecast_process",
CanonicalPath: "oa.forecast_process",
CLIPath: "oa approval forecast-process",
PrimaryCLIPath: "oa approval forecast-process",
},
Description: "根据表单值预测审批流程与自选节点",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "forecast_process"},
},
Selection: contract.SelectionSpec{
AgentSummary: "预测审批流程与自选审批节点",
UseWhen: []string{"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"},
AvoidWhen: []string{"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"},
Examples: []string{
"dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessForecastPopRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessForecastPopRequest", InterfaceType: "object"},
},
},
})
approvalCreateCmd := &cobra.Command{
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
RunE: func(cmd *cobra.Command, args []string) error {
if !commandDryRun(cmd) {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
return fmt.Errorf("发起审批实例会创建真实业务数据;请先核对参数,然后添加 --yes 确认执行")
}
}
var request map[string]any
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
var err error
request, err = decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
} else {
if err := validateRequiredFlags(cmd, "process-code", "form-values"); err != nil {
return err
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
request = map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "formComponentValues": values}
if dept, _ := cmd.Flags().GetString("dept-id"); dept != "" {
value, err := strconv.ParseInt(dept, 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
request["deptId"] = value
}
if userID, _ := cmd.Flags().GetString("originator-user-id"); userID != "" {
request["originatorUserId"] = userID
}
if rawApprovers, _ := cmd.Flags().GetString("approvers"); rawApprovers != "" {
action, _ := cmd.Flags().GetString("approvers-action-type")
if action != "AND" && action != "OR" && action != "NONE" {
return fmt.Errorf("--approvers-action-type 必须为 AND、OR 或 NONE")
}
request["approvers"] = []map[string]any{{"actionType": action, "userIds": strings.Split(rawApprovers, ",")}}
}
if rawCC, _ := cmd.Flags().GetString("cc-list"); rawCC != "" {
position, _ := cmd.Flags().GetString("cc-position")
if position != "START" && position != "FINISH" && position != "START_FINISH" {
return fmt.Errorf("--cc-position 必须为 START、FINISH 或 START_FINISH")
}
request["ccList"] = strings.Split(rawCC, ",")
request["ccPosition"] = position
}
}
return callMCPTool("start_process_instance", map[string]any{"ProcessInstanceCreationPopRequest": request})
},
}
DeclareLeafMetadata(approvalCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "start_process_instance",
CanonicalPath: "oa.start_process_instance",
CLIPath: "oa approval create-instance",
PrimaryCLIPath: "oa approval create-instance",
},
Description: "发起审批实例(需要 --yes 确认)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "start_process_instance"},
},
Selection: contract.SelectionSpec{
AgentSummary: "发起新的审批实例",
UseWhen: []string{"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"},
AvoidWhen: []string{"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"},
Examples: []string{
"dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceCreationPopRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessInstanceCreationPopRequest", InterfaceType: "object"},
},
},
})
approvalListPendingCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
approvalListPendingCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
approvalListPendingCmd.Flags().String("page", "", "分页页码 (可选)")
@@ -1087,6 +1310,46 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd.Flags().String("target-activity-id", "", "退回到的节点 ID(退回发起人固定传 sid-startevent)(必填)")
approvalRevertTaskCmd.Flags().String("action", "", "退回方式:REVERT_FOR_APPROVAL(退回到审批人)/ REVERT_FOR_RESUBMIT(退回到发起人)(必填)")
approvalRevertTaskCmd.Flags().String("remark", "", "退回说明 (可选)")
approvalFormSchemaCmd.Flags().String("process-code", "", "审批模板 processCode (必填)")
approvalForecastCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("dept-id", "", "发起人部门 ID(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalForecastCmd.MarkFlagsOneRequired("request", "process-code")
approvalForecastCmd.MarkFlagsRequiredTogether("process-code", "dept-id", "form-values")
forecastMutuallyExclusive := make([][]string, 0, 3)
for _, name := range []string{"process-code", "dept-id", "form-values"} {
approvalForecastCmd.MarkFlagsMutuallyExclusive("request", name)
forecastMutuallyExclusive = append(forecastMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalForecastCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: forecastMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
})
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalCreateCmd.Flags().String("originator-user-id", "", "审批发起人 userId")
approvalCreateCmd.Flags().String("approvers", "", "审批人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("approvers-action-type", "OR", "审批类型:AND、OR 或 NONE")
approvalCreateCmd.Flags().String("cc-list", "", "抄送人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("cc-position", "START", "抄送时点:START、FINISH 或 START_FINISH")
approvalCreateCmd.MarkFlagsOneRequired("request", "process-code")
approvalCreateCmd.MarkFlagsRequiredTogether("process-code", "form-values")
createSimpleFlags := []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"}
createMutuallyExclusive := make([][]string, 0, len(createSimpleFlags))
for _, name := range createSimpleFlags {
approvalCreateCmd.MarkFlagsMutuallyExclusive("request", name)
createMutuallyExclusive = append(createMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalCreateCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: createMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "form-values"}},
})
approvalCmd.AddCommand(
approvalListPendingCmd,
@@ -1109,6 +1372,9 @@ func newOaCommand() *cobra.Command {
approvalAppendTaskCmd,
approvalRevertActivitiesCmd,
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
approvalCreateCmd,
)
root.AddCommand(approvalCmd)
+169 -1
View File
@@ -1,6 +1,34 @@
package helpers
import "testing"
import (
"io"
"os"
"strings"
"testing"
)
func executeOACommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
previous := deps
previousArgs := os.Args
os.Args = []string{"dws", "oa"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = previous
os.Args = previousArgs
})
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
@@ -31,3 +59,143 @@ func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
t.Fatalf("revert task: %v", err)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceMapsInternalSimpleOptions(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--process-code", "PROC",
"--form-values", `{"事由":"测试"}`,
"--originator-user-id", "originator",
"--approvers", "approver-1,approver-2",
"--approvers-action-type", "AND",
"--cc-list", "cc-1,cc-2",
"--cc-position", "FINISH",
"--yes",
)
if err != nil {
t.Fatalf("create instance: %v", err)
}
if caller.server != "oa" || caller.tool != "start_process_instance" {
t.Fatalf("called %s/%s, want oa/start_process_instance", caller.server, caller.tool)
}
request, ok := caller.args["ProcessInstanceCreationPopRequest"].(map[string]any)
if !ok {
t.Fatalf("request payload = %#v", caller.args)
}
if got := request["originatorUserId"]; got != "originator" {
t.Fatalf("originatorUserId = %#v", got)
}
approvers, ok := request["approvers"].([]map[string]any)
if !ok || len(approvers) != 1 || approvers[0]["actionType"] != "AND" {
t.Fatalf("approvers = %#v", request["approvers"])
}
if got := approvers[0]["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "approver-1" || got.([]string)[1] != "approver-2" {
t.Fatalf("approver userIds = %#v", got)
}
if got := request["ccList"]; len(got.([]string)) != 2 || got.([]string)[0] != "cc-1" || got.([]string)[1] != "cc-2" {
t.Fatalf("ccList = %#v", got)
}
if got := request["ccPosition"]; got != "FINISH" {
t.Fatalf("ccPosition = %#v", got)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRejectsMixedRequestModes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
"--process-code", "PROC",
"--yes",
)
if err == nil {
t.Fatal("mixed request modes returned nil")
}
if caller.calls != 0 {
t.Fatalf("unexpected MCP call count: %d", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
)
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("create instance without --yes error = %v, want explicit --yes requirement", err)
}
if caller.calls != 0 {
t.Fatalf("create instance without --yes made %d MCP calls", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
validCases := []struct {
name string
args []string
tool string
}{
{
name: "form schema",
args: []string{"approval", "form-schema", "--process-code", "PROC"},
tool: "get_process_schema",
},
{
name: "forecast simple mode",
args: []string{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", `{"金额":"100"}`},
tool: "forecast_process",
},
{
name: "forecast request mode",
args: []string{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`},
tool: "forecast_process",
},
{
name: "create request mode",
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
tool: "start_process_instance",
},
}
for _, tc := range validCases {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, tc.args...); err != nil {
t.Fatalf("execute %v: %v", tc.args, err)
}
if caller.tool != tc.tool || caller.calls != 1 {
t.Fatalf("called tool=%q calls=%d, want %q once", caller.tool, caller.calls, tc.tool)
}
})
}
invalidCases := [][]string{
{"approval", "form-schema"},
{"approval", "forecast-process"},
{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
{"approval", "forecast-process", "--request", "{"},
{"approval", "forecast-process", "--request", "null"},
{"approval", "forecast-process", "--request", "{} {}"},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "bad", "--form-values", `{"金额":"100"}`},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", "["},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`},
{"approval", "create-instance", "--yes"},
{"approval", "create-instance", "--request", "{", "--yes"},
{"approval", "create-instance", "--request", "null", "--yes"},
{"approval", "create-instance", "--request", "{} {}", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", "[", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
}
for _, args := range invalidCases {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, args...); err == nil {
t.Fatalf("invalid args %v returned nil", args)
}
if caller.calls != 0 {
t.Fatalf("invalid args %v made %d MCP calls", args, caller.calls)
}
}
}
@@ -24,10 +24,16 @@ type scriptedToolCaller struct {
format string
dry bool
calls int
server string
tool string
args map[string]any
}
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls++
c.server = serverID
c.tool = toolName
c.args = args
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
@@ -0,0 +1,709 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeMarkdownDiff(t *testing.T, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "markdown"}, args...)
root := newMarkdownCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
t.Run("oversized local file", func(t *testing.T) {
testseam.Swap(t, &maxDiffFileSize, int64(8))
big := filepath.Join(t.TempDir(), "big.md")
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(big); err == nil {
t.Fatal("expected oversized local file")
}
})
t.Run("download helpers and ensure type", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("left\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
t.Fatalf("download latest: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
t.Fatalf("download version: %v", err)
}
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return nil, errors.New("dl boom")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
t.Fatal("expected download limited error")
}
for _, tc := range []struct {
ext string
ok bool
}{
{"md", true}, {"markdown", true}, {"", true},
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
} {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
}})
err := ensureMarkdownDiffType(context.Background(), "n1")
if tc.ok && err != nil {
t.Fatalf("ext %q: %v", tc.ext, err)
}
if !tc.ok && err == nil {
t.Fatalf("ext %q: expected type error", tc.ext)
}
}
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
if describeDingTalkDocType(ext) == "" {
t.Fatalf("describe %q empty", ext)
}
}
})
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("data"))
}))
t.Cleanup(okSrv.Close)
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
if err != nil || string(got) != "data" {
t.Fatalf("ok download: %v %q", err, got)
}
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("nope"))
}))
t.Cleanup(badStatus.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
t.Fatal("expected non-200")
}
testseam.Swap(t, &maxDiffFileSize, int64(3))
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("xxxx"))
}))
t.Cleanup(tooBigHeader.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
t.Fatal("expected content-length guard")
}
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// No Content-Length: force LimitReader path to observe oversize body.
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "no hijack", 500)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
payload := strings.Repeat("x", 16)
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
_ = bufrw.Flush()
}))
t.Cleanup(chunkedBig.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
t.Fatal("expected body size guard")
}
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
_ = bufrw.Flush()
_ = conn.Close() // truncate body → ReadAll error
}))
t.Cleanup(readFail.Close)
maxDiffFileSize = 1000
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
t.Fatal("expected read error")
}
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
t.Fatal("expected bad url")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
t.Fatal("expected canceled ctx")
}
})
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
}
})
t.Run("validation and dry-run modes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeMarkdownDiff(t, "diff"); err == nil {
t.Fatal("expected missing node")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
t.Fatal("expected version>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
t.Fatal("expected version2>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
t.Fatal("expected context>=0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
t.Fatal("expected file/version2 mutex")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
t.Fatal("expected remote needs version")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
t.Fatalf("dry-run remote: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local latest: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local version: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
t.Fatalf("dry-run version2-only: %v", err)
}
})
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("alpha\n"), nil
}
local := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
t.Fatal(err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
t.Fatalf("local json: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
if strings.Contains(url, "/r") {
return []byte("alpha\n"), nil
}
return []byte("gamma\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text changed: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("same\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text unchanged: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"adoc"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
t.Fatal("expected type guard")
}
testseam.Protect(t, &maxDiffFileSize)
maxDiffFileSize = 2
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
t.Fatal("expected local size fail")
}
maxDiffFileSize = 10 * 1024 * 1024
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
missingLocal := filepath.Join(t.TempDir(), "missing.md")
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
// Create file for size check, then make ReadFile fail via directory path.
dirAsFile := t.TempDir()
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
t.Fatal("expected read local dir failure")
}
_ = missingLocal
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{err: errors.New("right fail")},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
// second download uses version tool — make parse fail on second call via empty resource
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
t.Fatal("expected right download parse fail")
}
})
t.Run("json marshal and compute timeout", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
testseam.Protect(t, &diffJSONMarshalIndent)
testseam.Protect(t, &runMarkdownUnifiedDiff)
testseam.Protect(t, &diffComputeTimeout)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("a\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
return nil, errors.New("marshal boom")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
t.Fatalf("expected marshal err, got %v", err)
}
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
time.Sleep(50 * time.Millisecond)
return "", 0, 0, 0, false
}
diffComputeTimeout = time.Millisecond
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("expected timeout, got %v", err)
}
})
t.Run("fetchFileInfo extension field", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":".MD","name":"x.md"}`},
}})
info := fetchFileInfo(context.Background(), "n")
if info.extension != "md" || info.name != "x.md" {
t.Fatalf("info=%+v", info)
}
})
}
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
t.Run("message export dry-run and execute", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
t.Fatalf("export dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export dry-run default name: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export: %v", err)
}
if _, err := os.Stat("Hello_World.eml"); err != nil {
t.Fatalf("missing eml: %v", err)
}
// exist without overwrite
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"x"}`},
}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
t.Fatal("expected exist error")
}
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"y"}`},
}}
installScriptedCaller(t, caller3)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
t.Fatalf("overwrite: %v", err)
}
// subject fallback to message id when missing
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{}}`},
{text: `{"emlContent":"z"}`},
}}
installScriptedCaller(t, caller4)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
t.Fatalf("fallback name: %v", err)
}
})
t.Run("share-to-chat paths", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
t.Fatalf("share dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("share dry-run no users: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
t.Fatal("expected parse error")
}
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
// so skipConfirm=false and the server-sign reconfirm branch runs.
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected server reconfirm, got %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig"}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig","riskMessage":"careful"}`},
{text: `{"ok":true}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("share with yes: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("json success path: %v", err)
}
})
t.Run("calendar-event missing folder id", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "u@c.com",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
); err == nil {
t.Fatal("expected missing id/folder-id")
}
})
t.Run("sanitize and atomic write", func(t *testing.T) {
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
t.Fatalf("sanitize=%q", got)
}
if got := sanitizeMailFilename(" "); got != "mail" {
t.Fatalf("empty sanitize=%q", got)
}
dir := t.TempDir()
path := filepath.Join(dir, "out.eml")
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
t.Fatal("expected exist without overwrite")
}
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
t.Fatal("expected create temp fail")
}
testseam.Protect(t, &atomicCreateTemp)
testseam.Protect(t, &atomicRemove)
testseam.Protect(t, &atomicRename)
atomicRemove = func(string) error { return nil }
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("chmod: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
t.Fatalf("write: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
t.Fatalf("sync: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
t.Fatalf("close: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{}, nil
}
atomicRename = func(string, string) error { return errors.New("rename boom") }
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
t.Fatalf("rename: %v", err)
}
})
t.Run("export save non-exist failure", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
return nil, errors.New("nospc")
})
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"body"}`},
}})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
t.Fatalf("expected save failure, got %v", err)
}
})
}
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetIn(in)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
// Expand matches backward + negative context clamp + missing newline marker.
old := []byte("a\nb\nc\nd\ne")
neu := []byte("a\nb\nC\nd\ne\n")
diff := UnifiedDiff("o", old, "n", neu, -3)
if len(diff) == 0 {
t.Fatal("expected diff with negative context")
}
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
t.Fatalf("unexpected diff2=%q", diff2)
}
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
// Non-unique "common" lines before a unique anchor → backward expand (L90).
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
t.Fatalf("nonNeg")
}
}
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
items := []map[string]any{
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
{"nodeType": "Folder", "name": "folder"},
}
got := applyDriveListLatest(items, 10)
if len(got) != 3 {
t.Fatalf("len=%d", len(got))
}
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
t.Fatal(err)
}
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
t.Fatalf("json.Number millis=%v %v", ms, ok)
}
if _, ok := toMillis(json.Number("-1")); ok {
t.Fatal("negative json.Number")
}
if _, ok := toMillis(struct{}{}); ok {
t.Fatal("unknown type")
}
// pagination + quiet=false progress + shortfall hint with pattern
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller)
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "drive", "list"}
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
t.Fatalf("latest paginate: %v", err)
}
// nil context uses Background
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller2)
cmd2 := &cobra.Command{Use: "list"}
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
t.Fatalf("nil ctx: %v", err)
}
}
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
t.Fatal("expected numeric folder rejection")
}
}
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
useDriveDepthArgs(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","modifiedTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
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)
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
}
_ = out
}
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
if buildWhiteboardCardJSONML("b", "w") != "" {
t.Fatal("expected empty on marshal fail")
}
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
return "", errors.New("bad template")
})
installScriptedCaller(t, &scriptedToolCaller{})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
t.Fatalf("expected prepare fail, got %v", err)
}
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "doc", "whiteboard"}
// nil entry in blocks list
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
t.Fatalf("nil entry skip: %v", err)
}
}
@@ -0,0 +1,488 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"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"
)
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", root.Name()}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Run("hot-word delete dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
t.Fatalf("hot-word delete dry-run: %v", err)
}
})
t.Run("hot-word delete missing words", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
t.Fatal("expected missing --words error")
}
})
t.Run("hot-word delete executes", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
t.Fatalf("hot-word delete: %v", err)
}
if caller.tool != "delete_personal_hotword" {
t.Fatalf("tool=%q", caller.tool)
}
})
t.Run("permission apply dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
t.Fatalf("permission apply dry-run: %v", err)
}
})
t.Run("permission apply alias uuid", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
t.Fatalf("permission apply alias: %v", err)
}
if caller.tool != "apply_minutes_permission" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
t.Fatalf("args=%#v", caller.args)
}
})
t.Run("permission apply invalid policy", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
t.Fatal("expected invalid policy")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
t.Fatal("expected non-numeric policy")
}
})
t.Run("permission apply missing flags", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
t.Fatal("expected missing id")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
t.Fatal("expected missing policy")
}
})
t.Run("audio-memo list default", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
t.Fatalf("audio-memo list: %v", err)
}
if caller.tool != "list_audio_memos" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != float64(200) {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
})
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
err := executePR868Command(t, newMinutesCommand(),
"audio-memo", "list",
"--max", "10",
"--cursor", "1740000000000",
"--start", "2026-01-01T00:00:00+08:00",
"--end", "2026-07-21T23:59:59+08:00",
)
if err != nil {
t.Fatalf("audio-memo list ranged: %v", err)
}
if caller.args["cursor"] != float64(1740000000000) {
t.Fatalf("cursor=%#v", caller.args["cursor"])
}
start, _ := caller.args["startTime"].(string)
end, _ := caller.args["endTime"].(string)
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
t.Fatalf("start/end=%q/%q", start, end)
}
})
t.Run("audio-memo list validation", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
t.Fatal("expected max validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
t.Fatal("expected max upper bound")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
t.Fatal("expected cursor validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
t.Fatal("expected reversed range error")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
t.Fatal("expected bad start")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
t.Fatal("expected bad end")
}
})
}
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
// Existing primary --job-id must remain usable.
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
t.Fatalf("export get --job-id: %v", err)
}
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
}
// Add-only synonym --task-id.
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
t.Fatalf("export get --task-id: %v", err)
}
if caller2.args["jobId"] != "job-123" {
t.Fatalf("task-id args=%#v", caller2.args)
}
}
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
t.Run("permission list max-results", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.args["maxResults"] != 10 {
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
}
})
t.Run("cover file-id alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
t.Fatalf("cover --file-id: %v", err)
}
})
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
t.Fatalf("revert --doc-id: %v", err)
}
})
t.Run("star add url alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
t.Fatalf("star add --url: %v", err)
}
})
t.Run("download --version routes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
out := filepath.Join(t.TempDir(), "out.pdf")
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
t.Fatalf("download --version: %v", err)
}
})
}
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "a@b.com",
"--folder-id", "cal-1",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
)
if err != nil {
t.Fatalf("calendar-event list --folder-id: %v", err)
}
}
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
t.Fatalf("identical should be nil, got %q", got)
}
old := []byte("one\ntwo\nthree\n")
neu := []byte("one\nTWO\nthree\nfour\n")
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
t.Fatalf("expected hunk diff, got %q", diff)
}
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
}
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().Int("latest", 0, "")
cmd.Flags().String("order-by", "", "")
cmd.Flags().String("order", "", "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("max", 0, "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("next-token", "", "")
if err := validateDriveListLatest(cmd, 0); err == nil {
t.Fatal("expected latest lower bound")
}
if err := validateDriveListLatest(cmd, 51); err == nil {
t.Fatal("expected latest upper bound")
}
_ = cmd.ParseFlags([]string{"--order-by=name"})
if err := validateDriveListLatest(cmd, 3); err == nil {
t.Fatal("expected exclusive order-by")
}
cmd2 := &cobra.Command{Use: "list"}
cmd2.Flags().Int("latest", 0, "")
cmd2.Flags().String("order-by", "", "")
cmd2.Flags().String("order", "", "")
cmd2.Flags().Int("limit", 0, "")
cmd2.Flags().Int("max", 0, "")
cmd2.Flags().String("cursor", "", "")
cmd2.Flags().String("next-token", "", "")
_ = cmd2.ParseFlags([]string{"--limit=10"})
if err := validateDriveListLatest(cmd2, 3); err == nil {
t.Fatal("expected exclusive limit")
}
cmd3 := &cobra.Command{Use: "list"}
cmd3.Flags().Int("latest", 0, "")
cmd3.Flags().String("order-by", "", "")
cmd3.Flags().String("order", "", "")
cmd3.Flags().Int("limit", 0, "")
cmd3.Flags().Int("max", 0, "")
cmd3.Flags().String("cursor", "", "")
cmd3.Flags().String("next-token", "", "")
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
if err := validateDriveListLatest(cmd3, 3); err == nil {
t.Fatal("expected exclusive cursor")
}
if err := validateDriveListLatest(cmd3, 3); err == nil {
// already failed above
}
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
items := []map[string]any{
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
{"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)
if len(got) != 1 {
t.Fatalf("latest len=%d", len(got))
}
stripDriveDepthDecorations(got)
if _, ok := got[0]["sortTime"]; ok {
t.Fatal("sortTime should be stripped")
}
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
t.Fatalf("float millis=%v %v", ms, ok)
}
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
}
if _, ok := toMillis(""); ok {
t.Fatal("empty string should fail")
}
if _, ok := toMillis(float64(-1)); ok {
t.Fatal("negative float should fail")
}
if ms, ok := toMillis("42"); !ok || ms != 42 {
t.Fatalf("int string=%v %v", ms, ok)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
listCmd := &cobra.Command{Use: "list"}
listCmd.SetContext(context.Background())
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
t.Fatalf("dry-run latest: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
}}}
installScriptedCaller(t, caller)
oldArgs := os.Args
os.Args = []string{"dws", "drive", "list"}
t.Cleanup(func() { os.Args = oldArgs })
listCmd2 := &cobra.Command{Use: "list"}
listCmd2.SetContext(context.Background())
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
t.Fatalf("latest scan: %v", err)
}
}
func executeWhiteboardCommand(t *testing.T, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root := newDocWhiteboardCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
t.Run("dry-run plan", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
t.Fatalf("whiteboard insert dry-run: %v", err)
}
})
t.Run("insert with placement and verify", func(t *testing.T) {
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t,
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
t.Fatalf("whiteboard insert: %v", err)
}
})
t.Run("soft success when verify empty", func(t *testing.T) {
// Empty blocks are eventual-consistency pending, not hard query failure.
caller := &pr868FlexibleCaller{emptyVerify: true}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
t.Fatalf("pending-block soft success: %v", err)
}
})
t.Run("helpers", func(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
t.Cleanup(func() { os.Args = oldArgs })
if buildWhiteboardCardJSONML("b", "w") == "" {
t.Fatal("empty jsonml")
}
if extractWhiteboardID(nil) != "" {
t.Fatal("nil attrs")
}
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
t.Fatal("extract id")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected query error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing block")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected jsonml parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing attrs")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected nil attrs")
}
})
}
type pr868FlexibleCaller struct {
whiteboardID string
emptyVerify bool
format string
dry bool
}
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
if tool == "insert_document_block" {
return textToolResult(`{}`), nil
}
if tool == "list_document_blocks" {
if c.emptyVerify {
return textToolResult(`{"blocks":[]}`), nil
}
blockID, _ := args["blockId"].(string)
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
return textToolResult(payload), nil
}
return textToolResult(`{}`), nil
}
func (c *pr868FlexibleCaller) Format() string { return c.format }
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
func (*pr868FlexibleCaller) Fields() string { return "" }
func (*pr868FlexibleCaller) JQ() string { return "" }
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
t.Fatal("formatFileSize")
}
small := filepath.Join(t.TempDir(), "ok.md")
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(small); err != nil {
t.Fatalf("checkFileSize small: %v", err)
}
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("expected missing file")
}
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
if !changed || add < 1 || hunks < 1 || diff == "" {
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
}
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
t.Fatal("identical should be unchanged")
}
right := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
t.Fatal(err)
}
if describeDingTalkDocType("adoc") == "" {
t.Fatal("describeDingTalkDocType")
}
_ = right
}
@@ -141,18 +141,23 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
previousStdin := os.Stdin
previousPut := httpPutFile
previousGet := httpGetFile
previousWhiteboardSleep := whiteboardSleep
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
os.Stdin = previousStdin
httpPutFile = previousPut
httpGetFile = previousGet
whiteboardSleep = previousWhiteboardSleep
})
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
// Product examples execute real RunE paths; whiteboard insert retries must
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
whiteboardSleep = func(time.Duration) {}
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
if destPath == "" {
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
})
}
@@ -271,6 +271,10 @@ func TestCrossPlatformCoverageProtectSheetMutationCommandPanics(t *testing.T) {
func TestSheetMutationGuardRejectsPipedYesEvenWithContractConfirmSafety(t *testing.T) {
// Sheet agent hardening: outer --yes-only gate must win over ConfirmSafety
// honoring piped stdin yes (review: delete-sheet / range clear / version revert).
// This fixture exercises the no-caller fallback. Isolate it from commands
// built by earlier tests, which may initialize the package-level deps.
testseam.Protect(t, &deps)
deps = nil
ran := false
cmd := &cobra.Command{
Use: "delete-sheet",
+352
View File
@@ -0,0 +1,352 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardServerID = "whiteboard"
whiteboardQueryTool = "read_whiteboard_content"
whiteboardUpdateTool = "update_whiteboard"
)
type whiteboardUpdateFile struct {
Overwrite bool `json:"overwrite"`
Source *whiteboardOpenSource `json:"source"`
}
type whiteboardOpenSource struct {
SchemaVersion string `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Nodes json.RawMessage `json:"nodes"`
}
var compactWhiteboardJSON = json.Compact
func newWhiteboardCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "whiteboard",
Selection: contract.ProductSelectionDecl{
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
queryCmd := &cobra.Command{
Use: "query",
Short: "读取白板内容",
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
return err
}
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
})
},
}
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
DeclareLeafMetadata(queryCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "query",
CanonicalPath: "whiteboard.query",
CLIPath: "whiteboard query",
PrimaryCLIPath: "whiteboard query",
},
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
},
},
})
updateCmd := &cobra.Command{
Use: "update",
Short: "追加或整页重建白板内容",
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
return err
}
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
if err != nil {
return err
}
mode := "append"
if input.Overwrite {
mode = "overwrite"
}
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
"mode": mode,
"nodes": nodesJSON,
})
},
}
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
updateExampleIndex := 0
DeclareLeafMetadata(updateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "update",
CanonicalPath: "whiteboard.update",
CLIPath: "whiteboard update",
PrimaryCLIPath: "whiteboard update",
},
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
ExampleDispositions: []contract.ExampleDisposition{{
Index: &updateExampleIndex,
Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonLocalState,
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
Reviewed: true,
}},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
{Name: "source", Required: boolPtr(true)},
},
},
})
root.AddCommand(queryCmd, updateCmd)
return root
}
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
for _, name := range []string{"jq", "fields"} {
flag := cmd.Flags().Lookup(name)
if flag == nil {
flag = cmd.InheritedFlags().Lookup(name)
}
if flag != nil && flag.Changed {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
Suggestion: "直接读取命令返回的结构化 JSON",
}
}
}
return nil
}
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
data, err := os.ReadFile(path)
if err != nil {
code := CodeInvalidPath
if os.IsNotExist(err) {
code = CodeFileNotFound
}
return nil, "", &CLIError{
Code: code,
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
Cause: err,
}
}
var input whiteboardUpdateFile
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if input.Source == nil {
return nil, "", invalidWhiteboardSourceParam("source is required")
}
if input.Source.SchemaVersion != "1.0" {
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
}
if input.Source.CatalogVersion != "dml-v1" {
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
}
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
if err != nil {
return nil, "", err
}
if !input.Overwrite && nodeCount == 0 {
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
}
return &input, nodesJSON, nil
}
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
} else if !errors.Is(err, io.EOF) {
return err
}
return nil
}
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
}
var nodes []json.RawMessage
if err := json.Unmarshal(raw, &nodes); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
for i, node := range nodes {
var object map[string]any
if err := json.Unmarshal(node, &object); err != nil || object == nil {
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
}
}
var compact bytes.Buffer
if err := compactWhiteboardJSON(&compact, raw); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
return compact.String(), len(nodes), nil
}
func invalidWhiteboardSourceJSON(err error) error {
return &CLIError{
Code: CodeInvalidJSON,
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
Cause: err,
}
}
func invalidWhiteboardSourceParam(message string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: message,
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
}
}
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer(whiteboardServerID, toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
if err != nil {
return err
}
if text == "" {
return nil
}
var response map[string]any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&response); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if response == nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
}
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
var result any
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
resultDecoder.UseNumber()
if err := resultDecoder.Decode(&result); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
response["resultJson"] = result
}
return deps.Out.PrintJSON(response)
}
func invalidWhiteboardToolResult(toolName string, err error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: "白板服务返回了无法解析的 JSON",
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
Operation: whiteboardServerID + "/" + toolName,
Cause: err,
}
}
@@ -0,0 +1,310 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
previousMarshal := whiteboardJSONMarshal
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
t.Fatalf("got %q", got)
}
whiteboardJSONMarshal = previousMarshal
previousPrepare := prepareWhiteboardCard
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
caller := &whiteboardTestCaller{}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
t.Fatalf("err=%v", err)
}
previousCompact := compactWhiteboardJSON
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
t.Fatal("expected compact error")
}
}
func TestDocWhiteboardInsertDryRun(t *testing.T) {
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
}
func writeWhiteboardFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
tests := []struct {
name string
content string
}{
{name: "invalid json", content: `{`},
{name: "trailing value", content: `{}` + ` {}`},
{name: "missing source", content: `{}`},
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "unknown field", content: `{"unknown":true}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing-file error")
}
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
t.Fatal("expected directory read error")
}
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
t.Fatal("expected malformed nodes array error")
}
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
if err != nil || !input.Overwrite || nodes != "[]" {
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
}
}
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
for _, name := range []string{"jq", "fields"} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(name, "", "")
if err := cmd.Flags().Set(name, ".result"); err != nil {
t.Fatal(err)
}
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
t.Fatal("expected rejected output filter")
}
})
}
responses := []string{
`{`,
`{} {}`,
`null`,
`{"resultJson":"{"}`,
`{"resultJson":"{} {}"}`,
}
for _, response := range responses {
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
t.Fatalf("response %q should fail", response)
}
}
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
t.Fatal(err)
}
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
t.Fatal(err)
}
}
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
tests := []struct {
name string
response string
attrs bool
}{
{name: "invalid response", response: `{`},
{name: "missing block", response: `{"blocks":[]}`},
{name: "non object block", response: `{"blocks":[1]}`},
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
installWhiteboardTestCaller(t, caller)
var err error
if test.attrs {
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
} else {
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
}
if err == nil {
t.Fatal("expected query validation error")
}
})
}
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
installWhiteboardTestCaller(t, caller)
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected caller error")
}
}
func TestWhiteboardCommandValidationBranches(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
for _, args := range [][]string{
{"query", "--node", "n"},
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
{"update", "--node", "n", "--part-id", "p"},
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
} {
cmd := newWhiteboardCommand()
cmd.PersistentFlags().String("jq", "", "")
cmd.PersistentFlags().String("fields", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["mode"] != "overwrite" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
if err := cmd.Execute(); err == nil {
t.Fatal("expected source error")
}
}
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
}}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
file := writeWhiteboardFixture(t, "svg")
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
for _, args := range [][]string{
{"media", "upload", "--node", "n"},
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
{"media", "upload", "--node", "n", "--file", t.TempDir()},
} {
cmd = newDocCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestDocMediaUploadRemainingBranches(t *testing.T) {
file := writeWhiteboardFixture(t, "svg")
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *whiteboardTestCaller
response string
}{
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
}}},
} {
t.Run(test.name, func(t *testing.T) {
installWhiteboardTestCaller(t, test.caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected upload error")
}
})
}
}
func TestDocWhiteboardInsertCallerError(t *testing.T) {
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
if index == 0 {
return errors.New("insert")
}
return nil
}}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected insert error")
}
}
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
if got := extractWhiteboardID(nil); got != "" {
t.Fatalf("got %q", got)
}
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
t.Fatalf("got %q", got)
}
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
var value any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
t.Fatal("expected trailing token error")
}
}
+411
View File
@@ -0,0 +1,411 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type whiteboardTestCall struct {
server string
tool string
args map[string]any
}
type whiteboardTestCaller struct {
dry bool
format string
err func(whiteboardTestCall, int) error
response func(whiteboardTestCall, int) string
calls []whiteboardTestCall
}
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
call := whiteboardTestCall{server: server, tool: tool, args: args}
c.calls = append(c.calls, call)
if c.err != nil {
if err := c.err(call, len(c.calls)-1); err != nil {
return nil, err
}
}
text := `{}`
if c.response != nil {
text = c.response(call, len(c.calls)-1)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *whiteboardTestCaller) Format() string { return c.format }
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
func (*whiteboardTestCaller) Fields() string { return "" }
func (*whiteboardTestCaller) JQ() string { return "" }
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
output := &bytes.Buffer{}
deps.Out.w = output
deps.Out.errW = &bytes.Buffer{}
return output
}
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
},
}
output := installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
t.Fatalf("args = %#v", caller.calls[0].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if _, ok := payload["resultJson"].(map[string]any); !ok {
t.Fatalf("resultJson was not decoded: %#v", payload)
}
}
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
t.Fatalf("err = %v, want cancellation", err)
}
if len(caller.calls) != 0 {
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
t.Fatalf("args = %#v", caller.calls[0].args)
}
}
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
var blockID string
caller := &whiteboardTestCaller{
format: "json",
response: func(call whiteboardTestCall, index int) string {
if index == 0 {
var node []any
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
attrs := node[1].(map[string]any)
blockID = attrs["uuid"].(string)
return `{}`
}
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
encoded, _ := json.Marshal(jsonml)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
}
output := installWhiteboardTestCaller(t, caller)
previousDelays := whiteboardRetryDelays
whiteboardRetryDelays = nil
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
t.Fatalf("unexpected servers: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
if result["whiteboardId"] != "part-real" {
t.Fatalf("output = %#v", payload)
}
}
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
// 让回查桩可以用真实块 ID 组装响应。
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
t.Helper()
raw, _ := call.args["jsonml"].(string)
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
if len(node) < 2 {
t.Fatalf("jsonml node missing attrs: %q", raw)
}
attrs, _ := node[1].(map[string]any)
id, _ := attrs["uuid"].(string)
if id == "" {
t.Fatalf("jsonml node missing uuid: %q", raw)
}
return id
}
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
t.Helper()
previousDelays := whiteboardRetryDelays
previousSleep := whiteboardSleep
stub := make([]time.Duration, delays)
for i := range stub {
stub[i] = time.Millisecond
}
slept := 0
whiteboardRetryDelays = stub
whiteboardSleep = func(time.Duration) { slept++ }
t.Cleanup(func() {
whiteboardRetryDelays = previousDelays
whiteboardSleep = previousSleep
})
return func() int { return slept }
}
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
// 继续带着空 partId 调用 whiteboard query/update。
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
tests := []struct {
name string
queryErr error
queryBody func(blockID string) string
}{
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
{
name: "response missing blocks field",
queryBody: func(string) string { return `{"success":true}` },
},
{
name: "blocks field is not an array",
queryBody: func(string) string { return `{"blocks":{}}` },
},
{
name: "block jsonml unparsable",
queryBody: func(blockID string) string {
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
},
},
{
name: "card node without attrs",
queryBody: func(blockID string) string {
encoded, _ := json.Marshal(`[]`)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
blockID := ""
caller := &whiteboardTestCaller{format: "json"}
caller.response = func(call whiteboardTestCall, index int) string {
if index == 0 {
blockID = whiteboardCardBlockID(t, call)
return `{}`
}
if test.queryBody == nil {
return `{}`
}
return test.queryBody(blockID)
}
if test.queryErr != nil {
caller.err = func(_ whiteboardTestCall, index int) error {
if index == 0 {
return nil
}
return test.queryErr
}
}
installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
t.Fatalf("err = %v, want fail-closed verification error", err)
}
if !strings.Contains(err.Error(), blockID) {
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
}
if len(caller.calls) != 2 || slept() != 0 {
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
len(caller.calls), slept())
}
})
}
}
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
// whiteboardId 为 null。
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(_ whiteboardTestCall, index int) string {
if index == 0 {
return `{}`
}
return `{"blocks":[]}`
},
}
output := installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("block-not-visible must stay a soft success: %v", err)
}
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
if len(caller.calls) != 4 || slept() != 2 {
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
whiteboardID, present := result["whiteboardId"]
if payload["success"] != true || !present || whiteboardID != nil {
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
}
if result["blockId"] == "" || result["blockId"] == nil {
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
}
}
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{
name: "ref-block with parent-block",
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
},
{
name: "where with parent-block",
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(test.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
}
if len(caller.calls) != 0 {
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
}
})
}
}
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
},
}
output := installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
t.Fatalf("output = %#v", payload)
}
}
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
uploadURL := "https://upload.example.test/secret-token"
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
},
}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
return fmt.Errorf("PUT %s: connection reset", uploadURL)
}
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
err := cmd.Execute()
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
t.Fatalf("err = %v, want redacted temporary upload URL", err)
}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// Package profilectx owns the process-local profile selector without importing
// authentication or transport packages.
package profilectx
import (
"strings"
"sync"
)
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// Set records the explicit profile selector for the current process.
func Set(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// Get returns the explicit process-local profile selector.
func Get() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package profilectx
import "testing"
func TestCrossPlatformCoverageSetAndGet(t *testing.T) {
t.Cleanup(func() { Set("") })
Set(" fixture-profile ")
if got := Get(); got != "fixture-profile" {
t.Fatalf("Get() = %q, want fixture-profile", got)
}
Set("")
if got := Get(); got != "" {
t.Fatalf("Get() after reset = %q, want empty", got)
}
}
+47 -1
View File
@@ -36,6 +36,16 @@ func FromShortcut(s Shortcut) corecmd.Spec {
if !safetySpecDeclared(safety) {
safety = shortcutSafetySpec(s.risk())
}
declaredContract := s.Contract
if !declaredContract.Empty() && len(s.Aliases) > 0 && len(declaredContract.Identity.Aliases) == 0 {
declaredContract.Identity.Aliases = make([]string, 0, len(s.Aliases))
for _, alias := range s.Aliases {
declaredContract.Identity.Aliases = append(
declaredContract.Identity.Aliases,
s.Service+" "+strings.TrimSpace(alias),
)
}
}
return corecmd.Spec{
Use: s.Command,
Short: s.Description,
@@ -47,11 +57,12 @@ func FromShortcut(s Shortcut) corecmd.Spec {
Flags: fromShortcutFlags(s.Flags),
Constraints: fromShortcutConstraints(s.Constraints),
Safety: safety,
Contract: s.Contract,
Contract: declaredContract,
// Preserve the shipped Shortcut Catalog provenance: Cobra remains the
// source for type/default/usage, while command adds Required/Enum/rules.
ParameterProjection: corecmd.ProjectCobraParameters,
Validate: fromShortcutValidate(s),
PostMount: fromShortcutPostMount(s),
// Multi-step body: command stays backend-agnostic, so the shortcut's own
// RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is
// built here from the Ctx's command.
@@ -65,6 +76,40 @@ func FromShortcut(s Shortcut) corecmd.Spec {
}
}
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" {
return nil
}
return func(cmd *cobra.Command) {
cmd.Aliases = append([]string(nil), s.Aliases...)
name := strings.TrimSpace(s.SinglePositionalAliasFor)
if name == "" {
return
}
cmd.Args = func(cmd *cobra.Command, args []string) error {
if err := cobra.MaximumNArgs(1)(cmd, args); err != nil {
return err
}
if len(args) == 0 {
return nil
}
flag := cmd.Flags().Lookup(name)
if flag == nil {
return apperrors.NewInternal(fmt.Sprintf(
"shortcut %s %s positional alias flag --%s is not registered",
s.Service, s.Command, name))
}
if flag.Changed {
return apperrors.NewValidation(fmt.Sprintf("位置参数与 --%s 不能同时提供", name))
}
if err := cmd.Flags().Set(name, args[0]); err != nil {
return apperrors.NewValidation(fmt.Sprintf("位置参数无法写入 --%s: %v", name, err))
}
return nil
}
}
}
func safetySpecDeclared(safety contract.SafetySpec) bool {
return strings.TrimSpace(safety.Effect) != "" ||
strings.TrimSpace(safety.Risk) != "" ||
@@ -133,6 +178,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
for _, f := range flags {
out = append(out, corecmd.FlagSpec{
Name: f.Name,
Shorthand: f.Shorthand,
Usage: flagHelp(f),
Kind: fromShortcutFlagKind(f.Type),
Default: f.Default,
+69 -2
View File
@@ -20,6 +20,7 @@ import (
"github.com/spf13/pflag"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// TestCrossPlatformCoverageFromShortcutMapsSharedBase verifies FromShortcut
@@ -33,7 +34,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
Hidden: true,
Tips: []string{"dws chat +demo --name a"},
Flags: []Flag{
{Name: "name", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
{Name: "name", Shorthand: "n", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
{Name: "count", Type: FlagInt, Desc: "数量"},
{Name: "flag", Type: FlagBool, Desc: "开关"},
{Name: "ids", Type: FlagStringSlice, Desc: "列表"},
@@ -85,7 +86,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
}
}
name := cs.Flags[0]
if name.Name != "name" || !name.Required || name.Default != "d" ||
if name.Name != "name" || name.Shorthand != "n" || !name.Required || name.Default != "d" ||
!name.Hidden || name.ValidationMode != corecmd.ValidationShortcut ||
name.RequiredError != "缺少必填参数 --name:名称" ||
strings.Join(name.Enum, ",") != "a,b" {
@@ -120,6 +121,72 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
}
}
func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T) {
executed := ""
s := Shortcut{
Service: "chat",
Command: "+search",
Aliases: []string{"+search-group"},
SinglePositionalAliasFor: "query",
Description: "搜索群",
Intent: "按名称搜索群",
Contract: corecmd.ContractDecl{
Description: "按名称搜索群",
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "test composite",
},
Selection: contract.SelectionSpec{
AgentSummary: "搜索群", UseWhen: []string{"按名称搜索群"}, AvoidWhen: []string{"不要用于成员搜索"}, Examples: []string{"dws chat +search --query demo"},
},
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
},
},
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}},
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
}
spec := FromShortcut(s)
if got := spec.Contract.Identity.Aliases; len(got) != 1 || got[0] != "chat +search-group" {
t.Fatalf("contract aliases = %#v", got)
}
cmd := mount(s)
if !cmd.HasAlias("+search-group") {
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
}
cmd.SetArgs([]string{"项目群"})
if err := cmd.Execute(); err != nil || executed != "项目群" {
t.Fatalf("positional execute err=%v value=%q", err, executed)
}
conflict := mount(s)
conflict.SetArgs([]string{"项目群", "--query", "另一个群"})
if err := conflict.Execute(); err == nil || !strings.Contains(err.Error(), "不能同时提供") {
t.Fatalf("positional/flag conflict err = %v", err)
}
tooMany := mount(s)
tooMany.SetArgs([]string{"one", "two"})
if err := tooMany.Execute(); err == nil {
t.Fatal("multiple positional aliases unexpectedly accepted")
}
missing := mount(Shortcut{
Service: "chat", Command: "+missing", Description: "missing",
SinglePositionalAliasFor: "query", Execute: func(*RuntimeContext) error { return nil },
})
missing.SetArgs([]string{"value"})
if err := missing.Execute(); err == nil || !strings.Contains(err.Error(), "is not registered") {
t.Fatalf("missing positional flag err = %v", err)
}
invalid := mount(Shortcut{
Service: "chat", Command: "+invalid", Description: "invalid",
SinglePositionalAliasFor: "query", Flags: []Flag{{Name: "query", Type: FlagInt}}, Execute: func(*RuntimeContext) error { return nil },
})
invalid.SetArgs([]string{"not-an-int"})
if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "无法写入") {
t.Fatalf("invalid positional value err = %v", err)
}
}
// TestCrossPlatformCoverageFromShortcutMatchesMountSurface pins the live
// adapter surface: flag set (names/types/usage) and rendered Long must agree.
// This catches a double-rendered 参数约束 or lost flagHelp decoration.
+41 -17
View File
@@ -5,6 +5,9 @@
package chat
import (
"fmt"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -28,14 +31,15 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
})
}
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"tool": tool,
"actionCount": len(actions),
"failedCount": 0,
"actions": actions,
"requestedCount": len(items),
"contractVersion": "im.batch-write.v1",
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"tool": tool,
"actionCount": len(actions),
"failedCount": 0,
"actions": actions,
"requestedCount": len(items),
})
}
@@ -56,13 +60,33 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
}
succeeded = append(succeeded, entry)
}
return rt.Output(map[string]any{
"ok": len(failures) == 0,
"partial": len(succeeded) > 0 && len(failures) > 0,
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"succeeded": succeeded,
"failures": failures,
})
result := map[string]any{
"contractVersion": "im.batch-write.v1",
"ok": len(failures) == 0,
"partial": len(succeeded) > 0 && len(failures) > 0,
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"succeeded": succeeded,
"failures": failures,
}
if err := rt.Output(result); err != nil {
return err
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("批量执行 %s 失败:%d/%d 个目标未完成", tool, len(failures), len(items)),
apperrors.WithOperation(product+"/"+tool),
apperrors.WithReason("batch_write_failed"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"partial": len(succeeded) > 0,
}),
)
}
return nil
}
+2 -2
View File
@@ -228,8 +228,8 @@ func botFindProject(data map[string]any) []map[string]any {
// SearchCommonGroups searches groups shared with given people (search_common_groups, chat server).
func init() {
shortcut.Register(
shortcut.Register(withReviewedChatShortcutContracts(
BotSearch,
BotFind,
)
)...)
}
+140 -30
View File
@@ -15,6 +15,7 @@ package chat
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
@@ -88,8 +89,8 @@ var ConversationSetTop = shortcut.Shortcut{
Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
},
Constraints: []shortcut.Constraint{
@@ -164,7 +165,7 @@ var ConversationMuteAtAll = shortcut.Shortcut{
Command: "+conversation-mute-at-all",
Product: "im",
Description: "关闭/开启 @所有人消息提醒",
Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。",
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复'@所有人'提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -185,7 +186,7 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{
Command: "+conversation-mute-red-envelope",
Product: "im",
Description: "关闭/开启红包消息提醒",
Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。",
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复红包提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,或刚恢复过@所有人提醒,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -284,8 +285,8 @@ var ConversationList = shortcut.Shortcut{
Service: "chat",
Command: "+conversation-list",
Product: "im",
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。",
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -299,47 +300,143 @@ var ConversationList = shortcut.Shortcut{
CLIPath: "chat +conversation-list",
PrimaryCLIPath: "chat +conversation-list",
},
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "分页获取当前用户的全部会话列表(单聊+群聊)",
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。"},
AgentSummary: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +conversation-list --limit 50"},
},
},
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"},
{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-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []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"},
},
Tips: []string{
`dws chat +conversation-list --limit 50`,
`dws chat +conversation-list --page-all --limit 100`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1-100 之间;读取全部会话请使用 --page-all")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
return nil
},
Tips: []string{`dws chat +conversation-list --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
cursor := int64(rt.Int("cursor"))
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
if rt.Int("cursor") > 0 {
params["cursor"] = rt.Int("cursor")
convs := make([]map[string]any, 0)
seenConversations := map[string]bool{}
seenCursors := map[int64]bool{cursor: true}
pagesFetched := 0
complete := false
hasMore := false
nextCursor := int64(0)
failures := make([]map[string]any, 0)
for pagesFetched < pageLimit {
params := map[string]any{"limit": rt.Int("limit")}
if cursor > 0 {
params["cursor"] = cursor
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
if pagesFetched == 0 {
return err
}
failures = append(failures, map[string]any{"stage": "conversation-page", "cursor": cursor, "error": err.Error()})
break
}
pagesFetched++
for _, conversation := range conversationListProject(data) {
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
if id != "" && id != "<nil>" {
if seenConversations[id] {
continue
}
seenConversations[id] = true
}
convs = append(convs, conversation)
}
page := chatmsg.Pagination(data)
hasMoreValue, known := page["hasMore"].(bool)
hasMore = hasMoreValue
if !known {
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
break
}
if !hasMore {
complete = true
break
}
nextCursor, err = conversationPaginationCursor(page["nextCursor"])
if err != nil || nextCursor == 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "hasMore=true 但 nextCursor 缺失、无效或未前进"})
break
}
if !rt.Bool("page-all") {
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
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)})
}
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
return err
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,
}
convs := conversationListProject(data)
payload := map[string]any{"count": len(convs), "conversations": convs}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
}
func conversationPaginationCursor(value any) (int64, error) {
switch typed := value.(type) {
case int:
return int64(typed), nil
case int64:
return typed, nil
case float64:
return int64(typed), nil
case string:
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
// conversationListProject reshapes the raw list_all_conversations response into a
// clean conversation list — clean output projection. Both the list
// container and the per-item field names are probed defensively across candidate
@@ -379,12 +476,12 @@ func conversationListResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return arr
return unwrapConversationTuple(arr)
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
return unwrapConversationTuple(arr)
}
}
}
@@ -392,6 +489,19 @@ func conversationListResolveList(data map[string]any) []any {
return []any{}
}
// unwrapConversationTuple handles gateway responses shaped as
// result:[conversationList,nextCursor,hasMore] while leaving ordinary arrays
// untouched. This prevents the first list from being mistaken for one row.
func unwrapConversationTuple(values []any) []any {
if len(values) == 0 {
return values
}
if nested, ok := values[0].([]any); ok {
return nested
}
return values
}
// conversationListFirst returns the first present candidate key's value.
func conversationListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
@@ -576,12 +686,12 @@ func conversationListTopResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return arr
return unwrapConversationTuple(arr)
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
return unwrapConversationTuple(arr)
}
}
}
@@ -1070,7 +1180,7 @@ var CategoryRemoveConversation = shortcut.Shortcut{
}
func init() {
shortcut.Register(
shortcut.Register(withReviewedChatShortcutContracts(
ConversationInfo,
ConversationSetTop,
ConversationMute,
@@ -1091,5 +1201,5 @@ func init() {
CategoryRename,
CategoryAddConversation,
CategoryRemoveConversation,
)
)...)
}
@@ -14,13 +14,15 @@
package chat
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
data := map[string]any{
"result": map[string]any{
"items": []any{
@@ -75,7 +77,7 @@ func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
}
}
func TestConversationListTopRejectsInvalidType(t *testing.T) {
func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -87,3 +89,82 @@ func TestConversationListTopRejectsInvalidType(t *testing.T) {
t.Fatalf("invalid --type reached lower tool %s/%s", fake.product, fake.tool)
}
}
func TestCrossPlatformCoverageConversationListProjectUnwrapsGatewayTuple(t *testing.T) {
data := map[string]any{
"result": []any{
[]any{map[string]any{"openConversationId": "cid-1", "title": "项目群"}},
float64(2),
true,
},
}
if got := conversationListProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
t.Fatalf("conversation tuple projection = %#v", got)
}
if got := conversationListTopProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
t.Fatalf("top tuple projection = %#v", got)
}
}
func TestCrossPlatformCoverageConversationListPageAllFollowsTypedCursor(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"conversationList":[{"openConversationId":"cid-2","title":"二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+conversation-list", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != int64(2) {
t.Fatalf("calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one page", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["hasMore"] != true || payload["nextCursor"] != float64(2) {
t.Fatalf("pagination payload = %#v", payload)
}
}
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}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list"})
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) {
t.Fatalf("deduplicated payload = %#v", payload)
}
}
+61 -19
View File
@@ -23,16 +23,19 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"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"
)
// ChatSearch searches groups by keyword (search_groups on the im server).
var ChatSearch = shortcut.Shortcut{
Service: "chat",
Command: "+chat-search",
Product: "im",
Description: "按关键词搜索群聊",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Risk: shortcut.RiskRead,
Service: "chat",
Command: "+chat-search",
Aliases: []string{"+chat-group-search", "+search-group"},
SinglePositionalAliasFor: "query",
Product: "im",
Description: "按关键词搜索群聊",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
@@ -147,7 +150,7 @@ var ChatInviteURL = shortcut.Shortcut{
Command: "+chat-invite-url",
Product: "im",
Description: "获取群邀请链接",
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -169,20 +172,32 @@ var ChatInviteURL = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "获取群邀请链接",
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。"},
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +chat-invite-url --group <openConversationId>"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{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: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
},
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
},
Tips: []string{
`dws chat +chat-invite-url --group <openConversationId>`,
`dws chat +chat-invite-url --group "项目群"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
groupID, err := resolveStableOrNamedChat(rt)
if err != nil {
return err
}
params := map[string]any{
"openConversationId": rt.Str("group"),
"cid": rt.Str("group"),
"openConversationId": groupID,
"cid": groupID,
}
if rt.Changed("expires-seconds") {
params["expiresSeconds"] = rt.Int("expires-seconds")
@@ -777,7 +792,7 @@ var ChatBots = shortcut.Shortcut{
Command: "+chat-bots",
Product: "bot",
Description: "查看群内所有机器人",
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
Intent: "当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -799,17 +814,29 @@ var ChatBots = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查看群内所有机器人",
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。"},
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +chat-bots --group <openConversationId>"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{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},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
},
Tips: []string{
`dws chat +chat-bots --group <openConversationId>`,
`dws chat +chat-bots --group "项目群"`,
},
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
groupID, err := resolveStableOrNamedChat(rt)
if err != nil {
return err
}
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": groupID})
if err != nil {
return err
}
@@ -818,6 +845,21 @@ var ChatBots = shortcut.Shortcut{
},
}
// resolveStableOrNamedChat gives read-only group shortcuts one safe target
// contract. Stable cid values bypass search; natural names always go through
// the shared exact-match, full-pagination and ambiguity rules.
func resolveStableOrNamedChat(rt *shortcut.RuntimeContext) (string, error) {
resolved, err := targetresolver.ResolveChatTarget(
rt,
strings.TrimSpace(rt.Str("group")),
strings.TrimSpace(rt.StrFirst("chat-query", "group-query")),
)
if err != nil {
return "", err
}
return resolved.Selected.OpenConversationID, nil
}
// chatBotsProject reshapes list_group_bots into a clean bot list
// ({openBotId, name}) — clean output projection. List container and
// per-item field names are probed defensively across candidate keys so shape
@@ -1461,7 +1503,7 @@ var ChatRoleQueryUser = shortcut.Shortcut{
}
func init() {
shortcut.Register(
shortcut.Register(withReviewedChatShortcutContracts(
ChatSearch,
ChatMembersGet,
ChatTransferOwner,
@@ -1491,5 +1533,5 @@ func init() {
ChatRoleSetUser,
ChatRoleRemoveUser,
ChatRoleQueryUser,
)
)...)
}
+149 -67
View File
@@ -21,8 +21,8 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -110,8 +110,8 @@ var MessagesSendByWebhook = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-by-webhook",
Product: "bot",
Description: "自定义机器人 Webhook 发送群消息",
Intent: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。",
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
Intent: "只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -125,15 +125,15 @@ var MessagesSendByWebhook = shortcut.Shortcut{
CLIPath: "chat +messages-send-by-webhook",
PrimaryCLIPath: "chat +messages-send-by-webhook",
},
Description: "自定义机器人 Webhook 发送群消息",
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "自定义机器人 Webhook 发送群消息",
UseWhen: []string{"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"},
AgentSummary: "兼容旧入口的自定义机器人 Webhook 群消息发送",
UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
},
@@ -172,17 +172,56 @@ var MessagesRecall = shortcut.Shortcut{
Command: "+messages-recall",
Product: "im",
Description: "撤回当前用户发送的消息",
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息。推荐同时传会话 openConversationId 和消息 openMessageId;若只传一个消息 ID,CLI 会先只读查询消息详情并补齐会话 ID。兼容 --message-id/--message-ids 的单值写法。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId;省略时从消息详情解析"},
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId;一次只能撤回一个消息 ID;--message-ids 仅接受单值"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-id 的兼容单值别名;不支持批量撤回", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "message-ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"msg-id"}, Description: "一次只能撤回一个消息 ID;--message-ids 仅接受单值"},
},
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
Validate: func(rt *shortcut.RuntimeContext) error {
messageIDs := uniqueShortcutStrings(append(
[]string{rt.StrFirst("msg-id", "message-id")},
rt.StrSlice("message-ids")...,
))
if len(messageIDs) != 1 {
return apperrors.NewValidation("撤回一次只接受一个消息 ID;请通过 --msg-id 或单值 --message-ids 传入")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
messageIDs := uniqueShortcutStrings(append(
[]string{rt.StrFirst("msg-id", "message-id")},
rt.StrSlice("message-ids")...,
))
messageID := messageIDs[0]
conversationID := strings.TrimSpace(rt.StrFirst("conversation-id", "group", "id", "chat"))
if conversationID == "" {
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": []string{messageID}})
if err != nil {
return err
}
messages := listMessagesResolveMaps(data)
if len(messages) == 0 {
return apperrors.NewValidation("无法根据消息 ID 查询到会话;请补充 --conversation-id")
}
conversationID = strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(messages[0])))
if conversationID == "" || conversationID == "<nil>" {
return apperrors.NewValidation("消息详情未返回会话 ID;请补充 --conversation-id")
}
}
return rt.CallMCP("recall_message", map[string]any{
"openConversationId": rt.Str("conversation-id"),
"openMessageId": rt.Str("msg-id"),
"openConversationId": conversationID,
"openMessageId": messageID,
})
},
}
@@ -314,46 +353,22 @@ func listMessageProjectOne(m map[string]any) map[string]any {
}
func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any {
row := map[string]any{}
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
row["messageId"] = v
}
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
row["senderId"] = v
}
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
row["msgType"] = v
}
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
row["createTime"] = v
}
if text := chatmsg.Text(m); text != nil {
row["text"] = text
}
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
row["conversationId"] = conversationID
}
if threadID := chatmsg.ThreadID(m); threadID != nil {
row["threadId"] = threadID
}
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
row["updateTime"] = updateTime
}
if includeReactions {
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
row["reactions"] = reactions
row := chatmsg.ProjectMessageV1(m, includeReactions)
// The established mget/list projection omits absent scalar fields; keep
// that wire behavior even though the shared chat/search view retains them.
for _, key := range []string{"sender", "text", "createTime"} {
if row[key] == nil {
delete(row, key)
}
}
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
// Keep the historical mget/list msgType alias while adding the canonical
// messageType field from MessageViewV1.
if messageType := chatmsg.MessageType(m); messageType != nil {
row["msgType"] = messageType
}
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any {
return listMessageProjectOneWithReactions(item, includeReactions)
}
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
}); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
@@ -665,20 +680,35 @@ var MessagesMget = shortcut.Shortcut{
}
}
notFound := make([]string, 0)
failures := make([]map[string]any, 0)
for _, id := range ids {
if !found[id] {
notFound = append(notFound, id)
failures = append(failures, map[string]any{
"stage": "mget",
"messageId": id,
"error": "下层未返回该消息",
})
}
}
payload := map[string]any{
"contractVersion": chatmsg.MessageListContractVersion,
"requestedCount": len(ids),
"foundCount": len(ids) - len(notFound),
"notFoundCount": len(notFound),
"notFoundMessageIds": notFound,
"messages": messages,
"complete": len(notFound) == 0,
"hasMore": false,
"nextCursor": "",
"paginationKnown": true,
"pagesFetched": 1,
"enrichedCount": 0,
"failedCount": len(failures),
"failures": failures,
}
if rt.Bool("download-resources") {
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
AttachMessageResourceDownloads(payload, DownloadMessageResources(rt, rawMessages, ""))
}
return rt.Output(payload)
},
@@ -690,7 +720,7 @@ func MessageResourceDownloadFlags() []shortcut.Flag {
return []shortcut.Flag{
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的本地输出文件(默认拒绝)"},
}
}
@@ -902,6 +932,47 @@ func DownloadMessageResources(
}
}
// AttachMessageResourceDownloads publishes the download ledger and folds any
// resource failure into the task-level completeness contract without dropping
// successfully read messages or downloaded files.
func AttachMessageResourceDownloads(payload, ledger map[string]any) {
payload["resourceDownloads"] = ledger
failed := messageLedgerInt(ledger["failedCount"])
if failed == 0 {
return
}
payload["complete"] = false
payload["failedCount"] = messageLedgerInt(payload["failedCount"]) + failed
taskFailures, _ := payload["failures"].([]map[string]any)
resourceFailures, _ := ledger["failures"].([]map[string]any)
for _, failure := range resourceFailures {
row := make(map[string]any, len(failure)+1)
row["stage"] = "resource-download"
for key, value := range failure {
row[key] = value
}
taskFailures = append(taskFailures, row)
}
payload["failures"] = taskFailures
}
func messageLedgerInt(value any) int {
switch typed := value.(type) {
case int:
return typed
case int32:
return int(typed)
case int64:
return int(typed)
case float32:
return int(typed)
case float64:
return int(typed)
default:
return 0
}
}
func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
if !used[strings.ToLower(filename)] {
return filename
@@ -1168,7 +1239,7 @@ var MessagesSendCard = shortcut.Shortcut{
Command: "+messages-send-card",
Product: "im",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1190,7 +1261,7 @@ var MessagesSendCard = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
UseWhen: []string{"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"},
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
Examples: []string{
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
@@ -1221,7 +1292,7 @@ var MessagesSendCard = shortcut.Shortcut{
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); status < 1 || status > 5 {
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
if rt.Changed("flow-status") && rt.Str("content") == "" {
@@ -1253,11 +1324,12 @@ var MessagesSendCard = shortcut.Shortcut{
status := rt.Int("flow-status")
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"contractVersion": currentCardWorkflowContract.Version,
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"tool": "create_and_send_card",
@@ -1291,11 +1363,12 @@ var MessagesSendCard = shortcut.Shortcut{
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
"contractVersion": currentCardWorkflowContract.Version,
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
},
}
@@ -1362,7 +1435,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
Command: "+messages-update-card",
Product: "im",
Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。",
Intent: "当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1384,7 +1457,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
UseWhen: []string{"当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。"},
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
},
@@ -1392,9 +1465,18 @@ var MessagesUpdateCard = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "biz-id", Type: shortcut.FlagString, Desc: "send-card 返回的卡片业务 ID", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "卡片消息内容", Required: true},
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误", Required: true},
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间", Required: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"flow-status"}, Description: "--flow-status 必须在 1-5 之间"},
},
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
Validate: func(rt *shortcut.RuntimeContext) error {
if !validCardFlowStatus(rt.Int("flow-status")) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_streaming_card", map[string]any{
"bizId": rt.Str("biz-id"),
@@ -1735,7 +1817,7 @@ var MessagesUnsetTop = shortcut.Shortcut{
}
func init() {
shortcut.Register(
shortcut.Register(withReviewedChatShortcutContracts(
MessagesSendByBot,
MessagesBatchSendByBot,
MessagesSendByWebhook,
@@ -1764,5 +1846,5 @@ func init() {
MessagesListPin,
MessagesSetTop,
MessagesUnsetTop,
)
)...)
}
@@ -20,7 +20,7 @@ import (
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestListMessageProjectOne(t *testing.T) {
func TestCrossPlatformCoverageListMessageProjectOne(t *testing.T) {
// full field mapping + forwarded expansion; an encrypted body is marked (no
// cross-conversation recovery), not leaked as base64.
row := listMessageProjectOne(map[string]any{
@@ -75,7 +75,35 @@ func TestListMessageProjectOne(t *testing.T) {
}
}
func TestListPinProjectPreservesThreadIdentity(t *testing.T) {
func TestCrossPlatformCoverageAttachMessageResourceDownloadsPreservesMessagesAndMarksIncomplete(t *testing.T) {
payload := map[string]any{
"messages": []map[string]any{{"messageId": "msg-1"}},
"complete": true,
"failedCount": 0,
"failures": []map[string]any{},
}
ledger := map[string]any{
"failedCount": 1,
"failures": []map[string]any{{
"messageId": "msg-1",
"error": "download failed",
}},
}
AttachMessageResourceDownloads(payload, ledger)
if payload["complete"] != false || payload["failedCount"] != 1 {
t.Fatalf("task completeness = %#v", payload)
}
messages, _ := payload["messages"].([]map[string]any)
if len(messages) != 1 || messages[0]["messageId"] != "msg-1" {
t.Fatalf("messages were dropped: %#v", messages)
}
failures, _ := payload["failures"].([]map[string]any)
if len(failures) != 1 || failures[0]["stage"] != "resource-download" {
t.Fatalf("resource failures = %#v", failures)
}
}
func TestCrossPlatformCoverageListPinProjectPreservesThreadIdentity(t *testing.T) {
got := listPinProject(map[string]any{
"result": map[string]any{
"messages": []any{
@@ -94,6 +94,13 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "search_groups",
wantArgs: map[string]any{"keyword": "树莓派", "limit": 5},
},
{
name: "chat search command alias and positional query",
argv: []string{"chat", "+search-group", "树莓派", "--yes"},
wantProduct: "im",
wantTool: "search_groups",
wantArgs: map[string]any{"keyword": "树莓派", "limit": 20},
},
{
name: "bot find keyword",
argv: []string{"chat", "+bot-find", "--keyword", "日报", "--yes"},
@@ -175,6 +182,16 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
"openConversationId": "cid-1",
},
},
{
name: "message recall plural single id alias",
argv: []string{"chat", "+messages-recall", "--conversation-id", "cid-1", "--message-ids", "msg-1", "--yes"},
wantProduct: "im",
wantTool: "recall_message",
wantArgs: map[string]any{
"openConversationId": "cid-1",
"openMessageId": "msg-1",
},
},
}
for _, tc := range tests {
@@ -203,6 +220,15 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
}
}
func TestCrossPlatformCoverageConversationNotificationSubSwitchesPublishMutePrerequisite(t *testing.T) {
for _, command := range []shortcut.Shortcut{ConversationMuteAtAll, ConversationMuteRedEnvelope} {
if !strings.Contains(command.Intent, "+conversation-mute") ||
!strings.Contains(command.Intent, "NotificationOffNotEnabled") {
t.Errorf("%s intent does not publish the live platform prerequisite: %q", command.Command, command.Intent)
}
}
}
func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
t.Run("recognize open DingTalk IDs", func(t *testing.T) {
tests := []struct {
@@ -248,7 +274,7 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
})
}
func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
fake := &muteMemberResolutionCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -277,7 +303,7 @@ func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
}
}
func TestConversationCategoryTitleValidation(t *testing.T) {
func TestCrossPlatformCoverageConversationCategoryTitleValidation(t *testing.T) {
tests := []struct {
name string
argv []string
@@ -7,6 +7,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
@@ -91,6 +93,62 @@ func TestCrossPlatformCoverageConversationAndGroupListExecution(t *testing.T) {
}
}
func TestCrossPlatformCoverageConversationListFailureBoundaries(t *testing.T) {
for _, tail := range [][]string{
{"--limit", "0"},
{"--limit", "101"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
} {
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+conversation-list"}, tail...))
if err := root.Execute(); err == nil {
t.Errorf("invalid conversation args succeeded: %v", tail)
}
}
for _, tc := range []struct {
name string
caller *larkAlignmentCaller
args []string
wantError bool
}{
{name: "first read failure", caller: &larkAlignmentCaller{failProductTool: "im/list_all_conversations"}, wantError: true},
{name: "later read failure", caller: &larkAlignmentCaller{
sequenceResponses: map[string][]string{"im/list_all_conversations": {`{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}},
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
}, args: []string{"--page-all"}},
{name: "missing pagination", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[]}}`}}},
{name: "invalid cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":"bad"}}`}}},
{name: "stalled cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--cursor", "2"}},
{name: "page limit", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--page-limit", "1"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+conversation-list"}, tc.args...))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
}
})
}
for value, want := range map[any]int64{int(1): 1, int64(2): 2, float64(3): 3, "4": 4} {
got, err := conversationPaginationCursor(value)
if err != nil || got != want {
t.Fatalf("cursor %#v = %d, %v; want %d", value, got, err, want)
}
}
if _, err := conversationPaginationCursor(struct{}{}); err == nil {
t.Fatal("unsupported cursor unexpectedly accepted")
}
if got := unwrapConversationTuple(nil); got != nil {
t.Fatalf("empty tuple = %#v", got)
}
}
func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
tests := []struct {
name string
@@ -98,6 +156,12 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
args []string
wantError string
}{
{
name: "owner query resolution",
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--owner-query", "群主", "--yes"},
wantError: "fixture lower call failed",
},
{
name: "current profile call",
caller: &larkAlignmentCaller{failProductTool: "contact/get_current_user_profile"},
@@ -152,6 +216,18 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
args: []string{"chat", "+feed-group-query-item", "--category-id", "1", "--conversation-ids", "cid"},
wantError: "fixture lower call failed",
},
{
name: "chat update resolution",
caller: &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}},
args: []string{"chat", "+chat-update", "--group", "missing", "--name", "新群", "--yes"},
wantError: "没有找到",
},
{
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"},
wantError: "fixture lower call failed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -177,6 +253,27 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
dry.calls[1].tool != "create_group_conversation" {
t.Fatalf("chat-create dry-run calls = %#v", dry.calls)
}
external := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"外部联系人","openDingTalkId":"D-external"}]}`,
}}
helpers.InitDeps(external)
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageNaturalGroupReadFailures(t *testing.T) {
for _, command := range []string{"+chat-invite-url", "+chat-bots"} {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", command, "--group", "missing"})
if err := root.Execute(); err == nil {
t.Errorf("%s missing natural group unexpectedly resolved", command)
}
}
}
func TestCrossPlatformCoverageReplyShapeHelpers(t *testing.T) {
@@ -240,6 +337,45 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
for _, tc := range []struct {
name string
caller *larkAlignmentCaller
args []string
}{
{name: "multiple recall ids", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-recall", "--message-ids", "m1,m2", "--yes"}},
{name: "recall lookup failure", caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
{name: "recall lookup empty", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
{name: "recall lookup missing conversation", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[{"openMessageId":"m1"}]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
{name: "card status low", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "0", "--yes"}},
{name: "card status high", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "6", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.caller)
root := newPlatformCoverageRoot()
root.SetArgs(tc.args)
if err := root.Execute(); err == nil {
t.Fatal("failure boundary unexpectedly succeeded")
}
})
}
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
for value, want := range map[any]int{
int(1): 1, int32(2): 2, int64(3): 3, float32(4): 4, float64(5): 5, "bad": 0,
} {
if got := messageLedgerInt(value); got != want {
t.Fatalf("messageLedgerInt(%#v) = %d, want %d", value, got, want)
}
}
}
func TestCrossPlatformCoverageFeedCompleteAndExcludeMuted(t *testing.T) {
fake := &larkAlignmentCaller{category: `{"result":{"hasMore":false,"list":[{"openConversationId":"cid"}]}}`}
helpers.InitDeps(fake)
@@ -388,6 +524,90 @@ func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T)
}
}
func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.T) {
readGroupFile := messagesSendReadGroupFile
t.Cleanup(func() { messagesSendReadGroupFile = readGroupFile })
for _, tail := range [][]string{
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--groups", "c1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups", "c1", "--groups-file", "groups.txt", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--chat-query", "群", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups", "", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups", strings.Join(makeIDs(101), ","), "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups-file", "/absolute.txt", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups-file", "../escape.txt", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--groups-file", "missing.txt", "--text", "x"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+messages-send"}, append(tail, "--yes")...))
if err := root.Execute(); err == nil {
t.Errorf("invalid unified send args succeeded: %v", tail)
}
}
temp := t.TempDir()
if err := os.Mkdir(filepath.Join(temp, "groups-dir"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(temp, "large.txt"), make([]byte, messagesSendMaxGroupFileSize+1), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(temp, "unreadable.txt"), []byte("cid"), 0o600); err != nil {
t.Fatal(err)
}
messagesSendReadGroupFile = func(path string) ([]byte, error) {
if filepath.Base(path) == "unreadable.txt" {
return nil, os.ErrPermission
}
return readGroupFile(path)
}
t.Chdir(temp)
for _, path := range []string{"groups-dir", "large.txt", "unreadable.txt"} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "r", "--groups-file", path, "--text", "x", "--yes"})
if err := root.Execute(); err == nil {
t.Errorf("invalid group file %q succeeded", path)
}
}
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
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",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("batch calls = %#v", fake.calls)
}
for _, call := range fake.calls {
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
call.args["isAtAll"] != "true" {
t.Fatalf("batch mention args = %#v", call.args)
}
}
shortcut.Register(shortcut.Shortcut{
Service: "chat", Command: "+coverage-unified-execute-error",
Flags: MessagesSend.Flags, Execute: executeMessagesSend,
})
root = newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+coverage-unified-execute-error", "--identity", "bot", "--robot-code", "r",
"--groups-file", "missing-again.txt", "--text", "x",
})
if err := root.Execute(); err == nil {
t.Fatal("execute-time group file error was swallowed")
}
}
func TestCrossPlatformCoverageMuteMemberResolutionFailures(t *testing.T) {
tests := []struct {
name string
+11
View File
@@ -0,0 +1,11 @@
// 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.
//go:build !windows
package chat
import "os"
var replaceFileAtomically = os.Rename
@@ -0,0 +1,11 @@
// 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.
//go:build windows
package chat
import "golang.org/x/sys/windows"
var replaceFileAtomically = windows.Rename
+256 -2
View File
@@ -17,13 +17,22 @@ import (
"strings"
"testing"
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"
)
func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
type chatOutputErrorWriter struct {
err error
}
func (w chatOutputErrorWriter) Write([]byte) (int, error) {
return 0, w.err
}
func TestCrossPlatformCoverageMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
want := []string{
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id",
"users", "open-dingtalk-ids", "robot-code", "webhook-token",
"uuid", "idempotency-key",
}
@@ -54,6 +63,251 @@ func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
}
}
func TestCrossPlatformCoverageMessagesSendIdentityDescriptorMatchesRuntimeSurface(t *testing.T) {
capabilities := MessageIdentityCapabilities()
if len(capabilities) != 3 {
t.Fatalf("identity capabilities = %#v", capabilities)
}
byIdentity := make(map[string]MessageIdentityCapability, len(capabilities))
for _, capability := range capabilities {
byIdentity[capability.Identity] = capability
}
if !byIdentity["user"].IdempotencyKeys || byIdentity["user"].BatchLedger {
t.Fatalf("user capability = %#v", byIdentity["user"])
}
if !byIdentity["bot"].BatchLedger || byIdentity["bot"].IdempotencyKeys ||
!reflect.DeepEqual(byIdentity["bot"].ContentTypes, []string{"text", "markdown"}) {
t.Fatalf("bot capability = %#v", byIdentity["bot"])
}
if byIdentity["webhook"].BatchLedger || byIdentity["webhook"].IdempotencyKeys {
t.Fatalf("webhook capability = %#v", byIdentity["webhook"])
}
capabilities[0].ContentTypes[0] = "mutated"
if MessageIdentityCapabilities()[0].ContentTypes[0] == "mutated" {
t.Fatal("identity capability descriptor leaked mutable storage")
}
if !messageIdentitySupportsContent("user", "audio") ||
!messageIdentitySupportsContent("user", "video") ||
messageIdentitySupportsContent("missing", "text") {
t.Fatal("identity content normalization or unknown-identity guard drifted")
}
}
func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeBoundaries(t *testing.T) {
card := CurrentCardWorkflowContract()
if card.Version != "im.streaming-card.v1" || card.CallbackSupported ||
!reflect.DeepEqual(card.ContentTypes, []string{"streaming-text"}) || len(card.FlowStatuses) != 5 {
t.Fatalf("card contract = %#v", card)
}
card.Targets[0] = "mutated"
if CurrentCardWorkflowContract().Targets[0] == "mutated" {
t.Fatal("card contract leaked mutable storage")
}
boundaries := CurrentIMCapabilityBoundaries()
byName := make(map[string]bool, len(boundaries))
for _, boundary := range boundaries {
byName[boundary.Capability] = boundary.Supported
if boundary.Alternative == "" {
t.Errorf("boundary %s lacks alternative", boundary.Capability)
}
}
for _, unsupported := range []string{"thread-write", "bot-rich-media", "card-action-callback", "resource-resume"} {
if byName[unsupported] {
t.Errorf("unsupported boundary %s was advertised", unsupported)
}
}
for _, supported := range []string{"group-member-full-pagination", "group-owner-selection"} {
if !byName[supported] {
t.Errorf("supported boundary %s was hidden", supported)
}
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups", "cid-a,cid-b,cid-a", "--markdown", "通知", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("multi-group calls = %#v", fake.calls)
}
for index, target := range []string{"cid-a", "cid-b"} {
if fake.calls[index].tool != "send_robot_group_message" ||
fake.calls[index].args["openConversationId"] != target {
t.Fatalf("multi-group call[%d] = %#v", index, fake.calls[index])
}
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != "im.batch-write.v1" || payload["ok"] != true ||
payload["requestedCount"] != float64(2) || payload["succeededCount"] != float64(2) ||
payload["failedCount"] != float64(0) {
t.Fatalf("multi-group ledger = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupFailuresReturnNonzero(t *testing.T) {
tests := []struct {
name string
fake *larkAlignmentCaller
wantSucceeded float64
wantFailed float64
wantPartial bool
}{
{
name: "partial failure",
fake: &larkAlignmentCaller{failProductToolAt: map[string]int{
"bot/send_robot_group_message": 2,
}},
wantSucceeded: 1,
wantFailed: 1,
wantPartial: true,
},
{
name: "all failed",
fake: &larkAlignmentCaller{failProductTool: "bot/send_robot_group_message"},
wantSucceeded: 0,
wantFailed: 2,
wantPartial: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
helpers.InitDeps(tt.fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups", "cid-a,cid-b", "--markdown", "通知", "--yes",
})
err := root.Execute()
if err == nil {
t.Fatal("failed multi-group delivery returned success")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
typed.Reason != "batch_write_failed" || typed.ExitCode() == 0 {
t.Fatalf("batch error = %#v (%v)", typed, err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["ok"] != false || payload["partial"] != tt.wantPartial ||
payload["succeededCount"] != tt.wantSucceeded ||
payload["failedCount"] != tt.wantFailed {
t.Fatalf("failure ledger = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPropagatesOutputFailure(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
wantErr := errors.New("fixture output failed")
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: wantErr})
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups", "cid-a,cid-b", "--markdown", "通知", "--yes",
})
if err := root.Execute(); !errors.Is(err, wantErr) {
t.Fatalf("output error = %v, want %v", err, wantErr)
}
if len(fake.calls) != 2 {
t.Fatalf("multi-group calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendBotGroupsFileUsesSafeDeduplicatedTargets(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("groups.txt", []byte("# comment\ncid-a,cid-b\ncid-a\n"), 0o600); err != nil {
t.Fatal(err)
}
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
"--groups-file", "groups.txt", "--text", "通知", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("groups-file calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDeduplicatesMember(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
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",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_group_conversation" {
t.Fatalf("explicit owner calls = %#v", fake.calls)
}
create := fake.calls[0].args
if create["ownerOpenDingTalkId"] != "D-owner" {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "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"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
"--owner-query", "张三", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "create_group_conversation" {
t.Fatalf("owner query calls = %#v", fake.calls)
}
create := fake.calls[1].args
if create["ownerOpenDingTalkId"] != "D-owner" {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
if command.Risk != shortcut.RiskRead {
@@ -0,0 +1,81 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chat
// MessageIdentityCapability is the reviewed Runtime capability descriptor for
// +messages-send. Validation and Skill drift checks consume this same typed
// source; it describes only behavior that the current lower transports expose.
type MessageIdentityCapability struct {
Identity string
Targets []string
ContentTypes []string
NaturalTargets []string
MentionTargets []string
IdempotencyKeys bool
BatchLedger bool
}
var messageIdentityCapabilities = []MessageIdentityCapability{
{
Identity: "user",
Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"},
ContentTypes: []string{"text", "markdown", "image-media-id", "file", "audio-as-file", "video-as-file"},
NaturalTargets: []string{"chat-query", "user-query"},
MentionTargets: []string{"open-dingtalk-id", "all"},
IdempotencyKeys: true,
BatchLedger: false,
},
{
Identity: "bot",
Targets: []string{"group", "groups", "direct-users", "direct-open-dingtalk-ids"},
ContentTypes: []string{"text", "markdown"},
NaturalTargets: []string{},
MentionTargets: []string{"user-id", "open-dingtalk-id", "all"},
IdempotencyKeys: false,
BatchLedger: true,
},
{
Identity: "webhook",
Targets: []string{"token-owned-group"},
ContentTypes: []string{"text", "markdown"},
NaturalTargets: []string{},
MentionTargets: []string{"user-id", "mobile", "all"},
IdempotencyKeys: false,
BatchLedger: false,
},
}
// MessageIdentityCapabilities returns defensive copies of the public matrix.
func MessageIdentityCapabilities() []MessageIdentityCapability {
out := make([]MessageIdentityCapability, len(messageIdentityCapabilities))
for i, capability := range messageIdentityCapabilities {
out[i] = capability
out[i].Targets = append([]string(nil), capability.Targets...)
out[i].ContentTypes = append([]string(nil), capability.ContentTypes...)
out[i].NaturalTargets = append([]string(nil), capability.NaturalTargets...)
out[i].MentionTargets = append([]string(nil), capability.MentionTargets...)
}
return out
}
func messageIdentitySupportsContent(identity, contentType string) bool {
if contentType == "audio" || contentType == "video" {
contentType += "-as-file"
} else if contentType == "image" {
contentType = "image-media-id"
}
for _, capability := range messageIdentityCapabilities {
if capability.Identity != identity {
continue
}
for _, supported := range capability.ContentTypes {
if supported == contentType {
return true
}
}
return false
}
return false
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
// CardFlowStatus is one reviewed state accepted by DingTalk's streaming-card
// update transport.
type CardFlowStatus struct {
Value int
Name string
}
// CardWorkflowContract describes the real card subset exposed by the current
// Runtime. It intentionally does not claim Lark card JSON/component compilation
// or callback consumption, neither of which exists in the lower interface.
type CardWorkflowContract struct {
Version string
Targets []string
ContentTypes []string
FlowStatuses []CardFlowStatus
CallbackSupported bool
}
var currentCardWorkflowContract = CardWorkflowContract{
Version: "im.streaming-card.v1",
Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"},
ContentTypes: []string{"streaming-text"},
FlowStatuses: []CardFlowStatus{
{Value: 1, Name: "processing"},
{Value: 2, Name: "typing"},
{Value: 3, Name: "completed"},
{Value: 4, Name: "executing"},
{Value: 5, Name: "error"},
},
CallbackSupported: false,
}
// CurrentCardWorkflowContract returns a defensive copy for policy and docs.
func CurrentCardWorkflowContract() CardWorkflowContract {
contract := currentCardWorkflowContract
contract.Targets = append([]string(nil), contract.Targets...)
contract.ContentTypes = append([]string(nil), contract.ContentTypes...)
contract.FlowStatuses = append([]CardFlowStatus(nil), contract.FlowStatuses...)
return contract
}
func validCardFlowStatus(value int) bool {
for _, status := range currentCardWorkflowContract.FlowStatuses {
if status.Value == value {
return true
}
}
return false
}
// IMCapabilityBoundary makes unsupported Lark-parity requests explicit and
// testable instead of leaving them to prose or transport guessing.
type IMCapabilityBoundary struct {
Capability string
Supported bool
Alternative string
}
var currentIMCapabilityBoundaries = []IMCapabilityBoundary{
{Capability: "thread-write", Supported: false, Alternative: "quote reply with +messages-reply; thread reading with +thread-replies"},
{Capability: "bot-rich-media", Supported: false, Alternative: "bot text/markdown, or current-user file/image send"},
{Capability: "card-action-callback", Supported: false, Alternative: "streaming text card create/update only"},
{Capability: "resource-resume", Supported: false, Alternative: "atomic whole-file download with explicit retry"},
{Capability: "group-member-full-pagination", Supported: true, Alternative: "+chat-members-list or +group-members"},
{Capability: "group-owner-selection", Supported: true, Alternative: "+chat-create owner flags"},
}
// CurrentIMCapabilityBoundaries returns the reviewed positive/negative matrix.
func CurrentIMCapabilityBoundaries() []IMCapabilityBoundary {
return append([]IMCapabilityBoundary(nil), currentIMCapabilityBoundaries...)
}
+146 -32
View File
@@ -13,44 +13,81 @@ 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"
)
// ChatCreate creates a DingTalk group as the current user. It intentionally
// does not advertise Lark-only owner, description, initial-bot, or visibility
// semantics.
// ChatCreate creates a DingTalk group after resolving every natural member and
// the optional owner to stable DingTalk identities. Description, initial-bot,
// idempotency, and Lark visibility semantics remain deliberately unsupported.
var ChatCreate = shortcut.Shortcut{
Service: "chat",
Command: "+chat-create",
Product: "im",
Description: "以当前用户身份创建钉钉群聊",
Intent: "当你要创建一个基础钉钉群聊时使用;自动把当前用户加入成员列表并作为群主,支持 INTERNAL、EXTERNAL、NORMAL 和话题模式。它不支持指定其他 owner、群 description、初始机器人或 Lark public/private 语义。",
Description: "按成员和可选群主全量预检后创建一个钉钉群聊",
Intent: "当你要创建钉钉群聊时使用;成员可传稳定 ID 或 --member-query 姓名,群主默认当前用户,也可用 --owner-open-dingtalk-id 或 --owner-query 明确指定。所有自然身份会在唯一解析并去重后才执行一次创建,任一零命中或多命中都会整体停止。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "群名称", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表"},
{Name: "member-query", Type: shortcut.FlagStringSlice, Desc: "按姓名/花名唯一解析的初始成员,可逗号分隔或重复传入"},
{Name: "owner-open-dingtalk-id", Type: shortcut.FlagString, Desc: "明确指定群主 openDingTalkId(与 --owner-query 互斥;省略时群主为当前用户)"},
{Name: "owner-query", Type: shortcut.FlagString, Desc: "按姓名唯一解析群主 openDingTalkId(与 --owner-open-dingtalk-id 互斥)"},
{Name: "type", Type: shortcut.FlagString, Default: "INTERNAL", Desc: "群类型", Enum: []string{"INTERNAL", "EXTERNAL", "NORMAL"}},
{Name: "thread", Type: shortcut.FlagBool, Desc: "创建为话题群"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"users", "member-query"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"owner-open-dingtalk-id", "owner-query"}},
},
Tips: []string{
`dws chat +chat-create --name "项目冲刺群" --users userId1,userId2`,
`dws chat +chat-create --name "合作群" --users userId1,userId2 --type EXTERNAL`,
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
resolvedMembers, err := targetresolver.ResolveUsers(
rt,
rt.StrSlice("member-query"),
targetresolver.IdentityAny,
)
if err != nil {
return fmt.Errorf("读取当前用户以设置群主失败: %w", err)
return err
}
currentUserID := currentProfileUserID(profile)
if currentUserID == "" {
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
ownerOpenID := rt.Str("owner-open-dingtalk-id")
if query := rt.Str("owner-query"); query != "" {
resolvedOwner, resolveErr := targetresolver.ResolveUser(
rt, query, targetresolver.IdentityOpenDingTalkID)
if resolveErr != nil {
return resolveErr
}
ownerOpenID = resolvedOwner.Selected.OpenDingTalkID
}
members := make([]string, 0, len(rt.StrSlice("users"))+len(resolvedMembers)+1)
if ownerOpenID != "" {
members = append(members, ownerOpenID)
} else {
profile, profileErr := rt.CallMCPData("contact", "get_current_user_profile", nil)
if profileErr != nil {
return fmt.Errorf("读取当前用户以设置群主失败: %w", profileErr)
}
currentUserID := currentProfileUserID(profile)
if currentUserID == "" {
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
}
members = append(members, currentUserID)
}
members := []string{currentUserID}
for _, member := range rt.StrSlice("users") {
member = strings.TrimSpace(member)
if member != "" {
members = appendUniqueShortcutString(members, member)
}
}
for _, resolved := range resolvedMembers {
member := resolved.Selected.UserID
if member == "" {
member = resolved.Selected.OpenDingTalkID
}
members = appendUniqueShortcutString(members, member)
}
params := map[string]any{
"groupName": rt.Str("name"),
"groupMembers": members,
@@ -59,6 +96,9 @@ var ChatCreate = shortcut.Shortcut{
if rt.Bool("thread") {
params["convThreadEnabled"] = true
}
if ownerOpenID != "" {
params["ownerOpenDingTalkId"] = ownerOpenID
}
if rt.DryRun() {
return rt.CallMCP("create_group_conversation", params)
}
@@ -114,18 +154,23 @@ func normalizeCreatedConversation(data map[string]any) {
var ChatUpdate = shortcut.Shortcut{
Service: "chat",
Command: "+chat-update",
Aliases: []string{"+chat-rename"},
Product: "chat",
Description: "更新群名称(仅名称,不支持 description)",
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Intent: "当你只需要修改群名称时使用;--group 可传群名或 openConversationId,群名必须唯一解析后才会写入。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true},
},
Tips: []string{`dws chat +chat-update --group <openConversationId> --name "新群名"`},
Tips: []string{`dws chat +chat-update --group <群名或openConversationId> --name "新群名"`},
Execute: func(rt *shortcut.RuntimeContext) error {
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), "")
if err != nil {
return err
}
return rt.CallMCP("update_group_name", map[string]any{
"openconversation_id": rt.Str("group"),
"openconversation_id": resolved.Selected.OpenConversationID,
"group_name": rt.Str("name"),
})
},
@@ -138,8 +183,8 @@ var MessagesReply = shortcut.Shortcut{
Service: "chat",
Command: "+messages-reply",
Product: "chat",
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Description: "引用回复一条已有消息,并返回可继续查询或撤回的发送上下文",
Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -175,10 +220,79 @@ var MessagesReply = shortcut.Shortcut{
if value := rt.StrFirst("idempotency-key", "uuid"); value != "" {
params["uuid"] = value
}
return rt.CallMCP("send_personal_message", params)
if rt.DryRun() {
return rt.Output(map[string]any{
"contractVersion": "im.message-reply.v1",
"dryRun": true,
"willSend": false,
"transport": "chat/send_personal_message",
"arguments": params,
"conversationId": rt.Str("conversation-id"),
"referencedMessage": map[string]any{
"messageId": replyMessageID(rt),
"senderOpenDingTalkId": refSender,
},
})
}
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
if err != nil {
return err
}
enrichReplyResult(data, rt, refSender)
return rt.Output(data)
},
}
func enrichReplyResult(data map[string]any, rt *shortcut.RuntimeContext, refSender string) {
data["contractVersion"] = "im.message-reply.v1"
data["conversationId"] = rt.Str("conversation-id")
data["referencedMessage"] = map[string]any{
"messageId": replyMessageID(rt),
"senderOpenDingTalkId": refSender,
"resolutionSource": func() string {
if rt.Str("ref-sender") != "" {
return "explicit"
}
return "message_lookup"
}(),
}
if key := rt.StrFirst("idempotency-key", "uuid"); key != "" {
data["idempotencyKey"] = key
}
if value := replyResponseValue(data, "openMessageId", "openMsgId", "messageId", "msgId"); value != nil {
data["messageId"] = value
}
if value := replyResponseValue(data, "openConvThreadId", "threadId", "topicId"); value != nil {
data["threadId"] = value
}
if value := replyResponseValue(data, "deliveryStatus", "sendStatus", "status"); value != nil {
data["deliveryStatus"] = value
data["deliveryStatusKnown"] = true
} else {
data["deliveryStatus"] = "unknown"
data["deliveryStatusKnown"] = false
}
}
func replyResponseValue(data map[string]any, keys ...string) any {
scopes := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if nested, ok := data[wrapper].(map[string]any); ok {
scopes = append(scopes, nested)
}
}
for _, scope := range scopes {
for _, key := range keys {
if value, ok := scope[key]; ok && value != nil {
if text, isString := value.(string); !isString || strings.TrimSpace(text) != "" {
return value
}
}
}
}
return nil
}
func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) {
if value := rt.Str("ref-sender"); value != "" {
if isOpenID(value) {
@@ -303,8 +417,8 @@ var FlagCreate = shortcut.Shortcut{
Intent: "当你要把同一会话中的一条或多条消息加入当前用户的个人收藏时使用;逐项返回成功/失败 ledger。这是消息 favorite,不是消息 Pin、会话置顶或 feed-layer thread flag。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
},
Constraints: []shortcut.Constraint{
@@ -330,8 +444,8 @@ var FlagCancel = shortcut.Shortcut{
Intent: "当你要移除当前用户对同一会话中一条或多条消息的个人收藏标记时使用;逐项返回成功/失败 ledger,只影响 message favorite,不删除原消息,也不会修改 Pin 或会话置顶。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
},
Constraints: []shortcut.Constraint{
@@ -389,8 +503,8 @@ var FlagList = shortcut.Shortcut{
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
@@ -516,10 +630,10 @@ var ChatList = shortcut.Shortcut{
"不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型:group、p2p;省略时默认只返回群聊"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量(1-100)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标(钉钉为整数游标的字符串形式)"},
{Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型只能包含 group 和/或 p2p;省略时默认只返回群聊"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1-100 之间"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
},
@@ -721,7 +835,7 @@ func chatListFilterTypes(chats []map[string]any, types []string) []map[string]an
}
func init() {
shortcut.Register(
shortcut.Register(withReviewedChatShortcutContracts(
ChatCreate,
ChatList,
ChatUpdate,
@@ -730,5 +844,5 @@ func init() {
FlagCancel,
FlagList,
FeedGroupQueryItem,
)
)...)
}
+228 -22
View File
@@ -23,11 +23,14 @@ type larkAlignmentCall struct {
}
type larkAlignmentCaller struct {
calls []larkAlignmentCall
failTarget string
failProductTool string
category string
responses map[string]string
calls []larkAlignmentCall
failTarget string
failProductTool string
failProductToolAt map[string]int
callCounts map[string]int
category string
responses map[string]string
sequenceResponses map[string][]string
}
func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -36,9 +39,16 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
return nil, errors.New("fixture write failed")
}
key := product + "/" + tool
if f.callCounts == nil {
f.callCounts = map[string]int{}
}
f.callCounts[key]++
if f.failProductTool == key {
return nil, errors.New("fixture lower call failed")
}
if f.failProductToolAt[key] == f.callCounts[key] {
return nil, errors.New("fixture sequenced lower call failed")
}
text := `{"success":true}`
switch key {
case "contact/get_current_user_profile":
@@ -75,6 +85,10 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
if response, ok := f.responses[key]; ok {
text = response
}
if responses := f.sequenceResponses[key]; len(responses) > 0 {
text = responses[0]
f.sequenceResponses[key] = responses[1:]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -87,7 +101,67 @@ func (f *larkAlignmentCaller) DryRun() bool { return false }
func (f *larkAlignmentCaller) Fields() string { return "" }
func (f *larkAlignmentCaller) JQ() string { return "" }
func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
func TestCrossPlatformCoverageEvaluationRegressionNaturalGroupTargetsAndRecallInference(t *testing.T) {
t.Run("group name to bots", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`,
"bot/list_group_bots": `{"result":{"bots":[]}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-bots", "--group", "项目群"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].tool != "list_group_bots" || fake.calls[1].args["openConversationId"] != "cid-project" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("group query to invite url", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "项目群"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].tool != "get_group_invite_url" || fake.calls[1].args["openConversationId"] != "cid-project" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("stable id in group query bypasses search", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "cid-fixture-chat-0001"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "get_group_invite_url" ||
fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("message id fills conversation before recall", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-recall", "--message-ids", "msg", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_messages_by_ids" || fake.calls[1].tool != "recall_message" || fake.calls[1].args["openConversationId"] != "cid" {
t.Fatalf("calls = %#v", fake.calls)
}
})
}
func TestCrossPlatformCoverageChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -127,7 +201,80 @@ func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
}
}
func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
func TestCrossPlatformCoverageChatCreateResolvesEveryNaturalMemberBeforeCreating(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user","openDingTalkId":"D-resolved"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-create",
"--name", "测试群",
"--users", "explicit-user",
"--member-query", "张三",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 3 {
t.Fatalf("calls = %#v, want member resolve + current profile + create", fake.calls)
}
if fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "get_current_user_profile" ||
fake.calls[2].tool != "create_group_conversation" {
t.Fatalf("call order = %#v", fake.calls)
}
if got, want := fake.calls[2].args["groupMembers"], []string{"self-user", "explicit-user", "resolved-user"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageChatCreateNaturalMemberAmbiguityStopsBeforeProfileAndCreate(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1"},{"name":"张三","userId":"u2"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-create",
"--name", "测试群",
"--member-query", "张三",
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("ambiguous member unexpectedly created a group")
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("ambiguous member reached profile/create: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageChatCreateNaturalMemberDryRunUsesSameResolutionChain(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-create",
"--name", "测试群",
"--member-query", "张三",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 3 ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "get_current_user_profile" ||
fake.calls[2].tool != "create_group_conversation" {
t.Fatalf("dry-run calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
tests := []struct {
name string
args []string
@@ -201,7 +348,7 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
}
}
func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
func TestCrossPlatformCoverageMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -222,7 +369,7 @@ func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
}
}
func TestLarkAlignmentWriteMappings(t *testing.T) {
func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
tests := []struct {
name string
args []string
@@ -232,11 +379,11 @@ func TestLarkAlignmentWriteMappings(t *testing.T) {
}{
{
name: "chat-update-name-only",
args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"},
args: []string{"chat", "+chat-update", "--group", "cid-fixture-chat-0001", "--name", "新群名", "--yes"},
product: "chat",
tool: "update_group_name",
wantArgs: map[string]any{
"openconversation_id": "cid",
"openconversation_id": "cid-fixture-chat-0001",
"group_name": "新群名",
},
},
@@ -291,10 +438,32 @@ func TestLarkAlignmentWriteMappings(t *testing.T) {
}
}
func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
fake := &larkAlignmentCaller{}
func TestCrossPlatformCoverageObservedChatRenameAliasResolvesNameBeforeWrite(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目评测群"}],"hasMore":false}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-rename", "--group", "项目评测群", "--name", "项目讨论群", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "search_groups" || fake.calls[1].tool != "update_group_name" {
t.Fatalf("calls = %#v", fake.calls)
}
if fake.calls[1].args["openconversation_id"] != "cid-project" || fake.calls[1].args["group_name"] != "项目讨论群" {
t.Fatalf("write args = %#v", fake.calls[1].args)
}
}
func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/send_personal_message": `{"result":{"openMessageId":"new-msg","openConvThreadId":"thread-1","sendStatus":"accepted"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-reply",
"--conversation-id", "cid",
@@ -327,9 +496,46 @@ func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
content["content"] != "收到" {
t.Fatalf("reply content = %#v", content)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != "im.message-reply.v1" ||
payload["messageId"] != "new-msg" ||
payload["conversationId"] != "cid" ||
payload["threadId"] != "thread-1" ||
payload["deliveryStatus"] != "accepted" ||
payload["idempotencyKey"] != "reply-uuid" {
t.Fatalf("reply result context = %#v", payload)
}
referenced, _ := payload["referencedMessage"].(map[string]any)
if referenced["messageId"] != "msg" || referenced["resolutionSource"] != "explicit" {
t.Fatalf("referenced message context = %#v", referenced)
}
}
func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--text", "收到",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("reply dry-run reached write transport: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
fake := &larkAlignmentCaller{failTarget: "m2"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -341,8 +547,8 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
"--conversation-id", "cid",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
if err := root.Execute(); err == nil {
t.Fatal("partial batch failure returned success")
}
if len(fake.calls) != 2 {
t.Fatalf("calls = %#v", fake.calls)
@@ -359,7 +565,7 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
}
}
func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
func TestCrossPlatformCoverageConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -390,7 +596,7 @@ func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T
}
}
func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
func TestCrossPlatformCoverageMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -450,7 +656,7 @@ func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *test
}
}
func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
func TestCrossPlatformCoverageMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -479,7 +685,7 @@ func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
}
}
func TestFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
message := map[string]any{
"content": map[string]any{
"mentions": []any{
@@ -627,7 +833,7 @@ func TestChatListP2POnlyDropsGroups(t *testing.T) {
}
}
func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
func TestCrossPlatformCoverageFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
conversations := []map[string]any{
{"openConversationId": "cid-a", "conversationName": "A"},
{"openConversationId": "cid-b", "conversationName": "B"},
@@ -645,7 +851,7 @@ func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T
}
}
func TestFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
func TestCrossPlatformCoverageFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
fake := &larkAlignmentCaller{
category: `{"result":{"hasMore":true,"list":[{"openConversationId":"cid-a","conversationName":"A"}]}}`,
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chat
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ValidateMessageExportOutput applies the same workspace-relative and
// no-symlink boundary used by message resource downloads. The export target is
// always a file; directory-shaped paths are rejected instead of inventing a
// name.
func ValidateMessageExportOutput(output string) error {
trimmed := strings.TrimSpace(output)
if strings.HasSuffix(trimmed, "/") ||
strings.HasSuffix(trimmed, string(os.PathSeparator)) {
return apperrors.NewValidation("--output 必须是 JSON 文件路径,不能是目录")
}
if !strings.EqualFold(filepath.Ext(trimmed), ".json") {
return apperrors.NewValidation("--output 必须使用 .json 文件扩展名")
}
return validateResourceDownloadOutputFlag(output, "--output")
}
// WriteMessageExportJSON atomically publishes the exact structured message
// ledger. It defaults to no-clobber and never follows a symlink outside the
// current working directory.
func WriteMessageExportJSON(output string, overwrite bool, payload any) (relativePath string, size int, err error) {
if err := ValidateMessageExportOutput(output); err != nil {
return "", 0, err
}
cwd, err := resourceGetwd()
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
base, err := resourceAbs(cwd)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realBase, err := resourceEvalSymlinks(base)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
target := filepath.Join(realBase, filepath.Clean(strings.TrimSpace(output)))
parent := filepath.Dir(target)
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
return "", 0, err
}
realParent, err := resourceEvalSymlinks(parent)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
}
parentRel, err := resourceRel(realBase, realParent)
if err != nil || parentRel == ".." || strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) {
return "", 0, apperrors.NewValidation("--output 解析后逃逸工作目录")
}
target = filepath.Join(realParent, filepath.Base(target))
if info, statErr := resourceLstat(target); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
return "", 0, apperrors.NewValidation("--output 目标不能是符号链接")
}
if info.IsDir() {
return "", 0, apperrors.NewValidation("--output 目标是目录,无法写入 JSON 文件")
}
if !overwrite {
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", 0, apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
}
rendered, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("编码消息导出失败: %v", err))
}
rendered = append(rendered, '\n')
temp, err := resourceCreateTemp(realParent, "."+filepath.Base(target)+".part-*")
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("创建消息导出临时文件失败: %v", err))
}
tempPath := temp.Name()
defer func() {
_ = resourceTempClose(temp)
_ = os.Remove(tempPath)
}()
if _, err := temp.Write(rendered); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("写入消息导出失败: %v", err))
}
if err := resourceTempSync(temp); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("同步消息导出失败: %v", err))
}
if err := resourceTempClose(temp); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("关闭消息导出失败: %v", err))
}
if overwrite {
if err := resourceRename(tempPath, target); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
}
} else if err := resourceLink(tempPath, target); err != nil {
if errors.Is(err, os.ErrExist) {
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
}
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
}
relativePath, err = resourceRel(realBase, target)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
}
return filepath.ToSlash(relativePath), len(rendered), nil
}
+31 -19
View File
@@ -22,6 +22,7 @@ import (
"net/http"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"time"
@@ -44,7 +45,7 @@ var (
resourceCopy = io.Copy
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = os.Rename
resourceRename = replaceFileAtomically
resourceLink = os.Link
resourceDownload = downloadResourceAtomically
)
@@ -58,15 +59,15 @@ var MessagesResourceDownload = shortcut.Shortcut{
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。",
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;下载采用整文件临时落盘后原子发布,不支持 Range 断点续传。按既有安全本地下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文", Enum: []string{"mediaId", "fileId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对路径;不允许绝对路径或 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的目标文件(默认拒绝)"},
},
Constraints: []shortcut.Constraint{
{
@@ -204,24 +205,35 @@ func validateResourceDownloadOutputFlag(output, flagName string) error {
if output == "" {
return apperrors.NewValidation(flagName + " 不能为空")
}
// Reject OS-absolute paths and Unix-rooted forms ("/x") even on Windows,
// where filepath.IsAbs("/x") is false but the path is still not a cwd-relative output.
if filepath.IsAbs(output) || strings.HasPrefix(filepath.ToSlash(output), "/") {
if resourcePathIsAbsolute(output) {
return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径")
}
clean := filepath.Clean(filepath.FromSlash(output))
if resourcePathEscapesBase(clean) {
if resourcePathEscapesBase(output) {
return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录")
}
return nil
}
// resourcePathEscapesBase reports whether rel escapes its base directory.
// Rel values may use '/' or the OS separator (for example mocked filepath.Rel
// results), so normalize before comparing against "..".
func resourcePathEscapesBase(rel string) bool {
rel = filepath.Clean(filepath.FromSlash(strings.TrimSpace(rel)))
return rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator))
func resourcePathIsAbsolute(value string) bool {
if filepath.IsAbs(value) {
return true
}
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if pathpkg.IsAbs(portable) {
return true
}
return len(portable) >= 2 &&
((portable[0] >= 'a' && portable[0] <= 'z') ||
(portable[0] >= 'A' && portable[0] <= 'Z')) &&
portable[1] == ':'
}
// resourcePathEscapesBase reports whether a relative path escapes its base.
// Normalize both separators so the check remains portable on every host OS.
func resourcePathEscapesBase(value string) bool {
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
clean := pathpkg.Clean(portable)
return clean == ".." || strings.HasPrefix(clean, "../")
}
func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) {
@@ -600,5 +612,5 @@ func downloadResourceAtomically(
}
func init() {
shortcut.Register(MessagesResourceDownload)
shortcut.Register(withReviewedChatShortcutContracts(MessagesResourceDownload)...)
}
@@ -599,3 +599,221 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T
t.Fatalf("copied = %q", copied.String())
}
}
func TestCrossPlatformCoverageMessageExportFailureBoundaries(t *testing.T) {
for _, output := range []string{"exports/", "exports/messages.txt"} {
if err := ValidateMessageExportOutput(output); err == nil {
t.Fatalf("ValidateMessageExportOutput(%q) succeeded", output)
}
}
if _, _, err := WriteMessageExportJSON("not-json.txt", false, map[string]any{}); err == nil {
t.Fatal("invalid export path reached write pipeline")
}
baseSetup := func(t *testing.T) string {
t.Helper()
resetResourceDownloadHooks(t)
base := t.TempDir()
resourceGetwd = func() (string, error) { return base, nil }
resourceAbs = filepath.Abs
resourceEvalSymlinks = filepath.EvalSymlinks
resourceLstat = os.Lstat
resourceRel = filepath.Rel
resourceCreateTemp = os.CreateTemp
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = os.Rename
resourceLink = os.Link
return base
}
t.Run("path setup", func(t *testing.T) {
baseSetup(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("getwd failure ignored")
}
})
t.Run("absolute path", func(t *testing.T) {
baseSetup(t)
resourceAbs = func(string) (string, error) { return "", errors.New("abs") }
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("abs failure ignored")
}
})
t.Run("base symlink", func(t *testing.T) {
baseSetup(t)
resourceEvalSymlinks = func(string) (string, error) { return "", errors.New("eval") }
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("base symlink failure ignored")
}
})
t.Run("parent symlink", func(t *testing.T) {
base := baseSetup(t)
calls := 0
resourceEvalSymlinks = func(path string) (string, error) {
calls++
if calls == 1 {
return base, nil
}
return "", errors.New("parent")
}
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
t.Fatal("parent symlink failure ignored")
}
})
t.Run("parent creation", func(t *testing.T) {
baseSetup(t)
resourceLstat = func(string) (os.FileInfo, error) {
return os.Stat(filepath.Join(t.TempDir(), "missing-parent"))
}
resourceMkdir = func(string, os.FileMode) error { return errors.New("mkdir") }
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
t.Fatal("parent creation failure ignored")
}
})
t.Run("parent escapes", func(t *testing.T) {
base := baseSetup(t)
resourceEvalSymlinks = func(path string) (string, error) {
if path == base {
return base, nil
}
return filepath.Dir(base), nil
}
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
t.Fatal("parent escape accepted")
}
})
for _, tc := range []struct {
name string
setup func(t *testing.T, base string)
}{
{name: "existing symlink", setup: func(t *testing.T, base string) {
target := filepath.Join(base, "out.json")
if err := os.Symlink("missing", target); err != nil {
t.Fatal(err)
}
}},
{name: "existing directory", setup: func(t *testing.T, base string) {
if err := os.Mkdir(filepath.Join(base, "out.json"), 0o755); err != nil {
t.Fatal(err)
}
}},
{name: "existing no clobber", setup: func(t *testing.T, base string) {
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
}},
{name: "lstat error", setup: func(t *testing.T, _ string) {
resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") }
}},
} {
t.Run(tc.name, func(t *testing.T) {
base := baseSetup(t)
tc.setup(t, base)
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
t.Fatal("invalid existing target accepted")
}
})
}
for _, tc := range []struct {
name string
overwrite bool
payload any
setup func(t *testing.T, base string)
}{
{name: "marshal", payload: make(chan int)},
{name: "create temp", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") }
}},
{name: "write", payload: map[string]any{}, setup: func(t *testing.T, base string) {
resourceCreateTemp = func(string, string) (*os.File, error) {
file, err := os.CreateTemp(base, "closed-*")
if err == nil {
_ = file.Close()
}
return file, err
}
}},
{name: "sync", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceTempSync = func(*os.File) error { return errors.New("sync") }
}},
{name: "close", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceTempClose = func(file *os.File) error {
_ = file.Close()
return errors.New("close")
}
}},
{name: "rename", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) {
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
resourceRename = func(string, string) error { return errors.New("rename") }
}},
{name: "link exists", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceLink = func(string, string) error { return os.ErrExist }
}},
{name: "link", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
resourceLink = func(string, string) error { return errors.New("link") }
}},
{name: "final rel", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) {
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
calls := 0
resourceRel = func(from, to string) (string, error) {
calls++
if calls < 3 {
return filepath.Rel(from, to)
}
return "", errors.New("rel")
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
base := baseSetup(t)
if tc.setup != nil {
tc.setup(t, base)
}
if _, _, err := WriteMessageExportJSON("out.json", tc.overwrite, tc.payload); err == nil {
t.Fatal("failure hook was ignored")
}
})
}
}
func TestCrossPlatformCoverageMessageExportOverwriteReplacesExistingFile(t *testing.T) {
resetResourceDownloadHooks(t)
base := t.TempDir()
resourceGetwd = func() (string, error) { return base, nil }
resourceAbs = filepath.Abs
resourceEvalSymlinks = filepath.EvalSymlinks
resourceLstat = os.Lstat
resourceRel = filepath.Rel
resourceCreateTemp = os.CreateTemp
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = replaceFileAtomically
resourceLink = os.Link
target := filepath.Join(base, "out.json")
if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil {
t.Fatal(err)
}
relative, size, err := WriteMessageExportJSON(
"out.json", true, map[string]any{"value": "new"},
)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
const want = "{\n \"value\": \"new\"\n}\n"
if relative != "out.json" || size != len(want) || string(data) != want {
t.Fatalf("relative=%q size=%d data=%q", relative, size, data)
}
}

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