Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3519965285 | ||
|
|
a7074bf53f | ||
|
|
21144af79b | ||
|
|
3bdc30badb | ||
|
|
b82e975429 | ||
|
|
2808e71cb6 | ||
|
|
ec99654854 | ||
|
|
9aa76ea748 | ||
|
|
885c3fe021 | ||
|
|
d0d56cbaf5 | ||
|
|
9dbbd64f3c | ||
|
|
7ba12a8e4c | ||
|
|
dfba9546f4 | ||
|
|
82d02096f7 | ||
|
|
b3ba9fee97 | ||
|
|
41372b0597 | ||
|
|
ad08b6b499 | ||
|
|
cffc48406c | ||
|
|
250aab3ef1 | ||
|
|
e36b6dc049 | ||
|
|
f9e3476d42 | ||
|
|
d9d62fb2f7 | ||
|
|
1e04e301ea | ||
|
|
5f038d440b | ||
|
|
f9f61a4cc6 | ||
|
|
2b48f27a4b | ||
|
|
bf79a67efe | ||
|
|
8d22cd553a | ||
|
|
8936c20ef0 | ||
|
|
95d262bbb2 | ||
|
|
d5c260c7c0 | ||
|
|
4f31863aae | ||
|
|
6de2bf1518 | ||
|
|
9c297d0520 | ||
|
|
78b724480e | ||
|
|
37230d2d4d | ||
|
|
4724c30f4b | ||
|
|
fde6b59074 | ||
|
|
76b9f1536a | ||
|
|
809b9b3570 | ||
|
|
e2abc70e84 | ||
|
|
15a27a9f83 | ||
|
|
0be5b73518 | ||
|
|
a637a44b7a | ||
|
|
579eed81d9 | ||
|
|
c68d9facb2 | ||
|
|
1f9138e99a | ||
|
|
c3fd814630 | ||
|
|
5922a0717a | ||
|
|
c5bc1fdad4 | ||
|
|
9567cfd3d8 | ||
|
|
4567dd1cd6 | ||
|
|
1180510f40 | ||
|
|
75bb01bb64 | ||
|
|
2456660780 | ||
|
|
11a7ab8b7c | ||
|
|
c3dbe866c4 | ||
|
|
81f130c483 | ||
|
|
6b99685594 | ||
|
|
2e1cce8501 | ||
|
|
41e0fb381a | ||
|
|
9d59550890 | ||
|
|
870fba823b | ||
|
|
d083de5f84 | ||
|
|
1fb966dbff | ||
|
|
83f13d8e11 | ||
|
|
86bce64cc8 | ||
|
|
68e1a78810 | ||
|
|
e63a4bdf47 | ||
|
|
6ab01a365e | ||
|
|
d855edaad2 | ||
|
|
75fce5c4ff | ||
|
|
d28a50c0f4 | ||
|
|
7987fb3a35 | ||
|
|
3d6a9c6232 | ||
|
|
195569ddfa | ||
|
|
7827856876 | ||
|
|
f79f41e930 | ||
|
|
bfd53df9b2 | ||
|
|
4db117893e | ||
|
|
b314749ef7 | ||
|
|
5133103a54 | ||
|
|
9faa332306 | ||
|
|
17fa1e1b34 | ||
|
|
af1f8ccd05 | ||
|
|
c26cbbbbb8 | ||
|
|
2733f510af | ||
|
|
abc62622fb | ||
|
|
bbecd2f3a6 | ||
|
|
a705c9de0b | ||
|
|
f5d57c2e07 | ||
|
|
967cf26d44 | ||
|
|
d31cae2b0c | ||
|
|
e998e2609d | ||
|
|
4e71f56f97 | ||
|
|
69b8df3e40 | ||
|
|
8d988bc350 | ||
|
|
dc20ddecf6 | ||
|
|
a09790fc3f | ||
|
|
52045fb290 | ||
|
|
275c3430b8 | ||
|
|
56116bf99e |
@@ -438,7 +438,7 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -483,7 +483,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=8m "${packages[@]}"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -race -count=1 -timeout=10m "${packages[@]}"
|
||||
go test -v -race -count=1 -timeout=12m "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (workflow and release contracts)
|
||||
@@ -1160,14 +1160,20 @@ jobs:
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
run: |
|
||||
policy_profile=coverage-policy.txt
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
policy_profile=
|
||||
fi
|
||||
additional_profile=
|
||||
if [ -f coverage-shortcut.txt ]; then
|
||||
additional_profile=coverage-shortcut.txt
|
||||
fi
|
||||
COVERAGE_DIFF_PROFILE="$policy_profile" \
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
|
||||
@@ -51,5 +51,6 @@ jobs:
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
@@ -66,3 +66,6 @@ dwsbin
|
||||
# Local coverage artifacts
|
||||
coverage-shortcut.txt
|
||||
coverage-*.txt
|
||||
|
||||
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
|
||||
/cmd_param_aliases
|
||||
|
||||
@@ -95,11 +95,12 @@ The Schema system has two physically separated processes:
|
||||
**Generation** (build-time, slow, reviewed, one-way):
|
||||
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
|
||||
not in business code).
|
||||
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
|
||||
(standalone Go mains).
|
||||
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
|
||||
MCP metadata + parameter bindings + cobra tree).
|
||||
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
|
||||
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` +
|
||||
`cmd_param_aliases` (standalone Go mains).
|
||||
- Inputs: 7 authored source groups (registry + hints metadata + hints selection +
|
||||
MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree).
|
||||
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` +
|
||||
`param_aliases_generated.go`.
|
||||
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
|
||||
endpoints, merges with previous data for cross-server interface_ref).
|
||||
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
|
||||
|
||||
+83
-2
@@ -6,10 +6,91 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.55-beta.8] - 2026-07-30
|
||||
|
||||
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
|
||||
guarded release delivery. It carries no new product-facing command behavior;
|
||||
the new version is required because the published beta.7 artifacts succeeded
|
||||
on GitHub, npm, and Homebrew, but its enabled optional Gitee mirror failed and
|
||||
left that Release run ineligible for stable promotion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Complete promotion evidence** — republishes the validated v1.0.55 command, Runtime Schema, Skill, authentication, and projection changes with the optional Gitee upload fallback disabled, so the release can produce one successful auditable delivery proof before stable promotion.
|
||||
|
||||
## [1.0.55] - 2026-07-30
|
||||
|
||||
This release promotes the validated `v1.0.55-beta.8` baseline to stable. It
|
||||
expands the public Workspace command surface and personal event consumption,
|
||||
makes the full built-in shortcut catalog available to Agents, and hardens
|
||||
multi-account routing, authentication compatibility, command safety, and
|
||||
response projection across the CLI.
|
||||
|
||||
### Added
|
||||
|
||||
- **Broader Workspace command surface** (#621, #676) — adds roughly 30 reviewed Drive, Doc, Sheet, and Chat leaf commands synchronized from Wukong, including Drive version and permission operations, document styling, Sheet comment/version/formula verification, and in-place text-emotion updates. A reusable declarative `LeafSpec` framework now delivers command identity, safety, selection, and guarded Help metadata consistently.
|
||||
- **Complete Agent-visible shortcut delivery** (#802, #815) — publishes all 210 built-in shortcuts as reviewed Runtime Schema leaves across 16 products, including 88 validated Chat shortcuts, with executable paths, parameters, constraints, selection guidance, dry-run capabilities, and runtime-aligned confirmation semantics.
|
||||
- **Expanded enterprise and event capabilities** (#790) — adds the HR Brain talent-pool, employee-profile, and structured-search command families; `dws mcp url get` resolves MCP Market endpoints; personal event consumption supports eight additional IM event keys, multi-key consumers, and targeted shutdown.
|
||||
- **Agent integration identity** (#804, #816) — adds validated `DWS_AGENT_HOST` and `DWS_AGENT_PRODUCT` labels for observability and product attribution while keeping them separate from authentication and authorization.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Progressive multi-Skill guidance and account safety** (#621, #821) — reorganizes bundled product guidance for progressive discovery and restores the mandatory rule that Agents must not guess an account when a multi-account organization has no unique current default.
|
||||
- **Supported Chat file delivery** — retires the legacy AppKey/AppSecret-backed `chat media upload` command from discovery and routes local files through `chat message send --msg-type file --file-path`, while callers with an existing media ID can continue sending images directly.
|
||||
- **Guarded release delivery** (#791) — strengthens immutable GitHub, npm, Homebrew, optional mirror, recovery, and version-allocation checks while keeping beta and stable publication role-gated and auditable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Name→ID resolution kept external contacts** — the shared contact resolver (`chat +dm`, `+broadcast`, …) no longer drops `search_contact_by_key_word` rows that carry only an `openDingTalkId` (external / cross-org contacts have an empty `userId`), so those people are found instead of reported missing or collapsed into a wrong single match; the display name also falls back through `nick`/`showName`/`flowerName`/`staffName`/`userName`.
|
||||
- **`chat +messages-resource-url` flag aliases** — the media download-URL shortcut now accepts `--msg-id` / `--open-message-id` as aliases for `--message-id` (matching the `openMessageId`/`msgId` output field), so agents chaining from a message list no longer hit "unknown flag".
|
||||
- **Shortcut and message projection correctness** (#706, #783, #795) — prevents successful read shortcuts from silently projecting non-empty backend responses to empty results, renders rich, forwarded, and encrypted message forms safely, and fixes group-bot, bot-search, mail-thread, media-ID alias, and Todo paging response handling.
|
||||
- **Command contract edge cases** (#803) — makes approval revocation and document rollback honor dry-run before confirmation or preflight, fixes Drive and Doc rename semantics, restores Drive-specific metadata, and validates Todo reminder rules.
|
||||
- **Authentication and external-contact compatibility** (#756, #757) — migrates legacy global and organization-scoped credentials without cross-account token borrowing, preserves contacts that expose only `openDingTalkId`, and aligns message-resource flags with message-list output fields.
|
||||
|
||||
## [1.0.55-beta.7] - 2026-07-29
|
||||
|
||||
This beta supersedes the unpublished `v1.0.55-beta.6` candidate and packages
|
||||
PRs #621, #676, #757, #815, #816, and #821. It restores the mandatory
|
||||
multi-account safety rule caught by the sealed-release E2E gate while retaining
|
||||
the reviewed Wukong capability and multi-Skill synchronization, declarative
|
||||
command and Schema delivery, hardened Chat shortcuts, external contact
|
||||
resolution, and Agent product identity on top of the `v1.0.55-beta.5` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
|
||||
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
|
||||
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
|
||||
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
|
||||
- **Multi-account Skill safety contract** (#821) — restores the mandatory rule that an Agent must never choose the first, most recently logged-in, or most recently used account when an organization has multiple accounts without one unique `isOrgCurrent=true` default. A PR-level embedded-Skill regression test now catches removal before the full sealed-release E2E gate.
|
||||
|
||||
## [1.0.55-beta.6] - 2026-07-29
|
||||
|
||||
This beta packages PRs #621, #676, #757, #815, and #816, validating the Wukong
|
||||
capability and multi-Skill synchronization, declarative command and Schema
|
||||
delivery, hardened Chat shortcuts, external contact resolution, and Agent
|
||||
product identity on top of the `v1.0.55-beta.5` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
|
||||
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
|
||||
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
|
||||
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
|
||||
|
||||
## [1.0.55-beta.5] - 2026-07-28
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.55-beta.5"
|
||||
version "1.0.55-beta.8"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-arm64.tar.gz"
|
||||
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-arm64.tar.gz"
|
||||
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
|
||||
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
|
||||
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
|
||||
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.54"
|
||||
version "1.0.55"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
|
||||
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
|
||||
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
|
||||
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
|
||||
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
|
||||
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
|
||||
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
|
||||
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
|
||||
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -87,6 +87,9 @@ policy: test-auth-legacy-compat
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.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
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
|
||||
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
|
||||
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
|
||||
@@ -130,10 +133,14 @@ test-schema-agent-examples:
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
registry_guard=$$(mktemp -d); \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
metadata_guard=$$(mktemp -d); \
|
||||
selection_guard=$$(mktemp -d); \
|
||||
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
|
||||
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
@@ -141,6 +148,22 @@ generate-schema:
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
|
||||
exit 1; \
|
||||
|
||||
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
|
||||
orchestrator can determine when the consumer is ready, stop it cleanly,
|
||||
and machine-read why it exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
Scope of this branch: the five **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
@@ -92,6 +92,73 @@ Ownership-based cleanup:
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
### 5. Subscription-create retry orchestration and local guard
|
||||
|
||||
This policy covers all 16 public personal-event keys and every logical
|
||||
subscription in a multi-event command. It applies only before the ready
|
||||
marker; reconnecting an established Stream remains a separate mechanism.
|
||||
|
||||
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
|
||||
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
|
||||
process sends at most one subscription-create HTTP request for a logical
|
||||
subscription and performs no in-process automatic retry. The CLI persists
|
||||
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
|
||||
not persist or enforce the Agent/host attempt count across invocations.
|
||||
- ID resolution, `event consume`, and later `event status/stop` must use the
|
||||
same `--profile`. A user or conversation ID resolved under another profile
|
||||
must not be reused for the current subscription.
|
||||
- A logical subscription is keyed by the current profile/identity, event key,
|
||||
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
|
||||
does not create a new logical operation or reset the Agent/host budget.
|
||||
- For the Agent/host, `retryable=false` means
|
||||
`max_additional_attempts=0`.
|
||||
- For the Agent/host, `retryable=true` means
|
||||
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
|
||||
`next_retry_at` when present and must not retry early.
|
||||
- For the Agent/host, an omitted retryable value
|
||||
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
|
||||
failure stops the operation.
|
||||
- `in_flight` means the original logical request is still running.
|
||||
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
|
||||
it. These states must not recursively launch `event consume`, start a
|
||||
parallel equivalent subscription, or bypass the guard with a new subId or
|
||||
trace. The caller waits for the original request/guard or stops, while the
|
||||
Agent/host keeps its own orchestration count.
|
||||
- A multi-event command remains one original operation. A caller must not
|
||||
split out a failed event, reorder events, or restart the command to bypass
|
||||
a budget. Existing startup rollback cleans subscriptions created before a
|
||||
later item fails.
|
||||
|
||||
#### Local guard state operations
|
||||
|
||||
- The default open-edition state file is
|
||||
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
|
||||
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
|
||||
that edition's directory instead of `open`.
|
||||
- The identity directory is mode `0700`; both
|
||||
`personal_subscription_attempts.json` and
|
||||
`personal_subscription_attempts.lock` are mode `0600`.
|
||||
- A failure streak resets after 24h without another failure. A
|
||||
`terminal_hold` lasts 1h. Prefer waiting until the reported
|
||||
`next_retry_at`; do not clear the file as a normal retry mechanism.
|
||||
- For emergency recovery, first ensure that no subscription-create process is
|
||||
running for that identity. Delete only
|
||||
`personal_subscription_attempts.json`, never the lock file. This clears
|
||||
every protection record for that identity, not just one event.
|
||||
|
||||
**Verification**
|
||||
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
|
||||
contract and explicitly reject describing it as a CLI-persisted hard cap.
|
||||
- T5b (CLI): one process issues at most one create request per logical
|
||||
subscription; a changed subId/trace or process restart does not bypass the
|
||||
persisted fingerprint guard.
|
||||
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
|
||||
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
|
||||
and a partial startup still rolls back earlier subscriptions.
|
||||
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
|
||||
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
|
||||
operational recovery instructions.
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
|
||||
+38
-1
@@ -5,7 +5,8 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 宿主观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
|
||||
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
|
||||
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
@@ -13,6 +14,42 @@
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
|
||||
|
||||
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
|
||||
observation signals. They are not credentials, attestations, or proof of the
|
||||
calling host's identity. DingTalk services may record them for logs/BI and may
|
||||
combine supported values with separately authenticated context for PAT
|
||||
compatibility, PAT identity/source derivation, or Discovery eligibility. A
|
||||
service must allowlist supported values and must never grant access, bypass
|
||||
authentication, or skip authorization solely because either Header claims a
|
||||
particular product or runtime form. They are not used to select ordinary MCP
|
||||
tool endpoints.
|
||||
|
||||
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
|
||||
named `clawType` tool argument on IM send operations is an independent
|
||||
message-display axis used for the “Send from AI” label. It remains controlled
|
||||
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
|
||||
`DWS_AGENT_PRODUCT` does not change that message label.
|
||||
|
||||
For QwenWork, report the dimensions separately:
|
||||
|
||||
```bash
|
||||
DWS_AGENT_PRODUCT=qwenwork
|
||||
DWS_AGENT_HOST=cloud # or desktop
|
||||
```
|
||||
|
||||
Do not set arbitrary product values that the target service has not explicitly
|
||||
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
|
||||
generic syntax for compatibility, but new integrations should use the
|
||||
two-dimensional convention above.
|
||||
|
||||
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
|
||||
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
|
||||
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
|
||||
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
|
||||
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
| Code | Category | Description / 描述 |
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
const (
|
||||
envDWSAgentHost = "DWS_AGENT_HOST"
|
||||
headerDWSAgentHost = "x-dws-agent-host"
|
||||
maxAgentHostBytes = 64
|
||||
)
|
||||
|
||||
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
@@ -32,23 +33,27 @@ func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentHost,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
|
||||
Example: "qwenwork_cloud",
|
||||
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
|
||||
Example: "cloud",
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentHost normalizes and validates the caller-provided observation
|
||||
// label. CR/LF is rejected before trimming so it can never be hidden at the
|
||||
// edge of a value. An unset or whitespace-only value means "do not emit".
|
||||
// parseAgentHost normalizes and validates the caller-declared runtime-form
|
||||
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
|
||||
// or Unicode whitespace remains visible to validation and is rejected. An
|
||||
// unset or ASCII-whitespace-only value means "do not emit".
|
||||
func parseAgentHost(raw string) (string, error) {
|
||||
if strings.ContainsAny(raw, "\r\n") {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(raw)
|
||||
value := strings.Trim(raw, " \t")
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(value) > maxAgentHostBytes {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
if !agentHostPattern.MatchString(value) {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
@@ -59,7 +64,7 @@ func invalidAgentHostError() error {
|
||||
// Do not include the raw environment value in the error: it is an
|
||||
// untrusted caller-controlled string and may contain sensitive data.
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
|
||||
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
|
||||
apperrors.WithReason("invalid_agent_host"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -32,12 +33,14 @@ func TestParseAgentHost(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "unset", raw: "", want: ""},
|
||||
{name: "whitespace only", raw: " \t\u3000", want: ""},
|
||||
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
|
||||
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
|
||||
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
|
||||
{name: "ASCII whitespace only", raw: " \t ", want: ""},
|
||||
{name: "cloud", raw: "cloud", want: "cloud"},
|
||||
{name: "desktop", raw: "desktop", want: "desktop"},
|
||||
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
|
||||
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
|
||||
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
|
||||
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
|
||||
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
|
||||
}
|
||||
for _, tc := range valid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -64,6 +67,12 @@ func TestParseAgentHost(t *testing.T) {
|
||||
{name: "leading dash", raw: "-qwenwork"},
|
||||
{name: "leading underscore", raw: "_qwenwork"},
|
||||
{name: "control character", raw: "qwenwork\x00cloud"},
|
||||
{name: "vertical tab", raw: "\vcloud"},
|
||||
{name: "form feed", raw: "cloud\f"},
|
||||
{name: "next line", raw: "cloud\u0085"},
|
||||
{name: "non-breaking space", raw: "\u00a0cloud"},
|
||||
{name: "ideographic space", raw: "cloud\u3000"},
|
||||
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -91,6 +100,7 @@ func TestParseAgentHost(t *testing.T) {
|
||||
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
t.Setenv(envDingtalkAgent, "agent-test")
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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 (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: agentproduct.EnvName,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
|
||||
DefaultValue: "由当前发行版决定",
|
||||
Example: "qwenwork",
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentProduct converts the reusable package error into the CLI's stable
|
||||
// structured validation error without exposing the untrusted raw value.
|
||||
func parseAgentProduct(raw string) (string, error) {
|
||||
value, err := agentproduct.Parse(raw)
|
||||
if err != nil {
|
||||
return "", invalidAgentProductError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func invalidAgentProductError() error {
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
|
||||
apperrors.WithReason("invalid_agent_product"),
|
||||
)
|
||||
}
|
||||
|
||||
// resolveEffectiveAgentProduct resolves the request-header identity with one
|
||||
// shared precedence rule: a valid non-empty runtime override wins, otherwise
|
||||
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
|
||||
// Invalid runtime input falls back here for library callers that bypass root
|
||||
// validation; normal CLI execution rejects it before network access.
|
||||
func resolveEffectiveAgentProduct(headers map[string]string) string {
|
||||
fallback := edition.DefaultOSSClawType
|
||||
if value := headers[agentproduct.HeaderName]; value != "" {
|
||||
fallback = value
|
||||
}
|
||||
value, err := agentproduct.ResolveFromEnv(fallback)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func applyAgentProductOverride(headers map[string]string) map[string]string {
|
||||
value := resolveEffectiveAgentProduct(headers)
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
headers[agentproduct.HeaderName] = value
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
|
||||
const invalidValue = "DO_NOT ECHO"
|
||||
|
||||
got, err := parseAgentProduct(invalidValue)
|
||||
if got != "" {
|
||||
t.Fatalf("parseAgentProduct() = %q, want empty", got)
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if appErr.Reason != "invalid_agent_product" {
|
||||
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
headers["x-edition-header"] = "preserved"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "enterprise-default"
|
||||
headers["x-enterprise-header"] = "preserved"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("unset keeps edition default", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[agentproduct.HeaderName]; got != "wukong" {
|
||||
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid override is final", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, " qwenwork ")
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
if got := headers["x-edition-header"]; got != "preserved" {
|
||||
t.Fatalf("edition header = %q, want preserved", got)
|
||||
}
|
||||
if got := headers["x-enterprise-header"]; got != "preserved" {
|
||||
t.Fatalf("enterprise header = %q, want preserved", got)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid library input falls back to edition", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwen work")
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[agentproduct.HeaderName]; got != "wukong" {
|
||||
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
headers := applyAgentProductOverride(nil)
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT ECHO"
|
||||
t.Setenv(agentproduct.EnvName, invalidValue)
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||
hookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("root error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
|
||||
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("root error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
hookCalled = true
|
||||
headers[agentproduct.HeaderName] = "enterprise-default"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
ClawTypeValue: "message-brand",
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
if got := edition.ClawType(); got != "message-brand" {
|
||||
t.Fatalf("message clawType = %q, want message-brand", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
credentialHookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
|
||||
credentialHookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if !credentialHookCalled {
|
||||
t.Fatal("EnterpriseCredentialHeaders hook was not called")
|
||||
}
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
if got := effectiveClawType(); got != "qwenwork" {
|
||||
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
|
||||
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
|
||||
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
|
||||
}
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "invalid product")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -346,6 +347,7 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
t.Fatal("host control enabled without agent code")
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
|
||||
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("default claw type = %q", got)
|
||||
|
||||
@@ -199,6 +199,14 @@ func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
|
||||
sharedAuditSink = previousSink
|
||||
loadTokenForProfile = previousLoader
|
||||
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
|
||||
// The process-wide sink was initialized by TestMain. Preserve that
|
||||
// initialized state when restoring it: leaving auditSinkOnce unused
|
||||
// lets a later runner overwrite the live sink without closing its
|
||||
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
|
||||
auditSinkOnce.Do(func() {})
|
||||
if got := setupAuditSink(); got != previousSink {
|
||||
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
|
||||
}
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// 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 (
|
||||
stderrors "errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestAllDistributionBooleanFlagTypesNormalizeDetachedLiterals(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
unique := make(map[string]pipeline.FlagInfo)
|
||||
var visit func(*cobra.Command)
|
||||
visit = func(command *cobra.Command) {
|
||||
for _, spec := range pipeline.FlagInfoFromCommand(command) {
|
||||
if spec.Type != "bool" && spec.Type != "boolean" {
|
||||
continue
|
||||
}
|
||||
key := strings.Join([]string{spec.Name, spec.Shorthand, spec.Type}, "\x00")
|
||||
unique[key] = spec
|
||||
}
|
||||
for _, child := range command.Commands() {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
|
||||
keys := make([]string, 0, len(unique))
|
||||
for key := range unique {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) < 80 {
|
||||
t.Fatalf("boolean flag contract coverage is unexpectedly small: %d", len(keys))
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
spec := unique[key]
|
||||
for _, value := range []string{"true", "false"} {
|
||||
t.Run(spec.Name+"/"+value, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Command: "dws contract probe",
|
||||
Args: []string{"--" + spec.Name, value},
|
||||
FlagSpecs: []pipeline.FlagInfo{spec},
|
||||
}
|
||||
if err := (handlers.BoolValueHandler{}).Handle(ctx); err != nil {
|
||||
t.Fatalf("BoolValueHandler.Handle() error = %v", err)
|
||||
}
|
||||
want := []string{"--" + spec.Name + "=" + value}
|
||||
if !reflect.DeepEqual(ctx.Args, want) {
|
||||
t.Fatalf("normalized args = %v, want %v", ctx.Args, want)
|
||||
}
|
||||
|
||||
flags := pflag.NewFlagSet(spec.Name, pflag.ContinueOnError)
|
||||
flags.Bool(spec.Name, false, "")
|
||||
if err := flags.Parse(ctx.Args); err != nil {
|
||||
t.Fatalf("pflag rejected normalized args %v: %v", ctx.Args, err)
|
||||
}
|
||||
got, err := flags.GetBool(spec.Name)
|
||||
if err != nil || got != (value == "true") || !flags.Changed(spec.Name) {
|
||||
t.Fatalf("parsed %s = %v, changed=%v, error=%v", spec.Name, got, flags.Changed(spec.Name), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
t.Logf("verified detached boolean syntax for %d distinct distribution flag contracts", len(keys))
|
||||
}
|
||||
|
||||
func TestBooleanSyntaxPreservesDefaultsRequiredAndChangedContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
flag string
|
||||
value string
|
||||
wantDefault string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "root default false", path: "chat bot find", flag: "dry-run", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
{name: "root mock default false", path: "chat bot find", flag: "mock", value: "true", wantDefault: "false", wantValue: "true"},
|
||||
{name: "local force default false", path: "upgrade", flag: "force", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
{name: "local default true", path: "sheet find", flag: "match-case", value: "false", wantDefault: "true", wantValue: "false"},
|
||||
{name: "required explicit false", path: "contact dept create", flag: "create-dept-group", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
{name: "changed false remains explicit", path: "sheet csv-put", flag: "allow-overwrite", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
leaf := resolveParamLeaf(root, test.path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("command %q is not runnable", test.path)
|
||||
}
|
||||
flag := booleanContractFlag(leaf, test.flag)
|
||||
if flag == nil || flag.DefValue != test.wantDefault || flag.Changed {
|
||||
t.Fatalf("initial --%s contract = %#v, want default %q and unchanged", test.flag, flag, test.wantDefault)
|
||||
}
|
||||
|
||||
pathArgs := strings.Fields(test.path)
|
||||
rawArgs := append(append([]string(nil), pathArgs...), "--"+test.flag, test.value)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(pathArgs):]
|
||||
if err := leaf.ParseFlags(flagArgs); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", flagArgs, err)
|
||||
}
|
||||
flag = booleanContractFlag(leaf, test.flag)
|
||||
if flag == nil || flag.Value.String() != test.wantValue || !flag.Changed {
|
||||
t.Fatalf("final --%s contract = %#v, want value %q and changed", test.flag, flag, test.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedDryRunValuesReachTheExpectedFinalDispatchBoundary(t *testing.T) {
|
||||
base := []string{
|
||||
"mail", "folder", "update",
|
||||
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
|
||||
}
|
||||
|
||||
bareArgs := append(append([]string(nil), base...), "--dry-run")
|
||||
_, barePreview, bareAttempts, bareErr := executeParamAliasDryRunE2E(t, bareArgs...)
|
||||
if bareErr != nil || !barePreview.DryRun || barePreview.Executed || len(bareAttempts) != 0 {
|
||||
t.Fatalf("bare dry-run = preview:%#v attempts:%#v error:%v", barePreview, bareAttempts, bareErr)
|
||||
}
|
||||
|
||||
trueArgs := append(append([]string(nil), base...), "--dry-run", "TRUE")
|
||||
trueCtx, truePreview, trueAttempts, trueErr := executeParamAliasDryRunE2E(t, trueArgs...)
|
||||
if trueErr != nil || !reflect.DeepEqual(truePreview, barePreview) || len(trueAttempts) != 0 {
|
||||
t.Fatalf("detached true = context:%#v preview:%#v attempts:%#v error:%v", trueCtx, truePreview, trueAttempts, trueErr)
|
||||
}
|
||||
if !hasBooleanCorrection(trueCtx, "--dry-run TRUE", "--dry-run=true") {
|
||||
t.Fatalf("detached true correction = %#v", trueCtx)
|
||||
}
|
||||
|
||||
falseCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "detached", args: append(append([]string(nil), base...), "--dry-run", "false")},
|
||||
{name: "explicit", args: append(append([]string(nil), base...), "--dry-run=false")},
|
||||
}
|
||||
var wantAttempts []any
|
||||
for _, test := range falseCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, _, attempts, err := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "dry-run reached the injected command runner") {
|
||||
t.Fatalf("dry-run=false dispatch error = %v", err)
|
||||
}
|
||||
if len(attempts) != 1 || attempts[0].DryRun {
|
||||
t.Fatalf("dry-run=false attempts = %#v", attempts)
|
||||
}
|
||||
if test.name == "detached" && !hasBooleanCorrection(ctx, "--dry-run false", "--dry-run=false") {
|
||||
t.Fatalf("detached false correction = %#v", ctx)
|
||||
}
|
||||
serialized := []any{attempts[0].CanonicalProduct, attempts[0].Tool, attempts[0].Params, attempts[0].DryRun}
|
||||
if wantAttempts == nil {
|
||||
wantAttempts = serialized
|
||||
} else if !reflect.DeepEqual(serialized, wantAttempts) {
|
||||
t.Fatalf("detached and explicit false dispatch differ\nwant=%#v\ngot=%#v", wantAttempts, serialized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContradictoryBooleanValuesFailBeforeDestructiveDispatch(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"mail", "thread", "trash",
|
||||
"--email", "user@example.com", "--id", "conversation-1",
|
||||
"--yes", "true", "--yes=false",
|
||||
)
|
||||
var conflict *pipeline.BoolValueConflictError
|
||||
if !stderrors.As(err, &conflict) {
|
||||
t.Fatalf("conflicting confirmation error = %v, want BoolValueConflictError (ctx=%#v)", err, ctx)
|
||||
}
|
||||
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
|
||||
t.Fatalf("conflict = %#v", conflict)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("conflicting confirmation reached destructive dispatch: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func booleanContractFlag(command *cobra.Command, name string) *pflag.Flag {
|
||||
if command == nil {
|
||||
return nil
|
||||
}
|
||||
if flag := command.Flags().Lookup(name); flag != nil {
|
||||
return flag
|
||||
}
|
||||
return command.InheritedFlags().Lookup(name)
|
||||
}
|
||||
|
||||
func hasBooleanCorrection(ctx *pipeline.Context, original, corrected string) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "boolvalue" && correction.Original == original && correction.Corrected == corrected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1657,12 +1657,13 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
|
||||
CorpID: "corp", UserID: "user", ClientID: "client",
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
var cancelCount int
|
||||
var subscribeCount, cancelCount int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/event/sublist":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
|
||||
case "/subscription/user":
|
||||
subscribeCount++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
|
||||
case "/subscription/cancel":
|
||||
cancelCount++
|
||||
@@ -1697,8 +1698,8 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
|
||||
t.Fatal("invalid foreground consume succeeded")
|
||||
}
|
||||
if cancelCount == 0 {
|
||||
t.Fatal("failed foreground consume did not clean up subscription")
|
||||
if subscribeCount != 0 || cancelCount != 0 {
|
||||
t.Fatalf("invalid local configuration reached subscription control: subscribe=%d cancel=%d", subscribeCount, cancelCount)
|
||||
}
|
||||
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
|
||||
|
||||
@@ -161,7 +161,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
"subscribe-id", "rule", "event-types", "filter",
|
||||
"foreground", "force", "debug-raw-events",
|
||||
); err != nil {
|
||||
return fmt.Errorf("event consume: %w", err)
|
||||
return fmt.Errorf("event consume: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
}
|
||||
personalOpts.Common = commonConsumeOptions{
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/pkg/config"
|
||||
)
|
||||
|
||||
const personalSubscriptionAttemptOperation = "event.consume.personal.subscribe"
|
||||
|
||||
type personalSubscriptionAttemptStore interface {
|
||||
Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error)
|
||||
CompleteSuccess(*personal.AttemptClaim) error
|
||||
CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error)
|
||||
Release(*personal.AttemptClaim) error
|
||||
}
|
||||
|
||||
var (
|
||||
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
|
||||
return personal.NewAttemptStore(workDir)
|
||||
}
|
||||
personalSubscriptionAttemptNow = time.Now
|
||||
)
|
||||
|
||||
type personalSubscriptionAttemptItem struct {
|
||||
eventKey string
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type personalSubscriptionAttemptReservation struct {
|
||||
store personalSubscriptionAttemptStore
|
||||
claim *personal.AttemptClaim
|
||||
items []personalSubscriptionAttemptItem
|
||||
}
|
||||
|
||||
type personalSubscriptionFailureClass struct {
|
||||
retryability personal.Retryability
|
||||
retryAfter time.Duration
|
||||
code string
|
||||
traceID string
|
||||
reason string
|
||||
auth bool
|
||||
}
|
||||
|
||||
func reservePersonalSubscriptionAttempts(
|
||||
workDir string,
|
||||
client *personal.Client,
|
||||
identity personal.Identity,
|
||||
profileSelector string,
|
||||
plans []personalConsumeOptions,
|
||||
) (*personalSubscriptionAttemptReservation, error) {
|
||||
if len(plans) == 0 {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: no subscription attempts to reserve"),
|
||||
)
|
||||
}
|
||||
if client == nil {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: nil subscription control client"),
|
||||
)
|
||||
}
|
||||
if err := validatePersonalSubscriptionEndpoint(client.BaseURL); err != nil {
|
||||
return nil, personalSubscriptionValidationError(err)
|
||||
}
|
||||
|
||||
items := make([]personalSubscriptionAttemptItem, 0, len(plans))
|
||||
specs := make([]personal.AttemptSpec, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
prepared, err := preparePersonalSubscription(identity, plan)
|
||||
if err != nil {
|
||||
return nil, personalSubscriptionValidationError(err)
|
||||
}
|
||||
fingerprint := personal.Fingerprint(
|
||||
client.BaseURL,
|
||||
prepared.Request.IdempotencyKey,
|
||||
profileSelector,
|
||||
)
|
||||
items = append(items, personalSubscriptionAttemptItem{
|
||||
eventKey: prepared.EventKey,
|
||||
fingerprint: fingerprint,
|
||||
})
|
||||
specs = append(specs, personal.AttemptSpec{
|
||||
Fingerprint: fingerprint,
|
||||
EventKey: prepared.EventKey,
|
||||
})
|
||||
}
|
||||
|
||||
store := personalNewSubscriptionAttemptStore(workDir)
|
||||
if store == nil {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: subscription attempt store is unavailable"),
|
||||
)
|
||||
}
|
||||
claim, err := store.Claim(specs, personalSubscriptionAttemptLease(client, len(specs)))
|
||||
if err != nil {
|
||||
var blocked *personal.AttemptBlockedError
|
||||
if errors.As(err, &blocked) {
|
||||
return nil, personalSubscriptionBlockedError(blocked)
|
||||
}
|
||||
return nil, personalSubscriptionGuardError(err)
|
||||
}
|
||||
return &personalSubscriptionAttemptReservation{
|
||||
store: store,
|
||||
claim: claim,
|
||||
items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionEndpoint(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" ||
|
||||
(!strings.EqualFold(parsed.Scheme, "http") &&
|
||||
!strings.EqualFold(parsed.Scheme, "https")) {
|
||||
if err == nil {
|
||||
err = errors.New("an absolute http(s) URL is required")
|
||||
}
|
||||
return fmt.Errorf("personal event: invalid subscription control endpoint %q: %w", raw, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalSubscriptionAttemptLease(client *personal.Client, batchSize int) time.Duration {
|
||||
const (
|
||||
leaseOverhead = 30 * time.Second
|
||||
minLease = time.Minute
|
||||
maxLease = 10 * time.Minute
|
||||
)
|
||||
if batchSize < 1 {
|
||||
batchSize = 1
|
||||
}
|
||||
timeout := config.HTTPTimeout
|
||||
if client != nil && client.HTTPClient != nil && client.HTTPClient.Timeout > 0 {
|
||||
timeout = client.HTTPClient.Timeout
|
||||
}
|
||||
maxRequestBudget := maxLease - leaseOverhead
|
||||
if timeout <= 0 || timeout > maxRequestBudget/time.Duration(batchSize) {
|
||||
return maxLease
|
||||
}
|
||||
lease := timeout*time.Duration(batchSize) + leaseOverhead
|
||||
if lease < minLease {
|
||||
return minLease
|
||||
}
|
||||
return lease
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
)
|
||||
}
|
||||
if err := r.store.CompleteSuccess(r.claim); err != nil {
|
||||
return personalSubscriptionGuardError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeFailure(
|
||||
ctx context.Context,
|
||||
failedIndex int,
|
||||
succeededCount int,
|
||||
cause error,
|
||||
override *personalSubscriptionFailureClass,
|
||||
) error {
|
||||
if r == nil {
|
||||
return cause
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
cause,
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
))
|
||||
}
|
||||
if failedIndex < 0 || failedIndex >= len(r.items) ||
|
||||
succeededCount < 0 || succeededCount > failedIndex {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
cause,
|
||||
errors.New("personal event: invalid subscription attempt completion indexes"),
|
||||
))
|
||||
}
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
// Cancellation is not a failed attempt. Restoring the claim normally
|
||||
// completes immediately; if the lock cannot be acquired, leaving the
|
||||
// finite lease behind is still safer than recording a false failure.
|
||||
_ = r.store.Release(r.claim)
|
||||
return cause
|
||||
}
|
||||
|
||||
classification := classifyPersonalSubscriptionFailure(cause, personalSubscriptionAttemptNow())
|
||||
if override != nil {
|
||||
classification = *override
|
||||
}
|
||||
succeeded := make([]string, 0, succeededCount)
|
||||
for i := 0; i < succeededCount; i++ {
|
||||
succeeded = append(succeeded, r.items[i].fingerprint)
|
||||
}
|
||||
hold, err := r.store.CompleteFailure(r.claim, succeeded, personal.AttemptFailure{
|
||||
Fingerprint: r.items[failedIndex].fingerprint,
|
||||
Retryability: classification.retryability,
|
||||
RetryAfter: classification.retryAfter,
|
||||
ErrorCode: classification.code,
|
||||
TraceID: classification.traceID,
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionGuardError(errors.Join(cause, err))
|
||||
}
|
||||
return personalSubscriptionFailureError(cause, classification, hold)
|
||||
}
|
||||
|
||||
func personalSubscriptionCanceled(ctx context.Context, err error) bool {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
|
||||
}
|
||||
|
||||
func classifyPersonalSubscriptionFailure(err error, now time.Time) personalSubscriptionFailureClass {
|
||||
classification := personalSubscriptionFailureClass{
|
||||
retryability: personal.RetryabilityUnknown,
|
||||
reason: "personal_subscription_unknown",
|
||||
}
|
||||
|
||||
var apiErr *personal.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
classification.code = strings.TrimSpace(apiErr.Code)
|
||||
classification.traceID = strings.TrimSpace(apiErr.TraceID)
|
||||
classification.retryAfter = personalAPIRetryDelay(apiErr, now)
|
||||
classification.auth = personalSubscriptionAuthFailure(apiErr.HTTPStatus, apiErr.Code)
|
||||
switch {
|
||||
case apiErr.Retryable != nil && *apiErr.Retryable:
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_server_retryable"
|
||||
case apiErr.Retryable != nil:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_server_non_retryable"
|
||||
case apiErr.HTTPStatus == http.StatusRequestTimeout ||
|
||||
apiErr.HTTPStatus == http.StatusTooEarly ||
|
||||
apiErr.HTTPStatus == http.StatusTooManyRequests ||
|
||||
apiErr.HTTPStatus >= http.StatusInternalServerError:
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_transient_http"
|
||||
case apiErr.HTTPStatus == http.StatusUnauthorized ||
|
||||
apiErr.HTTPStatus == http.StatusForbidden:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_auth"
|
||||
case personalSubscriptionTerminalBusinessCode(apiErr.Code):
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_business_rejected"
|
||||
case personalSubscriptionErrorHasSubscribeID(apiErr):
|
||||
// A few legacy/proxy error shapes include an existing subscription
|
||||
// ID without a stable server contract. Keep the response as an
|
||||
// error, but do not turn that unverified shape into a one-hour hold.
|
||||
classification.reason = "personal_subscription_unverified_existing_id"
|
||||
case apiErr.HTTPStatus >= http.StatusBadRequest:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_http_rejected"
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_timeout"
|
||||
return classification
|
||||
}
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
if strings.EqualFold(strings.TrimSpace(urlErr.Op), "parse") {
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_invalid"
|
||||
return classification
|
||||
}
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
if strings.Contains(lower, "access token") || strings.Contains(lower, "oauth") {
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_auth"
|
||||
classification.auth = true
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
func personalSubscriptionErrorHasSubscribeID(apiErr *personal.APIError) bool {
|
||||
if apiErr == nil {
|
||||
return false
|
||||
}
|
||||
subscribeID, ok := apiErr.Details["subscribe_id"].(string)
|
||||
return ok && strings.TrimSpace(subscribeID) != ""
|
||||
}
|
||||
|
||||
func personalAPIRetryDelay(apiErr *personal.APIError, now time.Time) time.Duration {
|
||||
if apiErr == nil {
|
||||
return 0
|
||||
}
|
||||
var delay time.Duration
|
||||
if apiErr.RetryAfterSeconds != nil {
|
||||
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(*apiErr.RetryAfterSeconds))
|
||||
}
|
||||
if apiErr.NextRetryAt != nil {
|
||||
delay = maxPersonalRetryDelay(delay, apiErr.NextRetryAt.Sub(now))
|
||||
}
|
||||
if raw, ok := apiErr.Details["retry_after"].(string); ok {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(seconds))
|
||||
} else if next, err := http.ParseTime(raw); err == nil {
|
||||
delay = maxPersonalRetryDelay(delay, next.Sub(now))
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func personalRetrySeconds(seconds int64) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
if seconds > math.MaxInt64/int64(time.Second) {
|
||||
return time.Duration(math.MaxInt64)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func maxPersonalRetryDelay(left, right time.Duration) time.Duration {
|
||||
if right > left {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func personalSubscriptionTerminalBusinessCode(raw string) bool {
|
||||
code := strings.ToUpper(strings.TrimSpace(raw))
|
||||
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
|
||||
code = replacer.Replace(code)
|
||||
|
||||
// Keep this list deliberately conservative. Unknown server codes must stay
|
||||
// unknown so a newly introduced transient condition cannot accidentally be
|
||||
// converted into a one-hour terminal hold.
|
||||
switch code {
|
||||
case "INVALID_PARAM", "INVALID_PARAMS", "INVALID_PARAMETER", "INVALID_PARAMETERS",
|
||||
"ILLEGAL_PARAM", "ILLEGAL_PARAMS", "ILLEGAL_PARAMETER", "ILLEGAL_PARAMETERS",
|
||||
"PARAM_ERROR", "PARAMETER_ERROR",
|
||||
"CLIENT_ID_REQUIRED", "SOURCE_ID_REQUIRED", "EVENT_KEY_REQUIRED", "RULE_TYPE_REQUIRED",
|
||||
"NO_AUTH", "NO_PERMISSION", "PERMISSION_DENIED", "ACCESS_DENIED",
|
||||
"FORBIDDEN", "UNAUTHORIZED",
|
||||
"NOT_FOUND", "NOT_EXIST", "NOT_SUPPORTED", "UNSUPPORTED",
|
||||
"UNIFIED_APP_ID_NOT_FOUND":
|
||||
return true
|
||||
}
|
||||
|
||||
// Resource-qualified variants are stable business-rejection shapes. Avoid
|
||||
// broad substring matching (for example, RETRY_REQUIRED must remain
|
||||
// unknown).
|
||||
for _, suffix := range []string{
|
||||
"_NOT_BELONG_TO_ORG",
|
||||
"_DOES_NOT_BELONG_TO_ORG",
|
||||
"_NOT_FOUND",
|
||||
"_NOT_EXIST",
|
||||
"_NOT_SUPPORTED",
|
||||
"_UNSUPPORTED",
|
||||
"_NO_PERMISSION",
|
||||
"_PERMISSION_DENIED",
|
||||
"_ACCESS_DENIED",
|
||||
} {
|
||||
if strings.HasSuffix(code, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personalSubscriptionAuthFailure(status int, rawCode string) bool {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(rawCode))
|
||||
for _, marker := range []string{
|
||||
"NO_AUTH", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION", "ACCESS_DENIED",
|
||||
} {
|
||||
if strings.Contains(code, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personalSubscriptionFailureError(
|
||||
cause error,
|
||||
classification personalSubscriptionFailureClass,
|
||||
hold personal.AttemptHold,
|
||||
) error {
|
||||
options := personalSubscriptionErrorOptions(
|
||||
classification.retryability,
|
||||
hold.RetryAfter,
|
||||
hold.NextAllowedAt,
|
||||
classification.code,
|
||||
classification.traceID,
|
||||
classification.reason,
|
||||
cause,
|
||||
)
|
||||
message := cause.Error()
|
||||
if classification.retryability == personal.RetryabilityNonRetryable {
|
||||
if classification.auth {
|
||||
return apperrors.NewAuth(message, options...)
|
||||
}
|
||||
return apperrors.NewValidation(message, options...)
|
||||
}
|
||||
return apperrors.NewAPI(message, options...)
|
||||
}
|
||||
|
||||
func personalSubscriptionBlockedError(blocked *personal.AttemptBlockedError) error {
|
||||
if blocked == nil {
|
||||
return personalSubscriptionGuardError(
|
||||
errors.New("personal event: nil blocked subscription attempt"),
|
||||
)
|
||||
}
|
||||
reason := "personal_subscription_" + string(blocked.State)
|
||||
options := personalSubscriptionErrorOptions(
|
||||
blocked.Retryability,
|
||||
blocked.RetryAfter,
|
||||
blocked.NextAllowedAt,
|
||||
blocked.ErrorCode,
|
||||
blocked.TraceID,
|
||||
reason,
|
||||
blocked,
|
||||
)
|
||||
if blocked.Retryability == personal.RetryabilityNonRetryable {
|
||||
if personalSubscriptionAuthFailure(0, blocked.ErrorCode) {
|
||||
return apperrors.NewAuth(blocked.Error(), options...)
|
||||
}
|
||||
return apperrors.NewValidation(blocked.Error(), options...)
|
||||
}
|
||||
return apperrors.NewAPI(blocked.Error(), options...)
|
||||
}
|
||||
|
||||
func personalSubscriptionErrorOptions(
|
||||
retryability personal.Retryability,
|
||||
retryAfter time.Duration,
|
||||
nextRetryAt time.Time,
|
||||
code string,
|
||||
traceID string,
|
||||
reason string,
|
||||
cause error,
|
||||
) []apperrors.Option {
|
||||
options := []apperrors.Option{
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithCause(cause),
|
||||
}
|
||||
if retryable, known := retryability.Value(); known {
|
||||
options = append(options, apperrors.WithRetryable(retryable))
|
||||
}
|
||||
if retryAfter > 0 {
|
||||
options = append(options, apperrors.WithRetryAfterSeconds(ceilPersonalRetrySeconds(retryAfter)))
|
||||
}
|
||||
if !nextRetryAt.IsZero() {
|
||||
options = append(options, apperrors.WithNextRetryAt(nextRetryAt))
|
||||
}
|
||||
if code != "" || traceID != "" {
|
||||
options = append(options, apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
TraceID: strings.TrimSpace(traceID),
|
||||
ServerErrorCode: strings.TrimSpace(code),
|
||||
}))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func ceilPersonalRetrySeconds(delay time.Duration) int64 {
|
||||
if delay <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := int64(delay / time.Second)
|
||||
if delay%time.Second != 0 {
|
||||
seconds++
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func personalSubscriptionGuardError(cause error) error {
|
||||
if cause == nil {
|
||||
cause = errors.New("personal event: subscription attempt guard failed")
|
||||
}
|
||||
return apperrors.NewInternal(
|
||||
fmt.Sprintf("personal subscription attempt guard failed: %v", cause),
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason("personal_subscription_guard_failed"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func personalSubscriptionValidationError(cause error) error {
|
||||
if cause == nil {
|
||||
cause = errors.New("personal event: invalid subscription parameters")
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
cause.Error(),
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason("personal_subscription_invalid"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func personalSubscriptionLocalFailure() personalSubscriptionFailureClass {
|
||||
return personalSubscriptionFailureClass{
|
||||
retryability: personal.RetryabilityUnknown,
|
||||
reason: "personal_subscription_local_failure",
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -227,7 +227,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
@@ -238,7 +238,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
|
||||
|
||||
@@ -255,12 +255,12 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
}
|
||||
cfg := consume.Config{
|
||||
@@ -284,16 +284,100 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
if err := personalConsumeRun(ctx, cfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Complete all local validation before creating a remote subscription.
|
||||
// Otherwise an invalid output mode can repeatedly create and roll back a
|
||||
// valid subscription when an outer agent relaunches the command.
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundSource *source.PersonalSource
|
||||
if opts.Common.Foreground {
|
||||
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
var attempt *personalSubscriptionAttemptReservation
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
attempt, err = reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
identity,
|
||||
spawnProfileSelector,
|
||||
[]personalConsumeOptions{opts},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
}
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
err = attempt.completeFailure(ctx, 0, 0, err, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
if sub == nil {
|
||||
err = attempt.completeFailure(
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
errors.New("personal event: server returned an empty subscription"),
|
||||
nil,
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(sub.SubscribeID) == "" {
|
||||
err = attempt.completeFailure(
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
errors.New("personal event: server returned empty subscribe_id"),
|
||||
nil,
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
@@ -303,11 +387,21 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
wrapped := fmt.Errorf("save run state: %w", err)
|
||||
if attempt != nil {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
|
||||
cleanup(cleanupCtx)
|
||||
}
|
||||
return fmt.Errorf("event consume --as user: %w", wrapped)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
// unsubscribed on exit
|
||||
@@ -317,59 +411,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
defer cleanup(context.Background())
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
ReadySubscribeID: sub.SubscribeID,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
cfg.EventKey = eventKey
|
||||
cfg.ReadySubscribeID = sub.SubscribeID
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
@@ -378,18 +430,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
Source: foregroundSource,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -403,7 +455,7 @@ type personalMultiSubscription struct {
|
||||
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
plans, err := preparePersonalMultiOptions(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
@@ -414,7 +466,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
projector := personalEventProjector(false, opts.Flatten)
|
||||
|
||||
@@ -428,15 +480,16 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
baseCfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -451,11 +504,11 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
}
|
||||
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(baseCfg); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
@@ -464,13 +517,23 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
attempt, err := reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
identity,
|
||||
spawnProfileSelector,
|
||||
plans,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
created := make([]personalMultiSubscription, 0, len(plans))
|
||||
cleanup := func() {
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
ids := make([]string, 0, len(created))
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
id := strings.TrimSpace(created[i].Sub.SubscribeID)
|
||||
ids = append(ids, id)
|
||||
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
|
||||
if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
@@ -480,26 +543,45 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
failAndCleanup := func(
|
||||
failedIndex int,
|
||||
succeededCount int,
|
||||
cause error,
|
||||
override *personalSubscriptionFailureClass,
|
||||
) error {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
|
||||
// Persist the hold (or release a canceled claim) before any potentially
|
||||
// slow remote rollback. Otherwise the attempt lease can expire while
|
||||
// deleting earlier subscriptions and admit a duplicate create batch.
|
||||
cleanup(cleanupCtx)
|
||||
return completed
|
||||
}
|
||||
seenSubscribeIDs := make(map[string]struct{}, len(plans))
|
||||
for _, plan := range plans {
|
||||
for i, plan := range plans {
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
err = failAndCleanup(i, len(created), err, nil)
|
||||
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
|
||||
}
|
||||
if sub == nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
|
||||
cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
id := strings.TrimSpace(sub.SubscribeID)
|
||||
if id == "" {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
|
||||
cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
if _, exists := seenSubscribeIDs[id]; exists {
|
||||
_ = personalDeleteSubscription(client, context.Background(), id)
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
|
||||
cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
seenSubscribeIDs[id] = struct{}{}
|
||||
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
|
||||
@@ -512,11 +594,17 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
|
||||
cause := fmt.Errorf("save run state for %s: %w", eventKey, err)
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
cause = failAndCleanup(i, len(created)-1, cause, &classification)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
}
|
||||
defer cleanup()
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
defer cleanup(context.Background())
|
||||
|
||||
specs := make([]consume.ConsumerSpec, 0, len(created))
|
||||
for _, item := range created {
|
||||
@@ -705,6 +793,59 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
type personalPreparedSubscription struct {
|
||||
EventKey string
|
||||
RuleType string
|
||||
Request personal.CreateSubscriptionRequest
|
||||
}
|
||||
|
||||
func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) {
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
return personalPreparedSubscription{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) {
|
||||
sub, err := personalCreateSubscription(client, ctx, plan.Request)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, plan.EventKey, plan.RuleType, nil
|
||||
}
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
|
||||
@@ -727,42 +868,11 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
|
||||
return sub, eventKey, ruleType, nil
|
||||
}
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
plan, err := preparePersonalSubscription(identity, opts)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := personalCreateSubscription(client, ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, opts.EventKey, ruleType, nil
|
||||
return createPreparedPersonalSubscription(ctx, client, plan)
|
||||
}
|
||||
|
||||
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
|
||||
@@ -829,7 +939,7 @@ func ensurePublicPersonalEvent(eventKey string) error {
|
||||
if eventKey == "" {
|
||||
return nil
|
||||
}
|
||||
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
|
||||
if def, ok := personalLookupDefinition(eventKey); ok && !def.Public {
|
||||
return personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
return nil
|
||||
@@ -1091,6 +1201,12 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||
identity.AccessToken = ""
|
||||
client := personal.NewClient(baseURL, identity)
|
||||
version := strings.TrimSpace(RawVersion())
|
||||
if version == "" {
|
||||
version = "unknown"
|
||||
}
|
||||
client.ClientVersion = version
|
||||
client.UserAgent = "dws-cli/" + version
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
@@ -88,6 +89,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
@@ -97,6 +99,9 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
})
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return personalNoopAttemptStore{}
|
||||
}
|
||||
|
||||
wantErr := errors.New("consume")
|
||||
cmd := newPersonalCoverageCommand()
|
||||
@@ -154,11 +159,11 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != 0 {
|
||||
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
before := deletes
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != before {
|
||||
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
@@ -251,7 +252,7 @@ func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
defer func() { eventRunPersonalConsume = oldRun }()
|
||||
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
|
||||
@@ -379,7 +380,158 @@ func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", errors.New("second subscription failed")
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
||||
order = append(order, "delete")
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("partial creation unexpectedly succeeded")
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
||||
t.Fatalf("failure/rollback order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
return errors.New("state disk failed")
|
||||
}
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
||||
order = append(order, "delete")
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("run-state failure unexpectedly succeeded")
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
||||
t.Fatalf("failure/rollback order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", context.Canceled
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
|
||||
if cleanupCtx.Err() == nil {
|
||||
t.Fatal("cancellation cleanup received a live context")
|
||||
}
|
||||
order = append(order, "delete")
|
||||
return cleanupCtx.Err()
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd.SetContext(ctx)
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
|
||||
t.Fatalf("release/canceled-cleanup order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ensure func(int, personalConsumeOptions) *personal.Subscription
|
||||
@@ -641,16 +793,21 @@ func installPersonalManySeams(t *testing.T) func() {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldLookup := personalLookupDefinition
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldRunMany := personalConsumeRunMany
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return personalNoopAttemptStore{}
|
||||
}
|
||||
return func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalLookupDefinition = oldLookup
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
@@ -659,3 +816,68 @@ func installPersonalManySeams(t *testing.T) func() {
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
}
|
||||
}
|
||||
|
||||
type personalNoopAttemptStore struct{}
|
||||
|
||||
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
|
||||
fingerprints := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
fingerprints = append(fingerprints, spec.Fingerprint)
|
||||
}
|
||||
return &personal.AttemptClaim{
|
||||
AttemptID: "test-attempt",
|
||||
Fingerprints: fingerprints,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) CompleteFailure(
|
||||
_ *personal.AttemptClaim,
|
||||
_ []string,
|
||||
failure personal.AttemptFailure,
|
||||
) (personal.AttemptHold, error) {
|
||||
return personal.AttemptHold{
|
||||
Fingerprint: failure.Fingerprint,
|
||||
Retryability: failure.Retryability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type personalOrderingAttemptStore struct {
|
||||
order *[]string
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) Claim(
|
||||
specs []personal.AttemptSpec,
|
||||
lease time.Duration,
|
||||
) (*personal.AttemptClaim, error) {
|
||||
return personalNoopAttemptStore{}.Claim(specs, lease)
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
*s.order = append(*s.order, "complete_success")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) CompleteFailure(
|
||||
_ *personal.AttemptClaim,
|
||||
_ []string,
|
||||
failure personal.AttemptFailure,
|
||||
) (personal.AttemptHold, error) {
|
||||
*s.order = append(*s.order, "complete_failure")
|
||||
return personal.AttemptHold{
|
||||
Fingerprint: failure.Fingerprint,
|
||||
Retryability: failure.Retryability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
|
||||
*s.order = append(*s.order, "release")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,3 +103,65 @@ func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
|
||||
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsReviewedProtectionRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
for _, tc := range []struct {
|
||||
path []string
|
||||
flag string
|
||||
wantReason string
|
||||
wantHint string
|
||||
}{
|
||||
{path: []string{"chat", "message", "list-by-sender"}, flag: "time", wantReason: "blocked_flag", wantHint: "blocked"},
|
||||
{path: []string{"drive", "list"}, flag: "space", wantReason: "ambiguous_flag", wantHint: "ambiguous"},
|
||||
} {
|
||||
t.Run(strings.Join(tc.path, "/"), func(t *testing.T) {
|
||||
cmd := mustFindCommand(t, root, tc.path...)
|
||||
err := flagErrorWithSuggestions(cmd, fmt.Errorf("unknown flag: --%s", tc.flag))
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != tc.wantReason || !strings.Contains(ae.Hint, tc.wantHint) || !strings.Contains(ae.Hint, "--help") {
|
||||
t.Fatalf("protected error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedFlagProtectionAndInstallerEdges(t *testing.T) {
|
||||
if flag, protection, ok := reviewedFlagProtection(nil, "unknown flag: --time"); ok || flag != "" || protection != "" {
|
||||
t.Fatalf("nil command protection = %q, %q, %v", flag, protection, ok)
|
||||
}
|
||||
installReviewedFlagProtectionHandlers(nil)
|
||||
|
||||
root := NewRootCommand()
|
||||
cmd := mustFindCommand(t, root, "chat", "message", "list-by-sender")
|
||||
flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --time=value")
|
||||
if !ok || flag != "time" || protection != "blocked" {
|
||||
t.Fatalf("delimited protected flag = %q, %q, %v", flag, protection, ok)
|
||||
}
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --not-reviewed"); ok || flag != "" || protection != "" {
|
||||
t.Fatalf("unreviewed flag protection = %q, %q, %v", flag, protection, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedFlagProtectionInstallerPreservesLocalHandler(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
cmd := mustFindCommand(t, root, "contact", "dept", "list-children")
|
||||
handler := cmd.FlagErrorFunc()
|
||||
|
||||
unreviewed := handler(cmd, fmt.Errorf("unknown flag: --not-reviewed"))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(unreviewed, &structured) {
|
||||
t.Fatalf("unreviewed error bypassed the command's local handler: %#v", structured)
|
||||
}
|
||||
if !strings.HasSuffix(unreviewed.Error(), "See 'dws contact dept list-children --help' for usage.") {
|
||||
t.Fatalf("local handler output = %q", unreviewed)
|
||||
}
|
||||
|
||||
guarded := handler(cmd, fmt.Errorf("unknown flag: --name"))
|
||||
if !stderrors.As(guarded, &structured) || structured.Reason != "blocked_flag" {
|
||||
t.Fatalf("reviewed guard did not use the central handler: %#v", guarded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
||||
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
||||
// contract asserts the exact phrase below inside the installed
|
||||
// dws-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// fail at PR time instead of at release time.
|
||||
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize embedded multi skill source: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
|
||||
}
|
||||
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||
if !strings.Contains(string(data), rule) {
|
||||
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type paramAliasToolCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type paramAliasCaptureCaller struct {
|
||||
calls []paramAliasToolCall
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copyArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copyArgs[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
|
||||
text := paramAliasResponseForTool(tool)
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
// paramAliasResponseForTool supplies deterministic, business-shape-valid
|
||||
// responses for the complete-command equivalence matrix. Most commands only
|
||||
// print the transport result and need an empty object; smart shortcuts that
|
||||
// inspect a read response receive the smallest shape that lets their full RunE
|
||||
// complete without falling back to a validation error.
|
||||
func paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
|
||||
case "search_groups":
|
||||
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
|
||||
default:
|
||||
return `{}`
|
||||
}
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
func (*paramAliasCaptureCaller) JQ() string { return "" }
|
||||
|
||||
// paramAliasCaptureRunner covers helpers (currently dev app) that dispatch
|
||||
// through executor.Runner instead of edition.ToolCaller. Keeping both capture
|
||||
// boundaries in one call list lets the matrix compare the final request shape
|
||||
// without knowing which transport adapter a command uses.
|
||||
type paramAliasCaptureRunner struct {
|
||||
caller *paramAliasCaptureCaller
|
||||
}
|
||||
|
||||
func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
copyArgs := make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
copyArgs[key] = value
|
||||
}
|
||||
r.caller.calls = append(r.caller.calls, paramAliasToolCall{
|
||||
server: invocation.CanonicalProduct,
|
||||
tool: invocation.Tool,
|
||||
args: copyArgs,
|
||||
})
|
||||
invocation.Implemented = true
|
||||
return executor.Result{Invocation: invocation, Response: map[string]any{}}, nil
|
||||
}
|
||||
|
||||
type paramAliasDryRunRejectRunner struct {
|
||||
attempts []executor.Invocation
|
||||
}
|
||||
|
||||
func (r *paramAliasDryRunRejectRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.attempts = append(r.attempts, invocation)
|
||||
return executor.Result{}, stderrors.New("dry-run reached the injected command runner")
|
||||
}
|
||||
|
||||
type paramAliasDryRunPreview struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// executeParamAliasDryRunE2E uses the existing root --dry-run barrier as a
|
||||
// parameter-normalization probe. These commands do not publish command-owned
|
||||
// dry-run capabilities in Schema; the test deliberately makes no such claim.
|
||||
// A reject runner proves the preview stops before endpoint resolution,
|
||||
// authentication, or transport execution.
|
||||
func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context, paramAliasDryRunPreview, []executor.Invocation, error) {
|
||||
t.Helper()
|
||||
|
||||
originalArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
defer func() { os.Args = originalArgs }()
|
||||
|
||||
captureFile, err := os.CreateTemp(t.TempDir(), "param-alias-dry-run-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create dry-run output capture: %v", err)
|
||||
}
|
||||
defer captureFile.Close()
|
||||
originalStdout := os.Stdout
|
||||
originalCaller := helpers.GetCaller()
|
||||
os.Stdout = captureFile
|
||||
defer func() {
|
||||
os.Stdout = originalStdout
|
||||
helpers.InitDeps(originalCaller)
|
||||
}()
|
||||
rejectRunner := ¶mAliasDryRunRejectRunner{}
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
|
||||
return rejectRunner
|
||||
}
|
||||
root := NewRootCommand()
|
||||
rootNewCommandRunnerWithFlags = originalRunnerFactory
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
|
||||
ctx, executeErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if executeErr == nil {
|
||||
executeErr = root.Execute()
|
||||
}
|
||||
|
||||
if err := captureFile.Sync(); err != nil {
|
||||
t.Fatalf("sync dry-run output capture: %v", err)
|
||||
}
|
||||
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind dry-run output capture: %v", err)
|
||||
}
|
||||
output, err := io.ReadAll(captureFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read dry-run output capture: %v", err)
|
||||
}
|
||||
var preview paramAliasDryRunPreview
|
||||
if executeErr == nil {
|
||||
if err := json.Unmarshal(output, &preview); err != nil {
|
||||
t.Fatalf("decode dry-run preview: %v\noutput=%s", err, output)
|
||||
}
|
||||
}
|
||||
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
|
||||
}
|
||||
|
||||
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
originalArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
defer func() { os.Args = originalArgs }()
|
||||
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
|
||||
return ¶mAliasCaptureRunner{caller: caller}
|
||||
}
|
||||
root := NewRootCommand()
|
||||
rootNewCommandRunnerWithFlags = originalRunnerFactory
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
originalCaller := helpers.GetCaller()
|
||||
helpers.InitDeps(caller)
|
||||
defer helpers.InitDeps(originalCaller)
|
||||
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
return ctx, root.Execute()
|
||||
}
|
||||
|
||||
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation []string
|
||||
wantError string
|
||||
wantCalls int
|
||||
wantOriginal string
|
||||
wantCorrection string
|
||||
}{
|
||||
{name: "bare yes confirms", confirmation: []string{"--yes"}, wantCalls: 1},
|
||||
{name: "glued false stays unconfirmed", confirmation: []string{"--yesfalse"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yesfalse", wantCorrection: "--yes=false"},
|
||||
{name: "glued true confirms", confirmation: []string{"--yestrue"}, wantCalls: 1, wantOriginal: "--yestrue", wantCorrection: "--yes=true"},
|
||||
{name: "detached false stays unconfirmed", confirmation: []string{"--yes", "false"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes false", wantCorrection: "--yes=false"},
|
||||
{name: "detached no stays unconfirmed", confirmation: []string{"--yes", "no"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes no", wantCorrection: "--yes=false"},
|
||||
{name: "detached zero stays unconfirmed", confirmation: []string{"--yes", "0"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes 0", wantCorrection: "--yes=false"},
|
||||
{name: "detached true confirms", confirmation: []string{"--yes", "true"}, wantCalls: 1, wantOriginal: "--yes true", wantCorrection: "--yes=true"},
|
||||
{name: "detached yes confirms", confirmation: []string{"--yes", "yes"}, wantCalls: 1, wantOriginal: "--yes yes", wantCorrection: "--yes=true"},
|
||||
{name: "detached one confirms", confirmation: []string{"--yes", "1"}, wantCalls: 1, wantOriginal: "--yes 1", wantCorrection: "--yes=true"},
|
||||
{name: "explicit false remains unconfirmed", confirmation: []string{"--yes=false"}, wantError: "请添加 --yes 确认执行"},
|
||||
{name: "explicit true confirms", confirmation: []string{"--yes=true"}, wantCalls: 1},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
args := []string{
|
||||
"mail", "thread", "trash",
|
||||
"--email", "user@example.com",
|
||||
"--id", "conversation-1",
|
||||
}
|
||||
args = append(args, test.confirmation...)
|
||||
ctx, err := executeParamAliasE2E(t, caller, args...)
|
||||
if test.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed command error = %v", err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("command error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
if test.wantCorrection == "" {
|
||||
if ctx != nil && len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("confirmation spelling received corrections: %#v", ctx.Corrections)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != test.wantOriginal || ctx.Corrections[0].Corrected != test.wantCorrection {
|
||||
t.Fatalf("confirmation corrections = %#v, want %q -> %q", ctx, test.wantOriginal, test.wantCorrection)
|
||||
}
|
||||
if len(caller.calls) != test.wantCalls {
|
||||
t.Fatalf("destructive calls = %#v, want %d", caller.calls, test.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
start := "2026-03-10T14:00:00+08:00"
|
||||
end := "2026-03-10T18:00:00+08:00"
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"calendar", "event", "list",
|
||||
"--date", start,
|
||||
"--end-time", end,
|
||||
"--calendar", "primary",
|
||||
"--max-results", "7",
|
||||
"--next-cursor", "cursor-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calendar alias E2E error = %v", err)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--date" || ctx.Corrections[0].Corrected != "--start" {
|
||||
t.Fatalf("calendar corrections = %#v, want only --date to be normalized centrally", ctx.Corrections)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_calendar_events" {
|
||||
t.Fatalf("calendar calls = %#v", caller.calls)
|
||||
}
|
||||
startMS, _ := cmdutil.ParseISOTimeToMillis("start", start)
|
||||
endMS, _ := cmdutil.ParseISOTimeToMillis("end", end)
|
||||
want := map[string]any{
|
||||
"startTime": startMS,
|
||||
"endTime": endMS,
|
||||
"calendarId": "primary",
|
||||
"limit": 7,
|
||||
"cursor": "cursor-1",
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calendar payload = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--to-user", "D-recipient",
|
||||
"--text", "hello alias",
|
||||
"--uuid", "alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat write alias E2E error = %v", err)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--to-user" || ctx.Corrections[0].Corrected != "--user" {
|
||||
t.Fatalf("chat corrections = %#v", ctx.Corrections)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
t.Fatalf("chat payload identity fields = %#v", payload)
|
||||
}
|
||||
content, _ := payload["content"].(string)
|
||||
if !strings.Contains(content, "hello alias") {
|
||||
t.Fatalf("chat payload content = %q", content)
|
||||
}
|
||||
for _, forbidden := range []string{"user", "to-user", "userId"} {
|
||||
if _, exists := payload[forbidden]; exists {
|
||||
t.Fatalf("chat payload leaked pre-normalization field %q: %#v", forbidden, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
tool string
|
||||
required []string
|
||||
}{
|
||||
{
|
||||
name: "add emoji",
|
||||
command: []string{"chat", "message", "add-emoji"},
|
||||
tool: "add_emoji_reaction",
|
||||
required: []string{"--msg-id", "message-1", "--emoji", "like"},
|
||||
},
|
||||
{
|
||||
name: "remove emoji",
|
||||
command: []string{"chat", "message", "remove-emoji"},
|
||||
tool: "remove_emoji_reaction",
|
||||
required: []string{"--msg-id", "message-1", "--emoji", "like"},
|
||||
},
|
||||
{
|
||||
name: "add text emotion",
|
||||
command: []string{"chat", "message", "add-text-emotion"},
|
||||
tool: "add_text_emotion",
|
||||
required: []string{
|
||||
"--msg-id", "message-1", "--emotion-id", "emotion-1",
|
||||
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove text emotion",
|
||||
command: []string{"chat", "message", "remove-text-emotion"},
|
||||
tool: "remove_text_emotion",
|
||||
required: []string{
|
||||
"--msg-id", "message-1", "--emotion-id", "emotion-1",
|
||||
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
canonicalArgs := append([]string(nil), test.command...)
|
||||
canonicalArgs = append(canonicalArgs, "--conversation-id", "conversation-1")
|
||||
canonicalArgs = append(canonicalArgs, test.required...)
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("canonical execution failed: %v", err)
|
||||
}
|
||||
if len(canonicalCaller.calls) != 1 || canonicalCaller.calls[0].tool != test.tool {
|
||||
t.Fatalf("canonical calls = %#v, want one %s call", canonicalCaller.calls, test.tool)
|
||||
}
|
||||
if canonicalCaller.calls[0].args["openConversationId"] != "conversation-1" {
|
||||
t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args)
|
||||
}
|
||||
|
||||
// Numeric --group-id is a different identifier domain and is covered
|
||||
// by TestAllReviewedParamAliasGuardsReachRuntimeContract.
|
||||
for _, alias := range []string{"chat-id", "open-conversation-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
aliasArgs := append([]string(nil), test.command...)
|
||||
aliasArgs = append(aliasArgs, "--"+alias, "conversation-1")
|
||||
aliasArgs = append(aliasArgs, test.required...)
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final calls differ\ncanonical=%#v\nalias=%#v", canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
entries, err := cli.ReduceParamAliases(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ReduceParamAliases() error = %v", err)
|
||||
}
|
||||
|
||||
chatEntries := 0
|
||||
aliasCases := 0
|
||||
guardCases := map[pipeline.FlagProtection]int{}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.CLIPath, "chat ") {
|
||||
continue
|
||||
}
|
||||
chatEntries++
|
||||
leaf := resolveParamLeaf(root, entry.CLIPath)
|
||||
if leaf == nil {
|
||||
t.Fatalf("generated chat parameter path %q is not runnable", entry.CLIPath)
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(entry.Aliases))
|
||||
for emitted := range entry.Aliases {
|
||||
aliases = append(aliases, emitted)
|
||||
}
|
||||
sort.Strings(aliases)
|
||||
for _, emitted := range aliases {
|
||||
emitted := emitted
|
||||
canonical := entry.Aliases[emitted]
|
||||
aliasCases++
|
||||
t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) {
|
||||
value := paramFixtureValue(leaf, emitted, canonical)
|
||||
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
|
||||
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
|
||||
if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value {
|
||||
t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args)
|
||||
}
|
||||
if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil {
|
||||
t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, guard := range []struct {
|
||||
protection pipeline.FlagProtection
|
||||
emitted []string
|
||||
}{
|
||||
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
|
||||
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
|
||||
} {
|
||||
for _, emitted := range guard.emitted {
|
||||
emitted := emitted
|
||||
protection := guard.protection
|
||||
guardCases[protection]++
|
||||
t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) {
|
||||
value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection))
|
||||
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
|
||||
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
|
||||
}
|
||||
morphed := cmdutil.Morph(emitted)
|
||||
if ctx == nil || ctx.ProtectedFlags[morphed] != protection {
|
||||
t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, emitted, value)
|
||||
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
|
||||
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
|
||||
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 {
|
||||
t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
|
||||
func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
want string
|
||||
}{
|
||||
// These paths are reduced by the reviewed user_id concept.
|
||||
{command: "chat +chat-role-query-user", want: "user"},
|
||||
{command: "chat +chat-role-set-user", want: "user"},
|
||||
{command: "chat +messages-list-direct", want: "user"},
|
||||
{command: "chat chmod", want: "user"},
|
||||
{command: "chat message list", want: "user"},
|
||||
{command: "chat message send", want: "user"},
|
||||
|
||||
// These commands already own a hidden --userId compatibility flag.
|
||||
// The format/spelling handler rewrites --user-id to that real flag, and
|
||||
// the command's existing flagOrFallback wiring preserves its semantics.
|
||||
{command: "chat conversation-info", want: "userId"},
|
||||
{command: "chat group transfer-owner", want: "userId"},
|
||||
{command: "chat group-role query-user", want: "userId"},
|
||||
{command: "chat group-role remove-user", want: "userId"},
|
||||
{command: "chat group-role set-user", want: "userId"},
|
||||
{command: "chat group set-admin", want: "userId"},
|
||||
{command: "chat group-mute-member", want: "userId"},
|
||||
{command: "chat message read-status", want: "userId"},
|
||||
{command: "chat message search-advanced", want: "userId"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.command, func(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
leaf := resolveParamLeaf(root, test.command)
|
||||
if leaf == nil {
|
||||
t.Fatalf("IM command %q is not runnable", test.command)
|
||||
}
|
||||
rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user")
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(test.command)):]
|
||||
if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" {
|
||||
t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want)
|
||||
}
|
||||
if err := leaf.ParseFlags(flagArgs); err != nil {
|
||||
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
const command = "chat message list-direct"
|
||||
if _, ok := cli.LookupParamAlias(command); ok {
|
||||
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := resolveParamLeaf(root, command)
|
||||
if leaf == nil || !leaf.Hidden {
|
||||
t.Fatalf("%q must remain a live hidden compatibility command", command)
|
||||
}
|
||||
rawArgs := append(strings.Fields(command), "--user-id", "fixture-user")
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(command)):]
|
||||
if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
canonicalArgs []string
|
||||
aliasArgs []string
|
||||
wantCorrections int
|
||||
wantArgKeys []string
|
||||
}{
|
||||
{
|
||||
name: "calendar read with multiple aliases",
|
||||
tool: "list_calendar_events",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "calendar", "event", "list",
|
||||
"--start", "2026-03-10T14:00:00+08:00",
|
||||
"--end", "2026-03-10T18:00:00+08:00",
|
||||
"--calendar-id", "primary", "--limit", "7", "--cursor", "cursor-1",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "calendar", "event", "list",
|
||||
"--date", "2026-03-10T14:00:00+08:00",
|
||||
"--end-time", "2026-03-10T18:00:00+08:00",
|
||||
"--calendar", "primary", "--max-results", "7", "--next-cursor", "cursor-1",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"calendarId", "cursor", "endTime", "limit", "startTime"},
|
||||
},
|
||||
{
|
||||
name: "chat write scoped recipient alias",
|
||||
tool: "send_personal_message",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
|
||||
},
|
||||
{
|
||||
name: "mail write folder id concept alias",
|
||||
tool: "update_mail_folder",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "mail", "folder", "update",
|
||||
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "mail", "folder", "update",
|
||||
"--email", "fixture@example.com", "--folder-id", "folder-1", "--name", "Fixture Folder",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"email", "id", "name"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, canonical, canonicalAttempts, canonicalErr := executeParamAliasDryRunE2E(t, test.canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("canonical dry-run failed: %v", canonicalErr)
|
||||
}
|
||||
ctx, alias, aliasAttempts, aliasErr := executeParamAliasDryRunE2E(t, test.aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("alias dry-run failed: %v\ncontext=%#v", aliasErr, ctx)
|
||||
}
|
||||
|
||||
if ctx == nil || len(ctx.Corrections) != test.wantCorrections {
|
||||
t.Fatalf("alias dry-run corrections = %#v, want %d", ctx, test.wantCorrections)
|
||||
}
|
||||
if len(canonicalAttempts) != 0 || len(aliasAttempts) != 0 {
|
||||
t.Fatalf("dry-run reached command runner\ncanonical=%#v\nalias=%#v", canonicalAttempts, aliasAttempts)
|
||||
}
|
||||
for label, preview := range map[string]paramAliasDryRunPreview{"canonical": canonical, "alias": alias} {
|
||||
if !preview.DryRun || preview.Executed {
|
||||
t.Fatalf("%s preview execution state = %#v", label, preview)
|
||||
}
|
||||
if preview.Tool != test.tool {
|
||||
t.Fatalf("%s preview tool = %q, want %q", label, preview.Tool, test.tool)
|
||||
}
|
||||
keys := make([]string, 0, len(preview.Arguments))
|
||||
for key := range preview.Arguments {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if !reflect.DeepEqual(keys, test.wantArgKeys) {
|
||||
t.Fatalf("%s preview argument keys = %v, want %v", label, keys, test.wantArgKeys)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(alias, canonical) {
|
||||
t.Fatalf("dry-run previews differ\ncanonical=%#v\nalias=%#v", canonical, alias)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
for _, args := range [][]string{
|
||||
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
|
||||
{"calendar", "event", "list", "--start", "2026-03-11", "--date", "2026-03-10"},
|
||||
} {
|
||||
root := NewRootCommand()
|
||||
root.SetArgs(args)
|
||||
originalCaller := helpers.GetCaller()
|
||||
helpers.InitDeps(caller)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
helpers.InitDeps(originalCaller)
|
||||
var conflict *pipeline.FlagConflictError
|
||||
if !stderrors.As(err, &conflict) {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v, want FlagConflictError (ctx=%#v)", args, err, ctx)
|
||||
}
|
||||
if conflict.Canonical != "start" || !reflect.DeepEqual(conflict.Spellings, []string{"date", "start"}) {
|
||||
t.Fatalf("conflict = %#v", conflict)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("conflicting argv reached RunE/tool dispatch: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
paths := make(map[string]bool)
|
||||
for _, concept := range concepts.Concepts {
|
||||
for _, path := range concept.Commands {
|
||||
paths[path] = true
|
||||
}
|
||||
}
|
||||
sourceGuards := make(map[string]pipeline.FlagProtection)
|
||||
for _, override := range concepts.Overrides {
|
||||
paths[override.CommandPath] = true
|
||||
for _, emitted := range override.Block {
|
||||
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionBlocked
|
||||
}
|
||||
for _, emitted := range override.Ambiguous {
|
||||
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionAmbiguous
|
||||
}
|
||||
}
|
||||
orderedPaths := make([]string, 0, len(paths))
|
||||
for path := range paths {
|
||||
orderedPaths = append(orderedPaths, path)
|
||||
}
|
||||
sort.Strings(orderedPaths)
|
||||
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
guardCounts := map[pipeline.FlagProtection]int{}
|
||||
testedGuards := make(map[string]pipeline.FlagProtection)
|
||||
for _, path := range orderedPaths {
|
||||
entry, ok := cli.LookupParamAlias(path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
leaf := resolveParamLeaf(root, path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("generated guard path %q is not runnable", path)
|
||||
}
|
||||
|
||||
for _, protectionCase := range []struct {
|
||||
protection pipeline.FlagProtection
|
||||
emitted []string
|
||||
}{
|
||||
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
|
||||
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
|
||||
} {
|
||||
for _, emitted := range protectionCase.emitted {
|
||||
protectionCase := protectionCase
|
||||
emitted := emitted
|
||||
key := path + "\x00" + cmdutil.Morph(emitted)
|
||||
if previous, duplicate := testedGuards[key]; duplicate {
|
||||
t.Fatalf("generated guard %q/%q is classified twice: %s and %s", path, emitted, previous, protectionCase.protection)
|
||||
}
|
||||
testedGuards[key] = protectionCase.protection
|
||||
guardCounts[protectionCase.protection]++
|
||||
|
||||
t.Run(path+"/"+emitted, func(t *testing.T) {
|
||||
value := "FIXTURE_VALUE"
|
||||
pathArgs := strings.Fields(path)
|
||||
args := append(append([]string(nil), pathArgs...), "--"+emitted, value)
|
||||
ctx, runErr := pipeline.RunPreParseArgs(root, engine, args)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", args, runErr)
|
||||
}
|
||||
|
||||
morphed := cmdutil.Morph(emitted)
|
||||
if ctx == nil || ctx.ProtectedFlags[morphed] != protectionCase.protection {
|
||||
t.Fatalf("guard protection = %#v, want %s for %q", ctx, protectionCase.protection, morphed)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, emitted, value)
|
||||
flagArgs := ctx.Args[len(pathArgs):]
|
||||
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
|
||||
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, want := range sourceGuards {
|
||||
if got, ok := testedGuards[key]; !ok || got != want {
|
||||
t.Fatalf("reviewed source guard %q delivered as %s (present=%t), want %s", key, got, ok, want)
|
||||
}
|
||||
}
|
||||
if guardCounts[pipeline.FlagProtectionBlocked] == 0 || guardCounts[pipeline.FlagProtectionAmbiguous] == 0 {
|
||||
t.Fatalf("reviewed guard coverage is vacuous: blocked %d ambiguous %d", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
emitted string
|
||||
protection pipeline.FlagProtection
|
||||
reason string
|
||||
}{
|
||||
{path: "chat message list-by-sender", emitted: "time", protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag"},
|
||||
{path: "drive list", emitted: "space", protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag"},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.path+"/"+test.emitted, func(t *testing.T) {
|
||||
value := "FIXTURE_VALUE"
|
||||
args := append(strings.Fields(test.path), "--"+test.emitted, value)
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, executeErr := executeParamAliasE2E(t, caller, args...)
|
||||
|
||||
morphed := cmdutil.Morph(test.emitted)
|
||||
if ctx == nil || ctx.ProtectedFlags[morphed] != test.protection {
|
||||
t.Fatalf("guard protection = %#v, want %s for %q", ctx, test.protection, morphed)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, test.emitted, value)
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(executeErr, &appErr) {
|
||||
t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != test.reason || apperrors.ExitCode(executeErr) != 3 {
|
||||
t.Fatalf("final error contract = category %q reason %q exit %d, want validation/%s/3", appErr.Category, appErr.Reason, apperrors.ExitCode(executeErr), test.reason)
|
||||
}
|
||||
if !strings.Contains(appErr.Message, "unknown flag: --"+test.emitted) || !strings.Contains(appErr.Message, "See 'dws "+test.path+" --help' for usage.") {
|
||||
t.Fatalf("final error message = %q", appErr.Message)
|
||||
}
|
||||
if !strings.Contains(appErr.Hint, "--"+test.emitted) || !strings.Contains(appErr.Hint, "--help") {
|
||||
t.Fatalf("final error hint = %q", appErr.Hint)
|
||||
}
|
||||
wantAction := "Run 'dws " + test.path + " --help' for valid flags"
|
||||
if !reflect.DeepEqual(appErr.Actions, []string{wantAction}) || len(appErr.AvailableFlags) == 0 || appErr.Cause == nil {
|
||||
t.Fatalf("final recovery fields = actions %v flags %v cause %v", appErr.Actions, appErr.AvailableFlags, appErr.Cause)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("guarded flag reached RunE/tool dispatch: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
|
||||
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
|
||||
if err != want {
|
||||
t.Fatalf("FlagConflictError = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestParamAliasFixtureThroughEmbeddedDeliveryPath is the ⑥ regression gate.
|
||||
// It reads the reviewed validation_fixture straight from the embedded concept
|
||||
// dictionary and asserts every reviewed bad case through the REAL delivery
|
||||
// path — not a generator unit test and not a reimplementation of the reduction
|
||||
// logic:
|
||||
//
|
||||
// - the runtime PreParse engine built by newPipelineEngine() (the exact
|
||||
// handler chain root.go installs, whose SemanticAliasHandler is wired to
|
||||
// cli.LookupParamAlias over the embedded generated table),
|
||||
// - one distribution-owned Cobra tree, reused because PreParse reads command
|
||||
// and flag metadata but does not parse or mutate individual flag values,
|
||||
// and
|
||||
// - the embedded cli.LookupParamAlias query used to prove that a
|
||||
// did-you-mean case is an intentional block/ambiguous guard rather than a
|
||||
// name that merely happens to be absent from the table.
|
||||
//
|
||||
// Fixture expect semantics (see spec §⑥):
|
||||
// - expect=<realFlag> : emitted must reduce to that canonical flag.
|
||||
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
|
||||
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
|
||||
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
if len(concepts.Fixture) == 0 {
|
||||
t.Fatal("validation_fixture declares no cases; ⑥ gate would be vacuous")
|
||||
}
|
||||
|
||||
// The exact runtime handler chain (alias → semantic → sticky →
|
||||
// paramname), with the semantic table sourced from the embedded generated
|
||||
// snapshot. Build the distribution-owned tree once: constructing the full
|
||||
// 800+ command tree for every fixture made the macOS race package exceed its
|
||||
// 10-minute budget, while RunPreParseArgs itself only reads this tree.
|
||||
engine := newPipelineEngine()
|
||||
root := NewSchemaSourceRootCommand()
|
||||
for _, c := range concepts.Fixture {
|
||||
t.Run(c.Command+"/"+c.Emitted, func(t *testing.T) {
|
||||
leaf := resolveParamLeaf(root, c.Command)
|
||||
if leaf == nil {
|
||||
t.Fatalf("fixture command %q is not a live Cobra command", c.Command)
|
||||
}
|
||||
// Fixture command paths carry no "dws" prefix; LookupParamAlias
|
||||
// normalizes to the same key the generator used, so the runtime
|
||||
// lookup is byte-identical to the build-time key.
|
||||
entry, hasEntry := cli.LookupParamAlias(c.Command)
|
||||
fixtureValue := paramFixtureValue(leaf, c.Emitted, c.Expect)
|
||||
rawArgs := append(strings.Fields(c.Command), "--"+c.Emitted, fixtureValue)
|
||||
root.SetArgs(rawArgs)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, engine, rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs error = %v", err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs skipped a fixture command with real flags")
|
||||
}
|
||||
morphed := cmdutil.Morph(c.Emitted)
|
||||
|
||||
switch c.Expect {
|
||||
case "did-you-mean:ambiguous":
|
||||
if !hasEntry || !entry.IsAmbiguous(morphed) {
|
||||
t.Fatalf("%q on %q: expected co-occurrence guard (ambiguous) but embedded entry does not classify it; ambiguous=%v", c.Emitted, c.Command, entry.Ambiguous)
|
||||
}
|
||||
if commandHasRealFlagByMorph(leaf, morphed) {
|
||||
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
|
||||
case "did-you-mean:blocked":
|
||||
if !hasEntry || !entry.IsBlocked(morphed) {
|
||||
t.Fatalf("%q on %q: expected block guard but embedded entry does not classify it; blocked=%v", c.Emitted, c.Command, entry.Blocked)
|
||||
}
|
||||
if commandHasRealFlagByMorph(leaf, morphed) {
|
||||
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
|
||||
default:
|
||||
// Real-flag expect: the reviewed canonical outcome is delivered
|
||||
// one of two equally valid ways, and the gate accepts either
|
||||
// (failing only on a genuine unknown-flag hallucination):
|
||||
// 1. semantic rewrite — the emitted synonym is not a real flag,
|
||||
// so the embedded table rewrites it to the canonical flag; or
|
||||
// 2. native acceptance — the emitted synonym is still a genuine
|
||||
// (usually hidden) real flag the command accepts directly and
|
||||
// maps to the same entity via its fallback wiring. Native
|
||||
// compatibility flags intentionally remain command-owned.
|
||||
if !commandHasRealFlagByMorph(leaf, cmdutil.Morph(c.Expect)) {
|
||||
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
|
||||
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
got := flagArgs[0]
|
||||
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
|
||||
switch {
|
||||
case got == "--"+c.Expect:
|
||||
// (1) rewritten; the embedded table must agree.
|
||||
if !hasEntry {
|
||||
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
|
||||
}
|
||||
if canon, hit := entry.ResolveAlias(morphed); !hit || canon != c.Expect {
|
||||
t.Fatalf("embedded table ResolveAlias(%q) on %q = %q (hit=%v), want %q", morphed, c.Command, canon, hit, c.Expect)
|
||||
}
|
||||
case cmdutil.Morph(gotBare) == morphed && commandHasRealFlagByMorph(leaf, morphed):
|
||||
// (2) not rewritten — only valid if the command natively
|
||||
// accepts the emitted synonym as a real flag.
|
||||
default:
|
||||
t.Fatalf("%q on %q reduced to unexpected %q, want --%s or native --%s (args=%v)", c.Emitted, c.Command, got, c.Expect, c.Emitted, ctx.Args)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertLeftUnchanged verifies a guarded (blocked/ambiguous) synonym is never
|
||||
// silently rewritten: the flag token and its value survive verbatim so the
|
||||
// unknown-flag did-you-mean path can surface the reviewed candidates.
|
||||
func assertLeftUnchanged(t *testing.T, ctx *pipeline.Context, emitted, value string) {
|
||||
t.Helper()
|
||||
flagIndex := -1
|
||||
for i, arg := range ctx.Args {
|
||||
if arg == "--"+emitted || strings.HasPrefix(arg, "--"+emitted+"=") {
|
||||
flagIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if flagIndex < 0 {
|
||||
t.Fatalf("guarded synonym --%s disappeared: args=%v", emitted, ctx.Args)
|
||||
}
|
||||
if got := ctx.Args[flagIndex]; got != "--"+emitted {
|
||||
t.Fatalf("guarded synonym --%s was rewritten to %q (must be left for did-you-mean): args=%v", emitted, got, ctx.Args)
|
||||
}
|
||||
if len(ctx.Args) <= flagIndex+1 || ctx.Args[flagIndex+1] != value {
|
||||
t.Fatalf("guarded synonym --%s lost its value: args=%v", emitted, ctx.Args)
|
||||
}
|
||||
for _, corr := range ctx.Corrections {
|
||||
if corr.Handler == "semantic-alias" && corr.Original == "--"+emitted {
|
||||
t.Fatalf("guarded synonym --%s was corrected by %s (must not be): %+v", emitted, corr.Handler, corr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paramFixtureValue(cmd *cobra.Command, emitted, expect string) string {
|
||||
if cmd == nil {
|
||||
return "FIXTURE_VALUE"
|
||||
}
|
||||
wanted := []string{emitted}
|
||||
if !strings.HasPrefix(expect, "did-you-mean:") {
|
||||
wanted = append(wanted, expect)
|
||||
}
|
||||
for _, name := range wanted {
|
||||
var found *pflag.Flag
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if found == nil && cmdutil.Morph(flag.Name) == cmdutil.Morph(name) {
|
||||
found = flag
|
||||
}
|
||||
})
|
||||
if found == nil {
|
||||
continue
|
||||
}
|
||||
switch found.Value.Type() {
|
||||
case "bool":
|
||||
return "true"
|
||||
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64":
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
return "FIXTURE_VALUE"
|
||||
}
|
||||
|
||||
// resolveParamLeaf resolves a fixture command path (no "dws" prefix, e.g.
|
||||
// "chat message search-advanced") to its live Cobra command, or nil.
|
||||
func resolveParamLeaf(root *cobra.Command, path string) *cobra.Command {
|
||||
cmd, _, err := root.Find(strings.Fields(path))
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
return nil
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// commandHasRealFlagByMorph reports whether the command has any real flag
|
||||
// (local or inherited, including hidden) whose Morph matches morphed — the same
|
||||
// notion of "real flag" the build-time reducer uses to absorb legacy synonyms.
|
||||
func commandHasRealFlagByMorph(cmd *cobra.Command, morphed string) bool {
|
||||
found := false
|
||||
check := func(f *pflag.Flag) {
|
||||
if f.Name != "help" && cmdutil.Morph(f.Name) == morphed {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
cmd.Flags().VisitAll(check)
|
||||
cmd.InheritedFlags().VisitAll(check)
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
// fixture command path. Every argv is a complete, business-valid invocation:
|
||||
// required companion flags are present, time and enum values are valid, and
|
||||
// write commands use the capture caller rather than a real transport. The
|
||||
// target canonical flag must occur exactly once so the test can replace only
|
||||
// its spelling while holding every other input constant.
|
||||
var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
|
||||
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
|
||||
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
|
||||
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
|
||||
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
|
||||
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
|
||||
"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 +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"},
|
||||
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
|
||||
"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 +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"},
|
||||
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat category create": {"chat", "category", "create", "--title", "Fixture Cat", "--yes"},
|
||||
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
|
||||
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
|
||||
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
|
||||
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
|
||||
"chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"},
|
||||
"chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"},
|
||||
"chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"},
|
||||
"chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
"chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"},
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
|
||||
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
|
||||
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
|
||||
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
|
||||
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
|
||||
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
}
|
||||
|
||||
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
|
||||
// optimization. The dedicated gate below requires every one to remain active
|
||||
// in the embedded generated table and equivalent at the final transport.
|
||||
var paramAliasNewIMCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "chat +bot-find", emitted: "name", canonical: "query"},
|
||||
{command: "chat bot find", emitted: "name", canonical: "query"},
|
||||
{command: "chat +bot-search", emitted: "query", canonical: "name"},
|
||||
{command: "chat +bot-search", emitted: "current-page", canonical: "page"},
|
||||
{command: "chat +category-create", emitted: "name", canonical: "title"},
|
||||
{command: "chat +category-rename", emitted: "name", canonical: "title"},
|
||||
{command: "chat +messages-list-direct", emitted: "start", canonical: "time"},
|
||||
{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 +unread-chats", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
|
||||
{command: "chat bot search", emitted: "query", canonical: "name"},
|
||||
{command: "chat bot search", emitted: "current-page", canonical: "page"},
|
||||
{command: "chat category create", emitted: "name", canonical: "title"},
|
||||
{command: "chat category create-smart", emitted: "title", canonical: "name"},
|
||||
{command: "chat category rename", emitted: "name", canonical: "title"},
|
||||
{command: "chat message list", emitted: "start", canonical: "time"},
|
||||
{command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"},
|
||||
{command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"},
|
||||
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
|
||||
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
// checked through the embedded PreParse delivery path and against a complete
|
||||
// business-valid command template. The separate IM gate below continues to
|
||||
// execute every alias introduced by the current IM optimization.
|
||||
//
|
||||
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
|
||||
// executing the complete 800+ command Root twice per spelling under -race.
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
|
||||
continue
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
continue
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Errorf("complete command for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, canonicalArgs)
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed IM alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active {
|
||||
return
|
||||
}
|
||||
if target != test.canonical {
|
||||
t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
|
||||
}
|
||||
activeAliases++
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if activeAliases != len(paramAliasNewIMCases) {
|
||||
t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases))
|
||||
}
|
||||
}
|
||||
|
||||
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
complete, ok := paramAliasCompleteCommands[command]
|
||||
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
|
||||
if variant, exists := variants[canonical]; exists {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
func paramAliasPayloadCaseKey(command, emitted string) string {
|
||||
return command + "\x00" + emitted
|
||||
}
|
||||
|
||||
func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
return executeParamAliasE2E(t, caller, args...)
|
||||
}
|
||||
|
||||
func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
|
||||
out := append([]string(nil), args...)
|
||||
replacements := 0
|
||||
for index, arg := range out {
|
||||
if arg == "--"+canonical {
|
||||
out[index] = "--" + emitted
|
||||
replacements++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(arg, "--"+canonical+"=") {
|
||||
out[index] = "--" + emitted + strings.TrimPrefix(arg, "--"+canonical)
|
||||
replacements++
|
||||
}
|
||||
}
|
||||
return out, replacements
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist locks the
|
||||
// boundary between the command's original hidden compatibility flags and the
|
||||
// new central semantic normalizer. Existing real flags stay untouched and are
|
||||
// handled by calendar.go's flagOrFallback chain; only spellings that are not
|
||||
// real flags (for example --date, --from, and --since) are rewritten centrally.
|
||||
func TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist(t *testing.T) {
|
||||
engine := newPipelineEngine()
|
||||
|
||||
cases := []struct {
|
||||
emitted string
|
||||
value string
|
||||
canonical string
|
||||
isInt bool
|
||||
native bool
|
||||
}{
|
||||
// Existing Calendar compatibility flags remain native.
|
||||
{"start-time", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"startTime", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"start_time", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"start-date", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"min-time", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"time-min", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"end-time", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"endTime", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"end-date", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"max-time", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"time-max", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"max-results", "50", "limit", true, true},
|
||||
{"maxResults", "50", "limit", true, true},
|
||||
{"page-size", "50", "limit", true, true},
|
||||
{"size", "50", "limit", true, true},
|
||||
{"next-cursor", "TOKEN123", "cursor", false, true},
|
||||
{"nextCursor", "TOKEN123", "cursor", false, true},
|
||||
{"page-token", "TOKEN123", "cursor", false, true},
|
||||
{"next-token", "TOKEN123", "cursor", false, true},
|
||||
{"calendar", "primary", "calendar-id", false, true},
|
||||
{"calendarId", "primary", "calendar-id", false, true},
|
||||
// These spellings have no native Calendar flag and remain central aliases.
|
||||
{"from", "2026-03-10T14:00:00+08:00", "start", false, false},
|
||||
{"since", "2026-03-10T14:00:00+08:00", "start", false, false},
|
||||
{"date", "2026-03-10T14:00:00+08:00", "start", false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.emitted, func(t *testing.T) {
|
||||
// Fresh command tree per case: ParseFlags mutates flag state.
|
||||
root := NewRootCommand()
|
||||
target := mustFindCommand(t, root, "calendar", "event", "list")
|
||||
|
||||
ctx := &pipeline.Context{
|
||||
Args: []string{"calendar", "event", "list", "--" + tc.emitted, tc.value},
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: pipeline.FlagInfoFromCommand(target),
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error = %v", err)
|
||||
}
|
||||
|
||||
parsedFlag := tc.canonical
|
||||
if tc.native {
|
||||
parsedFlag = tc.emitted
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("native --%s triggered central corrections: %#v", tc.emitted, ctx.Corrections)
|
||||
}
|
||||
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.emitted+" "+tc.value) {
|
||||
t.Fatalf("native --%s did not survive unchanged: args = %v", tc.emitted, ctx.Args)
|
||||
}
|
||||
} else {
|
||||
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) {
|
||||
t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 {
|
||||
t.Fatalf("central --%s corrections = %#v, want one", tc.emitted, ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
flagArgs := ctx.Args[3:]
|
||||
if err := target.ParseFlags(flagArgs); err != nil {
|
||||
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
|
||||
}
|
||||
if tc.isInt {
|
||||
got, err := target.Flags().GetInt(parsedFlag)
|
||||
if err != nil || got != 50 {
|
||||
t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err)
|
||||
}
|
||||
} else {
|
||||
got, err := target.Flags().GetString(parsedFlag)
|
||||
if err != nil || got != tc.value {
|
||||
t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalendarEventListKeepsCountExclusion pins the reviewed decision that
|
||||
// pagination_size deliberately excludes --count (count != limit). The kept
|
||||
// hidden --count flag must be left untouched by the pipeline: it is a real
|
||||
// flag, not a concept member, so it must not be rewritten to --limit.
|
||||
func TestCalendarEventListKeepsCountExclusion(t *testing.T) {
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommand()
|
||||
target := mustFindCommand(t, root, "calendar", "event", "list")
|
||||
|
||||
ctx := &pipeline.Context{
|
||||
Args: []string{"calendar", "event", "list", "--count", "5"},
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: pipeline.FlagInfoFromCommand(target),
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error = %v", err)
|
||||
}
|
||||
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--count 5") {
|
||||
t.Fatalf("--count must not be rewritten: args = %v", ctx.Args)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("--count triggered corrections %#v, want none", ctx.Corrections)
|
||||
}
|
||||
if err := target.ParseFlags(ctx.Args[3:]); err != nil {
|
||||
t.Fatalf("Cobra ParseFlags error = %v", err)
|
||||
}
|
||||
if got, _ := target.Flags().GetInt("count"); got != 5 {
|
||||
t.Fatalf("flag --count = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
@@ -570,7 +570,8 @@ func handlePatAuthCheck(
|
||||
// or when flowId is absent, the CLI returns machine-readable JSON to
|
||||
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
|
||||
// used for this decision — it is only forwarded on the wire via
|
||||
// edition.MergeHeaders and surfaced in hostControl for traceability.
|
||||
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
|
||||
// hostControl for traceability.
|
||||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
)
|
||||
|
||||
func TestIsPatScopeError_MissingScope(t *testing.T) {
|
||||
@@ -1006,10 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
|
||||
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
|
||||
// hostControl.clawType — the open-source build pins that to the
|
||||
// literal edition.DefaultOSSClawType value ("openClaw").
|
||||
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
|
||||
// open-source edition default remains "openClaw".
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
@@ -1053,7 +1055,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
}
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
|
||||
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
|
||||
t.Fatalf("hostControl.clawType = %q, want openClaw (open-source edition default)", got)
|
||||
}
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
|
||||
@@ -27,11 +27,11 @@ import (
|
||||
//
|
||||
// Decision rule:
|
||||
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
|
||||
// - When triggered, `clawType` in the emitted hostControl block MUST
|
||||
// be the exact value the CLI actually injects on the wire into the
|
||||
// `claw-type` HTTP header. The open-source build pins that to
|
||||
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
|
||||
// is no per-spawn env override.
|
||||
// - When triggered, `clawType` in the emitted hostControl block MUST be the
|
||||
// exact value the CLI actually injects on the wire. Each edition supplies
|
||||
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
|
||||
// it. Invalid input falls back here for library compatibility; root command
|
||||
// execution rejects it before network access.
|
||||
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
|
||||
// HostControlBlock yields nil and no hostControl block is emitted.
|
||||
func init() {
|
||||
@@ -48,15 +48,16 @@ func hostControlProviderFromEnv() string {
|
||||
return effectiveClawType()
|
||||
}
|
||||
|
||||
// effectiveClawType returns the literal value that MergeHeaders will
|
||||
// inject into outbound `claw-type` headers. Going through the edition
|
||||
// hook (instead of a hard-coded constant) keeps this site correct for
|
||||
// downstream editions that override MergeHeaders.
|
||||
// effectiveClawType resolves the literal value injected into outbound
|
||||
// `claw-type` headers without invoking credential hooks from PAT error
|
||||
// serialization. MergeHeaders implementations that set claw-type must satisfy
|
||||
// the edition contract that this value is independent of the base map.
|
||||
func effectiveClawType() string {
|
||||
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
|
||||
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
|
||||
return v
|
||||
headers := make(map[string]string)
|
||||
if h := edition.Get(); h != nil {
|
||||
if h.MergeHeaders != nil {
|
||||
headers = h.MergeHeaders(headers)
|
||||
}
|
||||
}
|
||||
return edition.DefaultOSSClawType
|
||||
return resolveEffectiveAgentProduct(headers)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "camel case", args: []string{"--dryRun", "chat", "bot", "find", "--help"}},
|
||||
{name: "fuzzy boolean", args: []string{"--dry-rnu", "chat", "bot", "find", "--help"}},
|
||||
{name: "fuzzy value", args: []string{"--profle", "corp:user", "chat", "bot", "find", "--help"}},
|
||||
{name: "sticky value", args: []string{"--timeout30", "chat", "bot", "find", "--help"}},
|
||||
{name: "sticky boolean value", args: []string{"--verbosefalse", "chat", "bot", "find", "--help"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", test.args, err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat bot find" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("RunPreParseArgs(%v) context = %#v", test.args, ctx)
|
||||
}
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("corrected leading persistent flag failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
"chat", "message", "send",
|
||||
"--user-id", "123", "--user", "456", "--text", "hi",
|
||||
"--format", "table", "--debug",
|
||||
}
|
||||
_, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err == nil {
|
||||
t.Fatal("alias/canonical conflict unexpectedly succeeded")
|
||||
}
|
||||
if wantsJSONErrors(root) {
|
||||
t.Fatal("--format table was not applied before rendering the PreParse error")
|
||||
}
|
||||
if got := resolveVerbosity(root); got != apperrors.VerbosityDebug {
|
||||
t.Fatalf("PreParse error verbosity = %v, want debug", got)
|
||||
}
|
||||
|
||||
err = newPreParseValidationError(err)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("PreParse validation error = %T, want *errors.Error", err)
|
||||
}
|
||||
if strings.Contains(structured.Message, "pipeline") || strings.Contains(structured.Message, "semantic-alias") ||
|
||||
strings.Contains(structured.Cause.Error(), "pipeline") || strings.Contains(structured.Cause.Error(), "semantic-alias") {
|
||||
t.Fatalf("internal pipeline identity leaked to user error: message=%q cause=%q", structured.Message, structured.Cause)
|
||||
}
|
||||
var conflict *pipeline.FlagConflictError
|
||||
if !stderrors.As(err, &conflict) {
|
||||
t.Fatalf("PreParse validation error lost FlagConflictError: %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if printErr := printExecutionError(root, &output, &output, err); printErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", printErr)
|
||||
}
|
||||
rendered := output.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(rendered), "{") {
|
||||
t.Fatalf("--format table rendered JSON:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Reason: parameter_conflict") || !strings.Contains(rendered, "Cause:") {
|
||||
t.Fatalf("--debug details missing from early error:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
+116
-7
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -75,6 +76,7 @@ var (
|
||||
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
|
||||
rootPluginSyncSkills = plugin.SyncSkills
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
@@ -113,7 +115,11 @@ func Execute() (exitCode int) {
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
// This corrects model-generated errors like --userId → --user-id
|
||||
// and --limit100 → --limit 100.
|
||||
rootRunPreParse(root, engine)
|
||||
if err := rootRunPreParse(root, engine); err != nil {
|
||||
err = newPreParseValidationError(err)
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
|
||||
executed, err := rootExecuteCommand(root)
|
||||
if err != nil {
|
||||
@@ -135,6 +141,22 @@ func Execute() (exitCode int) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
userErr := err
|
||||
var handlerErr *pipeline.HandlerError
|
||||
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
|
||||
userErr = handlerErr.Unwrap()
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
userErr.Error(),
|
||||
apperrors.WithReason("parameter_conflict"),
|
||||
apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."),
|
||||
apperrors.WithCause(userErr),
|
||||
)
|
||||
}
|
||||
|
||||
func isUnknownCommandError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "unknown command")
|
||||
}
|
||||
@@ -182,6 +204,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||||
msgWithTail := errMsg + tail
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
|
||||
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
|
||||
reason := "blocked_flag"
|
||||
if protection == pipeline.FlagProtectionAmbiguous {
|
||||
hint = fmt.Sprintf("Parameter --%s is ambiguous on %q and cannot be normalized safely; choose the intended explicit flag from --help.", flag, cmd.CommandPath())
|
||||
reason = "ambiguous_flag"
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
|
||||
// Common flag aliases and suggestions
|
||||
suggestions := map[string]string{
|
||||
@@ -230,6 +268,33 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
return fmt.Errorf("%s%s", errMsg, tail)
|
||||
}
|
||||
|
||||
func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline.FlagProtection, bool) {
|
||||
if cmd == nil {
|
||||
return "", "", false
|
||||
}
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
morphed := cmdutil.Morph(flag)
|
||||
if entry.IsBlocked(morphed) {
|
||||
return flag, pipeline.FlagProtectionBlocked, true
|
||||
}
|
||||
if entry.IsAmbiguous(morphed) {
|
||||
return flag, pipeline.FlagProtectionAmbiguous, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
var raw apperrors.RawStderrError
|
||||
if stderrors.As(err, &raw) {
|
||||
@@ -338,7 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
loader := cli.EnvironmentLoader{
|
||||
LookupEnv: os.LookupEnv,
|
||||
}
|
||||
runner := newCommandRunnerWithFlags(loader, flags)
|
||||
runner := rootNewCommandRunnerWithFlags(loader, flags)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
@@ -353,12 +418,15 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Validate the optional observation label before any edition hook
|
||||
// Validate caller-provided identity labels before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
@@ -449,11 +517,38 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
root.SetFlagErrorFunc(flagErrorWithSuggestions)
|
||||
installReviewedFlagProtectionHandlers(root)
|
||||
root.SetContext(rootCtx)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// installReviewedFlagProtectionHandlers makes reviewed blocked/ambiguous
|
||||
// parameters authoritative even when an older command subtree has installed a
|
||||
// local FlagErrorFunc. Commands without a reviewed guard keep their existing
|
||||
// handler or inherit the root handler as before.
|
||||
func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
var visit func(*cobra.Command)
|
||||
visit = func(cmd *cobra.Command) {
|
||||
if entry, ok := cli.LookupParamAlias(cmd.CommandPath()); ok && (len(entry.Blocked) > 0 || len(entry.Ambiguous) > 0) {
|
||||
previous := cmd.FlagErrorFunc()
|
||||
cmd.SetFlagErrorFunc(func(current *cobra.Command, err error) error {
|
||||
if _, _, guarded := reviewedFlagProtection(current, err.Error()); guarded {
|
||||
return flagErrorWithSuggestions(current, err)
|
||||
}
|
||||
return previous(current, err)
|
||||
})
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
for i := 0; i < len(args); i++ {
|
||||
@@ -1248,13 +1343,27 @@ func newPipelineEngine() *pipeline.Engine {
|
||||
// Register handler runs during command tree building.
|
||||
handlers.RegisterHandler{},
|
||||
|
||||
// PreParse handlers run in order: alias → sticky → paramname.
|
||||
// Alias normalises case first (--userId → --user-id), then
|
||||
// sticky splits glued values (--limit100 → --limit 100), then
|
||||
// paramname fixes near-miss typos (--limt → --limit).
|
||||
// PreParse handlers run in order: alias → semantic → sticky → paramname
|
||||
// → boolvalue.
|
||||
// Alias normalises case first (--userId → --user-id), then semantic
|
||||
// resolves reviewed synonyms to the real flag (--keyword → --query),
|
||||
// then sticky splits glued values (--limit100 → --limit 100), then
|
||||
// paramname fixes near-miss typos (--limt → --limit). Boolvalue runs
|
||||
// last so detached values for every real boolean flag (for example
|
||||
// `--dry-run false`) become explicit `--flag=false` tokens before pflag
|
||||
// can interpret the bare flag as true.
|
||||
handlers.AliasHandler{},
|
||||
handlers.SemanticAliasHandler{
|
||||
// Inject the build-time reduced alias table with native types so
|
||||
// the handler package stays decoupled from cli.
|
||||
Lookup: func(rawCommandPath string) (map[string]string, []string, []string, bool) {
|
||||
e, ok := cli.LookupParamAlias(rawCommandPath)
|
||||
return e.Aliases, e.Blocked, e.Ambiguous, ok
|
||||
},
|
||||
},
|
||||
handlers.StickyHandler{},
|
||||
handlers.ParamNameHandler{},
|
||||
handlers.BoolValueHandler{},
|
||||
|
||||
// PostParse handlers normalise structured values.
|
||||
handlers.ParamValueHandler{},
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootResetRecoveryState = func() {}
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
@@ -52,6 +52,12 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("pre-parse conflict returned zero")
|
||||
}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
|
||||
+19
-5
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
@@ -1004,10 +1005,10 @@ func resolveIdentityHeaders() map[string]string {
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
||||
// x-dingtalk-agent header. It does NOT influence claw-type (which the
|
||||
// open-source edition pins to edition.DefaultOSSClawType via the
|
||||
// MergeHeaders hook below) and it does NOT influence the host-owned
|
||||
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
|
||||
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
|
||||
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
|
||||
// and it does NOT influence the host-owned PAT decision (driven solely by
|
||||
// DINGTALK_DWS_AGENTCODE).
|
||||
sessionID := os.Getenv(envDingtalkSessionID)
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envDWSSessionID)
|
||||
@@ -1056,7 +1057,7 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
|
||||
// DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command
|
||||
// execution validates it strictly in PersistentPreRunE. Library callers
|
||||
// that bypass the root command keep this best-effort API contract: invalid
|
||||
// values are omitted rather than changing the public function signature.
|
||||
@@ -1067,9 +1068,22 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
// Resolve the Agent Product before credential injection. The credential
|
||||
// hook has a separate contract and must not be able to replace the
|
||||
// request identity used by PAT hostControl serialization.
|
||||
headers = applyAgentProductOverride(headers)
|
||||
agentProduct := headers[agentproduct.HeaderName]
|
||||
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
|
||||
// claw-type wire header. Reassert the resolved product after credential
|
||||
// injection so that hook cannot alter identity. Invalid values are ignored
|
||||
// on this best-effort library path; root execution rejects them earlier.
|
||||
headers[agentproduct.HeaderName] = agentProduct
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
const (
|
||||
publicShortcutCount = 265
|
||||
schemaPublishedShortcutCount = 210
|
||||
schemaPublishedShortcutCount = 215
|
||||
)
|
||||
|
||||
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 120; got != want {
|
||||
if got, want := int(product["count"].(float64)), 129; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -116,8 +116,8 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
shortcutCount++
|
||||
}
|
||||
}
|
||||
if shortcutCount != 42 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
|
||||
if shortcutCount != 47 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string {
|
||||
"绝对路径",
|
||||
"..",
|
||||
"最多 15 个字符",
|
||||
"能力矩阵",
|
||||
}
|
||||
evidence := make([]string, 0, len(probes))
|
||||
for _, probe := range probes {
|
||||
|
||||
+6
-3
@@ -11,11 +11,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// gen.go is the single entry point for schema metadata generation. It isolates
|
||||
// gen.go is the single entry point for reviewed CLI asset generation. It isolates
|
||||
// all //go:generate pragmas from business code so that:
|
||||
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
|
||||
// - Generation is a standalone process (make generate-schema triggers this).
|
||||
// - The 6-input → 1-output contract is documented in one place.
|
||||
// - The authored-input → generated-output contract is documented in one place.
|
||||
//
|
||||
// Generation inputs (authored, reviewed):
|
||||
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
|
||||
@@ -23,11 +23,13 @@
|
||||
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
|
||||
// 4. schema_mcp_metadata.json MCP server tool definitions
|
||||
// 5. schema_parameter_bindings.json parameter type/property mappings
|
||||
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// 6. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 7. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// Generation outputs (embedded at build):
|
||||
// - schema_agent_metadata/*.json per-product agent metadata
|
||||
// - schema_catalog/ per-product catalog shards
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
|
||||
package cli
|
||||
|
||||
@@ -36,3 +38,4 @@ package cli
|
||||
// package cached by the preceding metadata generator with the old embedded
|
||||
// JSON files.
|
||||
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read internal/cli: %v", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || filepath.Ext(name) != ".go" || name == "gen.go" {
|
||||
continue
|
||||
}
|
||||
content, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
for _, line := range bytes.Split(content, []byte("\n")) {
|
||||
if strings.HasPrefix(strings.TrimSpace(string(line)), "//go:generate") {
|
||||
t.Errorf("%s contains //go:generate; all directives must stay in gen.go", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content, err := os.ReadFile("gen.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read gen.go: %v", err)
|
||||
}
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_agent_metadata",
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
} {
|
||||
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ParamAliasEntry is the reduced parameter-alias table for one runnable Cobra
|
||||
// leaf. It is the typed value the build-time generator serializes into
|
||||
// param_aliases_generated.go and that the runtime normalizer (P2) consumes.
|
||||
//
|
||||
// Aliases maps an already-morphed emitted name to the command's canonical real
|
||||
// flag; the runtime looks up Morph(emitted) here to resolve a synonym. Blocked
|
||||
// lists morphed names that must never be reduced (they route to did-you-mean),
|
||||
// and Ambiguous lists morphed names that a reviewed co-occurrence guard leaves
|
||||
// unresolved on purpose.
|
||||
type ParamAliasEntry struct {
|
||||
CLIPath string `json:"cli_path"`
|
||||
Aliases map[string]string `json:"aliases,omitempty"`
|
||||
Blocked []string `json:"blocked,omitempty"`
|
||||
Ambiguous []string `json:"ambiguous,omitempty"`
|
||||
}
|
||||
|
||||
// ReduceParamAliases resolves the reviewed concept dictionary against every
|
||||
// runnable leaf's real flags and returns the per-command alias table. It is the
|
||||
// single source of the reduction algorithm, shared by the generator and tests
|
||||
// so the build-time (intersection) and generated views can never disagree.
|
||||
//
|
||||
// The reduction is deliberately mechanical (no NLU): for each concept it morphs
|
||||
// the concept members (plus any command-bound generic flag) and intersects them
|
||||
// with the command's morphed real flags. An intersection of exactly one real
|
||||
// flag yields aliases onto it; two or more real flags is a co-occurrence that
|
||||
// must be an explicitly reviewed `ambiguous` entry or generation fails. Command
|
||||
// scoped aliases override, blocks are removed and recorded, and every override
|
||||
// path and target is validated against the live tree.
|
||||
func ReduceParamAliases(root *cobra.Command) ([]ParamAliasEntry, error) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reviewed parameter concepts: %w", err)
|
||||
}
|
||||
if root == nil {
|
||||
return nil, fmt.Errorf("parameter alias source root is nil")
|
||||
}
|
||||
|
||||
overrideByPath := make(map[string]CommandOverride, len(concepts.Overrides))
|
||||
for _, ov := range concepts.Overrides {
|
||||
overrideByPath[ov.CommandPath] = ov
|
||||
}
|
||||
usedOverride := make(map[string]bool, len(overrideByPath))
|
||||
conceptsByPath := make(map[string][]Concept)
|
||||
usedConceptScope := make(map[string]bool)
|
||||
for _, concept := range concepts.Concepts {
|
||||
for _, path := range concept.Commands {
|
||||
conceptsByPath[path] = append(conceptsByPath[path], concept)
|
||||
}
|
||||
}
|
||||
|
||||
var problems []string
|
||||
var entries []ParamAliasEntry
|
||||
|
||||
walkRunnableParamCommands(root, func(leaf *cobra.Command) {
|
||||
path := normalizeSchemaCLIPath(leaf.CommandPath())
|
||||
realByMorph := realFlagsByMorph(leaf)
|
||||
ov, hasOverride := overrideByPath[path]
|
||||
if hasOverride {
|
||||
usedOverride[path] = true
|
||||
}
|
||||
scopedConcepts := conceptsByPath[path]
|
||||
for _, concept := range scopedConcepts {
|
||||
usedConceptScope[concept.ID+"\x00"+path] = true
|
||||
if !conceptHasRealFlag(concept, ov, realByMorph) {
|
||||
problems = append(problems, fmt.Sprintf("concept %q reviewed command %q has no matching real flag or reviewed bind", concept.ID, path))
|
||||
}
|
||||
}
|
||||
|
||||
entry, entryProblems := reduceLeafParamAliases(path, realByMorph, scopedConcepts, ov)
|
||||
problems = append(problems, entryProblems...)
|
||||
if entry != nil {
|
||||
entries = append(entries, *entry)
|
||||
}
|
||||
})
|
||||
|
||||
for path := range overrideByPath {
|
||||
if !usedOverride[path] {
|
||||
problems = append(problems, fmt.Sprintf("command_override %q does not match any runnable Cobra leaf", path))
|
||||
}
|
||||
}
|
||||
for _, concept := range concepts.Concepts {
|
||||
for _, path := range concept.Commands {
|
||||
if !usedConceptScope[concept.ID+"\x00"+path] {
|
||||
problems = append(problems, fmt.Sprintf("concept %q command scope %q does not match any runnable Cobra command", concept.ID, path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("parameter alias reduction failed:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].CLIPath < entries[j].CLIPath })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// walkRunnableParamCommands invokes fn for every runnable command in the tree,
|
||||
// including runnable parents such as `chat group members` that expose their own
|
||||
// flags while also owning subcommands. Parameter aliasing applies to any command
|
||||
// that accepts flags, which is broader than the schema's leaf-only traversal.
|
||||
func walkRunnableParamCommands(root *cobra.Command, fn func(*cobra.Command)) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
var walk func(*cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
if cmd.Runnable() {
|
||||
fn(cmd)
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
if !sub.IsAvailableCommand() && !hasRuntimeSchemaCommand(sub) {
|
||||
continue
|
||||
}
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
|
||||
// realFlag is one of a leaf's real flags, remembering whether it is hidden so
|
||||
// the reduction can treat a hidden legacy alias flag (for example a hand-written
|
||||
// --base living next to the visible --base-id) as an absorbable synonym rather
|
||||
// than a genuine co-occurrence.
|
||||
type realFlag struct {
|
||||
name string
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// realFlagsByMorph maps each of a leaf's real flags (local + inherited) by its
|
||||
// morphed name to the real flags that share that morph.
|
||||
func realFlagsByMorph(leaf *cobra.Command) map[string][]realFlag {
|
||||
byMorph := make(map[string][]realFlag)
|
||||
visitManualAgentCommandFlags(leaf, func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Name == "help" {
|
||||
return
|
||||
}
|
||||
key := cmdutil.Morph(flag.Name)
|
||||
byMorph[key] = appendRealFlag(byMorph[key], realFlag{name: flag.Name, hidden: flag.Hidden})
|
||||
})
|
||||
return byMorph
|
||||
}
|
||||
|
||||
// reduceLeafParamAliases computes one leaf's alias entry and returns any
|
||||
// contract problems. A nil entry means the leaf produced no aliases, blocks, or
|
||||
// ambiguous guards.
|
||||
func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, concepts []Concept, ov CommandOverride) (*ParamAliasEntry, []string) {
|
||||
var problems []string
|
||||
aliasMap := make(map[string]string)
|
||||
blockedSet := make(map[string]bool)
|
||||
excludedSet := make(map[string]bool)
|
||||
pendingReview := ov.Confirm || ov.Investigate
|
||||
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; !ok {
|
||||
problems = append(problems, fmt.Sprintf("command_override %q binds %q to concept %q but %q is not a real flag", path, boundFlag, conceptID, boundFlag))
|
||||
}
|
||||
}
|
||||
|
||||
// (a) Concept auto-reduction. The caller has already admitted only the
|
||||
// concepts whose reviewed command scope contains this exact leaf.
|
||||
for _, concept := range concepts {
|
||||
eff := make(map[string]bool, len(concept.Members)+2)
|
||||
for _, member := range concept.Members {
|
||||
eff[cmdutil.Morph(member)] = true
|
||||
}
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
if conceptID == concept.ID {
|
||||
if pendingReview {
|
||||
for _, member := range concept.Members {
|
||||
morphed := cmdutil.Morph(member)
|
||||
if _, isReal := realByMorph[morphed]; !isReal {
|
||||
blockedSet[morphed] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eff[cmdutil.Morph(boundFlag)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gather the concept's candidate real flags on this command, then
|
||||
// choose a canonical. A single visible real flag wins and absorbs the
|
||||
// rest (including hidden legacy alias flags). Two or more visible real
|
||||
// flags is a genuine co-occurrence that must be reviewed.
|
||||
var candidates []realFlag
|
||||
for key := range eff {
|
||||
candidates = append(candidates, realByMorph[key]...)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
visible := distinctRealNames(candidates, true)
|
||||
var canon string
|
||||
switch len(visible) {
|
||||
case 1:
|
||||
canon = visible[0]
|
||||
case 0:
|
||||
names := distinctRealNames(candidates, false)
|
||||
if len(names) != 1 {
|
||||
continue
|
||||
}
|
||||
canon = names[0]
|
||||
default:
|
||||
// Genuine co-occurrence: this concept intersects two or more
|
||||
// visible real flags, so it cannot be auto-reduced. Require that
|
||||
// every emittable synonym of THIS concept (a concept member that is
|
||||
// not itself a real flag on the command) is acknowledged in the
|
||||
// reviewed ambiguous whitelist. Checking only that the command has
|
||||
// some ambiguous entry would let one concept's whitelist silently
|
||||
// vouch for a different concept's unreviewed co-occurrence.
|
||||
ambiguousSet := make(map[string]bool, len(ov.Ambiguous))
|
||||
for _, a := range ov.Ambiguous {
|
||||
ambiguousSet[cmdutil.Morph(a)] = true
|
||||
}
|
||||
var unreviewed []string
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
continue
|
||||
}
|
||||
if !ambiguousSet[m] {
|
||||
unreviewed = append(unreviewed, m)
|
||||
}
|
||||
}
|
||||
if len(unreviewed) > 0 {
|
||||
sort.Strings(visible)
|
||||
sort.Strings(unreviewed)
|
||||
problems = append(problems, fmt.Sprintf("command %q concept %q intersects visible real flags %s; unreviewed emittable members %s must be listed in the ambiguous whitelist", path, concept.ID, strings.Join(visible, ","), strings.Join(unreviewed, ",")))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
continue
|
||||
}
|
||||
if prev, ok := aliasMap[m]; ok && prev != canon {
|
||||
problems = append(problems, fmt.Sprintf("command %q emitted %q reduces to both %q and %q", path, m, prev, canon))
|
||||
continue
|
||||
}
|
||||
aliasMap[m] = canon
|
||||
}
|
||||
// Excludes are not passive prose: once this concept is active on a
|
||||
// reviewed command, a non-real excluded spelling is protected from
|
||||
// downstream fuzzy correction. A real flag is left alone because it
|
||||
// already has an independently valid command-local meaning.
|
||||
for _, exclude := range concept.Excludes {
|
||||
morphed := cmdutil.Morph(exclude)
|
||||
if _, isReal := realByMorph[morphed]; !isReal {
|
||||
excludedSet[morphed] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for excluded := range excludedSet {
|
||||
if _, isAlias := aliasMap[excluded]; !isAlias {
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
for emitted, target := range ov.ScopedAliases {
|
||||
morphedEmitted := cmdutil.Morph(emitted)
|
||||
reals, ok := realByMorph[cmdutil.Morph(target)]
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("command_override %q scoped alias %q->%q targets %q which is not a real flag", path, emitted, target, target))
|
||||
continue
|
||||
}
|
||||
if _, sourceIsReal := realByMorph[morphedEmitted]; sourceIsReal {
|
||||
problems = append(problems, fmt.Sprintf("command_override %q scoped alias source %q is already a real flag; keep its native compatibility path or remove it before enabling semantic rewrite", path, emitted))
|
||||
continue
|
||||
}
|
||||
if pendingReview {
|
||||
delete(aliasMap, morphedEmitted)
|
||||
blockedSet[morphedEmitted] = true
|
||||
continue
|
||||
}
|
||||
delete(blockedSet, morphedEmitted)
|
||||
aliasMap[morphedEmitted] = canonicalRealName(reals)
|
||||
}
|
||||
|
||||
// (c) Blocks are removed from the alias map and recorded for did-you-mean.
|
||||
for _, b := range ov.Block {
|
||||
mb := cmdutil.Morph(b)
|
||||
if _, isReal := realByMorph[mb]; isReal {
|
||||
problems = append(problems, fmt.Sprintf("command_override %q blocks %q but it is already a real flag; blocking must not disable a canonical/native parameter", path, b))
|
||||
continue
|
||||
}
|
||||
delete(aliasMap, mb)
|
||||
blockedSet[mb] = true
|
||||
}
|
||||
ambiguous := make([]string, 0, len(ov.Ambiguous))
|
||||
for _, a := range ov.Ambiguous {
|
||||
ma := cmdutil.Morph(a)
|
||||
if _, isReal := realByMorph[ma]; isReal {
|
||||
problems = append(problems, fmt.Sprintf("command_override %q marks %q ambiguous but it is already a real flag", path, a))
|
||||
continue
|
||||
}
|
||||
if canon, ok := aliasMap[ma]; ok {
|
||||
problems = append(problems, fmt.Sprintf("command %q name %q is both auto-reduced to %q and marked ambiguous; a name cannot be aliased and ambiguous at once", path, a, canon))
|
||||
}
|
||||
delete(aliasMap, ma)
|
||||
delete(blockedSet, ma)
|
||||
ambiguous = append(ambiguous, ma)
|
||||
}
|
||||
blocked := make([]string, 0, len(blockedSet))
|
||||
for b := range blockedSet {
|
||||
blocked = append(blocked, b)
|
||||
}
|
||||
|
||||
if len(aliasMap) == 0 && len(blocked) == 0 && len(ambiguous) == 0 {
|
||||
return nil, problems
|
||||
}
|
||||
return &ParamAliasEntry{
|
||||
CLIPath: path,
|
||||
Aliases: aliasMap,
|
||||
Blocked: sortedUnique(blocked),
|
||||
Ambiguous: sortedUnique(ambiguous),
|
||||
}, problems
|
||||
}
|
||||
|
||||
func conceptHasRealFlag(concept Concept, ov CommandOverride, realByMorph map[string][]realFlag) bool {
|
||||
for _, member := range concept.Members {
|
||||
if _, ok := realByMorph[cmdutil.Morph(member)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
if conceptID == concept.ID {
|
||||
if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendRealFlag(list []realFlag, value realFlag) []realFlag {
|
||||
for i, existing := range list {
|
||||
if existing.name == value.name {
|
||||
// Prefer the visible record if any registration is visible.
|
||||
if existing.hidden && !value.hidden {
|
||||
list[i] = value
|
||||
}
|
||||
return list
|
||||
}
|
||||
}
|
||||
list = append(list, value)
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].name < list[j].name })
|
||||
return list
|
||||
}
|
||||
|
||||
// distinctRealNames returns the sorted unique flag names among candidates,
|
||||
// optionally restricted to visible (non-hidden) flags.
|
||||
func distinctRealNames(candidates []realFlag, visibleOnly bool) []string {
|
||||
seen := make(map[string]bool, len(candidates))
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
if visibleOnly && c.hidden {
|
||||
continue
|
||||
}
|
||||
if seen[c.name] {
|
||||
continue
|
||||
}
|
||||
seen[c.name] = true
|
||||
out = append(out, c.name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// canonicalRealName chooses the canonical target among real flags sharing a
|
||||
// morph key, preferring a visible flag over a hidden legacy alias.
|
||||
func canonicalRealName(reals []realFlag) string {
|
||||
for _, r := range reals {
|
||||
if !r.hidden {
|
||||
return r.name
|
||||
}
|
||||
}
|
||||
if len(reals) > 0 {
|
||||
return reals[0].name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedUnique(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
// 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 cli
|
||||
|
||||
import "sync"
|
||||
|
||||
// paramAliasIndex is the lazily built per-command view of the generated
|
||||
// parameter-alias table, keyed by the same normalized CLI path the generator
|
||||
// used. It is populated once; the generated slice never changes at runtime.
|
||||
var (
|
||||
paramAliasIndexOnce sync.Once
|
||||
paramAliasIndex map[string]ParamAliasEntry
|
||||
)
|
||||
|
||||
func buildParamAliasIndex() {
|
||||
entries := loadGeneratedParamAliases()
|
||||
paramAliasIndex = make(map[string]ParamAliasEntry, len(entries))
|
||||
for _, e := range entries {
|
||||
paramAliasIndex[e.CLIPath] = e
|
||||
}
|
||||
}
|
||||
|
||||
// LookupParamAlias resolves the reduced parameter-alias entry for a command.
|
||||
//
|
||||
// rawCommandPath is Cobra's CommandPath() (it still carries the "dws" prefix).
|
||||
// It is normalized through normalizeSchemaCLIPath — the exact function the
|
||||
// build-time generator used to key each entry — so the runtime lookup key is
|
||||
// byte-identical to the generation key and there is zero mapping drift.
|
||||
func LookupParamAlias(rawCommandPath string) (ParamAliasEntry, bool) {
|
||||
paramAliasIndexOnce.Do(buildParamAliasIndex)
|
||||
e, ok := paramAliasIndex[normalizeSchemaCLIPath(rawCommandPath)]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// ResolveAlias returns the canonical real flag a morphed emitted name reduces
|
||||
// to, if this command aliases it. The caller is expected to pass an
|
||||
// already-morphed name (cmdutil.Morph), matching how the table is keyed.
|
||||
func (e ParamAliasEntry) ResolveAlias(morphed string) (string, bool) {
|
||||
canon, ok := e.Aliases[morphed]
|
||||
return canon, ok
|
||||
}
|
||||
|
||||
// IsBlocked reports whether a morphed emitted name is on this command's block
|
||||
// list: it must never be auto-rewritten and instead routes to did-you-mean.
|
||||
func (e ParamAliasEntry) IsBlocked(morphed string) bool {
|
||||
return containsParamAlias(e.Blocked, morphed)
|
||||
}
|
||||
|
||||
// IsAmbiguous reports whether a morphed emitted name is on this command's
|
||||
// reviewed co-occurrence whitelist: it is intentionally left unresolved so the
|
||||
// runtime asks instead of guessing between two real flags.
|
||||
func (e ParamAliasEntry) IsAmbiguous(morphed string) bool {
|
||||
return containsParamAlias(e.Ambiguous, morphed)
|
||||
}
|
||||
|
||||
func containsParamAlias(list []string, target string) bool {
|
||||
for _, v := range list {
|
||||
if v == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// realMap builds a per-leaf real-flag table keyed by the shared Morph so the
|
||||
// tests exercise exactly the same intersection the generator performs.
|
||||
func realMap(flags ...realFlag) map[string][]realFlag {
|
||||
m := make(map[string][]realFlag)
|
||||
for _, f := range flags {
|
||||
k := cmdutil.Morph(f.name)
|
||||
m[k] = appendRealFlag(m[k], f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// conceptFixture is a small synthetic concept set; reduceLeafParamAliases is
|
||||
// deliberately pure so it can be tested without the whole Cobra tree.
|
||||
func conceptFixture() []Concept {
|
||||
return []Concept{
|
||||
{ID: "pagination_size", CanonicalHint: "limit", Members: []string{"limit", "size", "page-size", "max-results"}},
|
||||
{ID: "base_id", CanonicalHint: "base-id", Members: []string{"base", "base-id", "base-token"}},
|
||||
{ID: "user_id", CanonicalHint: "user-id", Members: []string{"user", "users", "user-id", "uid"}},
|
||||
}
|
||||
}
|
||||
|
||||
func useParamConceptLoader(t *testing.T, concepts ParamConcepts, err error) {
|
||||
t.Helper()
|
||||
previous := loadReviewedParamConcepts
|
||||
loadReviewedParamConcepts = func() (ParamConcepts, error) { return concepts, err }
|
||||
t.Cleanup(func() { loadReviewedParamConcepts = previous })
|
||||
}
|
||||
|
||||
func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) {
|
||||
t.Run("load failure", func(t *testing.T) {
|
||||
useParamConceptLoader(t, ParamConcepts{}, errors.New("fixture load"))
|
||||
if _, err := ReduceParamAliases(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "fixture load") {
|
||||
t.Fatalf("ReduceParamAliases() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil root", func(t *testing.T) {
|
||||
useParamConceptLoader(t, ParamConcepts{Version: 1}, nil)
|
||||
if _, err := ReduceParamAliases(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
|
||||
t.Fatalf("ReduceParamAliases(nil) error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("real tree", func(t *testing.T) {
|
||||
concepts := ParamConcepts{
|
||||
Version: 1,
|
||||
Concepts: []Concept{
|
||||
{ID: "query", Members: []string{"query", "keyword"}, Commands: []string{"demo run"}},
|
||||
{ID: "user_id", Members: []string{"user-id", "uid"}, Commands: []string{"demo run"}},
|
||||
},
|
||||
Overrides: []CommandOverride{
|
||||
{CommandPath: "alpha", Block: []string{"unsafe"}},
|
||||
{CommandPath: "demo run", Bind: map[string]string{"id": "user_id"}},
|
||||
},
|
||||
}
|
||||
useParamConceptLoader(t, concepts, nil)
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
alpha := &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}}
|
||||
alpha.Flags().String("name", "", "name")
|
||||
demo := &cobra.Command{Use: "demo", Run: func(*cobra.Command, []string) {}}
|
||||
run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
run.Flags().String("query", "", "query")
|
||||
run.Flags().String("id", "", "id")
|
||||
demo.AddCommand(run)
|
||||
root.AddCommand(alpha, demo)
|
||||
root.AddCommand(&cobra.Command{Use: "help", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, Run: func(*cobra.Command, []string) {}})
|
||||
|
||||
entries, err := ReduceParamAliases(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ReduceParamAliases() error = %v", err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].CLIPath != "alpha" || entries[1].CLIPath != "demo run" {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
if entries[1].Aliases["keyword"] != "query" || entries[1].Aliases["uid"] != "id" {
|
||||
t.Fatalf("demo aliases = %#v", entries[1].Aliases)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale and unbound review inputs", func(t *testing.T) {
|
||||
concepts := ParamConcepts{
|
||||
Version: 1,
|
||||
Concepts: []Concept{
|
||||
{ID: "missing", Members: []string{"missing"}, Commands: []string{"demo run"}},
|
||||
{ID: "stale", Members: []string{"stale"}, Commands: []string{"ghost run"}},
|
||||
},
|
||||
Overrides: []CommandOverride{{CommandPath: "ghost run", Block: []string{"unsafe"}}},
|
||||
}
|
||||
useParamConceptLoader(t, concepts, nil)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
demo := &cobra.Command{Use: "demo"}
|
||||
run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
run.Flags().String("query", "", "query")
|
||||
demo.AddCommand(run)
|
||||
root.AddCommand(demo)
|
||||
|
||||
_, err := ReduceParamAliases(root)
|
||||
for _, want := range []string{"has no matching real flag", "does not match any runnable Cobra leaf", "does not match any runnable Cobra command"} {
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("ReduceParamAliases() error = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParamAliasHelperEdges(t *testing.T) {
|
||||
walkRunnableParamCommands(nil, func(*cobra.Command) { t.Fatal("nil root was visited") })
|
||||
helpOnly := &cobra.Command{Use: "demo"}
|
||||
helpOnly.Flags().String("help", "", "help")
|
||||
if got := realFlagsByMorph(helpOnly); len(got) != 0 {
|
||||
t.Fatalf("help flag entered the real parameter table: %#v", got)
|
||||
}
|
||||
|
||||
flags := []realFlag{{name: "same", hidden: true}}
|
||||
flags = appendRealFlag(flags, realFlag{name: "same"})
|
||||
if len(flags) != 1 || flags[0].hidden {
|
||||
t.Fatalf("visible duplicate did not replace hidden registration: %#v", flags)
|
||||
}
|
||||
flags = appendRealFlag(flags, realFlag{name: "same", hidden: true})
|
||||
if len(flags) != 1 || flags[0].hidden {
|
||||
t.Fatalf("hidden duplicate replaced visible registration: %#v", flags)
|
||||
}
|
||||
flags = appendRealFlag(flags, realFlag{name: "alpha"})
|
||||
if !reflect.DeepEqual([]string{flags[0].name, flags[1].name}, []string{"alpha", "same"}) {
|
||||
t.Fatalf("new real flags are not sorted: %#v", flags)
|
||||
}
|
||||
|
||||
candidates := []realFlag{{name: "hidden", hidden: true}, {name: "visible"}, {name: "visible"}}
|
||||
if got := distinctRealNames(candidates, true); !reflect.DeepEqual(got, []string{"visible"}) {
|
||||
t.Fatalf("visible names = %v", got)
|
||||
}
|
||||
if got := canonicalRealName([]realFlag{{name: "hidden", hidden: true}}); got != "hidden" {
|
||||
t.Fatalf("hidden-only canonical = %q", got)
|
||||
}
|
||||
if got := canonicalRealName(nil); got != "" {
|
||||
t.Fatalf("empty canonical = %q", got)
|
||||
}
|
||||
if got := sortedUnique(nil); got != nil {
|
||||
t.Fatalf("sortedUnique(nil) = %#v", got)
|
||||
}
|
||||
if got := sortedUnique([]string{"b", "a", "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||
t.Fatalf("sortedUnique() = %v", got)
|
||||
}
|
||||
|
||||
real := realMap(realFlag{name: "query"}, realFlag{name: "id"})
|
||||
if !conceptHasRealFlag(Concept{ID: "query", Members: []string{"query"}}, CommandOverride{}, real) {
|
||||
t.Fatal("concept member did not match a real flag")
|
||||
}
|
||||
if !conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"id": "user"}}, real) {
|
||||
t.Fatal("reviewed bind did not match a real flag")
|
||||
}
|
||||
if conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"missing": "user"}}, real) {
|
||||
t.Fatal("missing reviewed bind matched a real flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
|
||||
t.Run("pending reviewed bind blocks non-real members", func(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "id"}),
|
||||
[]Concept{{ID: "user_id", Members: []string{"user-id", "uid"}}},
|
||||
CommandOverride{Bind: map[string]string{"id": "user_id"}, Investigate: true},
|
||||
)
|
||||
if len(problems) != 0 || entry == nil ||
|
||||
!containsParamAlias(entry.Blocked, "user-id") || !containsParamAlias(entry.Blocked, "uid") {
|
||||
t.Fatalf("pending bind entry = %#v, problems = %v", entry, problems)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden-only canonical", func(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "query", hidden: true}),
|
||||
[]Concept{{ID: "query", Members: []string{"query", "keyword"}}},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 || entry == nil || entry.Aliases["keyword"] != "query" {
|
||||
t.Fatalf("hidden-only entry = %#v, problems = %v", entry, problems)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple hidden candidates stay unresolved", func(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "first", hidden: true}, realFlag{name: "second", hidden: true}),
|
||||
[]Concept{{ID: "choice", Members: []string{"first", "second", "choice"}}},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 || entry != nil {
|
||||
t.Fatalf("multiple hidden candidates entry = %#v, problems = %v", entry, problems)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two concepts cannot claim one emitted spelling", func(t *testing.T) {
|
||||
_, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "first"}, realFlag{name: "second"}),
|
||||
[]Concept{
|
||||
{ID: "first", Members: []string{"first", "shared"}},
|
||||
{ID: "second", Members: []string{"second", "shared"}},
|
||||
},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "reduces to both") {
|
||||
t.Fatalf("alias collision problems = %v", problems)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unclaimed exclude becomes blocked", func(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "query"}),
|
||||
[]Concept{{ID: "query", Members: []string{"query", "keyword"}, Excludes: []string{"name"}}},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 || entry == nil || !containsParamAlias(entry.Blocked, "name") {
|
||||
t.Fatalf("exclude entry = %#v, problems = %v", entry, problems)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParamAliasEntryLookupMethods(t *testing.T) {
|
||||
entry := ParamAliasEntry{
|
||||
Aliases: map[string]string{"uid": "user"},
|
||||
Blocked: []string{"count"},
|
||||
Ambiguous: []string{"user-id"},
|
||||
}
|
||||
if got, ok := entry.ResolveAlias("uid"); !ok || got != "user" {
|
||||
t.Fatalf("ResolveAlias(uid) = %q, %v", got, ok)
|
||||
}
|
||||
if _, ok := entry.ResolveAlias("missing"); ok {
|
||||
t.Fatal("ResolveAlias(missing) unexpectedly matched")
|
||||
}
|
||||
if !entry.IsBlocked("count") || entry.IsBlocked("missing") {
|
||||
t.Fatalf("blocked lookup mismatch: %#v", entry.Blocked)
|
||||
}
|
||||
if !entry.IsAmbiguous("user-id") || entry.IsAmbiguous("missing") {
|
||||
t.Fatalf("ambiguous lookup mismatch: %#v", entry.Ambiguous)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesAutoReduction(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(), CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
for _, emitted := range []string{"size", "page-size", "max-results"} {
|
||||
if entry.Aliases[emitted] != "limit" {
|
||||
t.Fatalf("alias %q = %q, want limit", emitted, entry.Aliases[emitted])
|
||||
}
|
||||
}
|
||||
if _, ok := entry.Aliases["limit"]; ok {
|
||||
t.Fatal("the real flag limit must never be an alias key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesCoOccurrenceRequiresReview(t *testing.T) {
|
||||
_, problems := reduceLeafParamAliases("demo cmd",
|
||||
realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(), CommandOverride{})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("two visible real flags for one concept must fail without a reviewed ambiguous whitelist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesAmbiguousWhitelist(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd",
|
||||
realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if _, ok := entry.Aliases["user-id"]; ok {
|
||||
t.Fatal("a reviewed co-occurrence must not auto-reduce its concept members")
|
||||
}
|
||||
if len(entry.Ambiguous) != 2 || entry.Ambiguous[0] != "uid" || entry.Ambiguous[1] != "user-id" {
|
||||
t.Fatalf("ambiguous = %v, want sorted [uid user-id]", entry.Ambiguous)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceLeafParamAliasesCoOccurrencePerConcept locks the per-concept guard:
|
||||
// reviewing one concept's co-occurrence must not silently vouch for a second,
|
||||
// unreviewed co-occurring concept on the same command. Here user_id (user +
|
||||
// users) is whitelisted while base_id (base + base-id) is not, so base_id's
|
||||
// unreviewed emittable member base-token must still fail generation.
|
||||
func TestReduceLeafParamAliasesCoOccurrencePerConcept(t *testing.T) {
|
||||
real := realMap(
|
||||
realFlag{name: "user"}, realFlag{name: "users"},
|
||||
realFlag{name: "base"}, realFlag{name: "base-id"},
|
||||
)
|
||||
_, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("an unreviewed second co-occurring concept must fail even when another concept is whitelisted")
|
||||
}
|
||||
found := false
|
||||
for _, p := range problems {
|
||||
if strings.Contains(p, `concept "base_id"`) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected a problem naming the unreviewed base_id concept, got: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceLeafParamAliasesCoOccurrenceBothReviewed confirms the per-concept
|
||||
// guard passes once every co-occurring concept's emittable members are listed.
|
||||
func TestReduceLeafParamAliasesCoOccurrenceBothReviewed(t *testing.T) {
|
||||
real := realMap(
|
||||
realFlag{name: "user"}, realFlag{name: "users"},
|
||||
realFlag{name: "base"}, realFlag{name: "base-id"},
|
||||
)
|
||||
_, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid", "base-token"}})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("both concepts reviewed should pass: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap locks the guard that a
|
||||
// single name cannot be both auto-reduced and marked ambiguous. Here only
|
||||
// --users is real, so user_id auto-reduces user-id to users; hand-listing
|
||||
// user-id as ambiguous would produce a self-contradictory entry.
|
||||
func TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap(t *testing.T) {
|
||||
_, problems := reduceLeafParamAliases("demo cmd",
|
||||
realMap(realFlag{name: "users"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id"}})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("a name that both auto-reduces and is listed ambiguous must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesAbsorbsHiddenLegacyAlias(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd",
|
||||
realMap(realFlag{name: "base-id"}, realFlag{name: "base", hidden: true}), conceptFixture(), CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("a hidden legacy alias flag must not be a co-occurrence: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if entry.Aliases["base-token"] != "base-id" {
|
||||
t.Fatalf("base-token = %q, want base-id", entry.Aliases["base-token"])
|
||||
}
|
||||
if _, ok := entry.Aliases["base"]; ok {
|
||||
t.Fatal("a real (hidden) flag must never be an alias key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesBindGenericFlag(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"id": "base_id"}})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry.Aliases["base"] != "id" || entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
|
||||
t.Fatalf("bind reduction wrong: %#v", entry.Aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesBindRejectsNonRealFlag(t *testing.T) {
|
||||
_, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"missing": "base_id"}})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("binding a non-real flag must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesScopedAlias(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"ding-id": "id"}})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil || entry.Aliases[cmdutil.Morph("ding-id")] != "id" {
|
||||
t.Fatalf("scoped alias not applied: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesScopedAliasRejectsNonRealTarget(t *testing.T) {
|
||||
_, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"foo": "nonexistent"}})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("a scoped alias onto a non-real flag must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesBlockRemovesAndRecords(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(),
|
||||
CommandOverride{CommandPath: "demo cmd", Block: []string{"size"}})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if _, ok := entry.Aliases["size"]; ok {
|
||||
t.Fatal("a blocked emitted name must be removed from the alias map")
|
||||
}
|
||||
found := false
|
||||
for _, b := range entry.Blocked {
|
||||
if b == "size" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("size not recorded in blocked: %v", entry.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesPendingReviewDoesNotEmit(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "query"}), nil,
|
||||
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"keyword": "query"}, Confirm: true})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil || entry.Aliases["keyword"] != "" || !containsParamAlias(entry.Blocked, "keyword") {
|
||||
t.Fatalf("pending mapping entered automatic aliases: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcept(t *testing.T) {
|
||||
concepts := []Concept{
|
||||
{ID: "page_number", Members: []string{"page", "page-no"}, Excludes: []string{"page-size"}},
|
||||
{ID: "page_size", Members: []string{"limit", "page-size"}},
|
||||
}
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "page"}, realFlag{name: "limit"}), concepts, CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry.Aliases["page-size"] != "limit" || containsParamAlias(entry.Blocked, "page-size") {
|
||||
t.Fatalf("another reviewed concept alias was overridden by an exclude: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
|
||||
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
|
||||
for name, override := range map[string]CommandOverride{
|
||||
"block": {CommandPath: "demo cmd", Block: []string{"user-id"}},
|
||||
"ambiguous": {CommandPath: "demo cmd", Ambiguous: []string{"user-id"}},
|
||||
"scoped": {CommandPath: "demo cmd", ScopedAliases: map[string]string{"user-id": "user"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, problems := reduceLeafParamAliases("demo cmd", real, nil, override); len(problems) == 0 {
|
||||
t.Fatal("real native flag was allowed to be reclassified")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
|
||||
// at the Go level, complementing the byte-identity drift gate.
|
||||
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
if len(generatedParamAliases) == 0 {
|
||||
t.Fatal("generated parameter alias table is empty")
|
||||
}
|
||||
seen := make(map[string]bool, len(generatedParamAliases))
|
||||
for _, e := range generatedParamAliases {
|
||||
if e.CLIPath == "" {
|
||||
t.Fatal("generated entry has an empty CLIPath")
|
||||
}
|
||||
if seen[e.CLIPath] {
|
||||
t.Fatalf("duplicate CLIPath %q in generated table", e.CLIPath)
|
||||
}
|
||||
seen[e.CLIPath] = true
|
||||
for emitted, canon := range e.Aliases {
|
||||
if emitted != cmdutil.Morph(emitted) {
|
||||
t.Fatalf("%s: alias key %q is not morph-normalized", e.CLIPath, emitted)
|
||||
}
|
||||
if canon == "" {
|
||||
t.Fatalf("%s: alias %q has an empty target", e.CLIPath, emitted)
|
||||
}
|
||||
if emitted == canon {
|
||||
t.Fatalf("%s: alias %q maps to itself", e.CLIPath, emitted)
|
||||
}
|
||||
}
|
||||
classified := make(map[string]string, len(e.Aliases)+len(e.Blocked)+len(e.Ambiguous))
|
||||
for emitted := range e.Aliases {
|
||||
classified[emitted] = "alias"
|
||||
}
|
||||
for kind, values := range map[string][]string{"blocked": e.Blocked, "ambiguous": e.Ambiguous} {
|
||||
for _, name := range values {
|
||||
if name != cmdutil.Morph(name) {
|
||||
t.Fatalf("%s: %s name %q is not morph-normalized", e.CLIPath, kind, name)
|
||||
}
|
||||
if previous := classified[name]; previous != "" {
|
||||
t.Fatalf("%s: %q is classified as both %s and %s", e.CLIPath, name, previous, kind)
|
||||
}
|
||||
classified[name] = kind
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const paramConceptsSchemaRef = "./param_concepts.schema.json"
|
||||
|
||||
// param_concepts.json is the reviewed, typed parameter concept dictionary and
|
||||
// the sole source of equivalent flag spellings ("concepts") plus per-command
|
||||
// overrides. Build-time generators reduce these concepts against each command's
|
||||
// real Cobra flags; generated alias tables are downstream views and must never
|
||||
// be read back here.
|
||||
|
||||
//go:embed param_concepts.json
|
||||
var embeddedParamConceptsJSON []byte
|
||||
|
||||
//go:embed param_concepts.schema.json
|
||||
var embeddedParamConceptsSchemaJSON []byte
|
||||
|
||||
var (
|
||||
paramConceptIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
paramFlagTokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
paramCommandPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*$`)
|
||||
)
|
||||
|
||||
// didYouMean sentinels are the only non-flag values a fixture case may expect.
|
||||
const (
|
||||
paramDidYouMeanAmbiguous = "did-you-mean:ambiguous"
|
||||
paramDidYouMeanBlocked = "did-you-mean:blocked"
|
||||
)
|
||||
|
||||
type paramConceptsSnapshot struct {
|
||||
Schema string `json:"$schema"`
|
||||
Version int `json:"version"`
|
||||
MorphRules map[string]ParamMorphRule `json:"morphological_rules,omitempty"`
|
||||
Concepts map[string]paramConceptSpec `json:"concepts"`
|
||||
Overrides map[string]paramCommandOverride `json:"command_overrides,omitempty"`
|
||||
Fixture *paramValidationFixtureSpec `json:"validation_fixture,omitempty"`
|
||||
}
|
||||
|
||||
type paramConceptSpec struct {
|
||||
Denotes string `json:"denotes"`
|
||||
CanonicalHint string `json:"canonical_hint"`
|
||||
Members []string `json:"members"`
|
||||
Excludes []string `json:"excludes,omitempty"`
|
||||
Commands []string `json:"commands"`
|
||||
Risk string `json:"risk"`
|
||||
}
|
||||
|
||||
type paramCommandOverride struct {
|
||||
Bind map[string]string `json:"bind,omitempty"`
|
||||
ScopedAliases map[string]string `json:"scoped_aliases,omitempty"`
|
||||
Block []string `json:"block,omitempty"`
|
||||
Ambiguous []string `json:"ambiguous,omitempty"`
|
||||
Confirm bool `json:"confirm,omitempty"`
|
||||
ScopeStrict bool `json:"scope_strict,omitempty"`
|
||||
Investigate bool `json:"investigate,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type paramValidationFixtureSpec struct {
|
||||
Cases []paramFixtureCaseSpec `json:"cases"`
|
||||
}
|
||||
|
||||
type paramFixtureCaseSpec struct {
|
||||
Command string `json:"command"`
|
||||
Emitted string `json:"emitted"`
|
||||
Expect string `json:"expect"`
|
||||
Via string `json:"via,omitempty"`
|
||||
Occ int `json:"occ,omitempty"`
|
||||
}
|
||||
|
||||
// ParamMorphRule documents one table-free name normalization behavior. It is
|
||||
// evidence for the shared Morph function; it is not a per-command alias.
|
||||
type ParamMorphRule struct {
|
||||
Desc string `json:"desc"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Guard string `json:"guard,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Concept is one reviewed set of equivalent flag spellings that all denote a
|
||||
// single entity. Members reduce onto the command's real flag; Excludes lists
|
||||
// spellings that denote a different entity and must never be reduced in.
|
||||
type Concept struct {
|
||||
ID string
|
||||
Denotes string
|
||||
CanonicalHint string
|
||||
Members []string
|
||||
Excludes []string
|
||||
Commands []string
|
||||
Risk string
|
||||
}
|
||||
|
||||
// CommandOverride is one reviewed per-command adjustment: binding a generic
|
||||
// real flag to a concept, command-scoped aliases, blocks, and the reviewed
|
||||
// co-occurrence whitelist.
|
||||
type CommandOverride struct {
|
||||
CommandPath string
|
||||
Bind map[string]string
|
||||
ScopedAliases map[string]string
|
||||
Block []string
|
||||
Ambiguous []string
|
||||
Confirm bool
|
||||
ScopeStrict bool
|
||||
Investigate bool
|
||||
Note string
|
||||
}
|
||||
|
||||
// ParamFixtureCase is one reviewed regression assertion derived from evaluation
|
||||
// bad cases: the emitted name on Command must reduce to Expect (a real flag) or
|
||||
// route to a did-you-mean sentinel.
|
||||
type ParamFixtureCase struct {
|
||||
Command string
|
||||
Emitted string
|
||||
Expect string
|
||||
Via string
|
||||
Occ int
|
||||
}
|
||||
|
||||
// ParamConcepts is the decoded, validated reviewed concept dictionary.
|
||||
type ParamConcepts struct {
|
||||
Version int
|
||||
Morph map[string]ParamMorphRule
|
||||
Concepts []Concept
|
||||
ByConcept map[string]Concept
|
||||
Overrides []CommandOverride
|
||||
Fixture []ParamFixtureCase
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedParamConceptsOnce sync.Once
|
||||
embeddedParamConceptsData ParamConcepts
|
||||
embeddedParamConceptsErr error
|
||||
loadReviewedParamConcepts = loadEmbeddedParamConcepts
|
||||
)
|
||||
|
||||
// LoadParamConcepts decodes and validates the embedded reviewed concept
|
||||
// dictionary exactly once.
|
||||
func LoadParamConcepts() (ParamConcepts, error) {
|
||||
return loadReviewedParamConcepts()
|
||||
}
|
||||
|
||||
func loadEmbeddedParamConcepts() (ParamConcepts, error) {
|
||||
embeddedParamConceptsOnce.Do(func() {
|
||||
embeddedParamConceptsData, embeddedParamConceptsErr = decodeParamConcepts(embeddedParamConceptsJSON)
|
||||
})
|
||||
return cloneParamConcepts(embeddedParamConceptsData), embeddedParamConceptsErr
|
||||
}
|
||||
|
||||
func decodeParamConcepts(data []byte) (ParamConcepts, error) {
|
||||
var snapshot paramConceptsSnapshot
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
return ParamConcepts{}, fmt.Errorf("unsupported parameter concepts version %d", snapshot.Version)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.Schema) != paramConceptsSchemaRef {
|
||||
return ParamConcepts{}, fmt.Errorf("parameter concepts must declare $schema=%q", paramConceptsSchemaRef)
|
||||
}
|
||||
if len(snapshot.Concepts) == 0 {
|
||||
return ParamConcepts{}, fmt.Errorf("parameter concepts declares no concepts")
|
||||
}
|
||||
|
||||
concepts, byConcept, err := decodeParamConceptSpecs(snapshot.Concepts)
|
||||
if err != nil {
|
||||
return ParamConcepts{}, err
|
||||
}
|
||||
|
||||
overrides, err := decodeParamCommandOverrides(snapshot.Overrides, byConcept)
|
||||
if err != nil {
|
||||
return ParamConcepts{}, err
|
||||
}
|
||||
|
||||
fixture, err := decodeParamFixtureCases(snapshot.Fixture)
|
||||
if err != nil {
|
||||
return ParamConcepts{}, err
|
||||
}
|
||||
|
||||
morph := make(map[string]ParamMorphRule, len(snapshot.MorphRules))
|
||||
for name, rule := range snapshot.MorphRules {
|
||||
if strings.TrimSpace(rule.Desc) == "" {
|
||||
return ParamConcepts{}, fmt.Errorf("parameter concepts morph rule %q has empty desc", name)
|
||||
}
|
||||
morph[name] = rule
|
||||
}
|
||||
|
||||
return ParamConcepts{
|
||||
Version: snapshot.Version,
|
||||
Morph: morph,
|
||||
Concepts: concepts,
|
||||
ByConcept: byConcept,
|
||||
Overrides: overrides,
|
||||
Fixture: fixture,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeParamConceptSpecs validates every concept and enforces two purity
|
||||
// invariants: members are unique across all concepts, and no member appears in
|
||||
// its own excludes list.
|
||||
func decodeParamConceptSpecs(specs map[string]paramConceptSpec) ([]Concept, map[string]Concept, error) {
|
||||
ids := make([]string, 0, len(specs))
|
||||
for id := range specs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
concepts := make([]Concept, 0, len(ids))
|
||||
byConcept := make(map[string]Concept, len(ids))
|
||||
memberOwner := make(map[string]string)
|
||||
for _, id := range ids {
|
||||
if !paramConceptIDPattern.MatchString(id) {
|
||||
return nil, nil, fmt.Errorf("parameter concepts contains invalid concept id %q", id)
|
||||
}
|
||||
spec := specs[id]
|
||||
if strings.TrimSpace(spec.Denotes) == "" {
|
||||
return nil, nil, fmt.Errorf("concept %s has empty denotes", id)
|
||||
}
|
||||
if !paramFlagTokenPattern.MatchString(spec.CanonicalHint) {
|
||||
return nil, nil, fmt.Errorf("concept %s has invalid canonical_hint %q", id, spec.CanonicalHint)
|
||||
}
|
||||
switch spec.Risk {
|
||||
case "green", "yellow":
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("concept %s has invalid risk %q", id, spec.Risk)
|
||||
}
|
||||
if len(spec.Members) == 0 {
|
||||
return nil, nil, fmt.Errorf("concept %s has no members", id)
|
||||
}
|
||||
if len(spec.Commands) == 0 {
|
||||
return nil, nil, fmt.Errorf("concept %s has no reviewed command scope", id)
|
||||
}
|
||||
members := make([]string, 0, len(spec.Members))
|
||||
memberSet := make(map[string]bool, len(spec.Members))
|
||||
for _, member := range spec.Members {
|
||||
if !paramFlagTokenPattern.MatchString(member) {
|
||||
return nil, nil, fmt.Errorf("concept %s has invalid member %q", id, member)
|
||||
}
|
||||
if memberSet[member] {
|
||||
return nil, nil, fmt.Errorf("concept %s repeats member %q", id, member)
|
||||
}
|
||||
memberSet[member] = true
|
||||
if owner, exists := memberOwner[member]; exists {
|
||||
return nil, nil, fmt.Errorf("member %q belongs to both concept %s and %s", member, owner, id)
|
||||
}
|
||||
memberOwner[member] = id
|
||||
members = append(members, member)
|
||||
}
|
||||
excludes := make([]string, 0, len(spec.Excludes))
|
||||
excludeSet := make(map[string]bool, len(spec.Excludes))
|
||||
for _, exclude := range spec.Excludes {
|
||||
if !paramFlagTokenPattern.MatchString(exclude) {
|
||||
return nil, nil, fmt.Errorf("concept %s has invalid exclude %q", id, exclude)
|
||||
}
|
||||
if excludeSet[exclude] {
|
||||
return nil, nil, fmt.Errorf("concept %s repeats exclude %q", id, exclude)
|
||||
}
|
||||
excludeSet[exclude] = true
|
||||
if memberSet[exclude] {
|
||||
return nil, nil, fmt.Errorf("concept %s lists %q as both member and exclude", id, exclude)
|
||||
}
|
||||
excludes = append(excludes, exclude)
|
||||
}
|
||||
commands := make([]string, 0, len(spec.Commands))
|
||||
commandSet := make(map[string]bool, len(spec.Commands))
|
||||
for _, command := range spec.Commands {
|
||||
if !validParamCommandPath(command) {
|
||||
return nil, nil, fmt.Errorf("concept %s has invalid command scope %q", id, command)
|
||||
}
|
||||
if commandSet[command] {
|
||||
return nil, nil, fmt.Errorf("concept %s repeats command scope %q", id, command)
|
||||
}
|
||||
commandSet[command] = true
|
||||
commands = append(commands, command)
|
||||
}
|
||||
sort.Strings(commands)
|
||||
concept := Concept{
|
||||
ID: id,
|
||||
Denotes: strings.TrimSpace(spec.Denotes),
|
||||
CanonicalHint: spec.CanonicalHint,
|
||||
Members: members,
|
||||
Excludes: excludes,
|
||||
Commands: commands,
|
||||
Risk: spec.Risk,
|
||||
}
|
||||
concepts = append(concepts, concept)
|
||||
byConcept[id] = concept
|
||||
}
|
||||
return concepts, byConcept, nil
|
||||
}
|
||||
|
||||
func decodeParamCommandOverrides(specs map[string]paramCommandOverride, byConcept map[string]Concept) ([]CommandOverride, error) {
|
||||
paths := make([]string, 0, len(specs))
|
||||
for path := range specs {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
overrides := make([]CommandOverride, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if !validParamCommandPath(path) {
|
||||
return nil, fmt.Errorf("command_overrides contains invalid command path %q", path)
|
||||
}
|
||||
spec := specs[path]
|
||||
if len(spec.Bind) == 0 && len(spec.ScopedAliases) == 0 && len(spec.Block) == 0 && len(spec.Ambiguous) == 0 {
|
||||
return nil, fmt.Errorf("command_override %q declares no bind/scoped_aliases/block/ambiguous", path)
|
||||
}
|
||||
for flag, conceptID := range spec.Bind {
|
||||
if !paramFlagTokenPattern.MatchString(flag) {
|
||||
return nil, fmt.Errorf("command_override %q bind has invalid flag %q", path, flag)
|
||||
}
|
||||
if _, ok := byConcept[conceptID]; !ok {
|
||||
return nil, fmt.Errorf("command_override %q binds %q to undeclared concept %q", path, flag, conceptID)
|
||||
}
|
||||
}
|
||||
for emitted, realFlag := range spec.ScopedAliases {
|
||||
if !paramFlagTokenPattern.MatchString(emitted) {
|
||||
return nil, fmt.Errorf("command_override %q scoped_aliases has invalid emitted %q", path, emitted)
|
||||
}
|
||||
if !paramFlagTokenPattern.MatchString(realFlag) {
|
||||
return nil, fmt.Errorf("command_override %q scoped_aliases has invalid target %q", path, realFlag)
|
||||
}
|
||||
}
|
||||
if err := validParamTokenList(path, "block", spec.Block); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validParamTokenList(path, "ambiguous", spec.Ambiguous); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overrides = append(overrides, CommandOverride{
|
||||
CommandPath: path,
|
||||
Bind: cloneStringMap(spec.Bind),
|
||||
ScopedAliases: cloneStringMap(spec.ScopedAliases),
|
||||
Block: append([]string(nil), spec.Block...),
|
||||
Ambiguous: append([]string(nil), spec.Ambiguous...),
|
||||
Confirm: spec.Confirm,
|
||||
ScopeStrict: spec.ScopeStrict,
|
||||
Investigate: spec.Investigate,
|
||||
Note: strings.TrimSpace(spec.Note),
|
||||
})
|
||||
}
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func decodeParamFixtureCases(spec *paramValidationFixtureSpec) ([]ParamFixtureCase, error) {
|
||||
if spec == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(spec.Cases) == 0 {
|
||||
return nil, fmt.Errorf("validation_fixture declares no cases")
|
||||
}
|
||||
cases := make([]ParamFixtureCase, 0, len(spec.Cases))
|
||||
for i, c := range spec.Cases {
|
||||
if !validParamCommandPath(c.Command) {
|
||||
return nil, fmt.Errorf("validation_fixture case %d has invalid command %q", i, c.Command)
|
||||
}
|
||||
if !paramFlagTokenPattern.MatchString(c.Emitted) {
|
||||
return nil, fmt.Errorf("validation_fixture case %d has invalid emitted %q", i, c.Emitted)
|
||||
}
|
||||
expect := strings.TrimSpace(c.Expect)
|
||||
if expect == "" {
|
||||
return nil, fmt.Errorf("validation_fixture case %d has empty expect", i)
|
||||
}
|
||||
if strings.HasPrefix(expect, "did-you-mean:") {
|
||||
if expect != paramDidYouMeanAmbiguous && expect != paramDidYouMeanBlocked {
|
||||
return nil, fmt.Errorf("validation_fixture case %d has unknown did-you-mean sentinel %q", i, expect)
|
||||
}
|
||||
} else if !paramFlagTokenPattern.MatchString(expect) {
|
||||
return nil, fmt.Errorf("validation_fixture case %d has invalid expect %q", i, expect)
|
||||
}
|
||||
if c.Occ < 0 {
|
||||
return nil, fmt.Errorf("validation_fixture case %d has negative occ %d", i, c.Occ)
|
||||
}
|
||||
cases = append(cases, ParamFixtureCase{
|
||||
Command: c.Command,
|
||||
Emitted: c.Emitted,
|
||||
Expect: expect,
|
||||
Via: strings.TrimSpace(c.Via),
|
||||
Occ: c.Occ,
|
||||
})
|
||||
}
|
||||
return cases, nil
|
||||
}
|
||||
|
||||
func validParamCommandPath(path string) bool {
|
||||
if strings.TrimSpace(path) != path || path == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range strings.Split(path, " ") {
|
||||
if !paramCommandPathPattern.MatchString(token) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validParamTokenList(path, field string, tokens []string) error {
|
||||
seen := make(map[string]bool, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if !paramFlagTokenPattern.MatchString(token) {
|
||||
return fmt.Errorf("command_override %q %s has invalid token %q", path, field, token)
|
||||
}
|
||||
if seen[token] {
|
||||
return fmt.Errorf("command_override %q %s repeats token %q", path, field, token)
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneParamConcepts(src ParamConcepts) ParamConcepts {
|
||||
dst := ParamConcepts{Version: src.Version}
|
||||
if src.Morph != nil {
|
||||
dst.Morph = make(map[string]ParamMorphRule, len(src.Morph))
|
||||
for k, v := range src.Morph {
|
||||
dst.Morph[k] = v
|
||||
}
|
||||
}
|
||||
if src.Concepts != nil {
|
||||
dst.Concepts = make([]Concept, 0, len(src.Concepts))
|
||||
for _, c := range src.Concepts {
|
||||
dst.Concepts = append(dst.Concepts, cloneConcept(c))
|
||||
}
|
||||
}
|
||||
if src.ByConcept != nil {
|
||||
dst.ByConcept = make(map[string]Concept, len(src.ByConcept))
|
||||
for k, v := range src.ByConcept {
|
||||
dst.ByConcept[k] = cloneConcept(v)
|
||||
}
|
||||
}
|
||||
if src.Overrides != nil {
|
||||
dst.Overrides = make([]CommandOverride, 0, len(src.Overrides))
|
||||
for _, o := range src.Overrides {
|
||||
dst.Overrides = append(dst.Overrides, cloneCommandOverride(o))
|
||||
}
|
||||
}
|
||||
if src.Fixture != nil {
|
||||
dst.Fixture = append([]ParamFixtureCase(nil), src.Fixture...)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneConcept(c Concept) Concept {
|
||||
c.Members = append([]string(nil), c.Members...)
|
||||
c.Excludes = append([]string(nil), c.Excludes...)
|
||||
c.Commands = append([]string(nil), c.Commands...)
|
||||
return c
|
||||
}
|
||||
|
||||
func cloneCommandOverride(o CommandOverride) CommandOverride {
|
||||
o.Bind = cloneStringMap(o.Bind)
|
||||
o.ScopedAliases = cloneStringMap(o.ScopedAliases)
|
||||
o.Block = append([]string(nil), o.Block...)
|
||||
o.Ambiguous = append([]string(nil), o.Ambiguous...)
|
||||
return o
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
{
|
||||
"$schema": "./param_concepts.schema.json",
|
||||
"version": 1,
|
||||
|
||||
"morphological_rules": {
|
||||
"kebab_camel_equivalence": {"desc": "--page-size == --pageSize", "enabled": true},
|
||||
"separator_normalization": {"desc": "-, _, . are equivalent separators", "enabled": true},
|
||||
"trailing_id_tolerance": {"desc": "--base tolerates --base-id when only one is a real flag on the command", "enabled": true, "guard": "the two must not both be real flags with different semantics"},
|
||||
"pluralization": {"desc": "--id<->--ids, --user<->--users", "enabled": false, "reason": "singular/list semantics can differ; handled by concept+intersection or command override instead"}
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
|
||||
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
|
||||
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
|
||||
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
|
||||
"mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"},
|
||||
"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 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."},
|
||||
"chat message list-favorites": {"scoped_aliases": {"limit": "size"}, "scope_strict": true, "note": "On this exact command, both names denote the same bounded result count; the numeric value is unchanged."},
|
||||
"chat +messages-list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
|
||||
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
|
||||
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
|
||||
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
|
||||
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
|
||||
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
|
||||
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
|
||||
"chat +category-rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."},
|
||||
"chat category rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."},
|
||||
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
"chat group members remove-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat +send-to-group": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
|
||||
"chat group share-invite": {"scoped_aliases": {"source-conversation-id": "source", "target-conversation-id": "target"}, "block": ["group-id", "group-ids", "user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."},
|
||||
"chat message combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."},
|
||||
"chat message forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."},
|
||||
"chat message forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Conversation and message source/destination roles are preserved; role-free identifiers are rejected."},
|
||||
"chat +conversation-info": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."},
|
||||
"chat group create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed identifier domains."},
|
||||
"chat +chat-set-admin": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."},
|
||||
"chat +messages-read-status": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."},
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
"cases": [
|
||||
{"command": "oa +search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 28},
|
||||
{"command": "aitable +list-tables", "emitted": "base-id", "expect": "base", "via": "concept:base_id+morph", "occ": 26},
|
||||
{"command": "mail +find-mail-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 18},
|
||||
{"command": "aitable +field-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
|
||||
{"command": "aitable +record-query", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
|
||||
{"command": "aitable +table-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
|
||||
{"command": "doc block update", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 6},
|
||||
{"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "concept:search_query+bind", "occ": 4},
|
||||
{"command": "devdoc article search", "emitted": "limit", "expect": "size", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "devdoc article search", "emitted": "page-size", "expect": "size", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "devdoc article search", "emitted": "current-page", "expect": "page", "via": "concept:page_number", "occ": 2},
|
||||
{"command": "mail message search", "emitted": "subject", "expect": "query", "via": "override:scoped_strict", "occ": 4},
|
||||
{"command": "aitable +record-share-url", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 3},
|
||||
{"command": "aitable record query", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "date", "expect": "start", "via": "override:scoped(reviewed+payload)", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "start-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "min-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "time-min", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "end-time", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "time-max", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "max-results", "expect": "limit", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "next-cursor", "expect": "cursor", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "calendar event list", "emitted": "calendar", "expect": "calendar-id", "via": "native:reviewed-compatibility-fallback", "occ": 2},
|
||||
{"command": "chat message list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "chat message list-by-sender", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-format-boundary", "occ": 2},
|
||||
{"command": "doc +template-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "doc block insert", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 2},
|
||||
{"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2},
|
||||
{"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2},
|
||||
{"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
{"command": "contact +search-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
{"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 31},
|
||||
{"command": "ding +receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 24},
|
||||
{"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 8},
|
||||
{"command": "contact +list-sub-depts", "emitted": "dept-id", "expect": "dept", "via": "concept:dept_id+morph", "occ": 4},
|
||||
{"command": "contact +list-sub-depts", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:name-vs-id", "occ": 2},
|
||||
{"command": "contact +list-sub-depts", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:query-vs-id", "occ": 2},
|
||||
{"command": "contact user profile get", "emitted": "user-id", "expect": "staff-id", "via": "concept:user_id", "occ": 2},
|
||||
{"command": "contact user profile get", "emitted": "id", "expect": "staff-id", "via": "override:scoped", "occ": 2},
|
||||
{"command": "contact user profile get", "emitted": "ids", "expect": "staff-id", "via": "override:scoped", "occ": 2},
|
||||
{"command": "chat message send-by-bot", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 4},
|
||||
{"command": "chat message send-by-bot", "emitted": "to-user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 1},
|
||||
{"command": "dev app get", "emitted": "app-id", "expect": "unified-app-id", "via": "concept:app_id", "occ": 5},
|
||||
{"command": "chat message list-all", "emitted": "from", "expect": "start", "via": "concept:time_start", "occ": 2},
|
||||
{"command": "chat message list-all", "emitted": "start-time", "expect": "start", "via": "concept:time_start", "occ": 2},
|
||||
{"command": "chat message search-advanced", "emitted": "group", "expect": "conversation-ids", "via": "native:reviewed-single-to-list", "occ": 4},
|
||||
{"command": "chat message send", "emitted": "to-user", "expect": "user", "via": "override:scoped(reviewed+payload)", "occ": 4},
|
||||
{"command": "contact +dept-members", "emitted": "name", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2},
|
||||
{"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "concept:search_query+bind", "occ": 2},
|
||||
{"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id", "occ": 2},
|
||||
{"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "concept:dept_id", "occ": 2},
|
||||
{"command": "ding message receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 2},
|
||||
{"command": "ding message receiver-status", "emitted": "open-ding-id", "expect": "ding-id", "via": "concept:ding_id", "occ": 2},
|
||||
{"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 3},
|
||||
{"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "concept:user_ids+exclude", "occ": 2},
|
||||
{"command": "attendance check result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids", "occ": 2},
|
||||
{"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 2},
|
||||
{"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2},
|
||||
{"command": "ding message send", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code", "occ": 2},
|
||||
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
|
||||
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
|
||||
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
{"command": "chat group get-by-group-id", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-group-id"},
|
||||
{"command": "chat group get-by-group-id", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-vs-group-id"},
|
||||
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
|
||||
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
{"command": "chat message search-advanced", "emitted": "open-conversation-ids", "expect": "conversation-ids", "via": "concept:open_conversation_ids"},
|
||||
{"command": "chat message search-advanced", "emitted": "group-ids", "expect": "did-you-mean:blocked", "via": "guard:group-id-list-vs-open-conversation-id-list"},
|
||||
{"command": "chat message recall", "emitted": "message-id", "expect": "msg-id", "via": "concept:open_message_id"},
|
||||
{"command": "chat message add-favorite", "emitted": "msg-id", "expect": "open-message-id", "via": "concept:open_message_id"},
|
||||
{"command": "chat message list-by-ids", "emitted": "message-ids", "expect": "msg-ids", "via": "concept:open_message_ids"},
|
||||
{"command": "chat message list-by-ids", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat message reply", "emitted": "ref-message-id", "expect": "ref-msg-id", "via": "concept:referenced_open_message_id"},
|
||||
{"command": "chat message reply", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"},
|
||||
{"command": "chat message forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-role"},
|
||||
{"command": "chat message forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"},
|
||||
{"command": "chat message combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-role"},
|
||||
{"command": "chat message forward-topic", "emitted": "dest-open-conversation-id", "expect": "dest-conversation-id", "via": "override:scoped-role"},
|
||||
{"command": "chat message forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat group share-invite", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-target-role"},
|
||||
{"command": "chat message send", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
|
||||
{"command": "chat message list", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
|
||||
{"command": "chat +messages-list-direct", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
|
||||
{"command": "attendance +check-result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"},
|
||||
{"command": "contact +list-sub-depts", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id"},
|
||||
{"command": "chat +conversation-info", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-vs-open-dingtalk-id"},
|
||||
{"command": "chat group members remove", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"},
|
||||
{"command": "chat group members remove", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat group create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-set-admin", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +messages-read-status", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat group members list-by-ids", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
|
||||
{"command": "chat group members remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
|
||||
{"command": "chat group members remove-bot", "emitted": "robot-code", "expect": "did-you-mean:blocked", "via": "guard:robot-code-vs-open-bot-id"},
|
||||
{"command": "chat group members add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat +bot-find", "emitted": "name", "expect": "query", "via": "override:scoped"},
|
||||
{"command": "chat bot find", "emitted": "name", "expect": "query", "via": "override:scoped"},
|
||||
{"command": "chat +bot-search", "emitted": "query", "expect": "name", "via": "override:scoped"},
|
||||
{"command": "chat bot search", "emitted": "query", "expect": "name", "via": "override:scoped"},
|
||||
{"command": "chat message list-favorites", "emitted": "limit", "expect": "size", "via": "override:scoped"},
|
||||
{"command": "chat bot search", "emitted": "current-page", "expect": "page", "via": "override:scoped"},
|
||||
{"command": "chat bot search", "emitted": "cursor", "expect": "did-you-mean:blocked", "via": "guard:page-number-vs-cursor"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat +messages-list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat message list", "emitted": "start", "expect": "time", "via": "override:scoped"},
|
||||
{"command": "chat message list", "emitted": "end", "expect": "did-you-mean:blocked", "via": "guard:single-time-vs-range"},
|
||||
{"command": "chat message list-all", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-range-required"},
|
||||
{"command": "chat message list-by-sender", "emitted": "user-id", "expect": "sender-user-id", "via": "override:scoped-role"},
|
||||
{"command": "chat message list-by-sender", "emitted": "open-dingtalk-id", "expect": "sender-open-dingtalk-id", "via": "override:scoped-role"},
|
||||
{"command": "chat category create-smart", "emitted": "title", "expect": "name", "via": "override:scoped"},
|
||||
{"command": "chat category create", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message send", "emitted": "file", "expect": "file-path", "via": "override:scoped"},
|
||||
{"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
|
||||
{"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"},
|
||||
{"command": "chat message send-by-bot", "emitted": "at-ids", "expect": "did-you-mean:ambiguous", "via": "guard:user-id-vs-open-dingtalk-id"},
|
||||
{"command": "chat +bot-search", "emitted": "current-page", "expect": "page", "via": "override:scoped"},
|
||||
{"command": "chat +category-create", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat +category-rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat +messages-list-direct", "emitted": "start", "expect": "time", "via": "override:scoped"},
|
||||
{"command": "chat +messages-list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat +messages-send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
|
||||
{"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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/param_concepts.schema.json",
|
||||
"title": "DWS Reviewed Parameter Concept Dictionary",
|
||||
"description": "Human-reviewed source of parameter concepts (equivalent flag spellings for the same entity) and per-command overrides. Build-time generators reduce these concepts against each command's real Cobra flags; they must never rewrite this file.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"$schema",
|
||||
"version",
|
||||
"concepts"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "./param_concepts.schema.json",
|
||||
"description": "Relative editor contract. Keep this value unchanged so agents can validate the dictionary without network access."
|
||||
},
|
||||
"version": {
|
||||
"const": 1,
|
||||
"description": "ParamConcepts source format version."
|
||||
},
|
||||
"morphological_rules": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/morphRule"
|
||||
},
|
||||
"description": "Table-free, global name normalization behaviors realized by pflag SetNormalizeFunc. Documentation of morph behavior, not per-command aliases."
|
||||
},
|
||||
"concepts": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/conceptId"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/concept"
|
||||
},
|
||||
"description": "Global concepts keyed by stable concept id. Go validation additionally rejects members that overlap across concepts and members that intersect their own excludes."
|
||||
},
|
||||
"command_overrides": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/commandOverride"
|
||||
},
|
||||
"description": "Per-command overrides keyed by the command path (without leading 'dws'). Only needed for generic-name binding, command-scoped aliases, co-occurrence whitelisting, or reject."
|
||||
},
|
||||
"validation_fixture": {
|
||||
"$ref": "#/$defs/validationFixture"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"conceptId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Stable concept id in lower snake_case."
|
||||
},
|
||||
"flagToken": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
|
||||
"description": "A flag spelling token without leading dashes."
|
||||
},
|
||||
"commandPath": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
|
||||
"description": "Exact command path without the leading 'dws' or flags. A leading '+' segment marks a shortcut subcommand. Existence against the real Cobra tree is validated by the generator, not this schema."
|
||||
},
|
||||
"risk": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"green",
|
||||
"yellow"
|
||||
],
|
||||
"description": "Reviewed risk band. green concepts reduce freely; yellow concepts need extra reviewer attention on excludes boundaries."
|
||||
},
|
||||
"morphRule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"desc",
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"desc": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guard": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"concept": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"denotes",
|
||||
"canonical_hint",
|
||||
"members",
|
||||
"commands",
|
||||
"risk"
|
||||
],
|
||||
"properties": {
|
||||
"denotes": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Human description of the single entity this concept denotes."
|
||||
},
|
||||
"canonical_hint": {
|
||||
"$ref": "#/$defs/flagToken",
|
||||
"description": "Governance hint only; the runtime reduces to whichever real flag the command exposes, not to this value."
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"description": "Equivalent spellings that all denote the concept's entity."
|
||||
},
|
||||
"excludes": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"description": "Spellings that denote a DIFFERENT entity and must never be reduced into this concept."
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"description": "Exact reviewed runnable command paths on which this concept may participate in reduction. A concept never activates on an unlisted command merely because a real flag has the same spelling."
|
||||
},
|
||||
"risk": {
|
||||
"$ref": "#/$defs/risk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"bind": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/conceptId"
|
||||
},
|
||||
"description": "Maps a generic real flag (e.g. id) to a concept id so concept members can reduce onto it. Go validation requires every value to be a declared concept id."
|
||||
},
|
||||
"scoped_aliases": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"description": "Command-scoped emitted->realFlag aliases. Never promoted to a global concept member."
|
||||
},
|
||||
"block": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"description": "Emitted names that must never be reduced on this command; they route to did-you-mean instead."
|
||||
},
|
||||
"ambiguous": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/flagToken"
|
||||
},
|
||||
"description": "Reviewed co-occurrence whitelist: when a concept intersects two or more real flags on this command, the emitted names listed here are expected to route to did-you-mean rather than fail generation."
|
||||
},
|
||||
"confirm": {
|
||||
"type": "boolean",
|
||||
"description": "Reviewer flagged this override as needing user confirmation of the mapping semantics."
|
||||
},
|
||||
"scope_strict": {
|
||||
"type": "boolean",
|
||||
"description": "This alias must stay strictly command-local; another command has a real and different flag with the same spelling."
|
||||
},
|
||||
"investigate": {
|
||||
"type": "boolean",
|
||||
"description": "Reviewer flagged this override as needing source-case investigation before landing."
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Reviewer note explaining the override."
|
||||
}
|
||||
}
|
||||
},
|
||||
"validationFixture": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"cases"
|
||||
],
|
||||
"properties": {
|
||||
"cases": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/fixtureCase"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fixtureCase": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"command",
|
||||
"emitted",
|
||||
"expect"
|
||||
],
|
||||
"properties": {
|
||||
"command": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"emitted": {
|
||||
"$ref": "#/$defs/flagToken",
|
||||
"description": "The name the model produced."
|
||||
},
|
||||
"expect": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Either the real flag the emitted name must reduce to, or one of the did-you-mean sentinels 'did-you-mean:ambiguous' / 'did-you-mean:blocked'."
|
||||
},
|
||||
"via": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Reviewer annotation of the reduction path; documentation only."
|
||||
},
|
||||
"occ": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Occurrence count in the evaluation batch; frequency evidence only."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParamConceptsJSONSchemaDocumentsClosedShape(t *testing.T) {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(embeddedParamConceptsSchemaJSON, &schema); err != nil {
|
||||
t.Fatalf("decode param_concepts.schema.json: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
|
||||
t.Fatalf("param concepts root schema is not closed: %#v", schema)
|
||||
}
|
||||
definitions := schema["$defs"].(map[string]any)
|
||||
concept := definitions["concept"].(map[string]any)
|
||||
if concept["additionalProperties"] != false {
|
||||
t.Fatalf("concept schema allows unknown fields: %#v", concept)
|
||||
}
|
||||
properties := concept["properties"].(map[string]any)
|
||||
for _, field := range []string{"denotes", "canonical_hint", "members", "excludes", "commands", "risk"} {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Fatalf("concept schema is missing %s", field)
|
||||
}
|
||||
}
|
||||
override := definitions["commandOverride"].(map[string]any)
|
||||
if override["additionalProperties"] != false {
|
||||
t.Fatalf("commandOverride schema allows unknown fields: %#v", override)
|
||||
}
|
||||
|
||||
var source map[string]any
|
||||
if err := json.Unmarshal(embeddedParamConceptsJSON, &source); err != nil {
|
||||
t.Fatalf("decode param_concepts.json: %v", err)
|
||||
}
|
||||
if source["$schema"] != paramConceptsSchemaRef {
|
||||
t.Fatalf("param concepts source $schema = %#v, want %q", source["$schema"], paramConceptsSchemaRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedParamConceptsLoadsAndSatisfiesInvariants(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
if len(concepts.Concepts) == 0 {
|
||||
t.Fatal("embedded param concepts declares no concepts")
|
||||
}
|
||||
|
||||
// Members must be globally unique and disjoint from their own excludes.
|
||||
memberOwner := make(map[string]string)
|
||||
for _, concept := range concepts.Concepts {
|
||||
if len(concept.Commands) == 0 {
|
||||
t.Fatalf("concept %s has no reviewed command scope", concept.ID)
|
||||
}
|
||||
excludeSet := make(map[string]bool, len(concept.Excludes))
|
||||
for _, exclude := range concept.Excludes {
|
||||
excludeSet[exclude] = true
|
||||
}
|
||||
for _, member := range concept.Members {
|
||||
if owner, exists := memberOwner[member]; exists {
|
||||
t.Fatalf("member %q belongs to both concept %s and %s", member, owner, concept.ID)
|
||||
}
|
||||
memberOwner[member] = concept.ID
|
||||
if excludeSet[member] {
|
||||
t.Fatalf("concept %s lists %q as both member and exclude", concept.ID, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every bind target must reference a declared concept.
|
||||
for _, override := range concepts.Overrides {
|
||||
if override.Confirm || override.Investigate {
|
||||
t.Fatalf("current override %q remains unresolved (confirm=%v investigate=%v)", override.CommandPath, override.Confirm, override.Investigate)
|
||||
}
|
||||
for flag, conceptID := range override.Bind {
|
||||
if _, ok := concepts.ByConcept[conceptID]; !ok {
|
||||
t.Fatalf("command_override %q binds %q to undeclared concept %q", override.CommandPath, flag, conceptID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture sentinels are limited to the two known did-you-mean forms.
|
||||
for _, c := range concepts.Fixture {
|
||||
if strings.HasPrefix(c.Expect, "did-you-mean:") &&
|
||||
c.Expect != paramDidYouMeanAmbiguous && c.Expect != paramDidYouMeanBlocked {
|
||||
t.Fatalf("fixture %q/%q has unknown sentinel %q", c.Command, c.Emitted, c.Expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamConceptRiskAuditBoundaries(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
assertMembers := func(id string, forbidden ...string) {
|
||||
t.Helper()
|
||||
concept, ok := concepts.ByConcept[id]
|
||||
if !ok {
|
||||
t.Fatalf("missing audited concept %q", id)
|
||||
}
|
||||
members := make(map[string]bool, len(concept.Members))
|
||||
for _, member := range concept.Members {
|
||||
members[member] = true
|
||||
}
|
||||
for _, name := range forbidden {
|
||||
if members[name] {
|
||||
t.Fatalf("audited concept %s still contains forbidden cross-semantics member %q", id, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertMembers("user_id", "users", "user-ids")
|
||||
assertMembers("user_ids", "user", "user-id")
|
||||
assertMembers("dept_id", "depts", "dept-ids")
|
||||
assertMembers("dept_ids", "dept", "dept-id")
|
||||
assertMembers("group_id", "conversation-ids", "group-ids")
|
||||
assertMembers("page_number", "page-index")
|
||||
assertMembers("robot_code", "robot-id")
|
||||
}
|
||||
|
||||
func TestDecodeParamConceptsRejectsUnknownFieldsAtEveryLevel(t *testing.T) {
|
||||
valid := `{"$schema":"./param_concepts.schema.json","version":1,` +
|
||||
`"concepts":{"search_query":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo cmd"],"risk":"green"}},` +
|
||||
`"command_overrides":{"chat group rename":{"bind":{"id":"search_query"}}}}`
|
||||
for name, input := range map[string]string{
|
||||
"root": strings.Replace(valid, `"version":1`, `"version":1,"unknown":true`, 1),
|
||||
"concept": strings.Replace(valid, `"risk":"green"`, `"risk":"green","unknown":true`, 1),
|
||||
"override": strings.Replace(valid, `"bind":{"id":"search_query"}`, `"bind":{"id":"search_query"},"unknown":true`, 1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeParamConcepts([]byte(input)); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("decodeParamConcepts() error = %v, want unknown field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeParamConceptsEnforcesReviewedConstraints(t *testing.T) {
|
||||
wrap := func(body string) string {
|
||||
return `{"$schema":"./param_concepts.schema.json","version":1,` + body + `}`
|
||||
}
|
||||
concept := func(members, excludes, risk string) string {
|
||||
return `"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":` + members + `,"excludes":` + excludes + `,"commands":["demo cmd"],"risk":"` + risk + `"}}`
|
||||
}
|
||||
tests := map[string]string{
|
||||
"missing schema ref": `{"version":1,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`,
|
||||
"wrong version": `{"$schema":"./param_concepts.schema.json","version":2,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`,
|
||||
"no concepts": wrap(`"concepts":{}`),
|
||||
"invalid concept id": wrap(`"concepts":{"BadID":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`),
|
||||
"empty denotes": wrap(`"concepts":{"c_one":{"denotes":"","canonical_hint":"query","members":["query"],"risk":"green"}}`),
|
||||
"invalid risk": wrap(concept(`["query"]`, `[]`, "red")),
|
||||
"no members": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":[],"risk":"green"}}`),
|
||||
"no command scope": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":[],"risk":"green"}}`),
|
||||
"member equals exclude": wrap(concept(`["query"]`, `["query"]`, "green")),
|
||||
"member overlaps concepts": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"},"c_two":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`),
|
||||
"bind undeclared concept": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{"bind":{"id":"missing"}}}`),
|
||||
"empty override": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{}}`),
|
||||
"bad fixture sentinel": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[{"command":"chat group rename","emitted":"group","expect":"did-you-mean:oops"}]}`),
|
||||
"empty fixture cases": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[]}`),
|
||||
}
|
||||
for name, input := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeParamConcepts([]byte(input)); err == nil {
|
||||
t.Fatal("decodeParamConcepts() unexpectedly accepted invalid reviewed source")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
got, err := decodeParamConcepts([]byte(wrap(concept(`["query","keyword"]`, `["name"]`, "green"))))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeParamConcepts() valid source error = %v", err)
|
||||
}
|
||||
if len(got.Concepts) != 1 || got.Concepts[0].ID != "c_one" {
|
||||
t.Fatalf("decodeParamConcepts() concepts = %#v", got.Concepts)
|
||||
}
|
||||
}
|
||||
|
||||
func validParamConceptSpecFixture() paramConceptSpec {
|
||||
return paramConceptSpec{
|
||||
Denotes: "a reviewed value",
|
||||
CanonicalHint: "query",
|
||||
Members: []string{"query"},
|
||||
Excludes: []string{"name"},
|
||||
Commands: []string{"demo run"},
|
||||
Risk: "green",
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeParamConceptsRemainingSyntaxEdges(t *testing.T) {
|
||||
valid := `{"$schema":"./param_concepts.schema.json","version":1,` +
|
||||
`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo run"],"risk":"green"}}}`
|
||||
if _, err := decodeParamConcepts([]byte(valid + ` {}`)); err == nil || !strings.Contains(err.Error(), "multiple JSON values") {
|
||||
t.Fatalf("multiple JSON values error = %v", err)
|
||||
}
|
||||
|
||||
withEmptyMorphDescription := strings.Replace(
|
||||
valid,
|
||||
`"concepts":`,
|
||||
`"morphological_rules":{"camel":{"desc":""}},"concepts":`,
|
||||
1,
|
||||
)
|
||||
if _, err := decodeParamConcepts([]byte(withEmptyMorphDescription)); err == nil || !strings.Contains(err.Error(), "empty desc") {
|
||||
t.Fatalf("empty morph description error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeParamConceptSpecsRemainingValidationEdges(t *testing.T) {
|
||||
base := validParamConceptSpecFixture()
|
||||
invalidCanonical := base
|
||||
invalidCanonical.CanonicalHint = "bad token"
|
||||
invalidMember := base
|
||||
invalidMember.Members = []string{"bad token"}
|
||||
repeatedMember := base
|
||||
repeatedMember.Members = []string{"query", "query"}
|
||||
invalidExclude := base
|
||||
invalidExclude.Excludes = []string{"bad token"}
|
||||
repeatedExclude := base
|
||||
repeatedExclude.Excludes = []string{"name", "name"}
|
||||
invalidCommand := base
|
||||
invalidCommand.Commands = []string{"demo /bad"}
|
||||
repeatedCommand := base
|
||||
repeatedCommand.Commands = []string{"demo run", "demo run"}
|
||||
|
||||
tests := map[string]map[string]paramConceptSpec{
|
||||
"invalid canonical hint": {"c_one": invalidCanonical},
|
||||
"invalid member": {"c_one": invalidMember},
|
||||
"repeated member": {"c_one": repeatedMember},
|
||||
"invalid exclude": {"c_one": invalidExclude},
|
||||
"repeated exclude": {"c_one": repeatedExclude},
|
||||
"invalid command": {"c_one": invalidCommand},
|
||||
"repeated command": {"c_one": repeatedCommand},
|
||||
"cross-concept member": {
|
||||
"c_one": base,
|
||||
"c_two": base,
|
||||
},
|
||||
}
|
||||
for name, specs := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := decodeParamConceptSpecs(specs); err == nil {
|
||||
t.Fatal("decodeParamConceptSpecs() unexpectedly accepted invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeParamCommandOverridesRemainingValidationEdges(t *testing.T) {
|
||||
byConcept := map[string]Concept{"c_one": {ID: "c_one"}}
|
||||
tests := map[string]map[string]paramCommandOverride{
|
||||
"invalid path": {
|
||||
" demo run": {Block: []string{"name"}},
|
||||
},
|
||||
"invalid bind flag": {
|
||||
"demo run": {Bind: map[string]string{"bad token": "c_one"}},
|
||||
},
|
||||
"invalid scoped emitted": {
|
||||
"demo run": {ScopedAliases: map[string]string{"bad token": "query"}},
|
||||
},
|
||||
"invalid scoped target": {
|
||||
"demo run": {ScopedAliases: map[string]string{"keyword": "bad token"}},
|
||||
},
|
||||
"invalid block token": {
|
||||
"demo run": {Block: []string{"bad token"}},
|
||||
},
|
||||
"repeated ambiguous token": {
|
||||
"demo run": {Ambiguous: []string{"uid", "uid"}},
|
||||
},
|
||||
}
|
||||
for name, specs := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeParamCommandOverrides(specs, byConcept); err == nil {
|
||||
t.Fatal("decodeParamCommandOverrides() unexpectedly accepted invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeParamFixtureCasesRemainingValidationEdges(t *testing.T) {
|
||||
tests := map[string]paramFixtureCaseSpec{
|
||||
"invalid command": {Command: " demo run", Emitted: "query", Expect: "query"},
|
||||
"invalid emitted": {Command: "demo run", Emitted: "bad token", Expect: "query"},
|
||||
"empty expect": {Command: "demo run", Emitted: "query", Expect: " "},
|
||||
"invalid expect": {Command: "demo run", Emitted: "query", Expect: "bad token"},
|
||||
"negative occ": {Command: "demo run", Emitted: "query", Expect: "query", Occ: -1},
|
||||
}
|
||||
for name, fixture := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeParamFixtureCases(¶mValidationFixtureSpec{Cases: []paramFixtureCaseSpec{fixture}}); err == nil {
|
||||
t.Fatal("decodeParamFixtureCases() unexpectedly accepted invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamConceptPathAndTokenValidationEdges(t *testing.T) {
|
||||
if validParamCommandPath(" demo run") {
|
||||
t.Fatal("command path with surrounding whitespace was accepted")
|
||||
}
|
||||
if validParamCommandPath("demo /bad") {
|
||||
t.Fatal("command path with an invalid token was accepted")
|
||||
}
|
||||
if err := validParamTokenList("demo run", "block", []string{"bad token"}); err == nil {
|
||||
t.Fatal("invalid parameter token was accepted")
|
||||
}
|
||||
if err := validParamTokenList("demo run", "block", []string{"uid", "uid"}); err == nil {
|
||||
t.Fatal("repeated parameter token was accepted")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
|
||||
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
|
||||
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"coverage": {
|
||||
"surface_products": 26,
|
||||
"products_with_metadata": 26,
|
||||
"surface_tools": 813,
|
||||
"tools_with_metadata": 813,
|
||||
"tools_with_agent_summary": 813,
|
||||
"tools_with_use_when": 813,
|
||||
"tools_with_avoid_when": 813,
|
||||
"tools_with_examples": 813,
|
||||
"tools_with_interface_mode": 813,
|
||||
"surface_tools": 845,
|
||||
"tools_with_metadata": 845,
|
||||
"tools_with_agent_summary": 845,
|
||||
"tools_with_use_when": 845,
|
||||
"tools_with_avoid_when": 845,
|
||||
"tools_with_examples": 845,
|
||||
"tools_with_interface_mode": 845,
|
||||
"unmatched_skill_tools": 122,
|
||||
"unreviewed_skill_tools": 11
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -168,7 +168,6 @@
|
||||
"chat +chat-get-by-id",
|
||||
"chat +chat-members-get",
|
||||
"chat +chat-members-list",
|
||||
"chat +chat-messages",
|
||||
"chat +chat-mute-member",
|
||||
"chat +chat-quit",
|
||||
"chat +chat-remove-bot",
|
||||
@@ -205,15 +204,11 @@
|
||||
"chat +messages-reply",
|
||||
"chat +messages-resource-download",
|
||||
"chat +messages-resource-url",
|
||||
"chat +messages-send",
|
||||
"chat +messages-send-by-bot",
|
||||
"chat +messages-send-card",
|
||||
"chat +messages-set-pin",
|
||||
"chat +messages-set-top",
|
||||
"chat +messages-unset-pin",
|
||||
"chat +messages-unset-top",
|
||||
"chat +search-msg",
|
||||
"chat +thread-replies"
|
||||
"chat +messages-unset-top"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,174 @@
|
||||
{
|
||||
"id": "chat",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "chat.shortcut_at_me",
|
||||
"cli_path": "chat +at-me"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_bot_find",
|
||||
"cli_path": "chat +bot-find"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_bot_search",
|
||||
"cli_path": "chat +bot-search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_broadcast",
|
||||
"cli_path": "chat +broadcast"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_create",
|
||||
"cli_path": "chat +category-create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_delete",
|
||||
"cli_path": "chat +category-delete"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_list",
|
||||
"cli_path": "chat +category-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_rename",
|
||||
"cli_path": "chat +category-rename"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_bots",
|
||||
"cli_path": "chat +chat-bots"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_dismiss",
|
||||
"cli_path": "chat +chat-dismiss"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_invite_url",
|
||||
"cli_path": "chat +chat-invite-url"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_list_all",
|
||||
"cli_path": "chat +chat-list-all"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_list_join_requests",
|
||||
"cli_path": "chat +chat-list-join-requests"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_list_mine",
|
||||
"cli_path": "chat +chat-list-mine"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_mute",
|
||||
"cli_path": "chat +chat-mute"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_add",
|
||||
"cli_path": "chat +chat-role-add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_list",
|
||||
"cli_path": "chat +chat-role-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_query_user",
|
||||
"cli_path": "chat +chat-role-query-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_set_user",
|
||||
"cli_path": "chat +chat-role-set-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_update",
|
||||
"cli_path": "chat +chat-role-update"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_search",
|
||||
"cli_path": "chat +chat-search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_set_admin",
|
||||
"cli_path": "chat +chat-set-admin"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_set_history",
|
||||
"cli_path": "chat +chat-set-history"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_update_alias",
|
||||
"cli_path": "chat +chat-update-alias"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_update_nick",
|
||||
"cli_path": "chat +chat-update-nick"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_clear_all_red_point",
|
||||
"cli_path": "chat +conversation-clear-all-red-point"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_info",
|
||||
"cli_path": "chat +conversation-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_list",
|
||||
"cli_path": "chat +conversation-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_list_top",
|
||||
"cli_path": "chat +conversation-list-top"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_dm",
|
||||
"cli_path": "chat +dm"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_group_members",
|
||||
"cli_path": "chat +group-members"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_list_direct",
|
||||
"cli_path": "chat +messages-list-direct"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_list_pin",
|
||||
"cli_path": "chat +messages-list-pin"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_list_unread_conversations",
|
||||
"cli_path": "chat +messages-list-unread-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_mget",
|
||||
"cli_path": "chat +messages-mget"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_query_send_status",
|
||||
"cli_path": "chat +messages-query-send-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_read_status",
|
||||
"cli_path": "chat +messages-read-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_send_by_webhook",
|
||||
"cli_path": "chat +messages-send-by-webhook"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_update_card",
|
||||
"cli_path": "chat +messages-update-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_my_groups",
|
||||
"cli_path": "chat +my-groups"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_send_to_group",
|
||||
"cli_path": "chat +send-to-group"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_unread_chats",
|
||||
"cli_path": "chat +unread-chats"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_bots",
|
||||
"cli_path": "chat bot find"
|
||||
@@ -9,6 +177,10 @@
|
||||
"canonical_path": "chat.search_my_robots",
|
||||
"cli_path": "chat bot search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conv_categories_info",
|
||||
"cli_path": "chat category batch-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_smart_conv_category",
|
||||
"cli_path": "chat category create-smart"
|
||||
@@ -17,6 +189,10 @@
|
||||
"canonical_path": "chat.list_user_define_conv_categories",
|
||||
"cli_path": "chat category list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conv_categories_by_conv",
|
||||
"cli_path": "chat category list-by-conv"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conversations_by_category",
|
||||
"cli_path": "chat category list-conversations"
|
||||
@@ -41,6 +217,10 @@
|
||||
"canonical_path": "chat.get_conv_info_by_group_id",
|
||||
"cli_path": "chat group get-by-group-id"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_group_mute_config",
|
||||
"cli_path": "chat group get-mute-config"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_group_invite_url",
|
||||
"cli_path": "chat group invite-url"
|
||||
@@ -89,10 +269,26 @@
|
||||
"canonical_path": "chat.update_group_icon",
|
||||
"cli_path": "chat group update-icon"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_nick",
|
||||
"cli_path": "chat group update-nick"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_settings",
|
||||
"cli_path": "chat group update-settings"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.upgrade_group_to_external",
|
||||
"cli_path": "chat group upgrade-to-external"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.batch_query_group_chat_settings",
|
||||
"cli_path": "chat group user-settings query"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.batch_update_group_chat_settings",
|
||||
"cli_path": "chat group user-settings set"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_group_mute",
|
||||
"cli_path": "chat group-mute"
|
||||
@@ -157,6 +353,10 @@
|
||||
"canonical_path": "chat.download_media",
|
||||
"cli_path": "chat message download-media"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.edit_message",
|
||||
"cli_path": "chat message edit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.forward_message",
|
||||
"cli_path": "chat message forward"
|
||||
@@ -277,6 +477,10 @@
|
||||
"canonical_path": "chat.update_streaming_card",
|
||||
"cli_path": "chat message update-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_text_emotion",
|
||||
"cli_path": "chat message update-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_notification_off",
|
||||
"cli_path": "chat mute"
|
||||
@@ -294,192 +498,24 @@
|
||||
"cli_path": "chat set-top"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.edit_message",
|
||||
"cli_path": "chat message edit"
|
||||
"canonical_path": "chat.shortcut_chat_messages",
|
||||
"cli_path": "chat +chat-messages"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conv_categories_info",
|
||||
"cli_path": "chat category batch-info"
|
||||
"canonical_path": "chat.shortcut_messages_send",
|
||||
"cli_path": "chat +messages-send"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conv_categories_by_conv",
|
||||
"cli_path": "chat category list-by-conv"
|
||||
"canonical_path": "chat.shortcut_messages_send_card",
|
||||
"cli_path": "chat +messages-send-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_nick",
|
||||
"cli_path": "chat group update-nick"
|
||||
"canonical_path": "chat.shortcut_search_msg",
|
||||
"cli_path": "chat +search-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.upgrade_group_to_external",
|
||||
"cli_path": "chat group upgrade-to-external"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_bot_search",
|
||||
"cli_path": "chat +bot-search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_bot_find",
|
||||
"cli_path": "chat +bot-find"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_info",
|
||||
"cli_path": "chat +conversation-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_clear_all_red_point",
|
||||
"cli_path": "chat +conversation-clear-all-red-point"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_list",
|
||||
"cli_path": "chat +conversation-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_conversation_list_top",
|
||||
"cli_path": "chat +conversation-list-top"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_list",
|
||||
"cli_path": "chat +category-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_create",
|
||||
"cli_path": "chat +category-create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_delete",
|
||||
"cli_path": "chat +category-delete"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_category_rename",
|
||||
"cli_path": "chat +category-rename"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_search",
|
||||
"cli_path": "chat +chat-search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_invite_url",
|
||||
"cli_path": "chat +chat-invite-url"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_dismiss",
|
||||
"cli_path": "chat +chat-dismiss"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_set_history",
|
||||
"cli_path": "chat +chat-set-history"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_update_nick",
|
||||
"cli_path": "chat +chat-update-nick"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_update_alias",
|
||||
"cli_path": "chat +chat-update-alias"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_list_mine",
|
||||
"cli_path": "chat +chat-list-mine"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_list_all",
|
||||
"cli_path": "chat +chat-list-all"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_list_join_requests",
|
||||
"cli_path": "chat +chat-list-join-requests"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_bots",
|
||||
"cli_path": "chat +chat-bots"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_set_admin",
|
||||
"cli_path": "chat +chat-set-admin"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_mute",
|
||||
"cli_path": "chat +chat-mute"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_list",
|
||||
"cli_path": "chat +chat-role-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_add",
|
||||
"cli_path": "chat +chat-role-add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_update",
|
||||
"cli_path": "chat +chat-role-update"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_set_user",
|
||||
"cli_path": "chat +chat-role-set-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_role_query_user",
|
||||
"cli_path": "chat +chat-role-query-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_send_by_webhook",
|
||||
"cli_path": "chat +messages-send-by-webhook"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_list_direct",
|
||||
"cli_path": "chat +messages-list-direct"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_list_unread_conversations",
|
||||
"cli_path": "chat +messages-list-unread-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_mget",
|
||||
"cli_path": "chat +messages-mget"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_query_send_status",
|
||||
"cli_path": "chat +messages-query-send-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_read_status",
|
||||
"cli_path": "chat +messages-read-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_update_card",
|
||||
"cli_path": "chat +messages-update-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_list_pin",
|
||||
"cli_path": "chat +messages-list-pin"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_at_me",
|
||||
"cli_path": "chat +at-me"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_broadcast",
|
||||
"cli_path": "chat +broadcast"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_dm",
|
||||
"cli_path": "chat +dm"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_group_members",
|
||||
"cli_path": "chat +group-members"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_my_groups",
|
||||
"cli_path": "chat +my-groups"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_send_to_group",
|
||||
"cli_path": "chat +send-to-group"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_unread_chats",
|
||||
"cli_path": "chat +unread-chats"
|
||||
"canonical_path": "chat.shortcut_thread_replies",
|
||||
"cli_path": "chat +thread-replies"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,74 @@
|
||||
{
|
||||
"id": "doc",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "doc.shortcut_comment_create",
|
||||
"cli_path": "doc +comment-create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_comment_list",
|
||||
"cli_path": "doc +comment-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_comment_reply",
|
||||
"cli_path": "doc +comment-reply"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_copy",
|
||||
"cli_path": "doc +copy"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_doc_append",
|
||||
"cli_path": "doc +doc-append"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_export_get",
|
||||
"cli_path": "doc +export-get"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_export_submit",
|
||||
"cli_path": "doc +export-submit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_find_doc",
|
||||
"cli_path": "doc +find-doc"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_list",
|
||||
"cli_path": "doc +list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_move",
|
||||
"cli_path": "doc +move"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_search",
|
||||
"cli_path": "doc +search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_share_doc",
|
||||
"cli_path": "doc +share-doc"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_template_list",
|
||||
"cli_path": "doc +template-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_template_search",
|
||||
"cli_path": "doc +template-search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_version_list",
|
||||
"cli_path": "doc +version-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_version_revert",
|
||||
"cli_path": "doc +version-revert"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_version_save",
|
||||
"cli_path": "doc +version-save"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.delete_document_block",
|
||||
"cli_path": "doc block delete"
|
||||
@@ -121,6 +189,26 @@
|
||||
"canonical_path": "doc.search_documents",
|
||||
"cli_path": "doc search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.style_background_clear",
|
||||
"cli_path": "doc style background clear"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.style_background_set",
|
||||
"cli_path": "doc style background set"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.style_cover_clear",
|
||||
"cli_path": "doc style cover clear"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.style_cover_set",
|
||||
"cli_path": "doc style cover set"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.get_document_style",
|
||||
"cli_path": "doc style get"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.template_apply",
|
||||
"cli_path": "doc template apply"
|
||||
@@ -152,74 +240,6 @@
|
||||
{
|
||||
"canonical_path": "doc.version_save",
|
||||
"cli_path": "doc version save"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_search",
|
||||
"cli_path": "doc +search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_list",
|
||||
"cli_path": "doc +list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_copy",
|
||||
"cli_path": "doc +copy"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_move",
|
||||
"cli_path": "doc +move"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_comment_list",
|
||||
"cli_path": "doc +comment-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_comment_create",
|
||||
"cli_path": "doc +comment-create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_comment_reply",
|
||||
"cli_path": "doc +comment-reply"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_export_submit",
|
||||
"cli_path": "doc +export-submit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_export_get",
|
||||
"cli_path": "doc +export-get"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_version_save",
|
||||
"cli_path": "doc +version-save"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_version_list",
|
||||
"cli_path": "doc +version-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_version_revert",
|
||||
"cli_path": "doc +version-revert"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_template_list",
|
||||
"cli_path": "doc +template-list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_template_search",
|
||||
"cli_path": "doc +template-search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_doc_append",
|
||||
"cli_path": "doc +doc-append"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_find_doc",
|
||||
"cli_path": "doc +find-doc"
|
||||
},
|
||||
{
|
||||
"canonical_path": "doc.shortcut_share_doc",
|
||||
"cli_path": "doc +share-doc"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
{
|
||||
"id": "drive",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "drive.shortcut_copy",
|
||||
"cli_path": "drive +copy"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_find_file",
|
||||
"cli_path": "drive +find-file"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_info",
|
||||
"cli_path": "drive +info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_move",
|
||||
"cli_path": "drive +move"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_recent",
|
||||
"cli_path": "drive +recent"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_search",
|
||||
"cli_path": "drive +search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_search_docs",
|
||||
"cli_path": "drive +search-docs"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.commit_upload",
|
||||
"cli_path": "drive commit"
|
||||
@@ -9,6 +37,10 @@
|
||||
"canonical_path": "drive.copy_document",
|
||||
"cli_path": "drive copy"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.get_cover",
|
||||
"cli_path": "drive cover"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.delete_document",
|
||||
"cli_path": "drive delete"
|
||||
@@ -17,6 +49,10 @@
|
||||
"canonical_path": "drive.download_file",
|
||||
"cli_path": "drive download"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.download_file_version",
|
||||
"cli_path": "drive download-version"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.get_file_info",
|
||||
"cli_path": "drive info"
|
||||
@@ -41,6 +77,14 @@
|
||||
"canonical_path": "drive.add_permission",
|
||||
"cli_path": "drive permission add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.apply_permission",
|
||||
"cli_path": "drive permission apply"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.query_permission_apply_info",
|
||||
"cli_path": "drive permission apply-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.list_permission",
|
||||
"cli_path": "drive permission list"
|
||||
@@ -49,6 +93,10 @@
|
||||
"canonical_path": "drive.permission_remove",
|
||||
"cli_path": "drive permission remove"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.transfer_owner",
|
||||
"cli_path": "drive permission transfer-owner"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.permission_update",
|
||||
"cli_path": "drive permission update"
|
||||
@@ -81,6 +129,10 @@
|
||||
"canonical_path": "drive.rename_document",
|
||||
"cli_path": "drive rename"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.revert_file_version",
|
||||
"cli_path": "drive revert"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.search_files",
|
||||
"cli_path": "drive search"
|
||||
@@ -89,6 +141,18 @@
|
||||
"canonical_path": "drive.create_shortcut",
|
||||
"cli_path": "drive shortcut"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.mark_star",
|
||||
"cli_path": "drive star add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.get_star_list",
|
||||
"cli_path": "drive star list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.unmark_star",
|
||||
"cli_path": "drive star remove"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.get_node_stats",
|
||||
"cli_path": "drive stats"
|
||||
@@ -100,34 +164,6 @@
|
||||
{
|
||||
"canonical_path": "drive.get_upload_info",
|
||||
"cli_path": "drive upload-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_info",
|
||||
"cli_path": "drive +info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_search",
|
||||
"cli_path": "drive +search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_search_docs",
|
||||
"cli_path": "drive +search-docs"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_copy",
|
||||
"cli_path": "drive +copy"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_move",
|
||||
"cli_path": "drive +move"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_recent",
|
||||
"cli_path": "drive +recent"
|
||||
},
|
||||
{
|
||||
"canonical_path": "drive.shortcut_find_file",
|
||||
"cli_path": "drive +find-file"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"id": "sheet",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "sheet.shortcut_list_sheets",
|
||||
"cli_path": "sheet +list-sheets"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.shortcut_read",
|
||||
"cli_path": "sheet +read"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.add_dimension",
|
||||
"cli_path": "sheet add-dimension"
|
||||
@@ -29,6 +37,26 @@
|
||||
"canonical_path": "sheet.chart_update",
|
||||
"cli_path": "sheet chart update"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.create_sheet_comment",
|
||||
"cli_path": "sheet comment create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.delete_sheet_comment",
|
||||
"cli_path": "sheet comment delete"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.list_sheet_comments",
|
||||
"cli_path": "sheet comment list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.reply_sheet_comment",
|
||||
"cli_path": "sheet comment reply"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.update_sheet_comment",
|
||||
"cli_path": "sheet comment update"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.create_cond_format",
|
||||
"cli_path": "sheet cond-format create"
|
||||
@@ -149,6 +177,10 @@
|
||||
"canonical_path": "sheet.find_cells",
|
||||
"cli_path": "sheet find"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.formula_verify",
|
||||
"cli_path": "sheet formula-verify"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.get_dropdown_lists",
|
||||
"cli_path": "sheet get-dropdown"
|
||||
@@ -165,10 +197,6 @@
|
||||
"canonical_path": "sheet.hide_gridline",
|
||||
"cli_path": "sheet hide-gridline"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.info",
|
||||
"cli_path": "sheet info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.import",
|
||||
"cli_path": "sheet import create"
|
||||
@@ -177,6 +205,10 @@
|
||||
"canonical_path": "sheet.import_get",
|
||||
"cli_path": "sheet import get"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.info",
|
||||
"cli_path": "sheet info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.insert_dimension",
|
||||
"cli_path": "sheet insert-dimension"
|
||||
@@ -319,17 +351,21 @@
|
||||
"canonical_path": "sheet.update_float_image",
|
||||
"cli_path": "sheet update-float-image"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.version_list",
|
||||
"cli_path": "sheet version list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.version_revert",
|
||||
"cli_path": "sheet version revert"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.version_save",
|
||||
"cli_path": "sheet version save"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.write_image",
|
||||
"cli_path": "sheet write-image"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.shortcut_list_sheets",
|
||||
"cli_path": "sheet +list-sheets"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.shortcut_read",
|
||||
"cli_path": "sheet +read"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func init() {
|
||||
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
|
||||
@@ -1152,6 +1152,32 @@
|
||||
"cli_path": "chat +chat-role-query-user",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_messages": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the executable Shortcut and group/direct lower read tools. Optional downloads stay read/not_required under the existing safe local-download convention: workspace-relative paths, no overwrite by default, and atomic publication.",
|
||||
"cli_path": "chat +chat-messages",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_send": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against every executable identity/content/target validation branch, authenticated contact-search response shape, and the real lower tool mappings; bot and webhook media are intentionally rejected because no equivalent lower capability exists.",
|
||||
"cli_path": "chat +messages-send",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_messages_send_by_webhook": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
@@ -1165,6 +1191,25 @@
|
||||
"cli_path": "chat +messages-send-by-webhook",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_messages_send_card": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
|
||||
"parameters": {
|
||||
"receiver-open-dingtalk-id": {
|
||||
"property": "receiverOpenDingTalkId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against authenticated contact-search resolution, create-only, two-action dry-run, create/update success, missing bizId, and partial-failure behavior that preserves the created bizId; the explicit openDingTalkId flag is bound to the lower interface's exact receiverOpenDingTalkId property.",
|
||||
"cli_path": "chat +messages-send-card",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_messages_list_direct": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
@@ -1200,7 +1245,7 @@
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
|
||||
"cli_path": "chat +messages-mget",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
@@ -1265,7 +1310,7 @@
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
|
||||
"cli_path": "chat +at-me",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
@@ -1321,6 +1366,19 @@
|
||||
"cli_path": "chat +my-groups",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_search_msg": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the executable advanced-search flags plus page and enrichment ledgers. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
|
||||
"cli_path": "chat +search-msg",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_send_to_group": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
@@ -1334,6 +1392,19 @@
|
||||
"cli_path": "chat +send-to-group",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_thread_replies": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the executable thread/topic alias contract and real list_topic_replies mapping. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
|
||||
"cli_path": "chat +thread-replies",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_unread_chats": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
@@ -1346,6 +1417,101 @@
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +unread-chats",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.update_text_emotion": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI calls im/update_text_emotion, which is proven by dws-wukong develop@30f13f02 but absent from the pinned MCP metadata snapshot; no pinned interface_ref can represent the command yet.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"background-id": {
|
||||
"property": "backgroundId",
|
||||
"description": "新文字表情的背景 ID",
|
||||
"required": true
|
||||
},
|
||||
"chat": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"conversation-id": {
|
||||
"property": "openConversationId",
|
||||
"description": "会话 openConversationId;与 --group、--id、--chat 四选一",
|
||||
"required": false
|
||||
},
|
||||
"emotion-id": {
|
||||
"property": "emotionId",
|
||||
"description": "新的文字表情 ID,可通过 create-text-emotion 获取",
|
||||
"required": true
|
||||
},
|
||||
"emotion-name": {
|
||||
"property": "emotionName",
|
||||
"description": "新的文字表情名称",
|
||||
"required": true
|
||||
},
|
||||
"group": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"id": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"msg-id": {
|
||||
"property": "openMsgId",
|
||||
"description": "需要原地更新文字表情的消息 openMsgId",
|
||||
"required": true
|
||||
},
|
||||
"old-emotion-id": {
|
||||
"property": "oldEmotionId",
|
||||
"description": "消息上当前文字表情的 emotionId",
|
||||
"required": true
|
||||
},
|
||||
"text": {
|
||||
"property": "text",
|
||||
"description": "新的文字表情内容",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"review_reason": "Ported from dws-wukong commits d6831318 and bcf56ccf, merged at 30f13f02. The four conversation locators alias one property; the other six business parameters are required by Cobra before the single im/update_text_emotion call.",
|
||||
"cli_path": "chat message update-text-emotion",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.get_group_mute_config": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.batch_query_group_chat_settings": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.batch_update_group_chat_settings": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +587,61 @@
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +share-doc",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.style_cover_set": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.style_cover_clear": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.style_background_set": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.style_background_clear": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.get_document_style": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"drive.apply_permission": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_delete"
|
||||
},
|
||||
"drive.commit_upload": {
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
@@ -64,6 +75,28 @@
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"drive.download_file_version": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.get_cover": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.get_file_info": {
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
@@ -75,6 +108,17 @@
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true
|
||||
},
|
||||
"drive.get_star_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.get_upload_info": {
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
@@ -100,6 +144,17 @@
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"drive.mark_star": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.move_document": {
|
||||
"interface_ref": {
|
||||
"product_id": "doc",
|
||||
@@ -164,6 +219,17 @@
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"drive.query_permission_apply_info": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.recent": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
@@ -212,28 +278,46 @@
|
||||
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
|
||||
"cli_path": "drive rename"
|
||||
},
|
||||
"drive.revert_file_version": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_delete"
|
||||
},
|
||||
"drive.search_files": {
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"drive.upload": {
|
||||
"drive.shortcut_copy": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"node": {
|
||||
"property": "nodeId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
|
||||
"cli_path": "drive upload",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +copy",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"drive.shortcut_find_file": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +find-file",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_info": {
|
||||
@@ -249,6 +333,32 @@
|
||||
"cli_path": "drive +info",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_move": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +move",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"drive.shortcut_recent": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +recent",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
@@ -275,56 +385,45 @@
|
||||
"cli_path": "drive +search-docs",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_copy": {
|
||||
"drive.transfer_owner": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +copy",
|
||||
"runtime_gate": "typed_yes"
|
||||
"runtime_gate": "confirm_delete"
|
||||
},
|
||||
"drive.shortcut_move": {
|
||||
"drive.unmark_star": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +move",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"drive.shortcut_recent": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +recent",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_find_file": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"drive.upload": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_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.",
|
||||
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +find-file",
|
||||
"parameters": {
|
||||
"node": {
|
||||
"property": "nodeId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
|
||||
"cli_path": "drive upload",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +734,105 @@
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "sheet +read",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.version_save": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.version_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.version_revert": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_delete"
|
||||
},
|
||||
"sheet.formula_verify": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.list_sheet_comments": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.create_sheet_comment": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.reply_sheet_comment": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.update_sheet_comment": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.delete_sheet_comment": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"channel": "open-source"
|
||||
},
|
||||
"coverage": {
|
||||
"source_tools": 813,
|
||||
"source_tools": 845,
|
||||
"matched_tools": 71
|
||||
},
|
||||
"tools": {
|
||||
|
||||
@@ -2110,6 +2110,46 @@
|
||||
"ShortcutRegistry:chat +chat-role-query-user"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_chat_messages": {
|
||||
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
|
||||
"use_when": [
|
||||
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat +chat-messages --group <openConversationId> --direction older",
|
||||
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored from the verified group/direct routing, stable projection, pagination, and optional safe resource workflow; it distinguishes conversation history from cross-conversation search and exact-ID mget.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#chat.shortcut_chat_messages",
|
||||
"cobra-help:dws chat +chat-messages",
|
||||
"ShortcutRegistry:chat +chat-messages"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_messages_send": {
|
||||
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
|
||||
"use_when": [
|
||||
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
|
||||
"dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored from the verified identity/content capability matrix, live-compatible contact search with exact userId matching, native local-file upload flow, and fail-closed rejection of unsupported bot/webhook media.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send",
|
||||
"cobra-help:dws chat +messages-send",
|
||||
"ShortcutRegistry:chat +messages-send"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_messages_send_by_webhook": {
|
||||
"agent_summary": "自定义机器人 Webhook 发送群消息",
|
||||
"use_when": [
|
||||
@@ -2129,6 +2169,26 @@
|
||||
"ShortcutRegistry:chat +messages-send-by-webhook"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_messages_send_card": {
|
||||
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
|
||||
"use_when": [
|
||||
"当你要发送一张流式卡片消息时使用;群 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 完成,避免卡片停留在加载中。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
|
||||
"dws chat +messages-send-card --receiver <userId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored from the verified create-only and create-then-update lifecycle, live-compatible contact search with exact userId matching, explicit userId/openDingTalkId routing, two-action dry-run, bizId extraction, and partial-failure reporting.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send_card",
|
||||
"cobra-help:dws chat +messages-send-card",
|
||||
"ShortcutRegistry:chat +messages-send-card"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_messages_list_direct": {
|
||||
"agent_summary": "拉取单聊会话消息",
|
||||
"use_when": [
|
||||
@@ -2170,7 +2230,7 @@
|
||||
"chat.shortcut_messages_mget": {
|
||||
"agent_summary": "根据消息 ID 批量查询消息(最多 50 条)",
|
||||
"use_when": [
|
||||
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
|
||||
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
@@ -2265,7 +2325,7 @@
|
||||
"chat.shortcut_at_me": {
|
||||
"agent_summary": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
|
||||
"use_when": [
|
||||
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
|
||||
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
@@ -2359,6 +2419,26 @@
|
||||
"ShortcutRegistry:chat +my-groups"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_search_msg": {
|
||||
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
|
||||
"use_when": [
|
||||
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored from the verified filter mapping, cursor pagination, batched mget enrichment, completeness ledger, and shared safe resource workflow.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#chat.shortcut_search_msg",
|
||||
"cobra-help:dws chat +search-msg",
|
||||
"ShortcutRegistry:chat +search-msg"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_send_to_group": {
|
||||
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
|
||||
"use_when": [
|
||||
@@ -2378,6 +2458,26 @@
|
||||
"ShortcutRegistry:chat +send-to-group"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_thread_replies": {
|
||||
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
|
||||
"use_when": [
|
||||
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
|
||||
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored from the verified thread/topic alias, lower list_topic_replies mapping, stable projection, group-context fallback, and optional safe resource workflow.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#chat.shortcut_thread_replies",
|
||||
"cobra-help:dws chat +thread-replies",
|
||||
"ShortcutRegistry:chat +thread-replies"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_unread_chats": {
|
||||
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
|
||||
"use_when": [
|
||||
@@ -2397,6 +2497,87 @@
|
||||
"cobra-help:dws chat +unread-chats",
|
||||
"ShortcutRegistry:chat +unread-chats"
|
||||
]
|
||||
},
|
||||
"chat.update_text_emotion": {
|
||||
"agent_summary": "把消息上已有的文字表情原地替换为新的文字表情",
|
||||
"use_when": [
|
||||
"需要更新消息的状态文字或表情,并避免先移除再添加造成闪烁和两次网络调用时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"消息上还没有文字表情时使用 chat message add-text-emotion",
|
||||
"只需清除文字表情时使用 chat message remove-text-emotion"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMessageId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name \"处理中\" --text \"处理中 2 分钟\" --background-id im_bg_5"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 issue #85 的原地状态更新场景、Wukong 30f13f02 的真实 Cobra/MCP 映射和开源实现复核;选择语义不改变执行、安全或参数事实。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.update_text_emotion",
|
||||
"cobra-help:dws chat message update-text-emotion",
|
||||
"dws-wukong:d6831318",
|
||||
"dws-wukong:bcf56ccf",
|
||||
"dws-wukong:30f13f02",
|
||||
"github:hugozhu/dingtalk-opencode-tag#85",
|
||||
"live-dws-schema:im.update_text_emotion#FAILED_NOT_AUTHENTICATED"
|
||||
]
|
||||
},
|
||||
"chat.get_group_mute_config": {
|
||||
"agent_summary": "查询群用户禁言配置(禁言黑名单/全员禁言白名单)",
|
||||
"use_when": [
|
||||
"用户说 看下群里谁被禁言/禁言配置"
|
||||
],
|
||||
"avoid_when": [
|
||||
"设置全员禁言用 chat group-mute;禁言个人用 chat group-mute-member"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group get-mute-config --group <openConversationId> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=chat.get_group_mute_config",
|
||||
"cobra-help:dws chat group get-mute-config",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"chat.batch_query_group_chat_settings": {
|
||||
"agent_summary": "批量查询当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
|
||||
"use_when": [
|
||||
"用户说 看下这些群我的置顶和免打扰设置"
|
||||
],
|
||||
"avoid_when": [
|
||||
"管理员级群功能开关用 chat group update-settings"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group user-settings query --groups cid1,cid2 --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=chat.batch_query_group_chat_settings",
|
||||
"cobra-help:dws chat group user-settings query",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"chat.batch_update_group_chat_settings": {
|
||||
"agent_summary": "批量更新当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
|
||||
"use_when": [
|
||||
"用户说 把这些群都设为免打扰/置顶"
|
||||
],
|
||||
"avoid_when": [
|
||||
"单个群昵称优先 chat group update-nick"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group user-settings set --items '[{\"openConversationId\":\"cid1\",\"top\":true,\"mute\":false}]' --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=chat.batch_update_group_chat_settings",
|
||||
"cobra-help:dws chat group user-settings set",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
|
||||
@@ -1226,6 +1226,101 @@
|
||||
"cobra-help:dws doc +share-doc",
|
||||
"ShortcutRegistry:doc +share-doc"
|
||||
]
|
||||
},
|
||||
"doc.style_cover_set": {
|
||||
"agent_summary": "设置钉钉文档顶部封面图(外链或本地图片上传)",
|
||||
"use_when": [
|
||||
"用户说 给文档设置封面/换个封面图"
|
||||
],
|
||||
"avoid_when": [
|
||||
"读取当前封面用 doc style get;移除封面用 cover clear"
|
||||
],
|
||||
"examples": [
|
||||
"dws doc style cover set --node <DOC_ID> --image https://img.example.com/cover.png --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=doc.style_cover_set",
|
||||
"cobra-help:dws doc style cover set",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"doc.style_cover_clear": {
|
||||
"agent_summary": "移除钉钉文档封面",
|
||||
"use_when": [
|
||||
"用户说 去掉文档封面"
|
||||
],
|
||||
"avoid_when": [
|
||||
"设置封面用 cover set"
|
||||
],
|
||||
"examples": [
|
||||
"dws doc style cover clear --node <DOC_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=doc.style_cover_clear",
|
||||
"cobra-help:dws doc style cover clear",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"doc.style_background_set": {
|
||||
"agent_summary": "设置钉钉文档背景纯色(#RRGGBB)",
|
||||
"use_when": [
|
||||
"用户说 给文档设置背景色"
|
||||
],
|
||||
"avoid_when": [
|
||||
"背景不支持图片;封面用 cover set"
|
||||
],
|
||||
"examples": [
|
||||
"dws doc style background set --node <DOC_ID> --color \"#E8F2FE\" --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=doc.style_background_set",
|
||||
"cobra-help:dws doc style background set",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"doc.style_background_clear": {
|
||||
"agent_summary": "清除钉钉文档背景",
|
||||
"use_when": [
|
||||
"用户说 去掉文档背景色"
|
||||
],
|
||||
"avoid_when": [
|
||||
"设置背景用 background set"
|
||||
],
|
||||
"examples": [
|
||||
"dws doc style background clear --node <DOC_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=doc.style_background_clear",
|
||||
"cobra-help:dws doc style background clear",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"doc.get_document_style": {
|
||||
"agent_summary": "读取文档当前封面与背景配置(只读)",
|
||||
"use_when": [
|
||||
"用户说 看下文档现在的封面/背景"
|
||||
],
|
||||
"avoid_when": [
|
||||
"修改用 cover set / background set"
|
||||
],
|
||||
"examples": [
|
||||
"dws doc style get --node <DOC_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=doc.get_document_style",
|
||||
"cobra-help:dws doc style get",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
|
||||
@@ -33,6 +33,25 @@
|
||||
"live-mcp:dws schema doc.add_permission"
|
||||
]
|
||||
},
|
||||
"drive.apply_permission": {
|
||||
"agent_summary": "向审批人发起文档权限申请(会真实通知审批人)",
|
||||
"use_when": [
|
||||
"用户确认要为无权限文档发起权限申请"
|
||||
],
|
||||
"avoid_when": [
|
||||
"先用 apply-info 查可申请角色与审批人;未经用户确认不得自行提交"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive permission apply --node <DOC_ID> --role READER --users <审批人userId> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.apply_permission",
|
||||
"cobra-help:dws drive permission apply",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.commit_upload": {
|
||||
"agent_summary": "提交文件上传",
|
||||
"use_when": [
|
||||
@@ -187,6 +206,47 @@
|
||||
"live-mcp:dws schema drive.download_file"
|
||||
]
|
||||
},
|
||||
"drive.download_file_version": {
|
||||
"agent_summary": "下载钉盘普通文件的指定历史版本到本地(两步下载:取签名 URL 后 HTTP GET)",
|
||||
"use_when": [
|
||||
"用户要下载文件的历史版本/旧版本",
|
||||
"版本号已通过 drive list --versions 获取"
|
||||
],
|
||||
"avoid_when": [
|
||||
"下载最新版本用 drive download",
|
||||
"在线文档(adoc)历史版本用 doc version 系列命令",
|
||||
"在线表格(axls)历史版本用 sheet version 系列命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop drive download --version 分支实现移植为独立命令;单一 download_file_version 远端调用,参数契约与帮助文档一致。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.download_file_version",
|
||||
"cobra-help:dws drive download-version",
|
||||
"wukong-develop:wukong/products/drive.go"
|
||||
]
|
||||
},
|
||||
"drive.get_cover": {
|
||||
"agent_summary": "获取节点封面图片地址(文档首图/图片缩略图/类型图标)",
|
||||
"use_when": [
|
||||
"用户说 封面/封面图/缩略图/预览图"
|
||||
],
|
||||
"avoid_when": [
|
||||
"设置文档封面用 doc style cover set"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive cover --node <dentryUuid> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.get_cover",
|
||||
"cobra-help:dws drive cover",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.get_file_info": {
|
||||
"agent_summary": "获取文件元数据信息",
|
||||
"use_when": [
|
||||
@@ -235,6 +295,26 @@
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)"
|
||||
]
|
||||
},
|
||||
"drive.get_star_list": {
|
||||
"agent_summary": "获取当前用户的收藏列表,支持分页与按内容类型筛选",
|
||||
"use_when": [
|
||||
"用户说 我的收藏/收藏列表/收藏了哪些文档"
|
||||
],
|
||||
"avoid_when": [
|
||||
"操作单个收藏用 mark_star/unmark_star"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive star list --format json",
|
||||
"dws drive star list --content-types doc,sheet --limit 10 --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.get_star_list",
|
||||
"cobra-help:dws drive star list",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.get_upload_info": {
|
||||
"agent_summary": "获取文件上传信息",
|
||||
"use_when": [
|
||||
@@ -334,6 +414,25 @@
|
||||
"live-mcp:dws schema drive.list_spaces"
|
||||
]
|
||||
},
|
||||
"drive.mark_star": {
|
||||
"agent_summary": "收藏文档/文件到当前用户收藏列表",
|
||||
"use_when": [
|
||||
"用户说 收藏这个文档/加个收藏/标星"
|
||||
],
|
||||
"avoid_when": [
|
||||
"取消收藏用 unmark_star;查看收藏列表用 get_star_list"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive star add --node <nodeId> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.mark_star",
|
||||
"cobra-help:dws drive star add",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.move_document": {
|
||||
"agent_summary": "将文件或文档移动到目标文件夹或知识库(原位置不再保留)",
|
||||
"use_when": [
|
||||
@@ -475,6 +574,25 @@
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)"
|
||||
]
|
||||
},
|
||||
"drive.query_permission_apply_info": {
|
||||
"agent_summary": "查询节点可申请的权限角色列表与审批人列表",
|
||||
"use_when": [
|
||||
"无权限访问文档时,先查可申请角色与审批人"
|
||||
],
|
||||
"avoid_when": [
|
||||
"实际发起申请用 apply_permission"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive permission apply-info --node <DOC_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.query_permission_apply_info",
|
||||
"cobra-help:dws drive permission apply-info",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.recent": {
|
||||
"agent_summary": "获取当前用户最近访问或编辑过的文档列表",
|
||||
"use_when": [
|
||||
@@ -568,6 +686,25 @@
|
||||
"live-mcp:dws schema doc.rename_document"
|
||||
]
|
||||
},
|
||||
"drive.revert_file_version": {
|
||||
"agent_summary": "回滚普通文件到指定历史版本(生成新最新版本,历史不丢失)",
|
||||
"use_when": [
|
||||
"用户说 回滚版本/恢复到某个版本/版本回退,且目标是普通文件"
|
||||
],
|
||||
"avoid_when": [
|
||||
"在线文档回滚用 doc version revert;在线表格用 sheet version revert"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive revert --node <dentryUuid> --version 3 --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.revert_file_version",
|
||||
"cobra-help:dws drive revert",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.search_files": {
|
||||
"agent_summary": "全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果",
|
||||
"use_when": [
|
||||
@@ -595,32 +732,43 @@
|
||||
"live-mcp:dws schema drive.search_files"
|
||||
]
|
||||
},
|
||||
"drive.upload": {
|
||||
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
|
||||
"drive.shortcut_copy": {
|
||||
"agent_summary": "复制文件/文档到指定位置",
|
||||
"use_when": [
|
||||
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
|
||||
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
|
||||
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认"
|
||||
"当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
|
||||
"要把文件作为文档正文附件插入改用 dws doc media insert",
|
||||
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
|
||||
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录"
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive upload --file ./report.pdf --format json",
|
||||
"dws drive upload --file ./README.md --node <dentryUuid> --format json"
|
||||
"dws drive +copy --node <nodeId> --folder <targetFolderId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.upload",
|
||||
"cobra-help:dws drive upload",
|
||||
"Skill:skills/mono/references/products/drive.md",
|
||||
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.upload",
|
||||
"structured-hint:internal/cli/schema_hints/products/drive.json",
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)"
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_copy",
|
||||
"cobra-help:dws drive +copy",
|
||||
"ShortcutRegistry:drive +copy"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_find_file": {
|
||||
"agent_summary": "按名称关键词搜索钉盘文件并投影关键字段(只读)",
|
||||
"use_when": [
|
||||
"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive +find-file --query 季度汇报",
|
||||
"dws drive +find-file --query 合同"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_find_file",
|
||||
"cobra-help:dws drive +find-file",
|
||||
"ShortcutRegistry:drive +find-file"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_info": {
|
||||
@@ -642,64 +790,6 @@
|
||||
"ShortcutRegistry:drive +info"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_search": {
|
||||
"agent_summary": "搜索钉盘文件",
|
||||
"use_when": [
|
||||
"当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive +search --query \"季度汇报\"",
|
||||
"dws drive +search --query \"合同\" --target file --extensions pdf,docx"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_search",
|
||||
"cobra-help:dws drive +search",
|
||||
"ShortcutRegistry:drive +search"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_search_docs": {
|
||||
"agent_summary": "搜索文档空间文档",
|
||||
"use_when": [
|
||||
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive +search-docs --query \"季度汇报\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_search_docs",
|
||||
"cobra-help:dws drive +search-docs",
|
||||
"ShortcutRegistry:drive +search-docs"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_copy": {
|
||||
"agent_summary": "复制文件/文档到指定位置",
|
||||
"use_when": [
|
||||
"当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive +copy --node <nodeId> --folder <targetFolderId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_copy",
|
||||
"cobra-help:dws drive +copy",
|
||||
"ShortcutRegistry:drive +copy"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_move": {
|
||||
"agent_summary": "移动文件/文档到指定位置",
|
||||
"use_when": [
|
||||
@@ -739,24 +829,109 @@
|
||||
"ShortcutRegistry:drive +recent"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_find_file": {
|
||||
"agent_summary": "按名称关键词搜索钉盘文件并投影关键字段(只读)",
|
||||
"drive.shortcut_search": {
|
||||
"agent_summary": "搜索钉盘文件",
|
||||
"use_when": [
|
||||
"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"
|
||||
"当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive +find-file --query 季度汇报",
|
||||
"dws drive +find-file --query 合同"
|
||||
"dws drive +search --query \"季度汇报\"",
|
||||
"dws drive +search --query \"合同\" --target file --extensions pdf,docx"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_find_file",
|
||||
"cobra-help:dws drive +find-file",
|
||||
"ShortcutRegistry:drive +find-file"
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_search",
|
||||
"cobra-help:dws drive +search",
|
||||
"ShortcutRegistry:drive +search"
|
||||
]
|
||||
},
|
||||
"drive.shortcut_search_docs": {
|
||||
"agent_summary": "搜索文档空间文档",
|
||||
"use_when": [
|
||||
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive +search-docs --query \"季度汇报\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#drive.shortcut_search_docs",
|
||||
"cobra-help:dws drive +search-docs",
|
||||
"ShortcutRegistry:drive +search-docs"
|
||||
]
|
||||
},
|
||||
"drive.transfer_owner": {
|
||||
"agent_summary": "转交文档或知识库所有者给指定用户(不可逆)",
|
||||
"use_when": [
|
||||
"用户明确要求转交文档/知识库所有权"
|
||||
],
|
||||
"avoid_when": [
|
||||
"普通协作权限变更用 permission add/update/remove"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive permission transfer-owner --node <DOC_ID> --new-owner <userId> --reserve-role EDITOR --recursive=false --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.transfer_owner",
|
||||
"cobra-help:dws drive permission transfer-owner",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.unmark_star": {
|
||||
"agent_summary": "将文档/文件从当前用户收藏列表移除",
|
||||
"use_when": [
|
||||
"用户说 取消收藏/去掉收藏/不收藏了"
|
||||
],
|
||||
"avoid_when": [
|
||||
"添加收藏用 mark_star"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive star remove --node <nodeId> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.unmark_star",
|
||||
"cobra-help:dws drive star remove",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"drive.upload": {
|
||||
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
|
||||
"use_when": [
|
||||
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
|
||||
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
|
||||
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认"
|
||||
],
|
||||
"avoid_when": [
|
||||
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
|
||||
"要把文件作为文档正文附件插入改用 dws doc media insert",
|
||||
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
|
||||
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录"
|
||||
],
|
||||
"examples": [
|
||||
"dws drive upload --file ./report.pdf --format json",
|
||||
"dws drive upload --file ./README.md --node <dentryUuid> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=drive.upload",
|
||||
"cobra-help:dws drive upload",
|
||||
"Skill:skills/mono/references/products/drive.md",
|
||||
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.upload",
|
||||
"structured-hint:internal/cli/schema_hints/products/drive.json",
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1629,6 +1629,177 @@
|
||||
"cobra-help:dws sheet +read",
|
||||
"ShortcutRegistry:sheet +read"
|
||||
]
|
||||
},
|
||||
"sheet.version_save": {
|
||||
"agent_summary": "手动保存表格版本快照",
|
||||
"use_when": [
|
||||
"用户说 保存版本/存个快照,目标是在线表格"
|
||||
],
|
||||
"avoid_when": [
|
||||
"回滚用 version revert;查看历史用 version list"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet version save --node <SHEET_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.version_save",
|
||||
"cobra-help:dws sheet version save",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.version_list": {
|
||||
"agent_summary": "查看表格历史版本列表",
|
||||
"use_when": [
|
||||
"用户说 看历史版本/版本列表,目标是在线表格"
|
||||
],
|
||||
"avoid_when": [
|
||||
"回滚用 version revert"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet version list --node <SHEET_ID> --limit 10 --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.version_list",
|
||||
"cobra-help:dws sheet version list",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.version_revert": {
|
||||
"agent_summary": "回滚表格到指定历史版本",
|
||||
"use_when": [
|
||||
"用户说 回滚到某个版本/恢复到之前的表格"
|
||||
],
|
||||
"avoid_when": [
|
||||
"普通文件回滚用 drive revert;在线文档用 doc version revert"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet version revert --node <SHEET_ID> --version 3 --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.version_revert",
|
||||
"cobra-help:dws sheet version revert",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.formula_verify": {
|
||||
"agent_summary": "扫描表格公式单元格并按错误类型聚合返回错误数量与位置",
|
||||
"use_when": [
|
||||
"用户说 校验公式/检查公式错误/公式错误扫描"
|
||||
],
|
||||
"avoid_when": [
|
||||
"读取公式文本用 range read --value-render-option formula"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet formula-verify --node <NODE_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.formula_verify",
|
||||
"cobra-help:dws sheet formula-verify",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.list_sheet_comments": {
|
||||
"agent_summary": "查询表格单元格评论列表,支持分页与按解决状态过滤",
|
||||
"use_when": [
|
||||
"用户说 看某格的评论/表格里有哪些批注"
|
||||
],
|
||||
"avoid_when": [
|
||||
"新建评论用 comment create"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet comment list --node <SHEET_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.list_sheet_comments",
|
||||
"cobra-help:dws sheet comment list",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.create_sheet_comment": {
|
||||
"agent_summary": "在指定单元格上创建评论,可 @ 用户",
|
||||
"use_when": [
|
||||
"用户说 给单元格加评论/批注/@某人讨论这个数据"
|
||||
],
|
||||
"avoid_when": [
|
||||
"回复已有评论用 comment reply;不要把评论写进单元格值"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content \"这个数字有问题\" --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.create_sheet_comment",
|
||||
"cobra-help:dws sheet comment create",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.reply_sheet_comment": {
|
||||
"agent_summary": "回复指定单元格评论,支持表情贴图回复",
|
||||
"use_when": [
|
||||
"用户说 回复这条评论"
|
||||
],
|
||||
"avoid_when": [
|
||||
"修改评论内容用 comment update"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content \"已核实\" --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.reply_sheet_comment",
|
||||
"cobra-help:dws sheet comment reply",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.update_sheet_comment": {
|
||||
"agent_summary": "更新单元格评论内容",
|
||||
"use_when": [
|
||||
"用户说 改一下这条评论"
|
||||
],
|
||||
"avoid_when": [
|
||||
"回复评论用 comment reply"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content \"已修正\" --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.update_sheet_comment",
|
||||
"cobra-help:dws sheet comment update",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
},
|
||||
"sheet.delete_sheet_comment": {
|
||||
"agent_summary": "删除单元格评论(不可恢复)",
|
||||
"use_when": [
|
||||
"用户明确要求删除某条评论"
|
||||
],
|
||||
"avoid_when": [
|
||||
"标记解决不等于删除"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=sheet.delete_sheet_comment",
|
||||
"cobra-help:dws sheet comment delete",
|
||||
"wukong-develop:wukong/products"
|
||||
]
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"version": 3,
|
||||
"baseline": {
|
||||
"manifest": "schema-parameter-bindings-v3",
|
||||
"sha256": "sha256:f48d7b55b6ca9719c466475ee32d678eab0deaee1d79ac4e4790c7ee441ebe7b",
|
||||
"reason": "Reviewed v3 baseline after exposing the pinned chat.search_messages messageType, onlyRobotMessages, and searchConvType properties through exact Cobra flags.",
|
||||
"sha256": "sha256:ff0f76145dd27da5429434a348344c9cdf74c44eaf6f4ea5411d29d904a9066f",
|
||||
"reason": "Reviewed v3 baseline after binding sheet.info --include to the include property while porting the wukong sheet info expansion flag.",
|
||||
"reviewed": true
|
||||
},
|
||||
"removals": {
|
||||
@@ -43,6 +43,10 @@
|
||||
"reason": "The public pagination flag was normalized from --size to --limit.",
|
||||
"replaced_by": "oa.list_user_visible_process --limit",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"bindings": {
|
||||
@@ -1108,7 +1112,8 @@
|
||||
"node": "nodeId"
|
||||
},
|
||||
"sheet.info": {
|
||||
"node": "nodeId"
|
||||
"node": "nodeId",
|
||||
"include": "include"
|
||||
},
|
||||
"sheet.insert_dimension": {
|
||||
"node": "nodeId"
|
||||
@@ -1590,7 +1595,10 @@
|
||||
"attendance.vacation_update_type --when-can-leave": "Reviewed unpinned adapter: attendance.vacation_update_type has no singular pinned interface_ref; --when-can-leave is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.add_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.add_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.add_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.add_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
|
||||
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1607,7 +1615,12 @@
|
||||
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --file-path": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --text": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --title": "serialized into the aggregate content payload",
|
||||
@@ -1632,8 +1645,8 @@
|
||||
"dev.disable_dev_app_robot --unified-app-id": "Reviewed unpinned adapter: dev.disable_dev_app_robot has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.enable_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.enable_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.enable_dev_app_robot --unified-app-id": "Reviewed unpinned adapter: dev.enable_dev_app_robot has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.get_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.get_dev_app --app-key": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --app-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.get_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.get_dev_app_credentials --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app_credentials has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.get_dev_app_version_detail --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app_version_detail has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"dev.get_dev_app_version_detail --version-id": "Reviewed unpinned adapter: dev.get_dev_app_version_detail has no singular pinned interface_ref; --version-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1728,6 +1741,7 @@
|
||||
"doc.get_document_content --scope": "Runtime extension sends scope for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
|
||||
"doc.get_document_content --start-block-id": "Runtime extension sends startBlockId for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
|
||||
"doc.get_document_content --tags": "Runtime extension sends tags for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
|
||||
"doc.get_document_style --node": "Reviewed unpinned adapter: doc.get_document_style has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.import_get --task-id": "Reviewed unpinned adapter: doc.import_get has no singular pinned interface_ref; --task-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
|
||||
@@ -1735,6 +1749,14 @@
|
||||
"doc.insert_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_background_set --node": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_cover_clear --node": "Reviewed unpinned adapter: doc.style_cover_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_cover_set --file": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --file is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_cover_set --image": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --image is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_cover_set --node": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_cover_set --position": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.template_apply --folder": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --folder is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.template_apply --name": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --name is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.template_apply --template-id": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --template-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1763,7 +1785,22 @@
|
||||
"doc.version_revert --node": "Reviewed unpinned adapter: doc.version_revert has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_revert --version": "Reviewed unpinned adapter: doc.version_revert has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_save --node": "Reviewed unpinned adapter: doc.version_save has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --node": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --notify-mode": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --notify-mode is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file --output": "local output path",
|
||||
"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.",
|
||||
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --cursor": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --limit": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --order-by": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --order-by is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --resource-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --resource-types is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --sort": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --sort is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.list_files --cursor": "composite route maps to nextToken for drive.list_files or pageToken for doc.list_nodes",
|
||||
"drive.list_files --folder": "composite route maps to parentId for drive.list_files or folderId for doc.list_nodes",
|
||||
"drive.list_files --limit": "composite route maps to maxResults for drive.list_files or pageSize for doc.list_nodes",
|
||||
@@ -1772,10 +1809,12 @@
|
||||
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
|
||||
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --permission": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --permission is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_unset --node": "Reviewed unpinned adapter: drive.publish_unset has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.query_permission_apply_info --node": "Reviewed unpinned adapter: drive.query_permission_apply_info has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.recent --creator-type": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --creator-type is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.recent --cursor": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.recent --file-types": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --file-types is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1786,6 +1825,14 @@
|
||||
"drive.recycle_list --limit": "Reviewed unpinned adapter: drive.recycle_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.recycle_list --space-id": "Reviewed unpinned adapter: drive.recycle_list has no singular pinned interface_ref; --space-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.recycle_restore --id": "Reviewed unpinned adapter: drive.recycle_restore has no singular pinned interface_ref; --id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.revert_file_version --node": "Reviewed unpinned adapter: drive.revert_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.revert_file_version --version": "Reviewed unpinned adapter: drive.revert_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.transfer_owner --new-owner": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --new-owner is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.transfer_owner --node": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.transfer_owner --recursive": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --recursive is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.transfer_owner --reserve-role": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --reserve-role is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.transfer_owner --workspace": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --workspace is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.unmark_star --node": "Reviewed unpinned adapter: drive.unmark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"mail.create_download_session --name": "local download filename",
|
||||
"mail.create_download_session --output": "local output path",
|
||||
"mail.create_draft --attachment": "reviewed local attachment preprocessing input",
|
||||
@@ -1831,9 +1878,16 @@
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-sheet-id": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_sheet_comment --content": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_sheet_comment --mention": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_sheet_comment --node": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_sheet_comment --range": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_sheet_comment --sheet-id": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.delete_cond_format --yes": "local confirmation control",
|
||||
"sheet.delete_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.delete_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.delete_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.delete_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.delete_sheet_comment --node": "Reviewed unpinned adapter: sheet.delete_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.filter_view_get_criteria --column": "local post-filter over get_filter_views response",
|
||||
"sheet.filter_view_get_criteria --filter-view-id": "local post-filter over get_filter_views response",
|
||||
"sheet.filter_view_info --filter-view-id": "local post-filter over get_filter_views response",
|
||||
@@ -1843,6 +1897,13 @@
|
||||
"sheet.filter_view_update_criteria --filter-view-id": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --filter-view-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.filter_view_update_criteria --node": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.filter_view_update_criteria --sheet-id": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --exit-on-error": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --exit-on-error is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --max-cells": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --max-cells is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --max-locations-per-error": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --max-locations-per-error is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --node": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --range": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --sheet-id": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.formula_verify --targets": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --targets is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.group_dimension --group-state": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --group-state is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.group_dimension --node": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.group_dimension --range": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1850,6 +1911,12 @@
|
||||
"sheet.hide_gridline --sheet-id": "Reviewed unpinned adapter: sheet.hide_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_pivot_tables --pivot-table-id": "Reviewed unpinned adapter: sheet.list_pivot_tables has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_pivot_tables --sheet-id": "Reviewed unpinned adapter: sheet.list_pivot_tables has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_sheet_comments --cursor": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_sheet_comments --limit": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_sheet_comments --node": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_sheet_comments --range": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_sheet_comments --resolve-status": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --resolve-status is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.list_sheet_comments --sheet-id": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1857,6 +1924,11 @@
|
||||
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
|
||||
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.submit_export_job --output": "local destination consumed after export job completion",
|
||||
"sheet.table_get --no-header": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --no-header is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -1881,6 +1953,15 @@
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_sheet_comment --content": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_sheet_comment --node": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.version_list --cursor": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.version_list --limit": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.version_list --node": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.version_revert --node": "Reviewed unpinned adapter: sheet.version_revert has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.version_revert --version": "Reviewed unpinned adapter: sheet.version_revert has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.version_save --node": "Reviewed unpinned adapter: sheet.version_save has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
|
||||
"sheet.write_image --mime-type": "local upload metadata",
|
||||
"sheet.write_image --name": "local upload metadata",
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
|
||||
}),
|
||||
)
|
||||
typed := err.(*Error)
|
||||
if typed.Retryable || typed.ServerDiag.TraceID != "trace" {
|
||||
if typed.Retryable || !typed.RetryableSet || typed.ServerDiag.TraceID != "trace" {
|
||||
t.Fatalf("diagnostics options = %#v", typed)
|
||||
}
|
||||
if (ServerDiagnostics{TraceID: "trace"}).IsEmpty() {
|
||||
|
||||
@@ -44,6 +44,7 @@ func WithServerDiag(diag ServerDiagnostics) Option {
|
||||
// Override retryable if server explicitly specified.
|
||||
if diag.ServerRetryable != nil {
|
||||
e.Retryable = *diag.ServerRetryable
|
||||
e.RetryableSet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-18
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
@@ -41,20 +42,23 @@ const (
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
type Error struct {
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
RetryableSet bool
|
||||
RetryAfterSeconds *int64
|
||||
NextRetryAt *time.Time
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
@@ -107,6 +111,31 @@ func WithServerKey(serverKey string) Option {
|
||||
func WithRetryable(retryable bool) Option {
|
||||
return func(err *Error) {
|
||||
err.Retryable = retryable
|
||||
err.RetryableSet = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryAfterSeconds records the server-recommended delay before a retry.
|
||||
// A zero delay is meaningful and is therefore preserved; negative values are
|
||||
// ignored as invalid server guidance.
|
||||
func WithRetryAfterSeconds(seconds int64) Option {
|
||||
return func(err *Error) {
|
||||
if seconds < 0 {
|
||||
return
|
||||
}
|
||||
value := seconds
|
||||
err.RetryAfterSeconds = &value
|
||||
}
|
||||
}
|
||||
|
||||
// WithNextRetryAt records the absolute time at which a retry may be attempted.
|
||||
func WithNextRetryAt(next time.Time) Option {
|
||||
return func(err *Error) {
|
||||
if next.IsZero() {
|
||||
return
|
||||
}
|
||||
value := next.UTC()
|
||||
err.NextRetryAt = &value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,8 +295,14 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if typed.ServerKey != "" {
|
||||
errorPayload["server_key"] = typed.ServerKey
|
||||
}
|
||||
if typed.Retryable {
|
||||
errorPayload["retryable"] = true
|
||||
if typed.RetryableSet {
|
||||
errorPayload["retryable"] = typed.Retryable
|
||||
}
|
||||
if typed.RetryAfterSeconds != nil {
|
||||
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
|
||||
}
|
||||
if typed.NextRetryAt != nil {
|
||||
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if typed.Hint != "" {
|
||||
errorPayload["hint"] = typed.Hint
|
||||
@@ -383,8 +418,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, tui.Dim(line))
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, tui.Warning("Retryable: true"))
|
||||
if typed.RetryableSet {
|
||||
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
|
||||
}
|
||||
if typed.RetryAfterSeconds != nil {
|
||||
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
|
||||
}
|
||||
if typed.NextRetryAt != nil {
|
||||
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
|
||||
}
|
||||
|
||||
// Always shown when present: Trace ID, Server Code
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExitCodeByCategory(t *testing.T) {
|
||||
@@ -77,6 +78,99 @@ func TestPrintJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
next := time.Date(2026, time.July, 30, 4, 5, 6, 0, time.FixedZone("CST", 8*60*60))
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantRetryable string
|
||||
wantRetryAfter bool
|
||||
wantNextRetryAt bool
|
||||
}{
|
||||
{
|
||||
name: "unknown is omitted",
|
||||
err: NewAPI("unknown"),
|
||||
},
|
||||
{
|
||||
name: "explicit false is preserved",
|
||||
err: NewValidation("terminal", WithRetryable(false)),
|
||||
wantRetryable: `"retryable": false`,
|
||||
},
|
||||
{
|
||||
name: "explicit true with timing",
|
||||
err: NewAPI("transient", WithRetryable(true), WithRetryAfterSeconds(30), WithNextRetryAt(next)),
|
||||
wantRetryable: `"retryable": true`,
|
||||
wantRetryAfter: true,
|
||||
wantNextRetryAt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var jsonOut strings.Builder
|
||||
if err := PrintJSON(&jsonOut, tt.err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
gotJSON := jsonOut.String()
|
||||
if tt.wantRetryable == "" {
|
||||
if strings.Contains(gotJSON, `"retryable"`) {
|
||||
t.Fatalf("unknown retryability must be omitted: %s", gotJSON)
|
||||
}
|
||||
} else if !strings.Contains(gotJSON, tt.wantRetryable) {
|
||||
t.Fatalf("missing %s in %s", tt.wantRetryable, gotJSON)
|
||||
}
|
||||
if got := strings.Contains(gotJSON, `"retry_after_seconds": 30`); got != tt.wantRetryAfter {
|
||||
t.Fatalf("retry_after_seconds presence = %v, want %v: %s", got, tt.wantRetryAfter, gotJSON)
|
||||
}
|
||||
if got := strings.Contains(gotJSON, `"next_retry_at": "2026-07-29T20:05:06Z"`); got != tt.wantNextRetryAt {
|
||||
t.Fatalf("next_retry_at presence = %v, want %v: %s", got, tt.wantNextRetryAt, gotJSON)
|
||||
}
|
||||
|
||||
var humanOut strings.Builder
|
||||
if err := PrintHuman(&humanOut, tt.err); err != nil {
|
||||
t.Fatalf("PrintHuman() error = %v", err)
|
||||
}
|
||||
gotHuman := humanOut.String()
|
||||
if tt.wantRetryable == "" {
|
||||
if strings.Contains(gotHuman, "Retryable:") {
|
||||
t.Fatalf("unknown retryability must be omitted: %s", gotHuman)
|
||||
}
|
||||
} else {
|
||||
want := "Retryable: true"
|
||||
if strings.Contains(tt.wantRetryable, "false") {
|
||||
want = "Retryable: false"
|
||||
}
|
||||
if !strings.Contains(gotHuman, want) {
|
||||
t.Fatalf("missing %q in %s", want, gotHuman)
|
||||
}
|
||||
}
|
||||
if tt.wantRetryAfter && !strings.Contains(gotHuman, "Retry After: 30s") {
|
||||
t.Fatalf("missing retry delay in %s", gotHuman)
|
||||
}
|
||||
if tt.wantNextRetryAt && !strings.Contains(gotHuman, "Next Retry At: 2026-07-29T20:05:06Z") {
|
||||
t.Fatalf("missing next retry time in %s", gotHuman)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := NewAPI(
|
||||
"invalid timing",
|
||||
WithRetryAfterSeconds(-1),
|
||||
WithNextRetryAt(time.Time{}),
|
||||
).(*Error)
|
||||
if err.RetryAfterSeconds != nil || err.NextRetryAt != nil {
|
||||
t.Fatalf("invalid retry timing was retained: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
// 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 personal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// AttemptStateFileName stores retry suppression state for personal
|
||||
// subscription creation. It intentionally does not share the successful
|
||||
// subscription run-state file.
|
||||
AttemptStateFileName = "personal_subscription_attempts.json"
|
||||
// AttemptStateLockFileName serializes cross-process attempt-state changes.
|
||||
AttemptStateLockFileName = "personal_subscription_attempts.lock"
|
||||
|
||||
attemptStateVersion = 1
|
||||
|
||||
attemptLockWaitTimeout = 5 * time.Second
|
||||
attemptLockRetryDelay = 25 * time.Millisecond
|
||||
attemptFailureReset = 24 * time.Hour
|
||||
attemptTerminalHold = time.Hour
|
||||
attemptMaxFieldLength = 256
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAttemptClaimStale means another process replaced at least one claimed
|
||||
// fingerprint. Completion is rejected as a unit so an old process cannot
|
||||
// overwrite newer state.
|
||||
ErrAttemptClaimStale = errors.New("personal event: subscription attempt claim is stale")
|
||||
)
|
||||
|
||||
// AttemptState is the persisted lifecycle state of one subscription attempt.
|
||||
type AttemptState string
|
||||
|
||||
const (
|
||||
AttemptStateInFlight AttemptState = "in_flight"
|
||||
AttemptStateCooldown AttemptState = "cooldown"
|
||||
AttemptStateTerminalHold AttemptState = "terminal_hold"
|
||||
)
|
||||
|
||||
// Retryability preserves the distinction between an explicit server decision
|
||||
// and an error for which the server did not provide retry guidance.
|
||||
type Retryability string
|
||||
|
||||
const (
|
||||
RetryabilityUnknown Retryability = "unknown"
|
||||
RetryabilityRetryable Retryability = "retryable"
|
||||
RetryabilityNonRetryable Retryability = "non_retryable"
|
||||
)
|
||||
|
||||
// Value converts retryability to a bool while preserving whether it is known.
|
||||
func (r Retryability) Value() (value bool, known bool) {
|
||||
switch r {
|
||||
case RetryabilityRetryable:
|
||||
return true, true
|
||||
case RetryabilityNonRetryable:
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// AttemptSpec identifies one normalized subscription request. Fingerprint must
|
||||
// come from Fingerprint; only the digest and the non-sensitive event key are
|
||||
// persisted.
|
||||
type AttemptSpec struct {
|
||||
Fingerprint string
|
||||
EventKey string
|
||||
}
|
||||
|
||||
// AttemptClaim is the ownership token returned by Claim. Callers must finish
|
||||
// it with CompleteSuccess, CompleteFailure, or Release.
|
||||
type AttemptClaim struct {
|
||||
AttemptID string
|
||||
Fingerprints []string
|
||||
|
||||
previous map[string]attemptPrevious
|
||||
}
|
||||
|
||||
type attemptPrevious struct {
|
||||
record attemptRecord
|
||||
present bool
|
||||
}
|
||||
|
||||
// AttemptBlockedError reports a local suppression decision. It never includes
|
||||
// the raw endpoint, idempotency key, profile selector, rule parameter, or
|
||||
// filter.
|
||||
type AttemptBlockedError struct {
|
||||
Fingerprint string
|
||||
EventKey string
|
||||
State AttemptState
|
||||
Retryability Retryability
|
||||
RetryAfter time.Duration
|
||||
NextAllowedAt time.Time
|
||||
FailureCount int
|
||||
ErrorCode string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
func (e *AttemptBlockedError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"personal event: subscription attempt %s until %s",
|
||||
e.State,
|
||||
e.NextAllowedAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
|
||||
// AttemptFailure describes the one failed item in a claimed batch.
|
||||
type AttemptFailure struct {
|
||||
Fingerprint string
|
||||
Retryability Retryability
|
||||
RetryAfter time.Duration
|
||||
ErrorCode string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// AttemptHold is the persisted suppression decision after CompleteFailure.
|
||||
type AttemptHold struct {
|
||||
Fingerprint string
|
||||
State AttemptState
|
||||
Retryability Retryability
|
||||
RetryAfter time.Duration
|
||||
NextAllowedAt time.Time
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
// AttemptStoreOption customizes an AttemptStore.
|
||||
type AttemptStoreOption func(*AttemptStore)
|
||||
|
||||
// WithAttemptClock installs a clock, primarily for deterministic tests.
|
||||
func WithAttemptClock(now func() time.Time) AttemptStoreOption {
|
||||
return func(store *AttemptStore) {
|
||||
if now != nil {
|
||||
store.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttemptIDGenerator installs an attempt-ID generator. IDs are persisted
|
||||
// only as CAS tokens and must not contain request data.
|
||||
func WithAttemptIDGenerator(generate func() (string, error)) AttemptStoreOption {
|
||||
return func(store *AttemptStore) {
|
||||
if generate != nil {
|
||||
store.newID = generate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AttemptStore coordinates personal subscription creation across CLI
|
||||
// processes sharing one identity work directory.
|
||||
type AttemptStore struct {
|
||||
workDir string
|
||||
now func() time.Time
|
||||
newID func() (string, error)
|
||||
|
||||
readFile func(string) ([]byte, error)
|
||||
mkdirAll func(string, os.FileMode) error
|
||||
tryAcquire func(string) (*eventlock.File, error)
|
||||
remove func(string) error
|
||||
marshal func(any, string, string) ([]byte, error)
|
||||
writeFile func(string, []byte, os.FileMode) error
|
||||
chmod func(string, os.FileMode) error
|
||||
rename func(string, string) error
|
||||
sleep func(time.Duration)
|
||||
}
|
||||
|
||||
// NewAttemptStore creates a fail-closed persistent attempt guard rooted in the
|
||||
// identity-specific personal event work directory.
|
||||
func NewAttemptStore(workDir string, options ...AttemptStoreOption) *AttemptStore {
|
||||
store := &AttemptStore{
|
||||
workDir: strings.TrimSpace(workDir),
|
||||
now: time.Now,
|
||||
newID: newAttemptID,
|
||||
readFile: os.ReadFile,
|
||||
mkdirAll: os.MkdirAll,
|
||||
tryAcquire: eventlock.TryAcquire,
|
||||
remove: os.Remove,
|
||||
marshal: json.MarshalIndent,
|
||||
writeFile: os.WriteFile,
|
||||
chmod: os.Chmod,
|
||||
rename: os.Rename,
|
||||
sleep: time.Sleep,
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(store)
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// Fingerprint hashes an endpoint, the existing subscription idempotency key,
|
||||
// and optional profile selectors into a stable non-reversible store key.
|
||||
// Passing the active selector ensures two profiles resolving to the same
|
||||
// corp/user can recover independently after a profile change.
|
||||
func Fingerprint(endpoint, idempotencyKey string, profileSelector ...string) string {
|
||||
endpoint = normalizeAttemptEndpoint(endpoint)
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if endpoint == "" || idempotencyKey == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
writeFingerprintPart(h, "dws-personal-subscription-attempt-v1")
|
||||
writeFingerprintPart(h, endpoint)
|
||||
writeFingerprintPart(h, idempotencyKey)
|
||||
for _, selector := range profileSelector {
|
||||
if selector = strings.TrimSpace(selector); selector != "" {
|
||||
writeFingerprintPart(h, selector)
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
type fingerprintWriter interface {
|
||||
Write([]byte) (int, error)
|
||||
}
|
||||
|
||||
func writeFingerprintPart(w fingerprintWriter, value string) {
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
|
||||
_, _ = w.Write(size[:])
|
||||
_, _ = w.Write([]byte(value))
|
||||
}
|
||||
|
||||
func normalizeAttemptEndpoint(raw string) string {
|
||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// Claim atomically reserves every fingerprint in specs. If any fingerprint is
|
||||
// still suppressed, none are reserved and an AttemptBlockedError is returned.
|
||||
func (s *AttemptStore) Claim(specs []AttemptSpec, lease time.Duration) (*AttemptClaim, error) {
|
||||
normalized, err := normalizeAttemptSpecs(specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lease <= 0 {
|
||||
return nil, errors.New("personal event: subscription attempt lease must be positive")
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptID, err := s.newID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personal event: generate subscription attempt id: %w", err)
|
||||
}
|
||||
attemptID = strings.TrimSpace(attemptID)
|
||||
if attemptID == "" || len(attemptID) > attemptMaxFieldLength {
|
||||
return nil, errors.New("personal event: generated subscription attempt id is invalid")
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
claim := &AttemptClaim{
|
||||
AttemptID: attemptID,
|
||||
Fingerprints: make([]string, 0, len(normalized)),
|
||||
previous: make(map[string]attemptPrevious, len(normalized)),
|
||||
}
|
||||
var blocked *AttemptBlockedError
|
||||
err = s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pruneAttemptRecords(state, now)
|
||||
|
||||
for _, spec := range normalized {
|
||||
record, ok := state.Records[spec.Fingerprint]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if candidate := blockedAttempt(record, now); candidate != nil {
|
||||
blocked = candidate
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, spec := range normalized {
|
||||
previous, present := state.Records[spec.Fingerprint]
|
||||
claim.previous[spec.Fingerprint] = attemptPrevious{
|
||||
record: previous,
|
||||
present: present,
|
||||
}
|
||||
claim.Fingerprints = append(claim.Fingerprints, spec.Fingerprint)
|
||||
record := previous
|
||||
record.Fingerprint = spec.Fingerprint
|
||||
record.EventKey = spec.EventKey
|
||||
record.State = AttemptStateInFlight
|
||||
record.AttemptID = attemptID
|
||||
record.LeaseUntil = now.Add(lease)
|
||||
record.NextAllowedAt = time.Time{}
|
||||
record.Retryability = ""
|
||||
record.ErrorCode = ""
|
||||
record.TraceID = ""
|
||||
state.Records[spec.Fingerprint] = record
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked != nil {
|
||||
return nil, blocked
|
||||
}
|
||||
return claim, nil
|
||||
}
|
||||
|
||||
// CompleteSuccess clears failure history for every item in a successful
|
||||
// claimed batch.
|
||||
func (s *AttemptStore) CompleteSuccess(claim *AttemptClaim) error {
|
||||
if err := validateAttemptClaim(claim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimOwnsAll(state, claim) {
|
||||
return ErrAttemptClaimStale
|
||||
}
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
delete(state.Records, fingerprint)
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteFailure records the failed item, clears successfully completed
|
||||
// items, and restores every unexecuted item to its exact pre-claim state.
|
||||
func (s *AttemptStore) CompleteFailure(
|
||||
claim *AttemptClaim,
|
||||
succeeded []string,
|
||||
failure AttemptFailure,
|
||||
) (AttemptHold, error) {
|
||||
var zero AttemptHold
|
||||
if err := validateAttemptClaim(claim); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
failure.Fingerprint = strings.TrimSpace(failure.Fingerprint)
|
||||
if !containsFingerprint(claim.Fingerprints, failure.Fingerprint) {
|
||||
return zero, errors.New("personal event: failed fingerprint is not part of the attempt claim")
|
||||
}
|
||||
if !validRetryability(failure.Retryability) {
|
||||
return zero, fmt.Errorf("personal event: invalid retryability %q", failure.Retryability)
|
||||
}
|
||||
succeededSet, err := normalizeSucceededFingerprints(claim, succeeded, failure.Fingerprint)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
var hold AttemptHold
|
||||
err = s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimOwnsAll(state, claim) {
|
||||
return ErrAttemptClaimStale
|
||||
}
|
||||
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
switch {
|
||||
case fingerprint == failure.Fingerprint:
|
||||
previous := claim.previous[fingerprint]
|
||||
record := previous.record
|
||||
count := record.FailureCount
|
||||
if record.LastFailureAt.IsZero() || now.Sub(record.LastFailureAt) >= attemptFailureReset {
|
||||
count = 0
|
||||
}
|
||||
count++
|
||||
delay, stateName := attemptFailureDelay(fingerprint, count, failure)
|
||||
record.Fingerprint = fingerprint
|
||||
record.EventKey = state.Records[fingerprint].EventKey
|
||||
record.State = stateName
|
||||
record.AttemptID = ""
|
||||
record.LeaseUntil = time.Time{}
|
||||
record.FailureCount = count
|
||||
record.LastFailureAt = now
|
||||
record.NextAllowedAt = now.Add(delay)
|
||||
record.Retryability = failure.Retryability
|
||||
record.ErrorCode = boundedAttemptField(failure.ErrorCode)
|
||||
record.TraceID = boundedAttemptField(failure.TraceID)
|
||||
state.Records[fingerprint] = record
|
||||
hold = AttemptHold{
|
||||
Fingerprint: fingerprint,
|
||||
State: stateName,
|
||||
Retryability: failure.Retryability,
|
||||
RetryAfter: delay,
|
||||
NextAllowedAt: record.NextAllowedAt,
|
||||
FailureCount: count,
|
||||
}
|
||||
case succeededSet[fingerprint]:
|
||||
delete(state.Records, fingerprint)
|
||||
default:
|
||||
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
|
||||
}
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return hold, nil
|
||||
}
|
||||
|
||||
// Release restores every claimed item without recording a remote failure. It
|
||||
// is intended for local failures before a subscription request is sent.
|
||||
func (s *AttemptStore) Release(claim *AttemptClaim) error {
|
||||
if err := validateAttemptClaim(claim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimOwnsAll(state, claim) {
|
||||
return ErrAttemptClaimStale
|
||||
}
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AttemptStore) validate() error {
|
||||
if s == nil {
|
||||
return errors.New("personal event: nil subscription attempt store")
|
||||
}
|
||||
if s.workDir == "" {
|
||||
return errors.New("personal event: subscription attempt work directory is required")
|
||||
}
|
||||
if s.now == nil || s.newID == nil || s.readFile == nil || s.mkdirAll == nil ||
|
||||
s.tryAcquire == nil || s.remove == nil || s.marshal == nil ||
|
||||
s.writeFile == nil || s.chmod == nil || s.rename == nil || s.sleep == nil {
|
||||
return errors.New("personal event: subscription attempt store is not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAttemptSpecs(specs []AttemptSpec) ([]AttemptSpec, error) {
|
||||
if len(specs) == 0 {
|
||||
return nil, errors.New("personal event: at least one subscription attempt is required")
|
||||
}
|
||||
out := make([]AttemptSpec, 0, len(specs))
|
||||
seen := make(map[string]struct{}, len(specs))
|
||||
for _, spec := range specs {
|
||||
spec.Fingerprint = strings.TrimSpace(spec.Fingerprint)
|
||||
spec.EventKey = strings.TrimSpace(spec.EventKey)
|
||||
if !validAttemptFingerprint(spec.Fingerprint) {
|
||||
return nil, errors.New("personal event: subscription attempt fingerprint is invalid")
|
||||
}
|
||||
if len(spec.EventKey) > attemptMaxFieldLength {
|
||||
return nil, errors.New("personal event: subscription attempt event key is too long")
|
||||
}
|
||||
if _, ok := seen[spec.Fingerprint]; ok {
|
||||
continue
|
||||
}
|
||||
seen[spec.Fingerprint] = struct{}{}
|
||||
out = append(out, spec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeSucceededFingerprints(
|
||||
claim *AttemptClaim,
|
||||
succeeded []string,
|
||||
failed string,
|
||||
) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(succeeded))
|
||||
for _, fingerprint := range succeeded {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if fingerprint == failed {
|
||||
return nil, errors.New("personal event: failed fingerprint cannot also be successful")
|
||||
}
|
||||
if !containsFingerprint(claim.Fingerprints, fingerprint) {
|
||||
return nil, errors.New("personal event: successful fingerprint is not part of the attempt claim")
|
||||
}
|
||||
out[fingerprint] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateAttemptClaim(claim *AttemptClaim) error {
|
||||
if claim == nil || strings.TrimSpace(claim.AttemptID) == "" ||
|
||||
len(claim.Fingerprints) == 0 || claim.previous == nil {
|
||||
return errors.New("personal event: invalid subscription attempt claim")
|
||||
}
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
if !validAttemptFingerprint(fingerprint) {
|
||||
return errors.New("personal event: subscription attempt claim contains an invalid fingerprint")
|
||||
}
|
||||
if _, ok := claim.previous[fingerprint]; !ok {
|
||||
return errors.New("personal event: subscription attempt claim is incomplete")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsFingerprint(fingerprints []string, target string) bool {
|
||||
for _, fingerprint := range fingerprints {
|
||||
if fingerprint == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validAttemptFingerprint(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && len(decoded) == sha256.Size
|
||||
}
|
||||
|
||||
func validRetryability(value Retryability) bool {
|
||||
switch value {
|
||||
case RetryabilityUnknown, RetryabilityRetryable, RetryabilityNonRetryable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func attemptFailureDelay(
|
||||
fingerprint string,
|
||||
count int,
|
||||
failure AttemptFailure,
|
||||
) (time.Duration, AttemptState) {
|
||||
if failure.Retryability == RetryabilityNonRetryable {
|
||||
return attemptTerminalHold, AttemptStateTerminalHold
|
||||
}
|
||||
base := attemptBackoff(count)
|
||||
delay := base + deterministicAttemptJitter(fingerprint, count, base)
|
||||
if failure.RetryAfter > delay {
|
||||
delay = failure.RetryAfter
|
||||
}
|
||||
return delay, AttemptStateCooldown
|
||||
}
|
||||
|
||||
func attemptBackoff(count int) time.Duration {
|
||||
switch count {
|
||||
case 1:
|
||||
return 30 * time.Second
|
||||
case 2:
|
||||
return 2 * time.Minute
|
||||
case 3:
|
||||
return 10 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func deterministicAttemptJitter(fingerprint string, count int, base time.Duration) time.Duration {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", fingerprint, count)))
|
||||
// 0..2000 basis points, inclusive (0%..20%).
|
||||
basisPoints := int(binary.BigEndian.Uint16(sum[:2])) % 2001
|
||||
return time.Duration(int64(base) * int64(basisPoints) / 10000)
|
||||
}
|
||||
|
||||
func boundedAttemptField(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > attemptMaxFieldLength {
|
||||
return value[:attemptMaxFieldLength]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func restoreAttemptPrevious(state *attemptStateFile, fingerprint string, previous attemptPrevious) {
|
||||
if previous.present {
|
||||
state.Records[fingerprint] = previous.record
|
||||
return
|
||||
}
|
||||
delete(state.Records, fingerprint)
|
||||
}
|
||||
|
||||
func claimOwnsAll(state *attemptStateFile, claim *AttemptClaim) bool {
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
record, ok := state.Records[fingerprint]
|
||||
if !ok || record.State != AttemptStateInFlight || record.AttemptID != claim.AttemptID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func blockedAttempt(record attemptRecord, now time.Time) *AttemptBlockedError {
|
||||
var next time.Time
|
||||
retryability := record.Retryability
|
||||
switch record.State {
|
||||
case AttemptStateInFlight:
|
||||
if !record.LeaseUntil.After(now) {
|
||||
return nil
|
||||
}
|
||||
next = record.LeaseUntil
|
||||
retryability = RetryabilityUnknown
|
||||
case AttemptStateCooldown, AttemptStateTerminalHold:
|
||||
if !record.NextAllowedAt.After(now) {
|
||||
return nil
|
||||
}
|
||||
next = record.NextAllowedAt
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &AttemptBlockedError{
|
||||
Fingerprint: record.Fingerprint,
|
||||
EventKey: record.EventKey,
|
||||
State: record.State,
|
||||
Retryability: retryability,
|
||||
RetryAfter: next.Sub(now),
|
||||
NextAllowedAt: next,
|
||||
FailureCount: record.FailureCount,
|
||||
ErrorCode: record.ErrorCode,
|
||||
TraceID: record.TraceID,
|
||||
}
|
||||
}
|
||||
|
||||
type attemptStateFile struct {
|
||||
Version int `json:"version"`
|
||||
Records map[string]attemptRecord `json:"records"`
|
||||
}
|
||||
|
||||
type attemptRecord struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
EventKey string `json:"event_key,omitempty"`
|
||||
State AttemptState `json:"state"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
LeaseUntil time.Time `json:"lease_until,omitempty"`
|
||||
FailureCount int `json:"failure_count,omitempty"`
|
||||
LastFailureAt time.Time `json:"last_failure_at,omitempty"`
|
||||
NextAllowedAt time.Time `json:"next_allowed_at,omitempty"`
|
||||
Retryability Retryability `json:"retryability,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *AttemptStore) load() (*attemptStateFile, error) {
|
||||
path := filepath.Join(s.workDir, AttemptStateFileName)
|
||||
data, err := s.readFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return newAttemptStateFile(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personal event: read subscription attempt state: %w", err)
|
||||
}
|
||||
var state attemptStateFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("personal event: decode subscription attempt state: %w", err)
|
||||
}
|
||||
if state.Version != attemptStateVersion {
|
||||
return nil, fmt.Errorf(
|
||||
"personal event: unsupported subscription attempt state version %d",
|
||||
state.Version,
|
||||
)
|
||||
}
|
||||
if state.Records == nil {
|
||||
return nil, errors.New("personal event: subscription attempt state has no records map")
|
||||
}
|
||||
for fingerprint, record := range state.Records {
|
||||
if err := validateAttemptRecord(fingerprint, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func newAttemptStateFile() *attemptStateFile {
|
||||
return &attemptStateFile{
|
||||
Version: attemptStateVersion,
|
||||
Records: make(map[string]attemptRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func validateAttemptRecord(fingerprint string, record attemptRecord) error {
|
||||
if !validAttemptFingerprint(fingerprint) || fingerprint != record.Fingerprint {
|
||||
return errors.New("personal event: subscription attempt state contains an invalid fingerprint")
|
||||
}
|
||||
if len(record.EventKey) > attemptMaxFieldLength ||
|
||||
len(record.AttemptID) > attemptMaxFieldLength ||
|
||||
len(record.ErrorCode) > attemptMaxFieldLength ||
|
||||
len(record.TraceID) > attemptMaxFieldLength {
|
||||
return errors.New("personal event: subscription attempt state contains an oversized field")
|
||||
}
|
||||
if record.FailureCount < 0 {
|
||||
return errors.New("personal event: subscription attempt state contains a negative failure count")
|
||||
}
|
||||
if record.Retryability != "" && !validRetryability(record.Retryability) {
|
||||
return errors.New("personal event: subscription attempt state contains invalid retryability")
|
||||
}
|
||||
switch record.State {
|
||||
case AttemptStateInFlight:
|
||||
if record.AttemptID == "" || record.LeaseUntil.IsZero() {
|
||||
return errors.New("personal event: in-flight subscription attempt state is incomplete")
|
||||
}
|
||||
case AttemptStateCooldown:
|
||||
if record.NextAllowedAt.IsZero() ||
|
||||
(record.Retryability != RetryabilityUnknown &&
|
||||
record.Retryability != RetryabilityRetryable) {
|
||||
return errors.New("personal event: cooldown subscription attempt state is invalid")
|
||||
}
|
||||
case AttemptStateTerminalHold:
|
||||
if record.NextAllowedAt.IsZero() ||
|
||||
record.Retryability != RetryabilityNonRetryable {
|
||||
return errors.New("personal event: terminal subscription attempt state is invalid")
|
||||
}
|
||||
default:
|
||||
return errors.New("personal event: subscription attempt state contains an invalid state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneAttemptRecords(state *attemptStateFile, now time.Time) {
|
||||
for fingerprint, record := range state.Records {
|
||||
switch {
|
||||
case !record.LastFailureAt.IsZero() &&
|
||||
!now.Before(record.LastFailureAt) &&
|
||||
now.Sub(record.LastFailureAt) >= attemptFailureReset &&
|
||||
((record.State == AttemptStateInFlight && !record.LeaseUntil.After(now)) ||
|
||||
((record.State == AttemptStateCooldown ||
|
||||
record.State == AttemptStateTerminalHold) &&
|
||||
!record.NextAllowedAt.After(now))):
|
||||
delete(state.Records, fingerprint)
|
||||
case record.State == AttemptStateInFlight &&
|
||||
record.LastFailureAt.IsZero() &&
|
||||
!record.LeaseUntil.IsZero() &&
|
||||
!now.Before(record.LeaseUntil) &&
|
||||
now.Sub(record.LeaseUntil) >= attemptFailureReset:
|
||||
delete(state.Records, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AttemptStore) write(state *attemptStateFile) error {
|
||||
if state == nil {
|
||||
return errors.New("personal event: nil subscription attempt state")
|
||||
}
|
||||
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(s.workDir, AttemptStateFileName)
|
||||
if len(state.Records) == 0 {
|
||||
if err := s.remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("personal event: remove empty subscription attempt state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(state.Records))
|
||||
for fingerprint := range state.Records {
|
||||
keys = append(keys, fingerprint)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ordered := make(map[string]attemptRecord, len(keys))
|
||||
for _, fingerprint := range keys {
|
||||
ordered[fingerprint] = state.Records[fingerprint]
|
||||
}
|
||||
payload := attemptStateFile{Version: attemptStateVersion, Records: ordered}
|
||||
data, err := s.marshal(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("personal event: encode subscription attempt state: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmp := path + ".tmp"
|
||||
if err := s.writeFile(tmp, data, config.FilePerm); err != nil {
|
||||
return fmt.Errorf("personal event: write subscription attempt state: %w", err)
|
||||
}
|
||||
if err := s.chmod(tmp, config.FilePerm); err != nil {
|
||||
_ = s.remove(tmp)
|
||||
return fmt.Errorf("personal event: secure subscription attempt state: %w", err)
|
||||
}
|
||||
if err := s.rename(tmp, path); err != nil {
|
||||
_ = s.remove(tmp)
|
||||
return fmt.Errorf("personal event: replace subscription attempt state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AttemptStore) withLock(fn func() error) error {
|
||||
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
|
||||
}
|
||||
lockPath := filepath.Join(s.workDir, AttemptStateLockFileName)
|
||||
deadline := s.now().Add(attemptLockWaitTimeout)
|
||||
for {
|
||||
held, err := s.tryAcquire(lockPath)
|
||||
if err == nil {
|
||||
if err := s.chmod(lockPath, config.FilePerm); err != nil {
|
||||
_ = held.Close()
|
||||
return fmt.Errorf("personal event: secure subscription attempt lock: %w", err)
|
||||
}
|
||||
defer held.Close()
|
||||
return fn()
|
||||
}
|
||||
if !errors.Is(err, eventlock.ErrBusy) {
|
||||
return fmt.Errorf("personal event: acquire subscription attempt lock: %w", err)
|
||||
}
|
||||
remaining := deadline.Sub(s.now())
|
||||
if remaining <= 0 {
|
||||
return fmt.Errorf(
|
||||
"personal event: timed out waiting for subscription attempt lock after %s",
|
||||
attemptLockWaitTimeout,
|
||||
)
|
||||
}
|
||||
delay := attemptLockRetryDelay
|
||||
if remaining < delay {
|
||||
delay = remaining
|
||||
}
|
||||
s.sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
func newAttemptID() (string, error) {
|
||||
return rand.Text(), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,6 +68,8 @@ type Client struct {
|
||||
HTTPClient *http.Client
|
||||
Identity Identity
|
||||
AccessTokenProvider func(context.Context) (string, error)
|
||||
ClientVersion string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type CreateSubscriptionRequest struct {
|
||||
@@ -141,10 +144,14 @@ func (s dwsSubscription) toSubscription() Subscription {
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
@@ -178,18 +185,6 @@ func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionR
|
||||
}
|
||||
var sub Subscription
|
||||
if err := c.do(ctx, http.MethodPost, "/subscription/user", nil, c.buildCreateRequest(req), &sub); err != nil {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if subID, ok := apiErr.Details["subscribe_id"].(string); ok && subID != "" {
|
||||
return &Subscription{
|
||||
SubscribeID: subID,
|
||||
EventKey: req.EventKey,
|
||||
RuleType: req.RuleType,
|
||||
Status: "active",
|
||||
SourceID: c.Identity.SourceID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if sub.EventKey == "" {
|
||||
@@ -378,14 +373,20 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
return fmt.Errorf("personal event: read response: %w", err)
|
||||
}
|
||||
responseLog := sanitizeLogPayload(data)
|
||||
responseID := firstNonEmpty(responseRequestID(data), responseHeaderRequestID(resp.Header))
|
||||
responseTrace := firstNonEmpty(responseBodyTraceID(data), responseTraceID(resp.Header))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if apiErr := decodeAPIError(data); apiErr != nil {
|
||||
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, responseRequestID(data))
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), apiErr)
|
||||
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, responseID, responseTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
|
||||
return fmt.Errorf("personal event: HTTP %d", resp.StatusCode)
|
||||
apiErr := withHTTPResponseDetails(&APIError{
|
||||
Code: fmt.Sprintf("HTTP_%d", resp.StatusCode),
|
||||
Message: firstNonEmpty(http.StatusText(resp.StatusCode), "HTTP request failed"),
|
||||
}, method, path, resp, responseID, responseTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil)
|
||||
@@ -393,23 +394,25 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
}
|
||||
var env responseEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") {
|
||||
envID := firstNonEmpty(env.requestID(), responseID)
|
||||
envTrace := firstNonEmpty(env.traceID(), responseTrace)
|
||||
if env.Success == nil {
|
||||
if apiErr := env.apiError(); apiErr != nil {
|
||||
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
|
||||
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
}
|
||||
if env.Success != nil && !*env.Success {
|
||||
if apiErr := env.apiError(); apiErr != nil {
|
||||
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
|
||||
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
|
||||
return errors.New("personal event: request failed")
|
||||
}
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
|
||||
if env.Result == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -419,10 +422,10 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
return decodeResult(env.Result, out)
|
||||
}
|
||||
if out == nil {
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
|
||||
return nil
|
||||
}
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
@@ -451,25 +454,58 @@ func (c *Client) decorate(req *http.Request, accessToken string) {
|
||||
if c.Identity.CorpID != "" {
|
||||
req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID)
|
||||
}
|
||||
if version := strings.TrimSpace(c.ClientVersion); version != "" {
|
||||
req.Header.Set("X-Cli-Version", version)
|
||||
}
|
||||
if userAgent := strings.TrimSpace(c.UserAgent); userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
type responseEnvelope struct {
|
||||
Success *bool `json:"success"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
RequestID2 string `json:"requestId,omitempty"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *APIError `json:"error"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Success *bool `json:"success"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
RequestID2 string `json:"requestId,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
TraceID2 string `json:"traceId,omitempty"`
|
||||
Arguments []json.RawMessage `json:"arguments,omitempty"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *APIError `json:"error"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
|
||||
RetryAfterSecondsCamel *int64 `json:"retryAfterSeconds,omitempty"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
|
||||
NextRetryAtCamel *time.Time `json:"nextRetryAt,omitempty"`
|
||||
}
|
||||
|
||||
func (e responseEnvelope) apiError() *APIError {
|
||||
if e.Error != nil {
|
||||
if e.Error.Retryable == nil {
|
||||
e.Error.Retryable = e.Retryable
|
||||
}
|
||||
if e.Error.RetryAfterSeconds == nil {
|
||||
e.Error.RetryAfterSeconds = firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel)
|
||||
}
|
||||
if e.Error.NextRetryAt == nil {
|
||||
e.Error.NextRetryAt = firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel)
|
||||
}
|
||||
if e.Error.TraceID == "" {
|
||||
e.Error.TraceID = e.traceID()
|
||||
}
|
||||
return e.Error
|
||||
}
|
||||
if e.ErrorCode != "" || e.ErrorMsg != "" {
|
||||
return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg}
|
||||
return &APIError{
|
||||
Code: e.ErrorCode,
|
||||
Message: e.ErrorMsg,
|
||||
Retryable: e.Retryable,
|
||||
RetryAfterSeconds: firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel),
|
||||
NextRetryAt: firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel),
|
||||
TraceID: e.traceID(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -478,6 +514,10 @@ func (e responseEnvelope) requestID() string {
|
||||
return firstNonEmpty(e.RequestID, e.RequestID2)
|
||||
}
|
||||
|
||||
func (e responseEnvelope) traceID() string {
|
||||
return firstNonEmpty(e.TraceID, e.TraceID2, firstArgumentString(e.Arguments))
|
||||
}
|
||||
|
||||
func decodeResult(raw json.RawMessage, out any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
@@ -511,11 +551,8 @@ func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool) {
|
||||
func decodeAPIError(data []byte) *APIError {
|
||||
var env responseEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
if env.Error != nil {
|
||||
return env.Error
|
||||
}
|
||||
if env.ErrorCode != "" || env.ErrorMsg != "" {
|
||||
return &APIError{Code: env.ErrorCode, Message: env.ErrorMsg}
|
||||
if apiErr := env.apiError(); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
}
|
||||
var apiErr APIError
|
||||
@@ -533,12 +570,24 @@ func responseRequestID(data []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func responseBodyTraceID(data []byte) string {
|
||||
var env responseEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
if env.Error != nil && strings.TrimSpace(env.Error.TraceID) != "" {
|
||||
return strings.TrimSpace(env.Error.TraceID)
|
||||
}
|
||||
return env.traceID()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError {
|
||||
if apiErr == nil {
|
||||
return nil
|
||||
}
|
||||
apiErr.HTTPStatus = status
|
||||
if apiErr.Details == nil {
|
||||
apiErr.Details = make(map[string]any, 4)
|
||||
apiErr.Details = make(map[string]any, 8)
|
||||
}
|
||||
apiErr.Details["method"] = method
|
||||
apiErr.Details["path"] = path
|
||||
@@ -549,6 +598,91 @@ func withRequestDetails(apiErr *APIError, method, path string, status int, reque
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func withHTTPResponseDetails(apiErr *APIError, method, path string, resp *http.Response, requestID, traceID string) *APIError {
|
||||
if apiErr == nil {
|
||||
return nil
|
||||
}
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
traceID = firstNonEmpty(apiErr.TraceID, traceID, responseTraceID(resp.Header))
|
||||
}
|
||||
apiErr = withRequestDetails(apiErr, method, path, status, requestID)
|
||||
if apiErr.TraceID == "" {
|
||||
apiErr.TraceID = strings.TrimSpace(traceID)
|
||||
}
|
||||
if resp == nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||
if retryAfter == "" {
|
||||
return apiErr
|
||||
}
|
||||
apiErr.Details["retry_after"] = retryAfter
|
||||
if apiErr.RetryAfterSeconds == nil && apiErr.NextRetryAt == nil {
|
||||
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 {
|
||||
apiErr.RetryAfterSeconds = &seconds
|
||||
} else if next, err := http.ParseTime(retryAfter); err == nil {
|
||||
next = next.UTC()
|
||||
apiErr.NextRetryAt = &next
|
||||
}
|
||||
}
|
||||
if apiErr.RetryAfterSeconds != nil {
|
||||
apiErr.Details["retry_after_seconds"] = *apiErr.RetryAfterSeconds
|
||||
}
|
||||
if apiErr.NextRetryAt != nil {
|
||||
apiErr.Details["next_retry_at"] = apiErr.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func responseTraceID(headers http.Header) string {
|
||||
for _, key := range []string{"X-Trace-Id", "X-Dingtalk-Trace-Id"} {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func responseHeaderRequestID(headers http.Header) string {
|
||||
return strings.TrimSpace(headers.Get("X-Request-Id"))
|
||||
}
|
||||
|
||||
func firstArgumentString(arguments []json.RawMessage) string {
|
||||
if len(arguments) == 0 {
|
||||
return ""
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(arguments[0], &value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func firstInt64Pointer(values ...*int64) *int64 {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstTimePointer(values ...*time.Time) *time.Time {
|
||||
for _, value := range values {
|
||||
if value == nil || value.IsZero() {
|
||||
continue
|
||||
}
|
||||
copy := value.UTC()
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) {
|
||||
attrs := []any{
|
||||
"method", method,
|
||||
@@ -568,6 +702,9 @@ func logControlRequest(message, method, path string, q url.Values, status int, r
|
||||
attrs = append(attrs, "request_id", requestID)
|
||||
}
|
||||
if apiErr != nil {
|
||||
if apiErr.TraceID != "" {
|
||||
attrs = append(attrs, "trace_id", apiErr.TraceID)
|
||||
}
|
||||
if apiErr.Code != "" {
|
||||
attrs = append(attrs, "error_code", apiErr.Code)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
|
||||
@@ -282,6 +283,12 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
|
||||
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
|
||||
t.Fatalf("details = %#v", apiErr.Details)
|
||||
}
|
||||
if apiErr.Retryable != nil {
|
||||
t.Fatalf("retryable = %v, want unknown", *apiErr.Retryable)
|
||||
}
|
||||
if apiErr.HTTPStatus != http.StatusOK || apiErr.TraceID != "" {
|
||||
t.Fatalf("HTTP diagnostics = status %d trace %q", apiErr.HTTPStatus, apiErr.TraceID)
|
||||
}
|
||||
out := logs.String()
|
||||
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
|
||||
if !strings.Contains(out, want) {
|
||||
@@ -290,6 +297,278 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientBusinessErrorPreservesRetryContractAndClientHeaders(t *testing.T) {
|
||||
nextRetryAt := "2026-07-30T04:05:06Z"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Cli-Version"); got != "1.2.3" {
|
||||
t.Fatalf("X-Cli-Version = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "dws-cli/1.2.3" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"errorCode": "RATE_LIMITED",
|
||||
"errorMsg": "slow down",
|
||||
"retryable": false,
|
||||
"retryAfterSeconds": 30,
|
||||
"nextRetryAt": nextRetryAt,
|
||||
"arguments": []string{"portal-trace-1"},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
c.ClientVersion = "1.2.3"
|
||||
c.UserAgent = "dws-cli/1.2.3"
|
||||
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %v, want *APIError", err)
|
||||
}
|
||||
if apiErr.Retryable == nil || *apiErr.Retryable {
|
||||
t.Fatalf("retryable = %v, want explicit false", apiErr.Retryable)
|
||||
}
|
||||
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 30 {
|
||||
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
|
||||
}
|
||||
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != nextRetryAt {
|
||||
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
|
||||
}
|
||||
if apiErr.TraceID != "portal-trace-1" || apiErr.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("HTTP diagnostics = trace %q status %d", apiErr.TraceID, apiErr.HTTPStatus)
|
||||
}
|
||||
if _, exists := apiErr.Details["request_id"]; exists {
|
||||
t.Fatalf("portal trace was mislabeled as request_id: %#v", apiErr.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientErrorDiagnosticIdentityPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
headers http.Header
|
||||
wantTraceID string
|
||||
wantRequestID string
|
||||
}{
|
||||
{
|
||||
name: "nested trace wins",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"requestId": "body-request",
|
||||
"traceId": "top-trace",
|
||||
"arguments": []string{"portal-trace"},
|
||||
"error": map[string]any{
|
||||
"code": "SYSTEM_ERROR",
|
||||
"message": "failed",
|
||||
"trace_id": "nested-trace",
|
||||
},
|
||||
},
|
||||
headers: http.Header{
|
||||
"X-Trace-Id": []string{"header-trace"},
|
||||
"X-Request-Id": []string{"header-request"},
|
||||
},
|
||||
wantTraceID: "nested-trace",
|
||||
wantRequestID: "body-request",
|
||||
},
|
||||
{
|
||||
name: "top-level trace wins over arguments and header",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"requestId": "body-request",
|
||||
"traceId": "top-trace",
|
||||
"arguments": []string{"portal-trace"},
|
||||
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
|
||||
},
|
||||
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
|
||||
wantTraceID: "top-trace",
|
||||
wantRequestID: "body-request",
|
||||
},
|
||||
{
|
||||
name: "portal argument wins over header",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"requestId": "body-request",
|
||||
"arguments": []string{"portal-trace"},
|
||||
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
|
||||
},
|
||||
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
|
||||
wantTraceID: "portal-trace",
|
||||
wantRequestID: "body-request",
|
||||
},
|
||||
{
|
||||
name: "headers are independent fallbacks",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
|
||||
},
|
||||
headers: http.Header{
|
||||
"X-Trace-Id": []string{"header-trace"},
|
||||
"X-Request-Id": []string{"header-request"},
|
||||
},
|
||||
wantTraceID: "header-trace",
|
||||
wantRequestID: "header-request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
for key, values := range test.headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(test.body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
})
|
||||
_, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %v, want *APIError", err)
|
||||
}
|
||||
if apiErr.TraceID != test.wantTraceID {
|
||||
t.Fatalf("trace_id = %q, want %q", apiErr.TraceID, test.wantTraceID)
|
||||
}
|
||||
if got, _ := apiErr.Details["request_id"].(string); got != test.wantRequestID {
|
||||
t.Fatalf("request_id = %q, want %q; details=%#v", got, test.wantRequestID, apiErr.Details)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientHTTPErrorPreservesRetryAfterAndHeaderTrace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
retryAfter string
|
||||
wantSeconds int64
|
||||
wantNextRetryAt string
|
||||
}{
|
||||
{name: "delta seconds", retryAfter: "45", wantSeconds: 45},
|
||||
{name: "http date", retryAfter: "Thu, 30 Jul 2026 04:05:06 GMT", wantNextRetryAt: "2026-07-30T04:05:06Z"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.Header().Set("Retry-After", tt.retryAfter)
|
||||
w.Header().Set("X-Trace-Id", "header-trace")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %v, want *APIError", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want one-shot client", calls)
|
||||
}
|
||||
if apiErr.Code != "HTTP_429" || apiErr.HTTPStatus != http.StatusTooManyRequests || apiErr.TraceID != "header-trace" {
|
||||
t.Fatalf("API error = %#v", apiErr)
|
||||
}
|
||||
if apiErr.Retryable != nil {
|
||||
t.Fatalf("retryable = %v, want unknown", apiErr.Retryable)
|
||||
}
|
||||
if tt.wantSeconds > 0 {
|
||||
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != tt.wantSeconds {
|
||||
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
|
||||
}
|
||||
}
|
||||
if tt.wantNextRetryAt != "" {
|
||||
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != tt.wantNextRetryAt {
|
||||
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientErrorDiagnosticHelpersHandleNilAndMalformedInputs(t *testing.T) {
|
||||
if got := withHTTPResponseDetails(nil, http.MethodPost, "/subscription/user", nil, "request-1", "trace-1"); got != nil {
|
||||
t.Fatalf("withHTTPResponseDetails(nil) = %#v, want nil", got)
|
||||
}
|
||||
|
||||
apiErr := &APIError{Code: "SYSTEM_ERROR", Message: "failed"}
|
||||
got := withHTTPResponseDetails(apiErr, http.MethodPost, "/subscription/user", nil, "request-1", " trace-1 ")
|
||||
if got != apiErr {
|
||||
t.Fatalf("withHTTPResponseDetails() returned a different error: got %#v, want %#v", got, apiErr)
|
||||
}
|
||||
if got.HTTPStatus != 0 || got.TraceID != "trace-1" {
|
||||
t.Fatalf("diagnostics = status %d trace %q", got.HTTPStatus, got.TraceID)
|
||||
}
|
||||
if got.Details["request_id"] != "request-1" {
|
||||
t.Fatalf("details = %#v", got.Details)
|
||||
}
|
||||
|
||||
if got := firstArgumentString([]json.RawMessage{json.RawMessage(`{`)}); got != "" {
|
||||
t.Fatalf("firstArgumentString(malformed) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientCreateSubscriptionRejectsErrorsWithSubscribeID(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
"SYSTEM_ERROR",
|
||||
"DUP",
|
||||
"DUPLICATE_SUBSCRIPTION",
|
||||
"SUBSCRIPTION_ALREADY_EXISTS",
|
||||
"SUBSCRIPTION_ALREADY_EXIST",
|
||||
"ALREADY_SUBSCRIBED",
|
||||
"DUPLICATE",
|
||||
} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": "registration failed",
|
||||
"details": map[string]any{"subscribe_id": "pending-sub"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
if err == nil || sub != nil {
|
||||
t.Fatalf("subscription = %#v, error = %v; API errors must stay failures", sub, err)
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != code {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-DWS-Corp-Id"); got != "" {
|
||||
|
||||
@@ -219,8 +219,8 @@ func TestCrossPlatformCoveragePersonalClientHelpersAndOperations(t *testing.T) {
|
||||
}
|
||||
base.HTTPClient = personalHTTPClient(400, `{"error":{"code":"DUP","details":{"subscribe_id":"existing"}}}`)
|
||||
created, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{EventKey: "e", RuleType: "r"})
|
||||
if err != nil || created.SubscribeID != "existing" {
|
||||
t.Fatalf("duplicate create = %#v, %v", created, err)
|
||||
if err == nil || created != nil {
|
||||
t.Fatalf("duplicate error create = %#v, %v", created, err)
|
||||
}
|
||||
request := base.buildCreateRequest(CreateSubscriptionRequest{
|
||||
EventKey: "e", RuleType: "r", Name: "n", RuleParam: map[string]any{"bad": make(chan int)},
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// 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.
|
||||
|
||||
// Command cmd_param_aliases reduces the reviewed parameter concept dictionary
|
||||
// (internal/cli/param_concepts.json) against the live Cobra command tree and
|
||||
// writes the deterministic per-command alias table to
|
||||
// internal/cli/param_aliases_generated.go.
|
||||
//
|
||||
// The reduction algorithm lives in package cli (cli.ReduceParamAliases) so the
|
||||
// build-time intersection and the runtime normalizer share exactly one
|
||||
// implementation. This command is a thin, deterministic serializer: it never
|
||||
// invents identity, and it fails closed on any un-whitelisted co-occurrence,
|
||||
// missing override path, or non-real alias target.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
|
||||
)
|
||||
|
||||
const defaultOutput = "internal/cli/param_aliases_generated.go"
|
||||
|
||||
var (
|
||||
newParamAliasRoot = app.NewSchemaSourceRootCommand
|
||||
reduceParamAliasEntries = cli.ReduceParamAliases
|
||||
formatParamAliasSource = format.Source
|
||||
writeParamAliasFile = os.WriteFile
|
||||
exitParamAliasProcess = os.Exit
|
||||
)
|
||||
|
||||
func main() {
|
||||
var rootPath string
|
||||
var outputPath string
|
||||
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
|
||||
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated parameter-alias table")
|
||||
flag.Parse()
|
||||
|
||||
if err := generateParamAliases(rootPath, outputPath); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func generateParamAliases(rootPath, outputPath string) error {
|
||||
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := reduceParamAliasEntries(newParamAliasRoot())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := renderParamAliases(entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeParamAliasFile(outputPath, source, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated parameter aliases: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stderr, "generated parameter aliases: output=%s commands=%d\n", outputPath, len(entries))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutputIsolation(rootPath, outputPath string) error {
|
||||
inputs := []outputguard.Input{
|
||||
{Name: "reviewed parameter concept dictionary", Path: "internal/cli/param_concepts.json"},
|
||||
{Name: "reviewed parameter concept schema", Path: "internal/cli/param_concepts.schema.json"},
|
||||
}
|
||||
target := outputguard.Target{Name: "--output", Path: outputPath}
|
||||
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
|
||||
return err
|
||||
}
|
||||
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
|
||||
}
|
||||
|
||||
// renderParamAliases serializes the reduced entries into gofmt-stable Go
|
||||
// source. Entries and every map/slice are emitted in sorted order so
|
||||
// consecutive generations are byte-identical.
|
||||
func renderParamAliases(entries []cli.ParamAliasEntry) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("// Copyright 2026 Alibaba Group\n")
|
||||
b.WriteString("// Licensed under the Apache License, Version 2.0 (the \"License\");\n\n")
|
||||
b.WriteString("// Code generated by cmd_param_aliases; DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: internal/cli/param_concepts.json reduced against the live Cobra tree.\n")
|
||||
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
|
||||
b.WriteString("package cli\n\n")
|
||||
b.WriteString("// generatedParamAliases is the per-command parameter-alias table reduced from\n")
|
||||
b.WriteString("// the reviewed concept dictionary. Each entry binds one runnable Cobra leaf.\n")
|
||||
b.WriteString("var generatedParamAliases = []ParamAliasEntry{\n")
|
||||
|
||||
for _, entry := range entries {
|
||||
b.WriteString("\t{\n")
|
||||
fmt.Fprintf(&b, "\t\tCLIPath: %q,\n", entry.CLIPath)
|
||||
if len(entry.Aliases) > 0 {
|
||||
b.WriteString("\t\tAliases: map[string]string{\n")
|
||||
for _, key := range sortedKeys(entry.Aliases) {
|
||||
fmt.Fprintf(&b, "\t\t\t%q: %q,\n", key, entry.Aliases[key])
|
||||
}
|
||||
b.WriteString("\t\t},\n")
|
||||
}
|
||||
if len(entry.Blocked) > 0 {
|
||||
fmt.Fprintf(&b, "\t\tBlocked: %s,\n", renderStringSlice(entry.Blocked))
|
||||
}
|
||||
if len(entry.Ambiguous) > 0 {
|
||||
fmt.Fprintf(&b, "\t\tAmbiguous: %s,\n", renderStringSlice(entry.Ambiguous))
|
||||
}
|
||||
b.WriteString("\t},\n")
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
b.WriteString("\nfunc loadGeneratedParamAliases() []ParamAliasEntry {\n")
|
||||
b.WriteString("\treturn generatedParamAliases\n")
|
||||
b.WriteString("}\n")
|
||||
|
||||
formatted, err := formatParamAliasSource(b.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format generated parameter aliases: %w", err)
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
func renderStringSlice(values []string) string {
|
||||
quoted := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
quoted[i] = fmt.Sprintf("%q", v)
|
||||
}
|
||||
return "[]string{" + strings.Join(quoted, ", ") + "}"
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
exitParamAliasProcess(1)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParamAliasGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
|
||||
got := reflect.ValueOf(newParamAliasRoot).Pointer()
|
||||
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
|
||||
if got != want {
|
||||
t.Fatal("parameter alias generator must use the distribution-owned Schema source command tree")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveParamAliasGeneratorGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
oldArgs, oldFlags := os.Args, flag.CommandLine
|
||||
oldRoot := newParamAliasRoot
|
||||
oldReduce := reduceParamAliasEntries
|
||||
oldFormat := formatParamAliasSource
|
||||
oldWrite := writeParamAliasFile
|
||||
oldExit := exitParamAliasProcess
|
||||
t.Cleanup(func() {
|
||||
os.Args, flag.CommandLine = oldArgs, oldFlags
|
||||
newParamAliasRoot = oldRoot
|
||||
reduceParamAliasEntries = oldReduce
|
||||
formatParamAliasSource = oldFormat
|
||||
writeParamAliasFile = oldWrite
|
||||
exitParamAliasProcess = oldExit
|
||||
})
|
||||
}
|
||||
|
||||
func repositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParamAliasMainWritesGeneratedFile(t *testing.T) {
|
||||
preserveParamAliasGeneratorGlobals(t)
|
||||
output := filepath.Join(t.TempDir(), "param_aliases_generated.go")
|
||||
flag.CommandLine = flag.NewFlagSet("param-alias-success", flag.ContinueOnError)
|
||||
os.Args = []string{"cmd_param_aliases", "-root", repositoryRoot(t), "-output", output}
|
||||
newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) {
|
||||
return []cli.ParamAliasEntry{{CLIPath: "demo get", Aliases: map[string]string{"uid": "user"}}}, nil
|
||||
}
|
||||
|
||||
main()
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "func loadGeneratedParamAliases() []ParamAliasEntry") ||
|
||||
!strings.Contains(string(data), `"uid": "user"`) {
|
||||
t.Fatalf("generated source missing runtime table:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParamAliasMainReportsFailure(t *testing.T) {
|
||||
preserveParamAliasGeneratorGlobals(t)
|
||||
flag.CommandLine = flag.NewFlagSet("param-alias-failure", flag.ContinueOnError)
|
||||
os.Args = []string{
|
||||
"cmd_param_aliases",
|
||||
"-root", repositoryRoot(t),
|
||||
"-output", filepath.Join(repositoryRoot(t), "internal", "cli", "param_concepts.json"),
|
||||
}
|
||||
exitParamAliasProcess = func(code int) { panic(code) }
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != 1 {
|
||||
t.Fatalf("main exit = %#v, want 1", recovered)
|
||||
}
|
||||
}()
|
||||
main()
|
||||
}
|
||||
|
||||
func TestGenerateParamAliasesFailurePaths(t *testing.T) {
|
||||
preserveParamAliasGeneratorGlobals(t)
|
||||
root := repositoryRoot(t)
|
||||
output := filepath.Join(t.TempDir(), "param_aliases_generated.go")
|
||||
newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
|
||||
if err := generateParamAliases(root, filepath.Join(root, "internal", "cli", "param_concepts.json")); err == nil {
|
||||
t.Fatal("generateParamAliases accepted an output that overlaps a reviewed input")
|
||||
}
|
||||
|
||||
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) {
|
||||
return nil, errors.New("reduce")
|
||||
}
|
||||
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
|
||||
t.Fatalf("reduction error = %v", err)
|
||||
}
|
||||
|
||||
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { return nil, nil }
|
||||
formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
|
||||
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
|
||||
t.Fatalf("format error = %v", err)
|
||||
}
|
||||
|
||||
formatParamAliasSource = format.Source
|
||||
writeParamAliasFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
|
||||
t.Fatalf("write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParamAliasOutputIsolation(t *testing.T) {
|
||||
root := repositoryRoot(t)
|
||||
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "aliases.go")); err != nil {
|
||||
t.Fatalf("temporary output rejected: %v", err)
|
||||
}
|
||||
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
|
||||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
|
||||
t.Fatalf("non-canonical repository output error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderParamAliasesDeterministicShape(t *testing.T) {
|
||||
preserveParamAliasGeneratorGlobals(t)
|
||||
entries := []cli.ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "demo get",
|
||||
Aliases: map[string]string{"z-name": "name", "a-name": "name"},
|
||||
Blocked: []string{"page", "count"},
|
||||
Ambiguous: []string{"user-id"},
|
||||
},
|
||||
{CLIPath: "demo empty"},
|
||||
}
|
||||
data, err := renderParamAliases(entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if strings.Index(text, `"a-name": "name"`) > strings.Index(text, `"z-name": "name"`) {
|
||||
t.Fatalf("alias keys are not sorted:\n%s", text)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"func loadGeneratedParamAliases() []ParamAliasEntry",
|
||||
`Blocked: []string{"page", "count"}`,
|
||||
`Ambiguous: []string{"user-id"}`,
|
||||
`CLIPath: "demo empty"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("generated source missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
empty, err := renderParamAliases(nil)
|
||||
if err != nil || !strings.Contains(string(empty), "var generatedParamAliases = []ParamAliasEntry{") {
|
||||
t.Fatalf("empty render = %q, error = %v", empty, err)
|
||||
}
|
||||
if got := renderStringSlice(nil); got != "[]string{}" {
|
||||
t.Fatalf("renderStringSlice(nil) = %q", got)
|
||||
}
|
||||
if got := sortedKeys(nil); len(got) != 0 {
|
||||
t.Fatalf("sortedKeys(nil) = %v", got)
|
||||
}
|
||||
if got := sortedKeys(map[string]string{"b": "2", "a": "1"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||
t.Fatalf("sortedKeys() = %v", got)
|
||||
}
|
||||
|
||||
formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
|
||||
if _, err := renderParamAliases(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
|
||||
t.Fatalf("formatter error = %v", err)
|
||||
}
|
||||
}
|
||||
+187
-4
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
@@ -289,6 +290,47 @@ func normalizeAtPlaceholders(text string, ids []string, wrapAngle bool) string {
|
||||
return text
|
||||
}
|
||||
|
||||
// NormalizeMessageMentions applies the placeholder convention required by the
|
||||
// selected sender identity and ensures a declared @all is present in the body.
|
||||
// Current-user messages use <@id>/<@all>; bot and webhook messages use
|
||||
// @id/@mobile/@all.
|
||||
func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) string {
|
||||
text = normalizeAtPlaceholders(text, ids, wrapAngle)
|
||||
allPlaceholder := "@all"
|
||||
if wrapAngle {
|
||||
text = normalizeAtPlaceholders(text, []string{"all"}, true)
|
||||
allPlaceholder = "<@all>"
|
||||
} else {
|
||||
text = strings.ReplaceAll(text, "<@all>", "@all")
|
||||
}
|
||||
if atAll && !containsMessageMention(text, allPlaceholder) {
|
||||
text = allPlaceholder + " " + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func containsMessageMention(text, placeholder string) bool {
|
||||
if strings.HasPrefix(placeholder, "<") {
|
||||
return strings.Contains(text, placeholder)
|
||||
}
|
||||
for searchFrom := 0; ; {
|
||||
offset := strings.Index(text[searchFrom:], placeholder)
|
||||
if offset < 0 {
|
||||
return false
|
||||
}
|
||||
end := searchFrom + offset + len(placeholder)
|
||||
if end == len(text) {
|
||||
return true
|
||||
}
|
||||
next, _ := utf8.DecodeRuneInString(text[end:])
|
||||
if !unicode.IsLetter(next) && !unicode.IsDigit(next) &&
|
||||
next != '_' && next != '-' {
|
||||
return true
|
||||
}
|
||||
searchFrom = end
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
|
||||
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
|
||||
if err != nil {
|
||||
@@ -1582,8 +1624,23 @@ func newChatCommand() *cobra.Command {
|
||||
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
|
||||
dentryId, spaceId, fileName, fileType, filePath, fileSize)
|
||||
}
|
||||
case "location":
|
||||
latitude, _ := cmd.Flags().GetString("latitude")
|
||||
longitude, _ := cmd.Flags().GetString("longitude")
|
||||
locationName, _ := cmd.Flags().GetString("location-name")
|
||||
mapThumbnailUrl, _ := cmd.Flags().GetString("map-thumbnail-url")
|
||||
if latitude == "" || longitude == "" || locationName == "" || mapThumbnailUrl == "" {
|
||||
return fmt.Errorf("--latitude, --longitude, --location-name, --map-thumbnail-url are all required for msgType=location")
|
||||
}
|
||||
contentJSON = fmt.Sprintf(`{"locationName":"%s","longitude":"%s","latitude":"%s","mapThumbnailUrl":"%s"}`, locationName, longitude, latitude, mapThumbnailUrl)
|
||||
case "profile":
|
||||
contactID, _ := cmd.Flags().GetString("contact-id")
|
||||
if contactID == "" {
|
||||
return fmt.Errorf("--contact-id is required for msgType=profile")
|
||||
}
|
||||
contentJSON = fmt.Sprintf(`{"openDingTalkId":"%s"}`, contactID)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video)", msgType)
|
||||
return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video, location, profile)", msgType)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
@@ -2184,8 +2241,10 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
if cmd.Flags().Changed("only-robot") {
|
||||
toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot")
|
||||
} else if cmd.Flags().Changed("only-robot-messages") {
|
||||
toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages")
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("conversation-type"); v != "" {
|
||||
if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" {
|
||||
toolArgs["searchConvType"] = v
|
||||
}
|
||||
|
||||
@@ -2492,7 +2551,12 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
|
||||
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
|
||||
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("map-thumbnail-url", "", "位置消息地图缩略图 mediaId,形如 @mediaId(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("contact-id", "", "联系人名片 openDingTalkId(msgType=profile 时必填)")
|
||||
chatMessageSendCmd.Flags().Int64("dentry-id", 0, "文件 dentryId(与 --space-id 成对传入时跳过自动上传)")
|
||||
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
|
||||
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
|
||||
@@ -2686,7 +2750,11 @@ func newChatCommand() *cobra.Command {
|
||||
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("group")
|
||||
chatMessageSearchAdvancedCmd.Flags().String("message-type", "", "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)")
|
||||
chatMessageSearchAdvancedCmd.Flags().Bool("only-robot", false, "只搜索机器人消息(可选;显式传 false 时也会传给下层)")
|
||||
chatMessageSearchAdvancedCmd.Flags().Bool("only-robot-messages", false, "--only-robot 的别名")
|
||||
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("only-robot-messages")
|
||||
chatMessageSearchAdvancedCmd.Flags().String("conversation-type", "", "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)")
|
||||
chatMessageSearchAdvancedCmd.Flags().String("search-conv-type", "", "--conversation-type 的别名")
|
||||
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("search-conv-type")
|
||||
chatMessageSearchAdvancedCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选)")
|
||||
chatMessageSearchAdvancedCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选)")
|
||||
chatMessageSearchAdvancedCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\")")
|
||||
@@ -3186,6 +3254,40 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageRemoveTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)")
|
||||
chatMessageRemoveTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)")
|
||||
|
||||
chatMessageUpdateTextEmotionCmd := &cobra.Command{
|
||||
Use: "update-text-emotion",
|
||||
Short: "更新消息的文字表情回应",
|
||||
Example: ` dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name "赞" --text "nice" --background-id im_bg_5`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "update_text_emotion", map[string]any{
|
||||
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
|
||||
"openMsgId": mustGetFlag(cmd, "msg-id"),
|
||||
"oldEmotionId": mustGetFlag(cmd, "old-emotion-id"),
|
||||
"emotionId": mustGetFlag(cmd, "emotion-id"),
|
||||
"emotionName": mustGetFlag(cmd, "emotion-name"),
|
||||
"text": mustGetFlag(cmd, "text"),
|
||||
"backgroundId": mustGetFlag(cmd, "background-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("old-emotion-id", "", "待更新的原表情 ID (必填)")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("emotion-id", "", "新表情 ID (必填)")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("emotion-name", "", "新表情名称 (必填)")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("text", "", "新文字内容 (必填)")
|
||||
chatMessageUpdateTextEmotionCmd.Flags().String("background-id", "", "新背景 ID (必填)")
|
||||
chatMessageUpdateTextEmotionCmd.MarkFlagsOneRequired("conversation-id", "group", "id", "chat")
|
||||
for _, name := range []string{"msg-id", "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"} {
|
||||
_ = chatMessageUpdateTextEmotionCmd.MarkFlagRequired(name)
|
||||
}
|
||||
|
||||
// ── 创建文字表情(获取 emotionId)──────────────────────
|
||||
|
||||
chatMessageCreateTextEmotionCmd := &cobra.Command{
|
||||
@@ -3415,7 +3517,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)")
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("output")
|
||||
|
||||
chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageUpdateTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd)
|
||||
chatBotCmd.AddCommand(chatBotSearchCmd)
|
||||
chatCategoryCmd.AddCommand(chatCategoryListCmd, chatCategoryConvsCmd, chatCategoryCreateCmd, chatCategoryDeleteCmd, chatCategoryRenameCmd, chatCategoryAddConvCmd, chatCategoryRemoveConvCmd, chatCategoryListByConvCmd, chatCategoryBatchInfoCmd)
|
||||
chatGroupCmd.AddCommand(chatGroupInfoByIdCmd)
|
||||
@@ -3732,6 +3834,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
_ = chatSetTopCmd.MarkFlagRequired("conversation-id")
|
||||
chatSetTopCmd.Flags().Bool("off", false, "取消置顶(不传则设置置顶)")
|
||||
|
||||
// ── group get-mute-config: 查询群用户禁言配置 ───────────────
|
||||
chatGroupGetMuteConfigCmd := &cobra.Command{
|
||||
Use: "get-mute-config",
|
||||
Short: "查询群用户禁言配置",
|
||||
Long: `查询指定群的用户禁言配置,包括单独禁言黑名单、全员禁言白名单及相关操作时间。
|
||||
返回的是原始配置记录,不等同于当前被禁言成员列表;全员禁言开关也不在本命令的返回范围内。`,
|
||||
Example: ` dws chat group get-mute-config --group <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "get_group_mute_config", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatGroupGetMuteConfigCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
chatGroupCmd.AddCommand(chatGroupGetMuteConfigCmd)
|
||||
|
||||
// ── group-mute: 全员禁言 ───────────────────────────────
|
||||
|
||||
chatGroupMuteCmd := &cobra.Command{
|
||||
@@ -5310,6 +5431,68 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatTextCmd.AddCommand(chatTextTranslateCmd)
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd)
|
||||
|
||||
// ── chat group user-settings ──
|
||||
chatGroupUserSettingsCmd := &cobra.Command{
|
||||
Use: "user-settings",
|
||||
Short: "批量查询或更新当前用户的群会话设置",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
chatGroupUserSettingsQueryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "批量查询当前用户的群会话设置",
|
||||
Example: ` dws chat group user-settings query --groups cid1,cid2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "groups"); err != nil {
|
||||
return err
|
||||
}
|
||||
convIds := parseCSVValues(mustGetFlag(cmd, "groups"))
|
||||
if len(convIds) == 0 {
|
||||
return fmt.Errorf("--groups must not be empty")
|
||||
}
|
||||
if len(convIds) > 100 {
|
||||
return fmt.Errorf("--groups batch size %d exceeds limit 100", len(convIds))
|
||||
}
|
||||
return callMCPToolOnServer("im", "batch_query_group_chat_settings", map[string]any{
|
||||
"openConversationIds": convIds,
|
||||
})
|
||||
},
|
||||
}
|
||||
chatGroupUserSettingsQueryCmd.Flags().String("groups", "", "群会话 openConversationId 列表,逗号分隔,最多 100 个 (必填)")
|
||||
chatGroupUserSettingsSetCmd := &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "批量更新当前用户的群会话设置",
|
||||
Example: ` dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "items"); err != nil {
|
||||
return err
|
||||
}
|
||||
itemsJSON := mustGetFlag(cmd, "items")
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
|
||||
return fmt.Errorf("--items JSON parse error: %w", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("--items must not be empty")
|
||||
}
|
||||
if len(items) > 100 {
|
||||
return fmt.Errorf("--items batch size %d exceeds limit 100", len(items))
|
||||
}
|
||||
// 每项必须携带非空 openConversationId,缺失时 fail-fast,避免下发无效批量更新。
|
||||
for i, item := range items {
|
||||
cid, _ := item["openConversationId"].(string)
|
||||
if strings.TrimSpace(cid) == "" {
|
||||
return fmt.Errorf("--items[%d] 缺少非空 openConversationId", i)
|
||||
}
|
||||
}
|
||||
return callMCPToolOnServer("im", "batch_update_group_chat_settings", map[string]any{
|
||||
"items": items,
|
||||
})
|
||||
},
|
||||
}
|
||||
chatGroupUserSettingsSetCmd.Flags().String("items", "", `JSON 数组,每项 {"openConversationId":"cid","top":bool,"mute":bool,"groupNick":"...","groupAlias":"..."} (必填)`)
|
||||
chatGroupUserSettingsCmd.AddCommand(chatGroupUserSettingsQueryCmd, chatGroupUserSettingsSetCmd)
|
||||
chatGroupCmd.AddCommand(chatGroupUserSettingsCmd)
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
|
||||
chatBotCmd.AddCommand(chatBotFindCmd)
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
|
||||
@@ -114,6 +114,18 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
|
||||
for _, wrap := range []bool{true, false} {
|
||||
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
|
||||
}
|
||||
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
|
||||
t.Fatalf("current-user mention normalization = %q", got)
|
||||
}
|
||||
if got := NormalizeMessageMentions("<@all> <@u1>", []string{"u1"}, true, false); got != "@all @u1" {
|
||||
t.Fatalf("bot mention normalization = %q", got)
|
||||
}
|
||||
if got := NormalizeMessageMentions("@alliance hello", nil, true, false); got != "@all @alliance hello" {
|
||||
t.Fatalf("bot @all token detection = %q", got)
|
||||
}
|
||||
if got := NormalizeMessageMentions("hello @all", nil, true, false); got != "hello @all" {
|
||||
t.Fatalf("trailing bot @all detection = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 helpers
|
||||
|
||||
import "context"
|
||||
|
||||
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
|
||||
// metadata to built-in semantic Shortcuts. It remains an alias so the native
|
||||
// Cobra leaf and Shortcut use exactly the same upload implementation.
|
||||
type ConversationLocalFileMeta = conversationLocalFileMeta
|
||||
|
||||
// BuildConversationLocalFileMeta validates a local file and computes the
|
||||
// metadata required by DingTalk's conversation-file upload flow.
|
||||
func BuildConversationLocalFileMeta(filePath, fileName, md5Value string) (ConversationLocalFileMeta, error) {
|
||||
return buildConversationLocalFileMeta(filePath, fileName, md5Value)
|
||||
}
|
||||
|
||||
// UploadConversationLocalFile executes the existing init -> HTTP upload ->
|
||||
// commit flow and returns the commit response for message-content assembly.
|
||||
func UploadConversationLocalFile(
|
||||
ctx context.Context,
|
||||
targetArgs map[string]any,
|
||||
meta ConversationLocalFileMeta,
|
||||
uuid string,
|
||||
) (string, error) {
|
||||
return uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
|
||||
}
|
||||
|
||||
// ParseConversationFileSendIDs extracts the committed dentry and space IDs.
|
||||
func ParseConversationFileSendIDs(text string) (int64, int64, error) {
|
||||
return parseConversationFileSendIDs(text)
|
||||
}
|
||||
|
||||
// BuildConversationFileContent renders the exact file-message content accepted
|
||||
// by send_personal_message.
|
||||
func BuildConversationFileContent(
|
||||
dentryID, spaceID int64,
|
||||
meta ConversationLocalFileMeta,
|
||||
) (string, error) {
|
||||
return buildConversationFileContent(dentryID, spaceID, meta)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func executeChatPortsCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want guardedMutationCall
|
||||
}{
|
||||
{
|
||||
name: "conversation-id flag",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
"--msg-id", "msg-1",
|
||||
"--old-emotion-id", "old-1",
|
||||
"--emotion-id", "new-1",
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
"--background-id", "im_bg_5",
|
||||
},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "conv-1",
|
||||
"openMsgId": "msg-1",
|
||||
"oldEmotionId": "old-1",
|
||||
"emotionId": "new-1",
|
||||
"emotionName": "like",
|
||||
"text": "nice",
|
||||
"backgroundId": "im_bg_5",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group alias for conversation-id",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--group", "conv-2",
|
||||
"--msg-id", "msg-2",
|
||||
"--old-emotion-id", "old-2",
|
||||
"--emotion-id", "new-2",
|
||||
"--emotion-name", "heart",
|
||||
"--text", "great",
|
||||
"--background-id", "im_bg_1",
|
||||
},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "conv-2",
|
||||
"openMsgId": "msg-2",
|
||||
"oldEmotionId": "old-2",
|
||||
"emotionId": "new-2",
|
||||
"emotionName": "heart",
|
||||
"text": "great",
|
||||
"backgroundId": "im_bg_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("chat %s returned error: %v", strings.Join(test.args, " "), err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
fullArgs := []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
"--msg-id", "msg-1",
|
||||
"--old-emotion-id", "old-1",
|
||||
"--emotion-id", "new-1",
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
"--background-id", "im_bg_5",
|
||||
}
|
||||
dropFlag := func(name string) []string {
|
||||
out := make([]string, 0, len(fullArgs))
|
||||
for i := 0; i < len(fullArgs); i++ {
|
||||
if fullArgs[i] == name {
|
||||
i++ // skip the flag value too
|
||||
continue
|
||||
}
|
||||
out = append(out, fullArgs[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing conversation-id and aliases",
|
||||
args: dropFlag("--conversation-id"),
|
||||
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
|
||||
},
|
||||
{
|
||||
name: "missing old-emotion-id",
|
||||
args: dropFlag("--old-emotion-id"),
|
||||
wantErr: `required flag(s) "old-emotion-id" not set`,
|
||||
},
|
||||
{
|
||||
name: "missing msg-id and background-id",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
"--old-emotion-id", "old-1",
|
||||
"--emotion-id", "new-1",
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
},
|
||||
wantErr: `required flag(s) "background-id", "msg-id" not set`,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config", "--group", "conv-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
want := guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T) {
|
||||
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
|
||||
{text: `{"muteBlackList":[],"muteWhiteList":[]}`},
|
||||
}}
|
||||
err := executeChatPortsCoverageCommand(t, caller,
|
||||
"group", "get-mute-config", "--group", "conv-9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want 1", caller.calls)
|
||||
}
|
||||
if caller.calls[0]["openConversationId"] != "conv-9" {
|
||||
t.Fatalf("raw args = %#v", caller.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config")
|
||||
if err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("err = %v, want message containing --group", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none", caller.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageUpdateTextEmotionMapsReplacementTemplate(t *testing.T) {
|
||||
for _, locator := range []string{"conversation-id", "group", "id", "chat"} {
|
||||
t.Run(locator, func(t *testing.T) {
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"message", "update-text-emotion",
|
||||
"--"+locator, "cid",
|
||||
"--msg-id", "mid",
|
||||
"--old-emotion-id", "old-emotion",
|
||||
"--emotion-id", "new-emotion",
|
||||
"--emotion-name", "赞",
|
||||
"--text", "nice",
|
||||
"--background-id", "im_bg_5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("update-text-emotion returned error: %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncCall(t, caller, wukongWeeklySyncCall{
|
||||
server: "im",
|
||||
tool: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"openMsgId": "mid",
|
||||
"oldEmotionId": "old-emotion",
|
||||
"emotionId": "new-emotion",
|
||||
"emotionName": "赞",
|
||||
"text": "nice",
|
||||
"backgroundId": "im_bg_5",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageUpdateTextEmotionRequiresEveryBusinessParameter(t *testing.T) {
|
||||
required := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "conversation-id", args: []string{"--group", "cid"}},
|
||||
{name: "msg-id", args: []string{"--msg-id", "mid"}},
|
||||
{name: "old-emotion-id", args: []string{"--old-emotion-id", "old-emotion"}},
|
||||
{name: "emotion-id", args: []string{"--emotion-id", "new-emotion"}},
|
||||
{name: "emotion-name", args: []string{"--emotion-name", "赞"}},
|
||||
{name: "text", args: []string{"--text", "nice"}},
|
||||
{name: "background-id", args: []string{"--background-id", "im_bg_5"}},
|
||||
}
|
||||
|
||||
for omitted := range required {
|
||||
t.Run("missing-"+required[omitted].name, func(t *testing.T) {
|
||||
args := []string{"message", "update-text-emotion"}
|
||||
for i, parameter := range required {
|
||||
if i != omitted {
|
||||
args = append(args, parameter.args...)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), required[omitted].name) {
|
||||
t.Fatalf("missing %s error = %v", required[omitted].name, err)
|
||||
}
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUserSettingsQueryValidation(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "query", "--groups", ","); err == nil {
|
||||
t.Fatal("empty groups returned nil")
|
||||
}
|
||||
|
||||
tooMany := make([]string, 101)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = "cid"
|
||||
}
|
||||
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "query", "--groups", strings.Join(tooMany, ",")); err == nil {
|
||||
t.Fatal("101 groups returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUserSettingsSetValidation(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "set", "--items", "[]"); err == nil {
|
||||
t.Fatal("empty items returned nil")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[")
|
||||
for i := 0; i < 101; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(`{"openConversationId":"cid"}`)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "set", "--items", sb.String()); err == nil {
|
||||
t.Fatal("101 items returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUserSettingsSetItemValidation(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "set", "--items", `[{"top":true}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "openConversationId") || !strings.Contains(err.Error(), "--items[0]") {
|
||||
t.Fatalf("err = %v, want missing openConversationId error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls = %#v, want no MCP call on invalid item", caller.calls)
|
||||
}
|
||||
|
||||
// 非首项缺失时错误需带正确下标;空白字符串同样视为缺失。
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1"},{"openConversationId":" "}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "--items[1]") {
|
||||
t.Fatalf("err = %v, want --items[1] error", err)
|
||||
}
|
||||
|
||||
// 合法条目仍然成功下发。
|
||||
caller = &guardedMutationCaller{}
|
||||
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true,"mute":false}]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "batch_update_group_chat_settings" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUserSettingsSetHappyPath(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true}]`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "batch_update_group_chat_settings" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "location", "--latitude", "39.9")
|
||||
if err == nil || !strings.Contains(err.Error(), "are all required for msgType=location") {
|
||||
t.Fatalf("err = %v, want missing location params", err)
|
||||
}
|
||||
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "location",
|
||||
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.args["msgType"] != "location" {
|
||||
t.Fatalf("msgType = %#v", call.args["msgType"])
|
||||
}
|
||||
content, _ := call.args["content"].(string)
|
||||
if !strings.Contains(content, `"latitude":"39.9"`) || !strings.Contains(content, `"mapThumbnailUrl":"@media1"`) {
|
||||
t.Fatalf("content = %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "profile")
|
||||
if err == nil || !strings.Contains(err.Error(), "--contact-id is required") {
|
||||
t.Fatalf("err = %v, want contact-id required", err)
|
||||
}
|
||||
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
content, _ := caller.calls[0].args["content"].(string)
|
||||
if !strings.Contains(content, `"openDingTalkId":"od123"`) {
|
||||
t.Fatalf("content = %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchAdvancedWukongAliases(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "search-advanced", "--query", "通知",
|
||||
"--only-robot-messages", "--search-conv-type", "group_chat")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
args := caller.calls[0].args
|
||||
if args["onlyRobotMessages"] != true {
|
||||
t.Fatalf("onlyRobotMessages = %#v", args["onlyRobotMessages"])
|
||||
}
|
||||
if args["searchConvType"] != "group_chat" {
|
||||
t.Fatalf("searchConvType = %#v", args["searchConvType"])
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,14 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
|
||||
path: "sheet range move-to",
|
||||
args: []string{"range", "move-to", "--node", "node-1", "--sheet-id", "sheet-1", "--source-range", "A1:B3", "--target-range", "D1"},
|
||||
},
|
||||
{
|
||||
path: "sheet comment delete",
|
||||
args: []string{"comment", "delete", "--node", "node-1", "--comment-key", "ck-1"},
|
||||
},
|
||||
{
|
||||
path: "sheet version revert",
|
||||
args: []string{"version", "revert", "--node", "node-1", "--version", "2"},
|
||||
},
|
||||
}
|
||||
|
||||
// A newly protected command must add a runnable case here. This keeps the
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -651,6 +652,37 @@ func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type devAppFailingCountRunner struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *devAppFailingCountRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls++
|
||||
return executor.Result{Invocation: invocation}, r.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppEventSubscribeRunnerFailureIsNotRetried(t *testing.T) {
|
||||
wantErr := stderrors.New("event subscription failed")
|
||||
runner := &devAppFailingCountRunner{err: wantErr}
|
||||
root := newDevAppTestRoot(runner)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"dev", "app", "event", "subscribe",
|
||||
"--unified-app-id", "u-1",
|
||||
"--event-codes", "a,b",
|
||||
"--yes",
|
||||
})
|
||||
|
||||
if err := root.Execute(); !stderrors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls = %d, want 1", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -2901,7 +2901,7 @@ 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)
|
||||
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())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// doc_style.go — `dws doc style`(文档封面/背景样式配置)。
|
||||
// 写入收口到 MCP 工具 update_document_style;读取用 get_document_style。
|
||||
|
||||
const docStyleToolName = "update_document_style"
|
||||
|
||||
const docStyleGetToolName = "get_document_style"
|
||||
|
||||
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
|
||||
func newDocStyleCommand() *cobra.Command {
|
||||
styleCmd := &cobra.Command{
|
||||
Use: "style",
|
||||
Short: "文档样式配置 (封面/背景)",
|
||||
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
|
||||
|
||||
命令结构:
|
||||
dws doc style cover set 设置文档封面
|
||||
dws doc style cover clear 移除文档封面
|
||||
dws doc style background set 设置文档背景纯色
|
||||
dws doc style background clear 清除文档背景
|
||||
dws doc style get 读取文档封面/背景 (只读)
|
||||
|
||||
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
coverCmd := &cobra.Command{
|
||||
Use: "cover",
|
||||
Short: "文档封面设置/移除",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
coverSetCmd := &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "设置文档封面",
|
||||
Long: `为钉钉文档设置顶部封面图,可指定竖直显示位置。图片来源二选一:--image 外链或 --file 本地文件。`,
|
||||
Example: ` dws doc style cover set --node DOC_ID --image https://img.example.com/cover.png
|
||||
dws doc style cover set --node DOC_ID --file ./cover.png --position 0.3`,
|
||||
RunE: runDocStyleCoverSet,
|
||||
}
|
||||
coverClearCmd := &cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "移除文档封面",
|
||||
Example: ` dws doc style cover clear --node DOC_ID`,
|
||||
RunE: runDocStyleCoverClear,
|
||||
}
|
||||
|
||||
backgroundCmd := &cobra.Command{
|
||||
Use: "background",
|
||||
Short: "文档背景设置/清除",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
backgroundSetCmd := &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "设置文档背景纯色",
|
||||
Long: `为钉钉文档设置背景纯色(--color)。背景仅支持纯色,不支持背景图片上传(对齐前端能力)。`,
|
||||
Example: ` dws doc style background set --node DOC_ID --color "#E8F2FE"`,
|
||||
RunE: runDocStyleBackgroundSet,
|
||||
}
|
||||
backgroundClearCmd := &cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "清除文档背景",
|
||||
Example: ` dws doc style background clear --node DOC_ID`,
|
||||
RunE: runDocStyleBackgroundClear,
|
||||
}
|
||||
getCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "读取文档封面/背景 (只读)",
|
||||
Long: `读取钉钉文档当前的封面与背景配置(只读,单接口收口 get_document_style)。`,
|
||||
Example: ` dws doc style get --node DOC_ID`,
|
||||
RunE: runDocStyleGet,
|
||||
}
|
||||
|
||||
// cover set flags
|
||||
coverSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
|
||||
coverSetCmd.Flags().String("image", "", "封面图片 URL (外链会自动转存为内部地址)")
|
||||
coverSetCmd.Flags().String("file", "", "本地图片文件路径 (与 --image 互斥)")
|
||||
coverSetCmd.Flags().Float64("position", 0.5, "封面竖直位置 [0,1],默认 0.5")
|
||||
coverSetCmd.MarkFlagsMutuallyExclusive("image", "file")
|
||||
|
||||
// cover clear flags
|
||||
coverClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
|
||||
|
||||
// background set flags(仅纯色,对齐前端;不支持背景图片上传)
|
||||
backgroundSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
|
||||
backgroundSetCmd.Flags().String("color", "", "背景纯色,如 #E8F2FE (必填)")
|
||||
|
||||
// background clear flags
|
||||
backgroundClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
|
||||
|
||||
// get flags
|
||||
getCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
|
||||
|
||||
// --node 隐藏别名 (--url/--id/--node-id/--doc-id/--file-id)
|
||||
for _, c := range []*cobra.Command{coverSetCmd, coverClearCmd, backgroundSetCmd, backgroundClearCmd, getCmd} {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
c.Flags().String("node-id", "", "--node 的别名")
|
||||
c.Flags().String("doc-id", "", "--node 的别名")
|
||||
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
|
||||
_ = c.Flags().MarkHidden("url")
|
||||
_ = c.Flags().MarkHidden("id")
|
||||
_ = c.Flags().MarkHidden("node-id")
|
||||
_ = c.Flags().MarkHidden("doc-id")
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
coverCmd.AddCommand(coverSetCmd, coverClearCmd)
|
||||
backgroundCmd.AddCommand(backgroundSetCmd, backgroundClearCmd)
|
||||
styleCmd.AddCommand(coverCmd, backgroundCmd, getCmd)
|
||||
|
||||
// 递归注册跨产品别名,让 cover set 的 --file 获得统一别名 --file-path。
|
||||
registerCrossProductAliasesRecursive(styleCmd)
|
||||
return styleCmd
|
||||
}
|
||||
|
||||
// registerCrossProductAliasesRecursive 递归为 cmd 及其所有子命令注册跨产品别名。
|
||||
// 可正确处理多层分组(如 style 的 cover/background 组下还有 set/clear 叶子)。
|
||||
func registerCrossProductAliasesRecursive(cmd *cobra.Command) {
|
||||
RegisterCrossProductAliases(cmd)
|
||||
for _, sub := range cmd.Commands() {
|
||||
registerCrossProductAliasesRecursive(sub)
|
||||
}
|
||||
}
|
||||
|
||||
// docStyleNodeID 解析目标文档标识,支持 --node 及隐藏别名。
|
||||
func docStyleNodeID(cmd *cobra.Command) (string, error) {
|
||||
return mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
}
|
||||
|
||||
func runDocStyleCoverSet(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := docStyleNodeID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 封面图片来源二选一:--image 外链(服务端转存公开)或 --file 本地上传(拿 resourceId 转存公开)。
|
||||
image, _ := cmd.Flags().GetString("image")
|
||||
filePath := flagOrFallback(cmd, "file", "file-path")
|
||||
// --image 与 --file/--file-path 互斥。cobra 的 MarkFlagsMutuallyExclusive 只认主名
|
||||
// image/file,拦不住别名 --file-path,故在此显式兜底。
|
||||
if image != "" && filePath != "" {
|
||||
return fmt.Errorf("--image and --file/--file-path are mutually exclusive; specify only one")
|
||||
}
|
||||
if image == "" && filePath == "" {
|
||||
return fmt.Errorf("flag --image or --file is required for `cover set`")
|
||||
}
|
||||
|
||||
cover := map[string]any{"action": "set"}
|
||||
// position 客户端校验:帮助文档承诺 [0,1],越界直接报错,避免触发上传/请求。
|
||||
if cmd.Flags().Changed("position") {
|
||||
pos, _ := cmd.Flags().GetFloat64("position")
|
||||
if pos < 0 || pos > 1 {
|
||||
return fmt.Errorf("--position must be within [0,1], got %v", pos)
|
||||
}
|
||||
cover["position"] = pos
|
||||
}
|
||||
|
||||
if image != "" {
|
||||
cover["imageUrl"] = image
|
||||
} else {
|
||||
// --file:先做本地校验(存在 + 必须为图片);dry-run 时在上传前短路,
|
||||
// 绝不读取/上传本地文件。
|
||||
mimeType, fileSize, err := validateCoverImageFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
cover["resourceId"] = fmt.Sprintf("(pending upload from --file %s)", filePath)
|
||||
} else {
|
||||
// 使用命令上下文,让 Ctrl-C 能够取消上传凭证获取与 OSS 上传两次网络请求。
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
resourceID, err := uploadDocStyleImage(ctx, nodeID, filePath, filepath.Base(filePath), mimeType, fileSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cover["resourceId"] = resourceID
|
||||
}
|
||||
}
|
||||
|
||||
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"cover": cover,
|
||||
})
|
||||
}
|
||||
|
||||
func runDocStyleCoverClear(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := docStyleNodeID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"cover": map[string]any{"action": "clear"},
|
||||
})
|
||||
}
|
||||
|
||||
func runDocStyleBackgroundSet(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := docStyleNodeID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 背景仅支持纯色(对齐前端,不支持背景图片上传)。
|
||||
color, _ := cmd.Flags().GetString("color")
|
||||
if color == "" {
|
||||
return fmt.Errorf("flag --color is required for `background set`")
|
||||
}
|
||||
// 帮助文档承诺 #RRGGBB 十六进制纯色,非法值直接报错,不下发请求。
|
||||
if !isHexColor(color) {
|
||||
return fmt.Errorf("--color must be a hex color like #E8F2FE, got %q", color)
|
||||
}
|
||||
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"background": map[string]any{
|
||||
"action": "set",
|
||||
"backgroundColor": color,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runDocStyleBackgroundClear(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := docStyleNodeID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"background": map[string]any{"action": "clear"},
|
||||
})
|
||||
}
|
||||
|
||||
// runDocStyleGet 读取文档当前封面/背景(只读)。
|
||||
func runDocStyleGet(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := docStyleNodeID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("doc", docStyleGetToolName, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
})
|
||||
}
|
||||
|
||||
// docStyleCoverMaxBytes 是 cover set --file 本地图片的大小上限(20 MiB)。
|
||||
// 悟空参考实现未设上限,此处补充客户端 fail-fast,避免超大文件被先行上传。
|
||||
const docStyleCoverMaxBytes = 20 << 20
|
||||
|
||||
// validateCoverImageFile 对 cover set --file 做本地校验(不上传/不读取文件内容):
|
||||
// 文件须存在、非目录、不超过大小上限,且按扩展名推断的 MIME 必须为 image/*。
|
||||
// 返回 mimeType 与文件大小,供 dry-run 预览与真实上传前 fail-fast,避免非法文件被先行上传。
|
||||
func validateCoverImageFile(filePath string) (mimeType string, fileSize int64, err error) {
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return "", 0, fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
if fileInfo.Size() > docStyleCoverMaxBytes {
|
||||
return "", 0, fmt.Errorf("封面图片文件过大:上限 %d 字节 (20 MiB),实际 %d 字节 (%s)", int64(docStyleCoverMaxBytes), fileInfo.Size(), filepath.Base(filePath))
|
||||
}
|
||||
mimeType = inferMimeType(filepath.Base(filePath))
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
return "", 0, fmt.Errorf("cover --file must be an image (png/jpg/jpeg/gif/svg/webp), got %s", filepath.Base(filePath))
|
||||
}
|
||||
return mimeType, fileInfo.Size(), nil
|
||||
}
|
||||
|
||||
// isHexColor 校验 #RRGGBB 形式的十六进制颜色(大小写均可)。
|
||||
func isHexColor(s string) bool {
|
||||
if len(s) != 7 || s[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, c := range s[1:] {
|
||||
switch {
|
||||
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// uploadDocStyleImage 复用附件上传子流程(get_doc_attachment_upload_info + httpPutFile),
|
||||
// 上传本地文件为鉴权资源,返回其 resourceId(供封面在服务端转存为公开读地址)。
|
||||
// 入参 mimeType/fileSize 由调用方经 validateCoverImageFile 预校验得到;进度写 stderr,
|
||||
// 保证 --format json 时 stdout 只含合法 JSON。本函数一定发起网络请求,调用方须自行做 dry-run 短路。
|
||||
func uploadDocStyleImage(ctx context.Context, nodeID, filePath, fileName, mimeType string, fileSize int64) (string, error) {
|
||||
deps.Out.PrintProgress(fmt.Sprintf("[1/2] 获取图片上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
credText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
uploadURL, resourceID, _, err := parseAttachmentUploadInfo(credText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
deps.Out.PrintProgress("[2/2] 上传图片到 OSS...")
|
||||
ossHeaders := map[string]string{"Content-Type": mimeType}
|
||||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resourceID, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempImage(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte("fake-image-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCoverImageFile(t *testing.T) {
|
||||
if _, _, err := validateCoverImageFile(t.TempDir()); err == nil {
|
||||
t.Fatal("directory returned nil")
|
||||
}
|
||||
path := writeTempImage(t, "cover.png")
|
||||
mimeType, size, err := validateCoverImageFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mimeType != "image/png" || size != int64(len("fake-image-bytes")) {
|
||||
t.Fatalf("mimeType=%q size=%d", mimeType, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsHexColorEdge(t *testing.T) {
|
||||
for _, valid := range []string{"#E8F2FE", "#e8f2fe", "#000000", "#aB1234"} {
|
||||
if !isHexColor(valid) {
|
||||
t.Fatalf("%s should be valid", valid)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"#GG0000", "E8F2FE", "#E8F2F", "#E8F2FEE", ""} {
|
||||
if isHexColor(invalid) {
|
||||
t.Fatalf("%s should be invalid", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func executeDocStyleCommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
root := newDocStyleCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetMutualExclusion(t *testing.T) {
|
||||
err := executeDocStyleCommand(t, &scriptedToolCaller{},
|
||||
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--file-path", "a.png")
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetPositionValidation(t *testing.T) {
|
||||
err := executeDocStyleCommand(t, &scriptedToolCaller{},
|
||||
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "1.5")
|
||||
if err == nil || !strings.Contains(err.Error(), "--position must be within [0,1]") {
|
||||
t.Fatalf("err = %v, want position range error", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := executeDocStyleCommand(t, caller,
|
||||
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "0.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetFileDryRun(t *testing.T) {
|
||||
path := writeTempImage(t, "cover.png")
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stubHTTPPutFile(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
old := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return err }
|
||||
t.Cleanup(func() { httpPutFile = old })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetFileUpload(t *testing.T) {
|
||||
path := writeTempImage(t, "cover.png")
|
||||
stubHTTPPutFile(t, nil)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
|
||||
{text: `{}`},
|
||||
}}
|
||||
if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetFileUploadFailure(t *testing.T) {
|
||||
path := writeTempImage(t, "cover.png")
|
||||
stubHTTPPutFile(t, errors.New("oss down"))
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
|
||||
}}
|
||||
err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path)
|
||||
if err == nil || !strings.Contains(err.Error(), "oss down") {
|
||||
t.Fatalf("err = %v, want oss failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUploadDocStyleImageFailures(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("cred failed")}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil {
|
||||
t.Fatal("cred failure returned nil")
|
||||
}
|
||||
|
||||
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `not json`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil {
|
||||
t.Fatal("invalid upload info returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCoverImageFileSizeLimit(t *testing.T) {
|
||||
// 上限以内的正常图片通过校验。
|
||||
path := writeTempImage(t, "cover.png")
|
||||
if _, _, err := validateCoverImageFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// os.Truncate 生成超过上限 1 字节的稀疏文件(瞬时创建,不占实际磁盘)。
|
||||
if err := os.Truncate(path, docStyleCoverMaxBytes+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err := validateCoverImageFile(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "封面图片文件过大") || !strings.Contains(err.Error(), "20 MiB") {
|
||||
t.Fatalf("err = %v, want size limit error", err)
|
||||
}
|
||||
|
||||
// 恰好等于上限时仍应通过。
|
||||
if err := os.Truncate(path, docStyleCoverMaxBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := validateCoverImageFile(path); err != nil {
|
||||
t.Fatalf("size == limit should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetSizeLimitViaCommand(t *testing.T) {
|
||||
path := writeTempImage(t, "cover.png")
|
||||
if err := os.Truncate(path, docStyleCoverMaxBytes+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path)
|
||||
if err == nil || !strings.Contains(err.Error(), "封面图片文件过大") {
|
||||
t.Fatalf("err = %v, want size limit error", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0 (fail before any network request)", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetNilContextFallback(t *testing.T) {
|
||||
// 直接调用 RunE(不经 Execute),cmd.Context() 为 nil,覆盖 context.Background() 兜底分支。
|
||||
path := writeTempImage(t, "cover.png")
|
||||
stubHTTPPutFile(t, nil)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
|
||||
{text: `{}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
root := newDocStyleCommand()
|
||||
coverSet, _, err := root.Find([]string{"cover", "set"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if coverSet.Context() != nil {
|
||||
t.Fatal("expected nil context on unexecuted command")
|
||||
}
|
||||
if err := coverSet.Flags().Set("node", "n1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := coverSet.Flags().Set("file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDocStyleCoverSet(coverSet, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocStyleCoverSetUsesCommandContext(t *testing.T) {
|
||||
// 经 ExecuteContext 执行时,上传流程应使用命令上下文;scriptedToolCaller 忽略 ctx,
|
||||
// 故此处做行为性断言:正常上下文下上传路径成功走通(覆盖 ctx := cmd.Context() 分支)。
|
||||
path := writeTempImage(t, "cover.png")
|
||||
stubHTTPPutFile(t, nil)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
|
||||
{text: `{}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
root := newDocStyleCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs([]string{"cover", "set", "--node", "n1", "--file", path})
|
||||
if err := root.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
|
||||
}
|
||||
}
|
||||
+424
-1
@@ -302,9 +302,68 @@ func newDriveCommand() *cobra.Command {
|
||||
dws drive list --workspace <workspaceId>
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern, _ := cmd.Flags().GetString("pattern")
|
||||
|
||||
depth, _ := cmd.Flags().GetInt("depth")
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
if cmd.Flags().Changed("versions") {
|
||||
if cmd.Flags().Changed("depth") && depth > 1 {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: "--versions 与 --depth 不能同时使用",
|
||||
}
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: "--versions 与 --pattern 不能同时使用",
|
||||
}
|
||||
}
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
toolArgs["maxResults"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" {
|
||||
toolArgs["nextCursor"] = v
|
||||
}
|
||||
return callMCPToolOnServer("drive", "list_file_versions", toolArgs)
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("depth") {
|
||||
if err := validateDriveListDepth(cmd, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 如果指定了 --workspace,路由到文档空间(doc MCP server)
|
||||
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
if rootFolder != "" {
|
||||
if err := validateDocFolderID(rootFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: "--pattern 仅适用于钉盘文件列表,不能与 --workspace 同时使用",
|
||||
}
|
||||
}
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
if folder := docFolderFlag(cmd, "node", "file-id"); folder != "" {
|
||||
if err := validateDocFolderID(folder); err != nil {
|
||||
@@ -321,6 +380,30 @@ func newDriveCommand() *cobra.Command {
|
||||
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
|
||||
}
|
||||
|
||||
if depth > 1 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
baseArgs["spaceId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
|
||||
baseArgs["orderBy"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("order"); v != "" {
|
||||
baseArgs["order"] = 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 runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
@@ -451,6 +534,73 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
driveDownloadVersionCmd := &cobra.Command{
|
||||
Use: "download-version",
|
||||
Short: "下载文件历史版本到本地",
|
||||
Long: `下载钉盘文件的指定历史版本到本地(两步下载流程)。
|
||||
|
||||
仅适用于普通文件(如 pdf、docx、xlsx、png 等):
|
||||
钉钉在线文档(adoc)请使用 dws doc version 系列命令
|
||||
钉钉在线表格(axls)请使用 dws sheet version 系列命令
|
||||
|
||||
流程:
|
||||
1. 获取历史版本下载 URL 和签名请求头 (download_file_version)
|
||||
2. HTTP GET 下载文件二进制内容到本地
|
||||
|
||||
版本号从 dws drive list --node <dentryUuid> --versions 获取。`,
|
||||
Example: ` dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf
|
||||
dws drive download-version --node <dentryUuid> --version 3 --output ~/downloads/`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fileID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionNum, _ := cmd.Flags().GetInt("version")
|
||||
if versionNum <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数,当前值: %d(版本号从 drive list --versions 获取)", versionNum)
|
||||
}
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
if outputPath == "" {
|
||||
return fmt.Errorf("flag --output is required")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
|
||||
deps.Out.PrintKeyValue("节点ID", fileID)
|
||||
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
|
||||
filename := extractFileNameFromResponse(text)
|
||||
if filename == "" {
|
||||
filename = inferFilename(resourceURL)
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
driveMkdirCmd := &cobra.Command{
|
||||
Use: "mkdir",
|
||||
Short: "创建文件夹",
|
||||
@@ -547,6 +697,11 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选,仅钉盘)")
|
||||
driveListCmd.Flags().String("order", "", "排序方向: asc|desc,默认 desc (可选,仅钉盘)")
|
||||
driveListCmd.Flags().Bool("thumbnail", false, "是否返回缩略图信息 (可选,仅钉盘)")
|
||||
driveListCmd.Flags().Bool("versions", false, "列出文件历史版本而非文件列表 (需配合 --node)")
|
||||
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 时有效) (可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -555,6 +710,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
|
||||
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
|
||||
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
|
||||
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
|
||||
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
|
||||
driveMkdirCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选)")
|
||||
@@ -1124,7 +1287,151 @@ func newDriveCommand() *cobra.Command {
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd)
|
||||
drivePermTransferOwnerCmd := &cobra.Command{
|
||||
Use: "transfer-owner",
|
||||
Short: "[危险] 转交所有者",
|
||||
Long: `转交文档或知识库的所有者给指定用户。此操作不可逆,执行前需要确认。
|
||||
|
||||
--node 和 --workspace 二选一。转交后原所有者保留角色由 --reserve-role 指定。
|
||||
使用 --yes 跳过确认时,--reserve-role 和 --recursive 必须显式指定。`,
|
||||
Example: ` dws drive permission transfer-owner --node DOC_ID --new-owner uid123 --reserve-role EDITOR --recursive=false --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, _ := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if nodeID == "" && workspaceID == "" {
|
||||
return fmt.Errorf("--node or --workspace is required")
|
||||
}
|
||||
// 不可逆高危操作:帮助文档承诺二选一,两者同传直接失败,绝不静默择一。
|
||||
if nodeID != "" && workspaceID != "" {
|
||||
return fmt.Errorf("--node and --workspace are mutually exclusive; specify exactly one")
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "new-owner"); err != nil {
|
||||
return err
|
||||
}
|
||||
newOwnerID := mustGetFlag(cmd, "new-owner")
|
||||
|
||||
yesMode, _ := cmd.Flags().GetBool("yes")
|
||||
if yesMode {
|
||||
if !cmd.Flags().Changed("reserve-role") {
|
||||
return fmt.Errorf("--reserve-role is required when using --yes")
|
||||
}
|
||||
if !cmd.Flags().Changed("recursive") {
|
||||
return fmt.Errorf("--recursive is required when using --yes")
|
||||
}
|
||||
}
|
||||
|
||||
if commandDryRun(cmd) {
|
||||
if deps.Caller.Format() == "json" {
|
||||
payload := map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"operation": "转交所有者",
|
||||
"newOwnerId": newOwnerID,
|
||||
}
|
||||
if nodeID != "" {
|
||||
payload["nodeId"] = nodeID
|
||||
} else {
|
||||
payload["workspaceId"] = workspaceID
|
||||
}
|
||||
return deps.Out.PrintJSON(payload)
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "转交所有者")
|
||||
deps.Out.PrintKeyValue("新所有者", newOwnerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
reserveRole := mustGetFlag(cmd, "reserve-role")
|
||||
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||
|
||||
target := nodeID
|
||||
if target == "" {
|
||||
target = workspaceID
|
||||
}
|
||||
if !yesMode && !confirmDangerousAction(cmd, "转交所有者", target) {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{"newOwnerId": newOwnerID}
|
||||
if nodeID != "" {
|
||||
toolArgs["nodeId"] = nodeID
|
||||
} else {
|
||||
toolArgs["workspaceId"] = workspaceID
|
||||
}
|
||||
if reserveRole != "" {
|
||||
toolArgs["reserveOldOwnerRole"] = reserveRole
|
||||
}
|
||||
if cmd.Flags().Changed("recursive") {
|
||||
toolArgs["recursiveChange"] = recursive
|
||||
}
|
||||
return callMCPToolOnServer("doc", "transfer_owner", toolArgs)
|
||||
},
|
||||
}
|
||||
drivePermTransferOwnerCmd.Flags().String("node", "", "目标节点 ID 或 URL(与 --workspace 二选一)")
|
||||
drivePermTransferOwnerCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(与 --node 二选一)")
|
||||
drivePermTransferOwnerCmd.Flags().String("new-owner", "", "新所有者的用户 userId (必填)")
|
||||
drivePermTransferOwnerCmd.Flags().String("reserve-role", "", "转交后原所有者保留角色: MANAGER / EDITOR / DOWNLOADER / READER / NONE")
|
||||
drivePermTransferOwnerCmd.Flags().Bool("recursive", false, "是否递归变更所有子节点的所有者")
|
||||
|
||||
drivePermApplyInfoCmd := &cobra.Command{
|
||||
Use: "apply-info",
|
||||
Short: "查询节点可申请的角色与审批人",
|
||||
Example: ` dws drive permission apply-info --node DOC_ID`,
|
||||
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
|
||||
}
|
||||
return callMCPToolOnServer("drive", "query_permission_apply_info", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
drivePermApplyInfoCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
|
||||
drivePermApplyCmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "发起权限申请",
|
||||
Long: `向指定节点的审批人发起权限申请。建议先用 apply-info 获取可申请角色与审批人。
|
||||
|
||||
注意: 本命令会真实通知审批人,Agent 必须先获得用户明确同意后再执行。`,
|
||||
Example: ` dws drive permission apply --node DOC_ID --role READER --users uid1
|
||||
dws drive permission apply --node DOC_ID --role EDITOR --users uid1,uid2 --reason "需要编辑该文档"`,
|
||||
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
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"receivers": userIds,
|
||||
}
|
||||
if v := mustGetFlag(cmd, "notify-mode"); v != "" {
|
||||
toolArgs["notifyMode"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "reason"); v != "" {
|
||||
toolArgs["reason"] = v
|
||||
}
|
||||
if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "发起权限申请", fmt.Sprintf("节点 %s(将真实通知审批人 %s)", nodeID, strings.Join(userIds, ","))) {
|
||||
return nil
|
||||
}
|
||||
return callMCPToolOnServer("drive", "apply_permission", toolArgs)
|
||||
},
|
||||
}
|
||||
drivePermApplyCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermApplyCmd.Flags().String("role", "", "申请的角色: EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermApplyCmd.Flags().String("users", "", "审批人 userId 列表,逗号分隔 (必填)")
|
||||
drivePermApplyCmd.Flags().String("user", "", "")
|
||||
_ = drivePermApplyCmd.Flags().MarkHidden("user")
|
||||
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
|
||||
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
|
||||
|
||||
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
|
||||
|
||||
// --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
driveNodeAliasCmds := []*cobra.Command{
|
||||
@@ -1397,11 +1704,124 @@ func newDriveCommand() *cobra.Command {
|
||||
driveRecentCmd.Flags().String("page-token", "", "")
|
||||
_ = driveRecentCmd.Flags().MarkHidden("page-token")
|
||||
|
||||
// ── drive star (文档收藏管理) ──
|
||||
driveStarCmd := &cobra.Command{
|
||||
Use: "star",
|
||||
Short: "文档收藏管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
driveStarAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "收藏文档",
|
||||
Example: ` dws drive star add --node <nodeId_or_URL>`,
|
||||
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
|
||||
}
|
||||
return callMCPToolOnServer("drive", "mark_star", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
driveStarAddCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
driveStarRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "取消收藏文档",
|
||||
Example: ` dws drive star remove --node <nodeId_or_URL>`,
|
||||
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
|
||||
}
|
||||
return callMCPToolOnServer("drive", "unmark_star", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
driveStarRemoveCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
driveStarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取收藏列表",
|
||||
Example: ` dws drive star list
|
||||
dws drive star list --content-types doc,sheet --limit 10`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
|
||||
toolArgs["orderBy"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("sort"); v != "" {
|
||||
toolArgs["sortType"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetStringSlice("resource-types"); len(v) > 0 {
|
||||
toolArgs["supportResourceTypes"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetStringSlice("content-types"); len(v) > 0 {
|
||||
toolArgs["contentTypes"] = v
|
||||
}
|
||||
return callMCPToolOnServer("drive", "get_star_list", toolArgs)
|
||||
},
|
||||
}
|
||||
driveStarListCmd.Flags().Int("limit", 0, "每页条数 (默认 20,最大 20)")
|
||||
driveStarListCmd.Flags().String("cursor", "", "分页游标")
|
||||
driveStarListCmd.Flags().String("order-by", "", "排序字段: createTime")
|
||||
driveStarListCmd.Flags().String("sort", "", "排序方向: asc|desc")
|
||||
driveStarListCmd.Flags().StringSlice("resource-types", nil, "资源大类: DENTRY, TEAM, WORKSPACE")
|
||||
driveStarListCmd.Flags().StringSlice("content-types", nil, "内容类型: doc,sheet,ppt,whiteboard,mind,notable,pdf,other,folder")
|
||||
driveStarCmd.AddCommand(driveStarAddCmd, driveStarRemoveCmd, driveStarListCmd)
|
||||
|
||||
// ── drive cover (获取节点封面地址) ──
|
||||
driveCoverCmd := &cobra.Command{
|
||||
Use: "cover",
|
||||
Short: "获取节点封面地址",
|
||||
Example: " dws drive cover --node <dentryUuid>",
|
||||
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
|
||||
}
|
||||
return callMCPToolOnServer("drive", "get_cover", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
|
||||
|
||||
// ── drive revert (回滚文件到指定历史版本) ──
|
||||
driveRevertCmd := &cobra.Command{
|
||||
Use: "revert",
|
||||
Short: "[危险] 回滚文件到指定历史版本",
|
||||
Long: `将指定文件回滚到某个历史版本。仅支持普通文件(Word、Excel、PDF、图片等)。
|
||||
在线文档请用 dws doc version revert,在线表格请用 dws sheet version revert。`,
|
||||
Example: ` dws drive revert --node <dentryUuid> --version 3 --yes`,
|
||||
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
|
||||
}
|
||||
versionNum, err := cmd.Flags().GetInt("version")
|
||||
if err != nil || versionNum <= 0 {
|
||||
return fmt.Errorf("flag --version is required and must be a positive integer")
|
||||
}
|
||||
if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "回滚文件版本", fmt.Sprintf("节点 %s 回滚到版本 %d", nodeID, versionNum)) {
|
||||
return nil
|
||||
}
|
||||
return callMCPToolOnServer("drive", "revert_file_version", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"version": versionNum,
|
||||
})
|
||||
},
|
||||
}
|
||||
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
driveListSpacesCmd,
|
||||
driveInfoCmd,
|
||||
driveDownloadCmd,
|
||||
driveDownloadVersionCmd,
|
||||
driveMkdirCmd,
|
||||
driveUploadInfoCmd,
|
||||
driveCommitCmd,
|
||||
@@ -1418,6 +1838,9 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermissionCmd,
|
||||
drivePublishCmd,
|
||||
recycleCmd,
|
||||
driveStarCmd,
|
||||
driveCoverCmd,
|
||||
driveRevertCmd,
|
||||
// deprecated 兼容命令(Phase 2)— 隐藏,保留向后兼容
|
||||
driveFolderCmd,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drive list --depth 递归编排(BFS + 限流补偿)
|
||||
//
|
||||
// 服务端无递归 API,CLI 侧 BFS 过渡方案;服务端递归 API 上线后本块整体退役。
|
||||
// 钉盘(list_files)与知识库(list_nodes)共用一份路由无关骨架,差异收敛在 driveDepthRoute。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// 50 来源于服务端 MAX_PAGE_SIZE 硬校验(超限抛错不 clamp),服务端放宽后仅需修改此常量。
|
||||
driveDepthPageSize = 50
|
||||
// 来源与 driveDepthPageSize 不同(list_nodes pageSize 上限),数值碰巧相同但不合并。
|
||||
docDepthPageSize = 50
|
||||
// API 调用数随目录宽度指数增长,超过报错不 clamp。
|
||||
driveDepthMax = 5
|
||||
driveDepthMaxItems = 2000
|
||||
// Sentinel 限流以 HTTP 200 + body errorCode 返回,transport 层不会自动重试,补偿只能在 BFS 层做。
|
||||
driveDepthRateLimitedCode = "invalidRequest.rateLimited"
|
||||
driveDepthCancelledMsg = "cancelled by user, partial result emitted"
|
||||
)
|
||||
|
||||
type driveDepthFolder struct {
|
||||
id string // 钉盘 dentryUuid / 知识库 nodeId
|
||||
name string
|
||||
depth int
|
||||
relPath string
|
||||
retried bool // 已因 rate_limited 重新入队过一次,不二次补偿
|
||||
// 限流重入队时保留失败页游标:已成功页的条目已进 collected,
|
||||
// 从头重扫会产生重复条目并提前耗尽全局 2000 上限。
|
||||
pageToken string
|
||||
}
|
||||
|
||||
type driveDepthError struct {
|
||||
Depth int `json:"depth"`
|
||||
FolderID string `json:"folderId"`
|
||||
FolderName string `json:"folderName"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// 取消不是错误:不记 errors[]、不复用 1~6 退出码,固定 130(128+SIGINT)。
|
||||
type driveDepthCancelledError struct{}
|
||||
|
||||
func (e *driveDepthCancelledError) Error() string { return driveDepthCancelledMsg }
|
||||
func (e *driveDepthCancelledError) RawStderr() string { return driveDepthCancelledMsg }
|
||||
func (e *driveDepthCancelledError) ExitCode() int { return 130 }
|
||||
|
||||
// depth==1 走原有单层路径,仅做范围校验,不触发互斥检查。
|
||||
func validateDriveListDepth(cmd *cobra.Command, depth int) error {
|
||||
if depth < 1 {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--depth 必须为 1~%d 的整数,当前: %d", driveDepthMax, depth),
|
||||
}
|
||||
}
|
||||
if depth > driveDepthMax {
|
||||
// 不 clamp:静默 clamp 会让用户误以为拿到了完整树。
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("DEPTH_EXCEED_MAX: --depth 最大为 %d,当前: %d(API 调用数随目录宽度指数增长,不做静默 clamp)", driveDepthMax, depth),
|
||||
}
|
||||
}
|
||||
if depth == 1 {
|
||||
return nil
|
||||
}
|
||||
// --workspace 不互斥:depth>1 时作为路由开关切到知识库 BFS。
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||||
return driveDepthExclusiveError("cursor", "depth>1 时多文件夹合成一份清单,无连续游标")
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return driveDepthExclusiveError("limit", "递归模式数据量由全局上限 2000 与 --depth 层数控制")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveDepthExclusiveError(flag, reason string) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--depth 不能与 --%s 同时使用:%s", flag, reason),
|
||||
}
|
||||
}
|
||||
|
||||
// 路由绑定差异全部收敛在此,BFS 主循环保持路由无关。
|
||||
type driveDepthRoute struct {
|
||||
serverID string // ""=钉盘自动路由 / "doc"=知识库
|
||||
toolName string
|
||||
pageSize int
|
||||
// 钉盘契约无 hasMore 字段,只信空 nextToken;知识库契约含 hasMore,显式 false 即停。
|
||||
// 钉盘的 hasMore 仅用于「hasMore=true 但 token 为空」异常检测。
|
||||
hasMoreAuthoritative bool
|
||||
buildArgs func(base map[string]any, folderID, pageToken string) map[string]any
|
||||
parsePage func(text string) (items []map[string]any, nextToken string, hasMore bool)
|
||||
isFolder func(item map[string]any) bool
|
||||
itemID func(item map[string]any) string
|
||||
}
|
||||
|
||||
func (r driveDepthRoute) fetchPage(ctx context.Context, args map[string]any) (string, error) {
|
||||
if r.serverID == "" {
|
||||
return callMCPToolReturnText(ctx, r.toolName, args)
|
||||
}
|
||||
return callMCPToolReturnTextOnServer(ctx, r.serverID, r.toolName, args)
|
||||
}
|
||||
|
||||
func newDrivePanDepthRoute() driveDepthRoute {
|
||||
return driveDepthRoute{
|
||||
serverID: "",
|
||||
toolName: "list_files",
|
||||
pageSize: driveDepthPageSize,
|
||||
hasMoreAuthoritative: false,
|
||||
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
|
||||
args := map[string]any{"maxResults": float64(driveDepthPageSize)}
|
||||
for k, v := range base {
|
||||
args[k] = v
|
||||
}
|
||||
if folderID != "" {
|
||||
args["parentId"] = folderID
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
},
|
||||
parsePage: parseDriveDepthPage,
|
||||
isFolder: isDriveDepthFolder,
|
||||
itemID: func(item map[string]any) string {
|
||||
id, _ := item["fileId"].(string)
|
||||
return id
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// folderId 为空 = 知识库根。
|
||||
func newDocDepthRoute() driveDepthRoute {
|
||||
return driveDepthRoute{
|
||||
serverID: "doc",
|
||||
toolName: "list_nodes",
|
||||
pageSize: docDepthPageSize,
|
||||
// 字段缺失(契约违背)时按停处理——保守方向,宁可少翻页不空转。
|
||||
hasMoreAuthoritative: true,
|
||||
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
|
||||
args := map[string]any{"pageSize": float64(docDepthPageSize)}
|
||||
for k, v := range base {
|
||||
args[k] = v
|
||||
}
|
||||
if folderID != "" {
|
||||
args["folderId"] = folderID
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["pageToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
},
|
||||
parsePage: parseDocDepthPage,
|
||||
isFolder: isDocDepthFolder,
|
||||
itemID: func(item map[string]any) string {
|
||||
id, _ := item["nodeId"].(string)
|
||||
return id
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
|
||||
parent := cmd.Context()
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(parent, os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
// 兜底服务端「空页 + 非空游标」类 bug:该形态下条目不增长、全局 2000 永不触发。
|
||||
// 不用「token 不推进即停」:兜不住每页 token 都不同的空页流。
|
||||
maxPagesPerFolder := driveDepthMaxItems/route.pageSize + 1
|
||||
|
||||
traceID := fmt.Sprintf("drive-depth-%d", time.Now().UnixNano())
|
||||
var (
|
||||
collected []map[string]any
|
||||
errs = make([]driveDepthError, 0)
|
||||
truncated bool
|
||||
visited = map[string]bool{}
|
||||
)
|
||||
queue := []driveDepthFolder{{id: rootFolderID, depth: 0}}
|
||||
if rootFolderID != "" {
|
||||
visited[rootFolderID] = true
|
||||
}
|
||||
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
start := time.Now()
|
||||
var folderErr error
|
||||
pageToken := folder.pageToken
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
folderErr = &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("pagination anomaly: folder exceeded %d pages with non-empty nextToken, cursor loop suspected, directory may be truncated", maxPagesPerFolder),
|
||||
}
|
||||
break
|
||||
}
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
break
|
||||
}
|
||||
items, next, hasMore := route.parsePage(text)
|
||||
for _, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
rel := name
|
||||
if folder.relPath != "" {
|
||||
rel = folder.relPath + "/" + name
|
||||
}
|
||||
item["depth"] = folder.depth + 1
|
||||
item["parentId"] = folder.id // 根级为空串
|
||||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= driveDepthMaxItems {
|
||||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
if route.isFolder(item) && folder.depth+1 < maxDepth {
|
||||
if id := route.itemID(item); id != "" && !visited[id] {
|
||||
// 重复条目静默丢弃
|
||||
visited[id] = true
|
||||
queue = append(queue, driveDepthFolder{id: id, name: name, depth: folder.depth + 1, relPath: rel})
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
break bfs
|
||||
}
|
||||
if hasMore && next == "" {
|
||||
// 不静默截断——显式落 api_error,让消费方感知数据不完整。
|
||||
folderErr = &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "pagination anomaly: hasMore=true but nextToken is empty, directory may be truncated",
|
||||
}
|
||||
break
|
||||
}
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
if route.hasMoreAuthoritative && !hasMore {
|
||||
break
|
||||
}
|
||||
pageToken = next
|
||||
}
|
||||
|
||||
slog.Info("drive list depth folder done",
|
||||
"traceId", traceID, "depth", folder.depth, "folderId", folder.id,
|
||||
"latency", time.Since(start).Milliseconds(), "failed", folderErr != nil)
|
||||
|
||||
if folderErr != nil {
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
}
|
||||
if driveDepthErrorCode(folderErr) == driveDepthRateLimitedCode && !folder.retried {
|
||||
// 限流命中在入口处、失败页本身无副作用;但此前成功页已进 collected,
|
||||
// 必须从失败页游标续扫而非整目录重扫。不加 sleep/退避。
|
||||
folder.retried = true
|
||||
folder.pageToken = pageToken
|
||||
queue = append(queue, folder)
|
||||
continue
|
||||
}
|
||||
if folder.depth == 0 && len(collected) == 0 {
|
||||
return folderErr
|
||||
}
|
||||
// 403 / transport 重试耗尽 / 限流重入队仍失败:记 errors[] 跳过
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
continue
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
display := folder.name
|
||||
if display == "" {
|
||||
display = folder.id
|
||||
}
|
||||
if display == "" {
|
||||
display = "<root>"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] depth=%d folder=%s done\n", folder.depth, display)
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if matchDriveNamePattern(name, pattern) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
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])
|
||||
})
|
||||
maxDepth := 0
|
||||
for _, item := range items {
|
||||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"items": items,
|
||||
"maxDepth": maxDepth,
|
||||
"truncated": truncated,
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
|
||||
// 不输出预估调用次数:零调用下平均子文件夹数不可知,硬编码上界会被误读为真实估算。
|
||||
func printDriveDepthDryRun(route driveDepthRoute, baseArgs map[string]any, maxDepth int) error {
|
||||
if deps.Caller.Format() == "json" {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": route.toolName,
|
||||
"baseArgs": baseArgs,
|
||||
"maxDepth": maxDepth,
|
||||
"pageSize": route.pageSize,
|
||||
"totalLimit": driveDepthMaxItems,
|
||||
"warning": "调用数随目录宽度指数增长",
|
||||
})
|
||||
}
|
||||
bold := color.New(color.FgYellow, color.Bold)
|
||||
bold.Println("[DRY-RUN] Preview only, not executed:")
|
||||
deps.Out.PrintKeyValue("Tool", route.toolName)
|
||||
argsJSON, _ := json.MarshalIndent(baseArgs, " ", " ")
|
||||
deps.Out.PrintKeyValue("baseArgs", "\n "+string(argsJSON))
|
||||
deps.Out.PrintKeyValue("maxDepth", fmt.Sprintf("%d", maxDepth))
|
||||
deps.Out.PrintKeyValue("每页条数", fmt.Sprintf("%d", route.pageSize))
|
||||
deps.Out.PrintKeyValue("全局上限", fmt.Sprintf("%d", driveDepthMaxItems))
|
||||
deps.Out.PrintWarning("调用数随目录宽度指数增长")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 钉盘契约无 hasMore 字段,hasMoreExplicit 仅供「hasMore=true 但 token 为空」异常检测。
|
||||
func parseDriveDepthPage(text string) (items []map[string]any, nextToken string, hasMoreExplicit bool) {
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(text), &body) != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
target := body
|
||||
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
|
||||
target = inner
|
||||
}
|
||||
key := driveDepthListItemsKey(target)
|
||||
if key == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
arr, _ := target[key].([]any)
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
nextToken, _ = target["nextToken"].(string)
|
||||
hasMoreExplicit, _ = target["hasMore"].(bool)
|
||||
return items, nextToken, hasMoreExplicit
|
||||
}
|
||||
|
||||
func driveDepthListItemsKey(m map[string]any) string {
|
||||
for _, k := range []string{"items", "dentryList"} {
|
||||
if arr, ok := m[k].([]any); ok && arr != nil {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 非法通配符降级为子串匹配,避免 filepath.Match 报错导致过滤静默失效。
|
||||
func matchDriveNamePattern(name, pattern string) bool {
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
p := pattern
|
||||
if !strings.ContainsAny(p, "*?[") {
|
||||
p = "*" + p + "*"
|
||||
}
|
||||
ok, err := filepath.Match(p, name)
|
||||
if err != nil {
|
||||
return strings.Contains(name, pattern)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func isDriveDepthFolder(item map[string]any) bool {
|
||||
for _, key := range []string{"type", "dentryType"} {
|
||||
if v, ok := item[key].(string); ok && strings.EqualFold(v, "FOLDER") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// list_nodes 游标字段叫 nextPageToken,与钉盘的 nextToken 不同名。
|
||||
func parseDocDepthPage(text string) (items []map[string]any, nextToken string, hasMore bool) {
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(text), &body) != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
arr, _ := body["nodes"].([]any)
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
nextToken, _ = body["nextPageToken"].(string)
|
||||
hasMore, _ = body["hasMore"].(bool)
|
||||
return items, nextToken, hasMore
|
||||
}
|
||||
|
||||
func isDocDepthFolder(item map[string]any) bool {
|
||||
v, _ := item["nodeType"].(string)
|
||||
return strings.EqualFold(v, "folder")
|
||||
}
|
||||
|
||||
func driveDepthItemID(item map[string]any) string {
|
||||
for _, key := range []string{"fileId", "nodeId"} {
|
||||
if s, ok := item[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func driveDepthUnrecoverable(err error) bool {
|
||||
var cliErr *CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
switch cliErr.Code {
|
||||
case CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CLIError.Message 保留了原始响应 JSON。
|
||||
func driveDepthErrorCode(err error) string {
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
return ""
|
||||
}
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(cliErr.Message), &body) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"errorCode", "error_code", "code"} {
|
||||
if s, ok := body[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 匹配顺序是硬约束:invalidRequest.rateLimited 自带 "invalidRequest." 前缀,
|
||||
// 若先做 invalidRequest.* 前缀分类,限流会被吞进参数错误类。必须先精确匹配限流,
|
||||
// 再 forbidden.* 前缀,最后兜底 api_error。未知 reason 按 api_error 兼容。
|
||||
func classifyDriveDepthReason(errorCode string) string {
|
||||
switch {
|
||||
case errorCode == driveDepthRateLimitedCode:
|
||||
return "rate_limited"
|
||||
case strings.HasPrefix(errorCode, "forbidden."):
|
||||
return "permission_denied"
|
||||
default:
|
||||
return "api_error"
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveDepthError(folder driveDepthFolder, err error) driveDepthError {
|
||||
return driveDepthError{
|
||||
Depth: folder.depth,
|
||||
FolderID: folder.id,
|
||||
FolderName: folder.name,
|
||||
Reason: classifyDriveDepthReason(driveDepthErrorCode(err)),
|
||||
Message: driveDepthErrorMessage(err),
|
||||
}
|
||||
}
|
||||
|
||||
func driveDepthErrorMessage(err error) string {
|
||||
var cliErr *CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(cliErr.Message), &body) == nil {
|
||||
for _, key := range []string{"errorMsg", "message"} {
|
||||
if s, ok := body[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return cliErr.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func driveDepthTestCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("next-token", "", "")
|
||||
cmd.Flags().Int("limit", 0, "")
|
||||
cmd.Flags().Int("max", 0, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthCancelledError(t *testing.T) {
|
||||
err := &driveDepthCancelledError{}
|
||||
if err.Error() != driveDepthCancelledMsg || err.RawStderr() != driveDepthCancelledMsg {
|
||||
t.Fatalf("messages = %q/%q", err.Error(), err.RawStderr())
|
||||
}
|
||||
if err.ExitCode() != 130 {
|
||||
t.Fatalf("ExitCode = %d, want 130", err.ExitCode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateDriveListDepth(t *testing.T) {
|
||||
newCmd := func() *cobra.Command { return driveDepthTestCmd() }
|
||||
|
||||
if err := validateDriveListDepth(newCmd(), 0); err == nil {
|
||||
t.Fatal("depth 0 returned nil")
|
||||
}
|
||||
if err := validateDriveListDepth(newCmd(), driveDepthMax+1); err == nil {
|
||||
t.Fatal("depth above max returned nil")
|
||||
}
|
||||
|
||||
shallow := newCmd()
|
||||
_ = shallow.Flags().Set("cursor", "c1")
|
||||
_ = shallow.Flags().Set("limit", "5")
|
||||
if err := validateDriveListDepth(shallow, 1); err != nil {
|
||||
t.Fatalf("depth 1 with cursor/limit returned %v", err)
|
||||
}
|
||||
|
||||
withCursor := newCmd()
|
||||
_ = withCursor.Flags().Set("cursor", "c1")
|
||||
if err := validateDriveListDepth(withCursor, 2); err == nil {
|
||||
t.Fatal("depth 2 with cursor returned nil")
|
||||
}
|
||||
|
||||
withNextToken := newCmd()
|
||||
_ = withNextToken.Flags().Set("next-token", "t1")
|
||||
if err := validateDriveListDepth(withNextToken, 2); err == nil {
|
||||
t.Fatal("depth 2 with next-token returned nil")
|
||||
}
|
||||
|
||||
withLimit := newCmd()
|
||||
_ = withLimit.Flags().Set("limit", "5")
|
||||
if err := validateDriveListDepth(withLimit, 2); err == nil {
|
||||
t.Fatal("depth 2 with limit returned nil")
|
||||
}
|
||||
|
||||
withMax := newCmd()
|
||||
_ = withMax.Flags().Set("max", "5")
|
||||
if err := validateDriveListDepth(withMax, 2); err == nil {
|
||||
t.Fatal("depth 2 with max returned nil")
|
||||
}
|
||||
|
||||
if err := validateDriveListDepth(newCmd(), 2); err != nil {
|
||||
t.Fatalf("depth 2 clean returned %v", err)
|
||||
}
|
||||
if err := validateDriveListDepth(newCmd(), driveDepthMax); err != nil {
|
||||
t.Fatalf("depth max clean returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthRoutesBuildArgs(t *testing.T) {
|
||||
pan := newDrivePanDepthRoute()
|
||||
args := pan.buildArgs(map[string]any{"spaceId": "s1"}, "folder1", "tok")
|
||||
if args["maxResults"] != float64(driveDepthPageSize) || args["spaceId"] != "s1" ||
|
||||
args["parentId"] != "folder1" || args["nextToken"] != "tok" {
|
||||
t.Fatalf("pan args = %#v", args)
|
||||
}
|
||||
empty := pan.buildArgs(nil, "", "")
|
||||
if _, ok := empty["parentId"]; ok {
|
||||
t.Fatalf("pan empty folder args = %#v", empty)
|
||||
}
|
||||
if _, ok := empty["nextToken"]; ok {
|
||||
t.Fatalf("pan empty token args = %#v", empty)
|
||||
}
|
||||
if id := pan.itemID(map[string]any{"fileId": "f1"}); id != "f1" {
|
||||
t.Fatalf("pan itemID = %q", id)
|
||||
}
|
||||
if id := pan.itemID(map[string]any{}); id != "" {
|
||||
t.Fatalf("pan missing itemID = %q", id)
|
||||
}
|
||||
|
||||
doc := newDocDepthRoute()
|
||||
dargs := doc.buildArgs(map[string]any{"workspaceId": "w1"}, "node1", "pt")
|
||||
if dargs["pageSize"] != float64(docDepthPageSize) || dargs["workspaceId"] != "w1" ||
|
||||
dargs["folderId"] != "node1" || dargs["pageToken"] != "pt" {
|
||||
t.Fatalf("doc args = %#v", dargs)
|
||||
}
|
||||
dempty := doc.buildArgs(nil, "", "")
|
||||
if _, ok := dempty["folderId"]; ok {
|
||||
t.Fatalf("doc empty folder args = %#v", dempty)
|
||||
}
|
||||
if id := doc.itemID(map[string]any{"nodeId": "n1"}); id != "n1" {
|
||||
t.Fatalf("doc itemID = %q", id)
|
||||
}
|
||||
if id := doc.itemID(map[string]any{}); id != "" {
|
||||
t.Fatalf("doc missing itemID = %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveDepthPage(t *testing.T) {
|
||||
if items, next, hasMore := parseDriveDepthPage(`{invalid`); items != nil || next != "" || hasMore {
|
||||
t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore)
|
||||
}
|
||||
if items, _, _ := parseDriveDepthPage(`{"foo":1}`); items != nil {
|
||||
t.Fatalf("missing items key = %#v", items)
|
||||
}
|
||||
items, next, hasMore := parseDriveDepthPage(`{"items":[{"name":"a"},42],"nextToken":"t","hasMore":true}`)
|
||||
if len(items) != 1 || items[0]["name"] != "a" || next != "t" || !hasMore {
|
||||
t.Fatalf("top-level items = %#v %q %v", items, next, hasMore)
|
||||
}
|
||||
items, next, _ = parseDriveDepthPage(`{"result":{"dentryList":[{"name":"b"}],"nextToken":"t2"}}`)
|
||||
if len(items) != 1 || items[0]["name"] != "b" || next != "t2" {
|
||||
t.Fatalf("result dentryList = %#v %q", items, next)
|
||||
}
|
||||
if items, _, _ := parseDriveDepthPage(`{"result":{"other":1}}`); items != nil {
|
||||
t.Fatalf("result without items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthListItemsKey(t *testing.T) {
|
||||
if k := driveDepthListItemsKey(map[string]any{"items": []any{}}); k != "items" {
|
||||
t.Fatalf("items key = %q", k)
|
||||
}
|
||||
if k := driveDepthListItemsKey(map[string]any{"dentryList": []any{1}}); k != "dentryList" {
|
||||
t.Fatalf("dentryList key = %q", k)
|
||||
}
|
||||
if k := driveDepthListItemsKey(map[string]any{"items": nil}); k != "" {
|
||||
t.Fatalf("nil items key = %q", k)
|
||||
}
|
||||
if k := driveDepthListItemsKey(map[string]any{}); k != "" {
|
||||
t.Fatalf("missing key = %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDocDepthPage(t *testing.T) {
|
||||
if items, next, hasMore := parseDocDepthPage(`{bad`); items != nil || next != "" || hasMore {
|
||||
t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore)
|
||||
}
|
||||
items, next, hasMore := parseDocDepthPage(`{"nodes":[{"name":"a"},"x"],"nextPageToken":"nt","hasMore":true}`)
|
||||
if len(items) != 1 || next != "nt" || !hasMore {
|
||||
t.Fatalf("nodes = %#v %q %v", items, next, hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMatchDriveNamePattern(t *testing.T) {
|
||||
if !matchDriveNamePattern("anything", "") {
|
||||
t.Fatal("empty pattern should match")
|
||||
}
|
||||
if !matchDriveNamePattern("report.xlsx", "*.xlsx") || matchDriveNamePattern("report.csv", "*.xlsx") {
|
||||
t.Fatal("glob match failed")
|
||||
}
|
||||
if !matchDriveNamePattern("weekly-report.xlsx", "report") || matchDriveNamePattern("a.txt", "report") {
|
||||
t.Fatal("substring wrap failed")
|
||||
}
|
||||
if !matchDriveNamePattern("a[b", "[") {
|
||||
t.Fatal("invalid pattern should fall back to substring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthItemClassifiers(t *testing.T) {
|
||||
if !isDriveDepthFolder(map[string]any{"type": "FOLDER"}) {
|
||||
t.Fatal("type FOLDER not detected")
|
||||
}
|
||||
if !isDriveDepthFolder(map[string]any{"dentryType": "folder"}) {
|
||||
t.Fatal("dentryType folder not detected")
|
||||
}
|
||||
if isDriveDepthFolder(map[string]any{"type": "FILE"}) || isDriveDepthFolder(map[string]any{}) {
|
||||
t.Fatal("non-folder detected as folder")
|
||||
}
|
||||
if !isDocDepthFolder(map[string]any{"nodeType": "Folder"}) {
|
||||
t.Fatal("doc folder not detected")
|
||||
}
|
||||
if isDocDepthFolder(map[string]any{"nodeType": "doc"}) {
|
||||
t.Fatal("doc detected as folder")
|
||||
}
|
||||
if id := driveDepthItemID(map[string]any{"fileId": "f"}); id != "f" {
|
||||
t.Fatalf("fileId = %q", id)
|
||||
}
|
||||
if id := driveDepthItemID(map[string]any{"nodeId": "n"}); id != "n" {
|
||||
t.Fatalf("nodeId = %q", id)
|
||||
}
|
||||
if id := driveDepthItemID(map[string]any{"fileId": ""}); id != "" {
|
||||
t.Fatalf("empty = %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthUnrecoverable(t *testing.T) {
|
||||
for _, code := range []string{CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable} {
|
||||
if !driveDepthUnrecoverable(&CLIError{Code: code}) {
|
||||
t.Fatalf("%s not unrecoverable", code)
|
||||
}
|
||||
}
|
||||
if driveDepthUnrecoverable(&CLIError{Code: CodeMCPToolError}) {
|
||||
t.Fatal("tool error marked unrecoverable")
|
||||
}
|
||||
if driveDepthUnrecoverable(errors.New("plain")) {
|
||||
t.Fatal("plain error marked unrecoverable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthErrorCode(t *testing.T) {
|
||||
if c := driveDepthErrorCode(errors.New("plain")); c != "" {
|
||||
t.Fatalf("plain = %q", c)
|
||||
}
|
||||
if c := driveDepthErrorCode(&CLIError{Message: "not json"}); c != "" {
|
||||
t.Fatalf("non-JSON = %q", c)
|
||||
}
|
||||
for _, tc := range []struct{ body, want string }{
|
||||
{`{"errorCode":"a.b"}`, "a.b"},
|
||||
{`{"error_code":"c.d"}`, "c.d"},
|
||||
{`{"code":"e.f"}`, "e.f"},
|
||||
{`{"errorCode":""}`, ""},
|
||||
} {
|
||||
if c := driveDepthErrorCode(&CLIError{Message: tc.body}); c != tc.want {
|
||||
t.Fatalf("%s = %q, want %q", tc.body, c, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassifyDriveDepthReason(t *testing.T) {
|
||||
if r := classifyDriveDepthReason(driveDepthRateLimitedCode); r != "rate_limited" {
|
||||
t.Fatalf("rate limit = %q", r)
|
||||
}
|
||||
if r := classifyDriveDepthReason("forbidden.noPermission"); r != "permission_denied" {
|
||||
t.Fatalf("forbidden = %q", r)
|
||||
}
|
||||
if r := classifyDriveDepthReason("other.x"); r != "api_error" {
|
||||
t.Fatalf("other = %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthErrorMessage(t *testing.T) {
|
||||
if m := driveDepthErrorMessage(&CLIError{Message: `{"errorMsg":"boom"}`}); m != "boom" {
|
||||
t.Fatalf("errorMsg = %q", m)
|
||||
}
|
||||
if m := driveDepthErrorMessage(&CLIError{Message: `{"message":"m2"}`}); m != "m2" {
|
||||
t.Fatalf("message = %q", m)
|
||||
}
|
||||
if m := driveDepthErrorMessage(&CLIError{Message: "raw text"}); m != "raw text" {
|
||||
t.Fatalf("raw = %q", m)
|
||||
}
|
||||
if m := driveDepthErrorMessage(errors.New("plain boom")); m != "plain boom" {
|
||||
t.Fatalf("plain = %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveDepthError(t *testing.T) {
|
||||
folder := driveDepthFolder{id: "f1", name: "folder one", depth: 2}
|
||||
derr := newDriveDepthError(folder, &CLIError{Code: CodeMCPToolError, Message: `{"errorCode":"forbidden.x","errorMsg":"denied"}`})
|
||||
if derr.Depth != 2 || derr.FolderID != "f1" || derr.FolderName != "folder one" ||
|
||||
derr.Reason != "permission_denied" || derr.Message != "denied" {
|
||||
t.Fatalf("driveDepthError = %#v", derr)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func installDepthCaller(t *testing.T, caller *scriptedToolCaller) *bytes.Buffer {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
buf := &bytes.Buffer{}
|
||||
deps.Out.w = buf
|
||||
return buf
|
||||
}
|
||||
|
||||
func decodeDepthResult(t *testing.T, buf *bytes.Buffer) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
out := installDepthCaller(t, &scriptedToolCaller{})
|
||||
items := []map[string]any{
|
||||
{"fileName": "b-file.xlsx", "rel_path": "b", "depth": 1, "fileId": "f2"},
|
||||
{"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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
got := result["items"].([]any)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filtered items = %#v", got)
|
||||
}
|
||||
if got[0].(map[string]any)["rel_path"] != "a" || got[1].(map[string]any)["rel_path"] != "b" {
|
||||
t.Fatalf("sort order = %#v", got)
|
||||
}
|
||||
if result["maxDepth"] != float64(2) {
|
||||
t.Fatalf("maxDepth = %#v", result["maxDepth"])
|
||||
}
|
||||
if result["truncated"] != false {
|
||||
t.Fatalf("truncated = %#v", result["truncated"])
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
if items, ok := result["items"].([]any); !ok || len(items) != 0 {
|
||||
t.Fatalf("nil items = %#v", result["items"])
|
||||
}
|
||||
|
||||
samePath := []map[string]any{
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "z9"},
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
if got[0].(map[string]any)["fileId"] != "a1" {
|
||||
t.Fatalf("tie-break order = %#v", got)
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintDriveDepthDryRun(t *testing.T) {
|
||||
jsonOut := installDepthCaller(t, &scriptedToolCaller{format: "json", dry: true})
|
||||
if err := printDriveDepthDryRun(newDrivePanDepthRoute(), map[string]any{"spaceId": "s1"}, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json dry-run output: %v", err)
|
||||
}
|
||||
if payload["dry_run"] != true || payload["tool"] != "list_files" || payload["maxDepth"] != float64(3) {
|
||||
t.Fatalf("json dry-run = %#v", payload)
|
||||
}
|
||||
|
||||
installDepthCaller(t, &scriptedToolCaller{format: "table", dry: true})
|
||||
if err := printDriveDepthDryRun(newDocDepthRoute(), map[string]any{"workspaceId": "w1"}, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func useDriveDepthArgs(t *testing.T) {
|
||||
t.Helper()
|
||||
old := os.Args
|
||||
os.Args = []string{"dws", "drive", "list", "--depth", "2"}
|
||||
t.Cleanup(func() { os.Args = old })
|
||||
}
|
||||
|
||||
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry-run calls = %d", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("dry-run output: %v", err)
|
||||
}
|
||||
if payload["dry_run"] != true {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
|
||||
{text: `{"items":[{"fileId":"fY","name":"y.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", caller.calls)
|
||||
}
|
||||
items := decodeDepthResult(t, out)["items"].([]any)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
var dirA, nested map[string]any
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
switch item["rel_path"] {
|
||||
case "dirA":
|
||||
dirA = item
|
||||
case "dirA/y.txt":
|
||||
nested = item
|
||||
}
|
||||
}
|
||||
if dirA == nil || nested == nil {
|
||||
t.Fatalf("rel_path tree = %#v", items)
|
||||
}
|
||||
if dirA["depth"] != float64(1) || dirA["parentId"] != "" {
|
||||
t.Fatalf("root item = %#v", dirA)
|
||||
}
|
||||
if nested["depth"] != float64(2) || nested["parentId"] != "fA" {
|
||||
t.Fatalf("nested item = %#v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthMaxDepthSkipsEnqueue(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
|
||||
}}
|
||||
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", caller.calls)
|
||||
}
|
||||
if len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("items = %#v", result["items"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthDedup(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"fA","name":"dirA","type":"FOLDER"},
|
||||
{"fileId":"fA","name":"dirA-copy","type":"FOLDER"},
|
||||
{"fileId":"","name":"no-id","type":"FOLDER"},
|
||||
{"fileId":"fB","name":"dirB","type":"FOLDER"}]}`},
|
||||
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
|
||||
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("calls = %d, want 3 (dedup + empty id skipped)", caller.calls)
|
||||
}
|
||||
if len(result["items"].([]any)) != 6 {
|
||||
t.Fatalf("items = %#v", result["items"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthDocRouteAnomaly(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[{"nodeId":"nB","name":"folderB","nodeType":"folder"},{"nodeId":"nD","name":"doc1","nodeType":"doc"}],"hasMore":false,"nextPageToken":"ignored"}`},
|
||||
{text: `{"nodes":[{"nodeId":"nE","name":"inner","nodeType":"doc"}],"hasMore":true}`},
|
||||
}}
|
||||
result, err := runDepthBFS(t, caller, newDocDepthRoute(), "root1", 3, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (hasMore=false stops authoritative route)", caller.calls)
|
||||
}
|
||||
errs := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("errors = %#v", result["errors"])
|
||||
}
|
||||
entry := errs[0].(map[string]any)
|
||||
if entry["folderId"] != "nB" || entry["reason"] != "api_error" {
|
||||
t.Fatalf("anomaly entry = %#v", entry)
|
||||
}
|
||||
if !strings.Contains(entry["message"].(string), "hasMore=true but nextToken is empty") {
|
||||
t.Fatalf("anomaly message = %#v", entry)
|
||||
}
|
||||
if len(result["items"].([]any)) != 3 {
|
||||
t.Fatalf("items = %#v", result["items"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthRateLimitRetry(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
|
||||
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
|
||||
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("calls = %d, want 3", caller.calls)
|
||||
}
|
||||
if errs := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("errors = %#v", errs)
|
||||
}
|
||||
if len(result["items"].([]any)) != 2 {
|
||||
t.Fatalf("items = %#v", result["items"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"f1","name":"page1.txt","type":"FILE"}],"nextToken":"p2"}`},
|
||||
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
|
||||
{text: `{"items":[{"fileId":"f2","name":"page2.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
out := &bytes.Buffer{}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
t.Fatalf("calls = %d, want 3", len(caller.calls))
|
||||
}
|
||||
if tok, _ := caller.calls[2]["nextToken"].(string); tok != "p2" {
|
||||
t.Fatalf("retry call args = %#v, want resume with nextToken=p2", caller.calls[2])
|
||||
}
|
||||
items := decodeDepthResult(t, out)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %#v, want 2 without duplicates", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthRateLimitExhausted(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
|
||||
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
|
||||
}}
|
||||
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("calls = %d, want 3 (one retry)", caller.calls)
|
||||
}
|
||||
errs := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("errors = %#v", errs)
|
||||
}
|
||||
entry := errs[0].(map[string]any)
|
||||
if entry["reason"] != "rate_limited" || entry["folderId"] != "fA" {
|
||||
t.Fatalf("rate limit entry = %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (single retry then fail)", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
|
||||
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
|
||||
t.Fatalf("err = %T %v", err, err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if len(result["items"].([]any)) != 2 {
|
||||
t.Fatalf("partial items = %#v", result["items"])
|
||||
}
|
||||
if len(result["errors"].([]any)) != 1 {
|
||||
t.Fatalf("errors = %#v", result["errors"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[],"nextToken":"t","hasMore":true}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
maxPages := driveDepthMaxItems/driveDepthPageSize + 1
|
||||
if caller.calls != maxPages {
|
||||
t.Fatalf("calls = %d, want %d", caller.calls, maxPages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthTruncation(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"}`, i, i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("truncated = %#v", result["truncated"])
|
||||
}
|
||||
if len(result["items"].([]any)) != driveDepthMaxItems {
|
||||
t.Fatalf("items len = %d", len(result["items"].([]any)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{}
|
||||
out := installDepthCaller(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("cancelled result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{}
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthPatternFilter(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
|
||||
{text: `{"items":[{"fileId":"fY","name":"keep.xlsx","type":"FILE"}]}`},
|
||||
}}
|
||||
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "*.xlsx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := result["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["name"] != "keep.xlsx" {
|
||||
t.Fatalf("filtered = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelOnCallCaller struct {
|
||||
scriptedToolCaller
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *cancelOnCallCaller) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.cancel()
|
||||
return c.scriptedToolCaller.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
route := newDrivePanDepthRoute()
|
||||
baseParse := route.parsePage
|
||||
route.parsePage = func(text string) ([]map[string]any, string, bool) {
|
||||
cancel()
|
||||
return baseParse(text)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}],"nextToken":"more"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if len(result["items"].([]any)) != 1 || result["truncated"] != true {
|
||||
t.Fatalf("cancelled partial = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
caller := &cancelOnCallCaller{
|
||||
scriptedToolCaller: scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
|
||||
}},
|
||||
cancel: cancel,
|
||||
}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("cancelled result = %#v", result)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user